Compare commits
10 Commits
52d1df4c2f
...
cce8a818d1
| Author | SHA1 | Date | |
|---|---|---|---|
| cce8a818d1 | |||
| edbcbe728f | |||
| b7f7aeeb9a | |||
| 97caee37cc | |||
| 7c2632a297 | |||
| cc4fad4532 | |||
| 8360d0d9b4 | |||
| be60c65ab8 | |||
| c24d94ca2b | |||
| 75df916d97 |
40
Cargo.lock
generated
40
Cargo.lock
generated
@@ -661,6 +661,19 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -1520,6 +1533,30 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.64"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core 0.58.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -2669,8 +2706,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rlogg"
|
name = "rlogg"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"eframe",
|
"eframe",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rlogg"
|
name = "rlogg"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Stanislav Pastushenko <staspast1@gmail.com>"]
|
authors = ["Stanislav Pastushenko <staspast1@gmail.com>"]
|
||||||
description = "A fast log file viewer with search, filtering, and highlighting capabilities"
|
description = "A fast log file viewer with search, filtering, and highlighting capabilities"
|
||||||
@@ -17,3 +17,4 @@ regex = "1.11"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
|
chrono = "0.4"
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ pub struct AppConfig {
|
|||||||
pub last_search_query: String,
|
pub last_search_query: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub highlight_rules: Vec<HighlightRule>,
|
pub highlight_rules: Vec<HighlightRule>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub date_range_enabled: bool,
|
||||||
|
#[serde(default = "default_date_format")]
|
||||||
|
pub date_format: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub date_from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub date_to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_date_format() -> String {
|
||||||
|
String::from("%Y-%m-%d %H:%M:%S")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
|||||||
243
src/main.rs
243
src/main.rs
@@ -7,6 +7,7 @@ mod highlight;
|
|||||||
mod line_index;
|
mod line_index;
|
||||||
mod search;
|
mod search;
|
||||||
mod tab_manager;
|
mod tab_manager;
|
||||||
|
mod theme;
|
||||||
mod types;
|
mod types;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -38,7 +39,11 @@ fn main() -> eframe::Result {
|
|||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"RLogg",
|
"RLogg",
|
||||||
options,
|
options,
|
||||||
Box::new(move |_cc| Ok(Box::new(LogViewerApp::new(config)))),
|
Box::new(move |cc| {
|
||||||
|
// Apply the modern dark purple theme
|
||||||
|
theme::apply_theme(&cc.egui_ctx);
|
||||||
|
Ok(Box::new(LogViewerApp::new(config)))
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +67,10 @@ impl LogViewerApp {
|
|||||||
case_sensitive: config.case_sensitive,
|
case_sensitive: config.case_sensitive,
|
||||||
use_regex: config.use_regex,
|
use_regex: config.use_regex,
|
||||||
history: config.search_history,
|
history: config.search_history,
|
||||||
|
date_range_enabled: config.date_range_enabled,
|
||||||
|
date_format: config.date_format,
|
||||||
|
date_from: config.date_from,
|
||||||
|
date_to: config.date_to,
|
||||||
},
|
},
|
||||||
indexing_state: IndexingState::new(),
|
indexing_state: IndexingState::new(),
|
||||||
search_state: SearchState::new(),
|
search_state: SearchState::new(),
|
||||||
@@ -85,6 +94,10 @@ impl LogViewerApp {
|
|||||||
use_regex: self.search_panel_state.use_regex,
|
use_regex: self.search_panel_state.use_regex,
|
||||||
last_search_query: self.search_panel_state.query.clone(),
|
last_search_query: self.search_panel_state.query.clone(),
|
||||||
highlight_rules: self.highlight_manager.rules.clone(),
|
highlight_rules: self.highlight_manager.rules.clone(),
|
||||||
|
date_range_enabled: self.search_panel_state.date_range_enabled,
|
||||||
|
date_format: self.search_panel_state.date_format.clone(),
|
||||||
|
date_from: self.search_panel_state.date_from.clone(),
|
||||||
|
date_to: self.search_panel_state.date_to.clone(),
|
||||||
};
|
};
|
||||||
config.save();
|
config.save();
|
||||||
}
|
}
|
||||||
@@ -102,10 +115,42 @@ impl LogViewerApp {
|
|||||||
|
|
||||||
fn handle_search(&mut self) {
|
fn handle_search(&mut self) {
|
||||||
if let Some(tab) = self.active_tab() {
|
if let Some(tab) = self.active_tab() {
|
||||||
|
// Validate date format if date range is enabled
|
||||||
|
if self.search_panel_state.date_range_enabled {
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
// Validate format by trying to parse example dates
|
||||||
|
let test_date = "2025-01-01 00:00:00";
|
||||||
|
if NaiveDateTime::parse_from_str(test_date, &self.search_panel_state.date_format).is_err() {
|
||||||
|
eprintln!("Invalid date format: {}", self.search_panel_state.date_format);
|
||||||
|
eprintln!("Expected format like: %Y-%m-%d %H:%M:%S");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date_from and date_to can be parsed
|
||||||
|
if !self.search_panel_state.date_from.is_empty() {
|
||||||
|
if NaiveDateTime::parse_from_str(&self.search_panel_state.date_from, &self.search_panel_state.date_format).is_err() {
|
||||||
|
eprintln!("Invalid date_from format. Expected format: {}", self.search_panel_state.date_format);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.search_panel_state.date_to.is_empty() {
|
||||||
|
if NaiveDateTime::parse_from_str(&self.search_panel_state.date_to, &self.search_panel_state.date_format).is_err() {
|
||||||
|
eprintln!("Invalid date_to format. Expected format: {}", self.search_panel_state.date_format);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let params = SearchParams {
|
let params = SearchParams {
|
||||||
query: self.search_panel_state.query.clone(),
|
query: self.search_panel_state.query.clone(),
|
||||||
case_sensitive: self.search_panel_state.case_sensitive,
|
case_sensitive: self.search_panel_state.case_sensitive,
|
||||||
use_regex: self.search_panel_state.use_regex,
|
use_regex: self.search_panel_state.use_regex,
|
||||||
|
date_range_enabled: self.search_panel_state.date_range_enabled,
|
||||||
|
date_format: self.search_panel_state.date_format.clone(),
|
||||||
|
date_from: self.search_panel_state.date_from.clone(),
|
||||||
|
date_to: self.search_panel_state.date_to.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let line_index = Arc::clone(&tab.line_index);
|
let line_index = Arc::clone(&tab.line_index);
|
||||||
@@ -127,6 +172,76 @@ impl LogViewerApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_export_filtered(&mut self) {
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
if let Some(tab) = self.active_tab() {
|
||||||
|
if tab.filtered_lines.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate timestamped filename
|
||||||
|
let original_path = &tab.file_path;
|
||||||
|
let file_stem = original_path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("export");
|
||||||
|
let extension = original_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("log");
|
||||||
|
|
||||||
|
// Generate timestamp
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Create new filename: filename_timestamp.extension
|
||||||
|
let export_filename = format!("{}_{}.{}", file_stem, timestamp, extension);
|
||||||
|
let export_path = original_path.with_file_name(export_filename);
|
||||||
|
|
||||||
|
// Write filtered lines to file
|
||||||
|
match File::create(&export_path) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
// Get the line index and file handle
|
||||||
|
let line_index = Arc::clone(&tab.line_index);
|
||||||
|
let file_path = tab.file_path.clone();
|
||||||
|
|
||||||
|
// Open the original file for reading
|
||||||
|
if let Ok(original_file) = File::open(&file_path) {
|
||||||
|
let mut reader = std::io::BufReader::new(original_file);
|
||||||
|
|
||||||
|
// Write each filtered line to the export file
|
||||||
|
for filtered_line in &tab.filtered_lines {
|
||||||
|
if let Some(content) = line_index.read_line(&mut reader, filtered_line.line_number) {
|
||||||
|
writeln!(file, "{}", content).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the exported file in a new tab
|
||||||
|
if let Ok(line_index) = crate::line_index::LineIndex::build(&export_path) {
|
||||||
|
if let Ok(exported_file) = File::open(&export_path) {
|
||||||
|
let new_tab = crate::file_tab::FileTab::new(
|
||||||
|
export_path,
|
||||||
|
line_index,
|
||||||
|
exported_file,
|
||||||
|
);
|
||||||
|
self.tabs.push(new_tab);
|
||||||
|
self.active_tab_index = self.tabs.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to create export file: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_keyboard_input(&mut self, ctx: &egui::Context) {
|
fn handle_keyboard_input(&mut self, ctx: &egui::Context) {
|
||||||
if let Some(tab) = self.active_tab_mut() {
|
if let Some(tab) = self.active_tab_mut() {
|
||||||
tab.page_scroll_direction = ctx.input(|i| {
|
tab.page_scroll_direction = ctx.input(|i| {
|
||||||
@@ -194,6 +309,16 @@ impl eframe::App for LogViewerApp {
|
|||||||
self.handle_close_tab(index);
|
self.handle_close_tab(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for Ctrl+F keyboard shortcut
|
||||||
|
let ctrl_f_pressed = ctx.input(|i| {
|
||||||
|
i.modifiers.ctrl && i.key_pressed(egui::Key::F)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for Ctrl+Enter to execute search globally
|
||||||
|
let ctrl_enter_pressed = ctx.input(|i| {
|
||||||
|
i.modifiers.ctrl && i.key_pressed(egui::Key::Enter)
|
||||||
|
});
|
||||||
|
|
||||||
// Render search panel
|
// Render search panel
|
||||||
let match_count = self.active_tab().map(|t| t.filtered_lines.len()).unwrap_or(0);
|
let match_count = self.active_tab().map(|t| t.filtered_lines.len()).unwrap_or(0);
|
||||||
let search_actions = render_search_panel(
|
let search_actions = render_search_panel(
|
||||||
@@ -201,9 +326,10 @@ impl eframe::App for LogViewerApp {
|
|||||||
&mut self.search_panel_state,
|
&mut self.search_panel_state,
|
||||||
&self.search_state,
|
&self.search_state,
|
||||||
match_count,
|
match_count,
|
||||||
|
ctrl_f_pressed,
|
||||||
);
|
);
|
||||||
|
|
||||||
if search_actions.execute_search {
|
if search_actions.execute_search || ctrl_enter_pressed {
|
||||||
add_to_history(
|
add_to_history(
|
||||||
&mut self.search_panel_state.history,
|
&mut self.search_panel_state.history,
|
||||||
&self.search_panel_state.query,
|
&self.search_panel_state.query,
|
||||||
@@ -219,18 +345,53 @@ impl eframe::App for LogViewerApp {
|
|||||||
self.save_config();
|
self.save_config();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render filtered view
|
// Render filtered view with improved styling
|
||||||
let show_filtered = !self.search_panel_state.query.is_empty();
|
// Show filtered view if there's a query OR if date range is enabled
|
||||||
|
let show_filtered = !self.search_panel_state.query.is_empty() || self.search_panel_state.date_range_enabled;
|
||||||
let highlight_rules = self.highlight_manager.rules.clone();
|
let highlight_rules = self.highlight_manager.rules.clone();
|
||||||
|
|
||||||
|
let mut export_clicked = false;
|
||||||
if show_filtered {
|
if show_filtered {
|
||||||
if let Some(tab) = self.active_tab_mut() {
|
if let Some(tab) = self.active_tab_mut() {
|
||||||
if !tab.filtered_lines.is_empty() {
|
if !tab.filtered_lines.is_empty() {
|
||||||
|
let palette = theme::get_palette();
|
||||||
|
|
||||||
egui::TopBottomPanel::bottom("filtered_view")
|
egui::TopBottomPanel::bottom("filtered_view")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_height(200.0)
|
.default_height(250.0)
|
||||||
|
.min_height(150.0)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.heading("Filtered View");
|
// Styled header
|
||||||
|
let header_frame = egui::Frame::none()
|
||||||
|
.fill(palette.bg_secondary)
|
||||||
|
.inner_margin(egui::Margin::symmetric(12.0, 8.0));
|
||||||
|
|
||||||
|
header_frame.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let icon = egui::RichText::new("🔍")
|
||||||
|
.size(16.0);
|
||||||
|
ui.label(icon);
|
||||||
|
|
||||||
|
let title = egui::RichText::new("Search Results")
|
||||||
|
.size(16.0)
|
||||||
|
.color(palette.text_primary)
|
||||||
|
.strong();
|
||||||
|
ui.label(title);
|
||||||
|
|
||||||
|
let count = egui::RichText::new(format!("({} matches)", tab.filtered_lines.len()))
|
||||||
|
.size(14.0)
|
||||||
|
.color(palette.accent_bright);
|
||||||
|
ui.label(count);
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
// Export button
|
||||||
|
if ui.button("📤 Export").clicked() {
|
||||||
|
export_clicked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
render_log_view(
|
render_log_view(
|
||||||
@@ -246,15 +407,57 @@ impl eframe::App for LogViewerApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle export after rendering the filtered view
|
||||||
|
if export_clicked {
|
||||||
|
self.handle_export_filtered();
|
||||||
|
}
|
||||||
|
|
||||||
// Handle keyboard input
|
// Handle keyboard input
|
||||||
self.handle_keyboard_input(ctx);
|
self.handle_keyboard_input(ctx);
|
||||||
|
|
||||||
// Render main view
|
// Render main view with improved styling
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
ui.heading("Main Log View");
|
let palette = theme::get_palette();
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
if let Some(tab) = self.active_tab_mut() {
|
if let Some(tab) = self.active_tab_mut() {
|
||||||
|
// Styled header
|
||||||
|
let header_frame = egui::Frame::none()
|
||||||
|
.fill(palette.bg_secondary)
|
||||||
|
.inner_margin(egui::Margin::symmetric(12.0, 8.0));
|
||||||
|
|
||||||
|
header_frame.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let icon = egui::RichText::new("📄")
|
||||||
|
.size(16.0);
|
||||||
|
ui.label(icon);
|
||||||
|
|
||||||
|
let title = egui::RichText::new("Log View")
|
||||||
|
.size(16.0)
|
||||||
|
.color(palette.text_primary)
|
||||||
|
.strong();
|
||||||
|
ui.label(title);
|
||||||
|
|
||||||
|
// Show filename
|
||||||
|
if let Some(filename) = tab.file_path.file_name() {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let filename_text = egui::RichText::new(format!("• {}", filename.to_string_lossy()))
|
||||||
|
.size(14.0)
|
||||||
|
.color(palette.text_secondary);
|
||||||
|
ui.label(filename_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show line count
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
let line_count = egui::RichText::new(format!("{} lines", tab.line_index.total_lines))
|
||||||
|
.size(13.0)
|
||||||
|
.color(palette.text_muted);
|
||||||
|
ui.label(line_count);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
render_log_view(
|
render_log_view(
|
||||||
ui,
|
ui,
|
||||||
LogViewContext {
|
LogViewContext {
|
||||||
@@ -265,7 +468,27 @@ impl eframe::App for LogViewerApp {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ui.centered_and_justified(|ui| {
|
ui.centered_and_justified(|ui| {
|
||||||
ui.label("Click 'Open File' to load a log file");
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(40.0);
|
||||||
|
|
||||||
|
let icon = egui::RichText::new("📂")
|
||||||
|
.size(48.0);
|
||||||
|
ui.label(icon);
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
let text = egui::RichText::new("No file loaded")
|
||||||
|
.size(18.0)
|
||||||
|
.color(palette.text_secondary);
|
||||||
|
ui.label(text);
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
let hint = egui::RichText::new("Click 'Open File' in the menu to get started")
|
||||||
|
.size(14.0)
|
||||||
|
.color(palette.text_muted);
|
||||||
|
ui.label(hint);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
178
src/search.rs
178
src/search.rs
@@ -5,6 +5,7 @@ use std::io::BufReader;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
use crate::line_index::LineIndex;
|
use crate::line_index::LineIndex;
|
||||||
use crate::types::FilteredLine;
|
use crate::types::FilteredLine;
|
||||||
@@ -43,6 +44,10 @@ pub struct SearchParams {
|
|||||||
pub query: String,
|
pub query: String,
|
||||||
pub case_sensitive: bool,
|
pub case_sensitive: bool,
|
||||||
pub use_regex: bool,
|
pub use_regex: bool,
|
||||||
|
pub date_range_enabled: bool,
|
||||||
|
pub date_format: String,
|
||||||
|
pub date_from: String,
|
||||||
|
pub date_to: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SearchParams {
|
impl SearchParams {
|
||||||
@@ -99,6 +104,136 @@ pub fn start_search(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_to_regex(format: &str) -> Option<String> {
|
||||||
|
// Convert chrono format to regex pattern
|
||||||
|
let mut regex = format.to_string();
|
||||||
|
regex = regex.replace("%Y", r"\d{4}"); // 4-digit year
|
||||||
|
regex = regex.replace("%m", r"\d{2}"); // 2-digit month
|
||||||
|
regex = regex.replace("%d", r"\d{2}"); // 2-digit day
|
||||||
|
regex = regex.replace("%H", r"\d{2}"); // 2-digit hour
|
||||||
|
regex = regex.replace("%M", r"\d{2}"); // 2-digit minute
|
||||||
|
regex = regex.replace("%S", r"\d{2}"); // 2-digit second
|
||||||
|
regex = regex.replace("%I", r"\d{2}"); // 2-digit hour (12h)
|
||||||
|
regex = regex.replace("%p", r"(AM|PM)"); // AM/PM
|
||||||
|
Some(regex)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_and_parse_date(content: &str, format: &str) -> Option<NaiveDateTime> {
|
||||||
|
// Convert format to regex pattern
|
||||||
|
let pattern = format_to_regex(format)?;
|
||||||
|
let regex = Regex::new(&pattern).ok()?;
|
||||||
|
let date_str = regex.captures(content)?.get(0)?.as_str();
|
||||||
|
|
||||||
|
// Parse using the provided format
|
||||||
|
NaiveDateTime::parse_from_str(date_str, format).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_line_with_date(
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_handle: &mut BufReader<File>,
|
||||||
|
start_line: usize,
|
||||||
|
format: &str,
|
||||||
|
move_down: bool,
|
||||||
|
) -> Option<(usize, NaiveDateTime)> {
|
||||||
|
let max_search = 1000; // Search up to 10 lines
|
||||||
|
let range: Box<dyn Iterator<Item = usize>> = if move_down {
|
||||||
|
Box::new(start_line..std::cmp::min(start_line + max_search, line_index.total_lines))
|
||||||
|
} else {
|
||||||
|
Box::new((start_line.saturating_sub(max_search)..=start_line).rev())
|
||||||
|
};
|
||||||
|
|
||||||
|
for line_num in range {
|
||||||
|
if let Some(content) = line_index.read_line(file_handle, line_num) {
|
||||||
|
if let Some(date) = extract_and_parse_date(&content, format) {
|
||||||
|
return Some((line_num, date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_search_date(
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_handle: &mut BufReader<File>,
|
||||||
|
target_date: &NaiveDateTime,
|
||||||
|
format: &str,
|
||||||
|
find_first: bool, // true = find first occurrence, false = find last
|
||||||
|
) -> Option<usize> {
|
||||||
|
let mut left = 0;
|
||||||
|
let mut right = line_index.total_lines;
|
||||||
|
let mut result = None;
|
||||||
|
|
||||||
|
while left < right {
|
||||||
|
let mid = (left + right) / 2;
|
||||||
|
|
||||||
|
// Find a line with a date starting from mid, moving down
|
||||||
|
match find_line_with_date(line_index, file_handle, mid, format, true) {
|
||||||
|
Some((line_with_date, date)) => {
|
||||||
|
if find_first {
|
||||||
|
// Finding first line >= target_date
|
||||||
|
if date >= *target_date {
|
||||||
|
result = Some(line_with_date);
|
||||||
|
right = mid;
|
||||||
|
} else {
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Finding last line <= target_date
|
||||||
|
if date <= *target_date {
|
||||||
|
result = Some(line_with_date);
|
||||||
|
// Ensure we make progress in the binary search
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Can't find a date near mid, try searching in a larger range
|
||||||
|
// or skip this section
|
||||||
|
if find_first {
|
||||||
|
// When finding first, if we can't find a date, try the right half
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
// When finding last, if we can't find a date, try the left half
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_date_range(
|
||||||
|
params: &SearchParams,
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_path: &Path,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
if !params.date_range_enabled || params.date_from.is_empty() || params.date_to.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the target dates using the provided format
|
||||||
|
let date_from = NaiveDateTime::parse_from_str(¶ms.date_from, ¶ms.date_format).ok()?;
|
||||||
|
let date_to = NaiveDateTime::parse_from_str(¶ms.date_to, ¶ms.date_format).ok()?;
|
||||||
|
|
||||||
|
let file = File::open(file_path).ok()?;
|
||||||
|
let mut file_handle = BufReader::new(file);
|
||||||
|
|
||||||
|
// Binary search for first line >= date_from
|
||||||
|
let start_line = binary_search_date(line_index, &mut file_handle, &date_from, ¶ms.date_format, true)?;
|
||||||
|
|
||||||
|
// Binary search for last line <= date_to
|
||||||
|
let end_line = binary_search_date(line_index, &mut file_handle, &date_to, ¶ms.date_format, false)?;
|
||||||
|
|
||||||
|
if start_line < end_line {
|
||||||
|
Some((start_line, end_line + 1)) // +1 to include the end line
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn search_lines(
|
fn search_lines(
|
||||||
params: &SearchParams,
|
params: &SearchParams,
|
||||||
line_index: &LineIndex,
|
line_index: &LineIndex,
|
||||||
@@ -110,17 +245,28 @@ fn search_lines(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine optimal chunk size based on total lines
|
// Determine the line range to search (all lines or date range)
|
||||||
// Aim for enough chunks to utilize all cores, but not too many to avoid overhead
|
let (search_start, search_end) = if let Some((start, end)) = find_date_range(params, line_index, file_path) {
|
||||||
let num_threads = rayon::current_num_threads();
|
(start, end)
|
||||||
let min_chunk_size = 1000; // Process at least 1000 lines per chunk
|
} else {
|
||||||
let chunk_size = (total_lines / (num_threads * 4)).max(min_chunk_size);
|
(0, total_lines)
|
||||||
|
};
|
||||||
|
|
||||||
// Split line numbers into chunks
|
let lines_to_search = search_end - search_start;
|
||||||
let chunks: Vec<(usize, usize)> = (0..total_lines)
|
if lines_to_search == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine optimal chunk size based on lines to search
|
||||||
|
let num_threads = rayon::current_num_threads();
|
||||||
|
let min_chunk_size = 1000;
|
||||||
|
let chunk_size = (lines_to_search / (num_threads * 4)).max(min_chunk_size);
|
||||||
|
|
||||||
|
// Split line numbers into chunks within the search range
|
||||||
|
let chunks: Vec<(usize, usize)> = (search_start..search_end)
|
||||||
.step_by(chunk_size)
|
.step_by(chunk_size)
|
||||||
.map(|start| {
|
.map(|start| {
|
||||||
let end = (start + chunk_size).min(total_lines);
|
let end = (start + chunk_size).min(search_end);
|
||||||
(start, end)
|
(start, end)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -141,13 +287,17 @@ fn search_lines(
|
|||||||
// Read lines in this chunk efficiently (one seek, sequential reads)
|
// Read lines in this chunk efficiently (one seek, sequential reads)
|
||||||
let lines = line_index.read_line_range(&mut file_handle, *start, *end);
|
let lines = line_index.read_line_range(&mut file_handle, *start, *end);
|
||||||
|
|
||||||
// Process each line
|
// Process each line - only store line numbers, not content
|
||||||
for (line_number, content) in lines {
|
for (line_number, content) in lines {
|
||||||
if params.matches_line(&content, ®ex_matcher) {
|
// If date range is enabled and query is empty, include all lines in range
|
||||||
chunk_results.push(FilteredLine {
|
let should_include = if params.date_range_enabled && params.query.is_empty() {
|
||||||
line_number,
|
true
|
||||||
content,
|
} else {
|
||||||
});
|
params.matches_line(&content, ®ex_matcher)
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_include {
|
||||||
|
chunk_results.push(FilteredLine { line_number });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
178
src/theme.rs
Normal file
178
src/theme.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
/// Modern dark purple color palette
|
||||||
|
pub struct ColorPalette {
|
||||||
|
// Background colors
|
||||||
|
pub bg_primary: egui::Color32, // Main background
|
||||||
|
pub bg_secondary: egui::Color32, // Secondary panels
|
||||||
|
pub bg_tertiary: egui::Color32, // Elevated elements
|
||||||
|
|
||||||
|
// Accent colors
|
||||||
|
pub accent_primary: egui::Color32, // Primary purple accent
|
||||||
|
pub accent_secondary: egui::Color32, // Secondary accent
|
||||||
|
pub accent_bright: egui::Color32, // Bright highlights
|
||||||
|
|
||||||
|
// Text colors
|
||||||
|
pub text_primary: egui::Color32,
|
||||||
|
pub text_secondary: egui::Color32,
|
||||||
|
pub text_muted: egui::Color32,
|
||||||
|
|
||||||
|
// UI element colors
|
||||||
|
pub selection: egui::Color32,
|
||||||
|
pub line_number: egui::Color32,
|
||||||
|
pub border: egui::Color32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorPalette {
|
||||||
|
pub fn dark_purple() -> Self {
|
||||||
|
Self {
|
||||||
|
// Deep purple-gray backgrounds
|
||||||
|
bg_primary: egui::Color32::from_rgb(24, 20, 32), // #18141F
|
||||||
|
bg_secondary: egui::Color32::from_rgb(32, 26, 42), // #201A2A
|
||||||
|
bg_tertiary: egui::Color32::from_rgb(42, 35, 54), // #2A2336
|
||||||
|
|
||||||
|
// Purple accents - modern and vibrant
|
||||||
|
accent_primary: egui::Color32::from_rgb(138, 98, 208), // #8A62D0
|
||||||
|
accent_secondary: egui::Color32::from_rgb(108, 68, 178), // #6C44B2
|
||||||
|
accent_bright: egui::Color32::from_rgb(168, 128, 238), // #A880EE
|
||||||
|
|
||||||
|
// Text colors with good contrast
|
||||||
|
text_primary: egui::Color32::from_rgb(230, 230, 240), // #E6E6F0
|
||||||
|
text_secondary: egui::Color32::from_rgb(190, 190, 210), // #BEBED2
|
||||||
|
text_muted: egui::Color32::from_rgb(140, 140, 165), // #8C8CA5
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
selection: egui::Color32::from_rgb(108, 68, 178), // #6C44B2
|
||||||
|
line_number: egui::Color32::from_rgb(110, 100, 130), // #6E6482
|
||||||
|
border: egui::Color32::from_rgb(60, 50, 75), // #3C324B
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the dark purple theme to the egui context
|
||||||
|
pub fn apply_theme(ctx: &egui::Context) {
|
||||||
|
let palette = ColorPalette::dark_purple();
|
||||||
|
|
||||||
|
let mut style = (*ctx.style()).clone();
|
||||||
|
|
||||||
|
// === Spacing and sizing for better UX ===
|
||||||
|
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||||
|
style.spacing.button_padding = egui::vec2(12.0, 6.0);
|
||||||
|
style.spacing.menu_margin = egui::Margin::same(8.0);
|
||||||
|
style.spacing.indent = 20.0;
|
||||||
|
style.spacing.scroll = egui::style::ScrollStyle {
|
||||||
|
bar_width: 10.0,
|
||||||
|
handle_min_length: 20.0,
|
||||||
|
bar_inner_margin: 2.0,
|
||||||
|
bar_outer_margin: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Color scheme ===
|
||||||
|
let visuals = &mut style.visuals;
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
visuals.dark_mode = true;
|
||||||
|
|
||||||
|
// Window and panel backgrounds
|
||||||
|
visuals.window_fill = palette.bg_primary;
|
||||||
|
visuals.panel_fill = palette.bg_primary;
|
||||||
|
visuals.faint_bg_color = palette.bg_secondary;
|
||||||
|
visuals.extreme_bg_color = palette.bg_tertiary;
|
||||||
|
|
||||||
|
// Text colors (using override_text_color to set custom text color)
|
||||||
|
visuals.override_text_color = Some(palette.text_primary);
|
||||||
|
|
||||||
|
// Widget colors
|
||||||
|
visuals.widgets.noninteractive.bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.noninteractive.weak_bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, palette.text_secondary);
|
||||||
|
|
||||||
|
// Inactive/hovered widgets
|
||||||
|
visuals.widgets.inactive.bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.inactive.weak_bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, palette.text_primary);
|
||||||
|
|
||||||
|
visuals.widgets.hovered.bg_fill = palette.bg_tertiary;
|
||||||
|
visuals.widgets.hovered.weak_bg_fill = palette.bg_tertiary;
|
||||||
|
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5, palette.accent_primary);
|
||||||
|
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.5, palette.text_primary);
|
||||||
|
|
||||||
|
// Active/clicked widgets
|
||||||
|
visuals.widgets.active.bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.active.weak_bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.active.bg_stroke = egui::Stroke::new(2.0, palette.accent_bright);
|
||||||
|
visuals.widgets.active.fg_stroke = egui::Stroke::new(2.0, palette.text_primary);
|
||||||
|
|
||||||
|
visuals.widgets.open.bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.open.weak_bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.5, palette.accent_primary);
|
||||||
|
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.5, palette.text_primary);
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
visuals.selection.bg_fill = palette.selection;
|
||||||
|
visuals.selection.stroke = egui::Stroke::new(1.0, palette.accent_bright);
|
||||||
|
|
||||||
|
// Hyperlinks
|
||||||
|
visuals.hyperlink_color = palette.accent_bright;
|
||||||
|
|
||||||
|
// Window styling
|
||||||
|
visuals.window_rounding = egui::Rounding::same(8.0);
|
||||||
|
visuals.window_shadow = egui::epaint::Shadow {
|
||||||
|
offset: egui::vec2(0.0, 8.0),
|
||||||
|
blur: 20.0,
|
||||||
|
spread: 0.0,
|
||||||
|
color: egui::Color32::from_black_alpha(80),
|
||||||
|
};
|
||||||
|
visuals.window_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
|
||||||
|
// Popup styling
|
||||||
|
visuals.popup_shadow = egui::epaint::Shadow {
|
||||||
|
offset: egui::vec2(0.0, 4.0),
|
||||||
|
blur: 16.0,
|
||||||
|
spread: 0.0,
|
||||||
|
color: egui::Color32::from_black_alpha(100),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resize handle
|
||||||
|
visuals.resize_corner_size = 12.0;
|
||||||
|
|
||||||
|
// Menu rounding
|
||||||
|
visuals.menu_rounding = egui::Rounding::same(6.0);
|
||||||
|
|
||||||
|
// Indent guide
|
||||||
|
visuals.indent_has_left_vline = true;
|
||||||
|
visuals.striped = true;
|
||||||
|
|
||||||
|
// Borders and separators
|
||||||
|
visuals.window_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
|
||||||
|
// === Text styles with better readability ===
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Body,
|
||||||
|
egui::FontId::proportional(14.0),
|
||||||
|
);
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Button,
|
||||||
|
egui::FontId::proportional(14.0),
|
||||||
|
);
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Heading,
|
||||||
|
egui::FontId::proportional(18.0),
|
||||||
|
);
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Monospace,
|
||||||
|
egui::FontId::monospace(13.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply the style
|
||||||
|
ctx.set_style(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the color palette for use in custom rendering
|
||||||
|
pub fn get_palette() -> ColorPalette {
|
||||||
|
ColorPalette::dark_purple()
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ pub struct HighlightRule {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtered lines now only store line numbers to save memory
|
||||||
|
// Content is loaded on-demand when rendering
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FilteredLine {
|
pub struct FilteredLine {
|
||||||
pub line_number: usize,
|
pub line_number: usize,
|
||||||
pub content: String,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use crate::file_tab::FileTab;
|
use crate::file_tab::FileTab;
|
||||||
|
use crate::theme;
|
||||||
use crate::types::HighlightRule;
|
use crate::types::HighlightRule;
|
||||||
|
|
||||||
pub struct LogViewContext<'a> {
|
pub struct LogViewContext<'a> {
|
||||||
@@ -86,7 +87,7 @@ fn handle_scroll_to_line(
|
|||||||
) {
|
) {
|
||||||
if tab.scroll_to_main {
|
if tab.scroll_to_main {
|
||||||
let target_row = tab.main_scroll_offset;
|
let target_row = tab.main_scroll_offset;
|
||||||
let adgusted_row_height = row_height + 3f32;
|
let adgusted_row_height = row_height + 6f32;
|
||||||
let scroll_offset = (target_row as f32) * adgusted_row_height;
|
let scroll_offset = (target_row as f32) * adgusted_row_height;
|
||||||
|
|
||||||
eprintln!("=== SCROLL TO LINE ===");
|
eprintln!("=== SCROLL TO LINE ===");
|
||||||
@@ -110,7 +111,7 @@ fn handle_page_scroll(
|
|||||||
total_lines: usize,
|
total_lines: usize,
|
||||||
) {
|
) {
|
||||||
if let Some(direction) = tab.page_scroll_direction.take() {
|
if let Some(direction) = tab.page_scroll_direction.take() {
|
||||||
let row_height_offset = row_height + 3f32;
|
let row_height_offset = row_height + 6f32;
|
||||||
let viewport_height = ui.available_height();
|
let viewport_height = ui.available_height();
|
||||||
let rows_per_page = (viewport_height / row_height_offset).floor().max(1.0);
|
let rows_per_page = (viewport_height / row_height_offset).floor().max(1.0);
|
||||||
let scroll_delta = direction * rows_per_page * row_height_offset;
|
let scroll_delta = direction * rows_per_page * row_height_offset;
|
||||||
@@ -161,15 +162,21 @@ fn render_visible_lines(
|
|||||||
|
|
||||||
fn get_line_content(tab: &mut FileTab, show_all: bool, display_idx: usize) -> (usize, String) {
|
fn get_line_content(tab: &mut FileTab, show_all: bool, display_idx: usize) -> (usize, String) {
|
||||||
if show_all {
|
if show_all {
|
||||||
|
// Main view: read line by display index
|
||||||
let content = tab
|
let content = tab
|
||||||
.line_index
|
.line_index
|
||||||
.read_line(&mut tab.file_handle, display_idx)
|
.read_line(&mut tab.file_handle, display_idx)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(display_idx, content)
|
(display_idx, content)
|
||||||
} else {
|
} else {
|
||||||
|
// Filtered view: get line number from filtered list, then read content on-demand
|
||||||
if display_idx < tab.filtered_lines.len() {
|
if display_idx < tab.filtered_lines.len() {
|
||||||
let filtered = &tab.filtered_lines[display_idx];
|
let line_number = tab.filtered_lines[display_idx].line_number;
|
||||||
(filtered.line_number, filtered.content.clone())
|
let content = tab
|
||||||
|
.line_index
|
||||||
|
.read_line(&mut tab.file_handle, line_number)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(line_number, content)
|
||||||
} else {
|
} else {
|
||||||
(0, String::new())
|
(0, String::new())
|
||||||
}
|
}
|
||||||
@@ -186,49 +193,59 @@ fn render_line<F>(
|
|||||||
) where
|
) where
|
||||||
F: FnOnce(bool),
|
F: FnOnce(bool),
|
||||||
{
|
{
|
||||||
|
let palette = theme::get_palette();
|
||||||
|
|
||||||
let highlight_color = highlight_rules
|
let highlight_color = highlight_rules
|
||||||
.iter()
|
.iter()
|
||||||
.find(|rule| rule.enabled && content.contains(&rule.pattern))
|
.find(|rule| rule.enabled && content.contains(&rule.pattern))
|
||||||
.map(|rule| egui::Color32::from_rgb(rule.color[0], rule.color[1], rule.color[2]));
|
.map(|rule| egui::Color32::from_rgb(rule.color[0], rule.color[1], rule.color[2]));
|
||||||
|
|
||||||
|
// Improved color scheme with better visual hierarchy
|
||||||
let bg_color = if is_selected {
|
let bg_color = if is_selected {
|
||||||
egui::Color32::from_rgb(70, 130, 180)
|
palette.selection
|
||||||
} else if let Some(color) = highlight_color {
|
} else if let Some(color) = highlight_color {
|
||||||
color
|
color
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::TRANSPARENT
|
egui::Color32::TRANSPARENT
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Better padding and margins for improved readability
|
||||||
let frame = egui::Frame::none()
|
let frame = egui::Frame::none()
|
||||||
.fill(bg_color)
|
.fill(bg_color)
|
||||||
.inner_margin(egui::Margin::symmetric(2.0, 1.0));
|
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||||
|
|
||||||
frame.show(ui, |ui| {
|
frame.show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let line_num_text = egui::RichText::new(format!("{:6} ", line_num + 1))
|
// Line numbers with better styling
|
||||||
|
let line_num_text = egui::RichText::new(format!("{:6}", line_num + 1))
|
||||||
.monospace()
|
.monospace()
|
||||||
.color(if is_selected {
|
.color(if is_selected {
|
||||||
egui::Color32::WHITE
|
palette.text_primary
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::DARK_GRAY
|
palette.line_number
|
||||||
});
|
});
|
||||||
|
|
||||||
let line_num_response = ui.label(line_num_text);
|
let line_num_response = ui.label(line_num_text);
|
||||||
|
|
||||||
|
// Separator between line number and content
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
// Content text with improved styling
|
||||||
let text = egui::RichText::new(content).monospace().color(
|
let text = egui::RichText::new(content).monospace().color(
|
||||||
if is_selected {
|
if is_selected {
|
||||||
egui::Color32::WHITE
|
palette.text_primary
|
||||||
} else {
|
} else {
|
||||||
ui.style().visuals.text_color()
|
palette.text_primary
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let text_response = ui
|
let text_response = ui
|
||||||
.scope(|ui| {
|
.scope(|ui| {
|
||||||
ui.style_mut().visuals.selection.bg_fill =
|
// Better text selection colors
|
||||||
egui::Color32::from_rgb(255, 180, 50);
|
ui.style_mut().visuals.selection.bg_fill = palette.accent_bright;
|
||||||
ui.style_mut().visuals.selection.stroke.color =
|
ui.style_mut().visuals.selection.stroke.color = palette.accent_primary;
|
||||||
egui::Color32::from_rgb(200, 140, 30);
|
|
||||||
ui.add(egui::Label::new(text).selectable(true))
|
ui.add(egui::Label::new(text).selectable(true))
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use crate::search::SearchState;
|
use crate::search::SearchState;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
pub struct SearchPanelState {
|
pub struct SearchPanelState {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
pub case_sensitive: bool,
|
pub case_sensitive: bool,
|
||||||
pub use_regex: bool,
|
pub use_regex: bool,
|
||||||
pub history: Vec<String>,
|
pub history: Vec<String>,
|
||||||
|
pub date_range_enabled: bool,
|
||||||
|
pub date_format: String,
|
||||||
|
pub date_from: String,
|
||||||
|
pub date_to: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SearchPanelActions {
|
pub struct SearchPanelActions {
|
||||||
@@ -20,6 +25,7 @@ pub fn render_search_panel(
|
|||||||
state: &mut SearchPanelState,
|
state: &mut SearchPanelState,
|
||||||
search_state: &SearchState,
|
search_state: &SearchState,
|
||||||
match_count: usize,
|
match_count: usize,
|
||||||
|
request_focus: bool,
|
||||||
) -> SearchPanelActions {
|
) -> SearchPanelActions {
|
||||||
let mut actions = SearchPanelActions {
|
let mut actions = SearchPanelActions {
|
||||||
execute_search: false,
|
execute_search: false,
|
||||||
@@ -27,59 +33,127 @@ pub fn render_search_panel(
|
|||||||
config_changed: false,
|
config_changed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
egui::TopBottomPanel::bottom("search_panel").show(ctx, |ui| {
|
let palette = theme::get_palette();
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("🔍 Filter:");
|
|
||||||
|
|
||||||
let text_edit_width = 200.0;
|
egui::TopBottomPanel::bottom("search_panel")
|
||||||
let text_response = ui.add_sized(
|
.frame(egui::Frame::none()
|
||||||
[text_edit_width, 20.0],
|
.fill(palette.bg_secondary)
|
||||||
egui::TextEdit::singleline(&mut state.query),
|
.inner_margin(egui::Margin::symmetric(12.0, 10.0)))
|
||||||
);
|
.show(ctx, |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Filter label with icon
|
||||||
|
let label = egui::RichText::new("🔍 Filter:")
|
||||||
|
.size(14.0)
|
||||||
|
.color(palette.text_primary);
|
||||||
|
ui.label(label);
|
||||||
|
|
||||||
let enter_pressed =
|
ui.add_space(4.0);
|
||||||
text_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
|
||||||
|
|
||||||
if !state.history.is_empty() {
|
// Text input with proper height matching buttons and stable ID
|
||||||
render_history_dropdown(ui, state);
|
let text_edit_width = 300.0;
|
||||||
}
|
let search_input_id = egui::Id::new("search_input_field");
|
||||||
|
let text_response = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.query)
|
||||||
|
.id(search_input_id)
|
||||||
|
.desired_width(text_edit_width)
|
||||||
|
.hint_text("Enter search query...")
|
||||||
|
);
|
||||||
|
|
||||||
let case_changed = ui
|
// Request focus if Ctrl+F was pressed
|
||||||
.checkbox(&mut state.case_sensitive, "Case sensitive")
|
if request_focus {
|
||||||
.changed();
|
text_response.request_focus();
|
||||||
let regex_changed = ui.checkbox(&mut state.use_regex, "Regex").changed();
|
|
||||||
|
|
||||||
if case_changed || regex_changed {
|
|
||||||
actions.config_changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("Search").clicked() || enter_pressed {
|
|
||||||
actions.execute_search = true;
|
|
||||||
actions.config_changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !state.query.is_empty() {
|
|
||||||
if ui.button("✖ Clear").clicked() {
|
|
||||||
actions.clear_search = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.label(format!("({} matches)", match_count));
|
let enter_pressed =
|
||||||
|
text_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||||
|
|
||||||
|
if !state.history.is_empty() {
|
||||||
|
render_history_dropdown(ui, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Checkboxes
|
||||||
|
let case_changed = ui
|
||||||
|
.checkbox(&mut state.case_sensitive, "Case sensitive")
|
||||||
|
.changed();
|
||||||
|
let regex_changed = ui.checkbox(&mut state.use_regex, "Regex").changed();
|
||||||
|
let date_range_changed = ui.checkbox(&mut state.date_range_enabled, "Date range").changed();
|
||||||
|
|
||||||
|
if case_changed || regex_changed || date_range_changed {
|
||||||
|
actions.config_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Search button
|
||||||
|
if ui.button("Search").clicked() || enter_pressed {
|
||||||
|
actions.execute_search = true;
|
||||||
|
actions.config_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear button and match count
|
||||||
|
if !state.query.is_empty() {
|
||||||
|
if ui.button("✖ Clear").clicked() {
|
||||||
|
actions.clear_search = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
let count_text = egui::RichText::new(format!("{} matches", match_count))
|
||||||
|
.color(palette.accent_bright)
|
||||||
|
.size(13.0);
|
||||||
|
ui.label(count_text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date range fields (show when date_range_enabled is true)
|
||||||
|
if state.date_range_enabled {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Format:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.date_format)
|
||||||
|
.desired_width(200.0)
|
||||||
|
.hint_text("%Y-%m-%d %H:%M:%S")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("From:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.date_from)
|
||||||
|
.desired_width(180.0)
|
||||||
|
.hint_text("2025-01-01 00:00:00")
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.label("To:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.date_to)
|
||||||
|
.desired_width(180.0)
|
||||||
|
.hint_text("2025-01-01 01:00:00")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
if search_state.is_searching() {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
let progress = search_state.get_progress();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::ProgressBar::new(progress)
|
||||||
|
.text(format!("Searching... {:.0}%", progress * 100.0))
|
||||||
|
.animate(true),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if search_state.is_searching() {
|
|
||||||
let progress = search_state.get_progress();
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.add(
|
|
||||||
egui::ProgressBar::new(progress)
|
|
||||||
.text(format!("Searching... {:.0}%", progress * 100.0))
|
|
||||||
.animate(true),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,29 +13,95 @@ pub fn render_tabs_panel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
egui::TopBottomPanel::top("tabs_panel").show(ctx, |ui| {
|
egui::TopBottomPanel::top("tabs_panel").show(ctx, |ui| {
|
||||||
ui.horizontal(|ui| {
|
// Get or initialize scroll offset from persistent storage
|
||||||
for (idx, tab) in tabs.iter().enumerate() {
|
let scroll_id = egui::Id::new("tabs_scroll_offset");
|
||||||
let is_active = idx == *active_tab_index;
|
let mut scroll_offset: f32 = ui.ctx().data_mut(|d| d.get_persisted(scroll_id).unwrap_or(0.0));
|
||||||
let button_text = if is_active {
|
|
||||||
egui::RichText::new(format!("📄 {}", tab.filename())).strong()
|
|
||||||
} else {
|
|
||||||
egui::RichText::new(format!("📄 {}", tab.filename()))
|
|
||||||
};
|
|
||||||
|
|
||||||
if ui.selectable_label(is_active, button_text).clicked() {
|
// Use horizontal ScrollArea for tabs with hidden scrollbar
|
||||||
*active_tab_index = idx;
|
let mut scroll_area = egui::ScrollArea::horizontal()
|
||||||
|
.auto_shrink([false; 2])
|
||||||
|
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
|
||||||
|
|
||||||
|
// Apply the stored scroll offset
|
||||||
|
scroll_area = scroll_area.horizontal_scroll_offset(scroll_offset);
|
||||||
|
|
||||||
|
let scroll_output = scroll_area.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (idx, tab) in tabs.iter().enumerate() {
|
||||||
|
let is_active = idx == *active_tab_index;
|
||||||
|
let filename = tab.filename();
|
||||||
|
|
||||||
|
// Truncate filename if too long (max 20 characters)
|
||||||
|
let display_name = if filename.len() > 20 {
|
||||||
|
format!("{}...", &filename[..17])
|
||||||
|
} else {
|
||||||
|
filename.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let button_text = if is_active {
|
||||||
|
egui::RichText::new(format!("📄 {}", display_name)).strong()
|
||||||
|
} else {
|
||||||
|
egui::RichText::new(format!("📄 {}", display_name))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fixed width for tab label (150 pixels)
|
||||||
|
let tab_response = ui.add_sized(
|
||||||
|
[150.0, ui.available_height()],
|
||||||
|
egui::SelectableLabel::new(is_active, button_text)
|
||||||
|
);
|
||||||
|
|
||||||
|
if tab_response.clicked() {
|
||||||
|
*active_tab_index = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show full filename on hover
|
||||||
|
if filename.len() > 20 {
|
||||||
|
tab_response.on_hover_text(&filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.small_button("✖").clicked() {
|
||||||
|
*on_close_tab = Some(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
}
|
}
|
||||||
|
})
|
||||||
if ui.small_button("✖").clicked() {
|
|
||||||
*on_close_tab = Some(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tab) = tabs.get(*active_tab_index) {
|
|
||||||
ui.label(format!("({} lines)", tab.line_index.total_lines));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if content overflows (tabs exceed screen width)
|
||||||
|
let content_width = scroll_output.content_size.x;
|
||||||
|
let viewport_width = scroll_output.inner_rect.width();
|
||||||
|
let content_overflows = content_width > viewport_width;
|
||||||
|
|
||||||
|
// Handle mouse wheel scrolling when hovering over tabs and content overflows
|
||||||
|
let mut should_update_offset = false;
|
||||||
|
if content_overflows && ui.rect_contains_pointer(scroll_output.inner_rect) {
|
||||||
|
// Get raw scroll delta outside of any closures
|
||||||
|
let raw_scroll_y = ui.input(|i| i.raw_scroll_delta.y);
|
||||||
|
|
||||||
|
// Check for raw mouse wheel events
|
||||||
|
if raw_scroll_y != 0.0 {
|
||||||
|
// Use vertical scroll (mouse wheel) for horizontal scrolling
|
||||||
|
let scroll_amount = -raw_scroll_y * 2.0;
|
||||||
|
scroll_offset = (scroll_offset + scroll_amount).max(0.0);
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
let max_offset = (content_width - viewport_width).max(0.0);
|
||||||
|
scroll_offset = scroll_offset.min(max_offset);
|
||||||
|
|
||||||
|
should_update_offset = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update offset from ScrollArea state (in case of other interactions)
|
||||||
|
scroll_offset = scroll_output.state.offset.x;
|
||||||
|
should_update_offset = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the offset outside of any input closures
|
||||||
|
if should_update_offset {
|
||||||
|
ui.ctx().data_mut(|d| {
|
||||||
|
d.insert_persisted(scroll_id, scroll_offset);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user