From 5cdf8a44978cf96af58bf1e59b1aff56da18ac6a Mon Sep 17 00:00:00 2001 From: Ivan Rudoy Date: Sun, 26 Apr 2026 03:02:07 +1000 Subject: [PATCH 1/8] fix(ui): apply zoom to view_window in cursor-tracking mode Pinch and cmd+wheel zoom were silently no-ops while cursor_tracking was on (the default). The cursor-tracking branch in the chart closure forces bounds to a window of view_window_seconds centered on the cursor every frame, overwriting any zoom egui_plot just applied. Translate zoom_delta (and smooth_scroll_delta.y when scroll_to_zoom is enabled) into changes of view_window_seconds itself, so the window shrinks/grows around the cursor as the user expects. --- src/ui/chart.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/ui/chart.rs b/src/ui/chart.rs index e4f2f0a..b300e3b 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -11,6 +11,10 @@ use crate::state::{ MIN_PLOT_HEIGHT, PLOT_RESIZE_HANDLE_HEIGHT, }; +/// Sensitivity multiplier for scroll-to-zoom (higher = faster zoom per scroll tick). +/// Shared between the cursor-tracking pre-show helper and the post-show closure. +const SCROLL_ZOOM_SENSITIVITY: f64 = 0.003; + impl UltraLogApp { /// Render the main chart with cached downsampled data pub fn render_chart(&mut self, ui: &mut egui::Ui) { @@ -27,8 +31,37 @@ impl UltraLogApp { } } + /// Translate pinch / cmd+wheel (and scroll if `scroll_to_zoom` is on) into + /// changes of `view_window_seconds` while in cursor-tracking mode. Without + /// this, the cursor-tracking branch in the plot closure forces bounds to a + /// fixed-width window every frame and any zoom is immediately overridden. + fn apply_zoom_to_view_window_if_tracking(&mut self, ui: &egui::Ui) { + if !self.cursor_tracking { + return; + } + let Some((min_t, max_t)) = self.get_time_range() else { + return; + }; + let zoom_delta = ui.input(|i| i.zoom_delta()) as f64; + let mut new_window = self.view_window_seconds; + if zoom_delta != 1.0 { + new_window /= zoom_delta; + } + if self.scroll_to_zoom { + let scroll_y = ui.input(|i| i.smooth_scroll_delta.y) as f64; + if scroll_y.abs() > 0.1 { + let factor = (1.0 - scroll_y * SCROLL_ZOOM_SENSITIVITY).clamp(0.8, 1.25); + new_window *= factor; + } + } + let max_window = (max_t - min_t).max(0.1); + self.view_window_seconds = new_window.clamp(0.1, max_window); + } + /// Render single-plot mode chart (original implementation) fn render_chart_single_mode(&mut self, ui: &mut egui::Ui) { + self.apply_zoom_to_view_window_if_tracking(ui); + // Get selected channels from active tab let selected_channels = self.get_selected_channels().to_vec(); @@ -129,8 +162,6 @@ impl UltraLogApp { // Fixed Y bounds for normalized data (0-1 with small padding) const Y_MIN: f64 = -0.05; const Y_MAX: f64 = 1.05; - /// Sensitivity multiplier for scroll-to-zoom (higher = faster zoom per scroll tick) - const SCROLL_ZOOM_SENSITIVITY: f64 = 0.003; // Build the plot - X-axis zoom only, Y fixed // When scroll_to_zoom is enabled, disable scroll-to-pan so we handle scroll as zoom @@ -324,6 +355,8 @@ impl UltraLogApp { /// Render stacked plot areas fn render_chart_stacked_mode(&mut self, ui: &mut egui::Ui) { + self.apply_zoom_to_view_window_if_tracking(ui); + let Some(tab_idx) = self.active_tab else { ui.centered_and_justified(|ui| { ui.label( From 660050d76d46d5c81b3f0ce6662685dbb059122a Mon Sep 17 00:00:00 2001 From: Ivan Rudoy Date: Sun, 26 Apr 2026 03:49:04 +1000 Subject: [PATCH 2/8] fix(mlg): correct timestamp unit and viewport-aware chart detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two MLG-related fixes bundled together: 1. Parser: MLG raw u16 timestamps are 10 µs/tick per the EFI Analytics MLG Binary Log Format spec, not 1 ms/tick. The previous formula compounded a 10× error in both the raw remainder and the wrap count for a net 100× over-estimate of wall-clock time (a ~30 min log showed as ~48 hours). Fixed via named constants MLG_TICK_SECONDS = 1e-5 and MLG_WRAP_TICKS = 65536, plus a regression test covering rusefi/speeduino sample logs. 2. UI: chart downsampling now slices raw data to the visible viewport before LTTB so detail scales with zoom. Previously LTTB compressed the full log to MAX_CHART_POINTS regardless of zoom, leaving fine detail invisible. Viewport bounds are remembered per plot area between frames; Y normalization uses the channel's global min/max so heights stay stable across pans. --- src/app.rs | 10 ++ src/parsers/speeduino.rs | 60 +++++++++++- src/ui/chart.rs | 159 ++++++++++++++++++------------- tests/parsers/speeduino_tests.rs | 49 ++++++++++ 4 files changed, 206 insertions(+), 72 deletions(-) diff --git a/src/app.rs b/src/app.rs index a18ed14..3f70c65 100644 --- a/src/app.rs +++ b/src/app.rs @@ -55,6 +55,11 @@ pub struct UltraLogApp { pub(crate) downsample_cache: HashMap>, /// Cache for channel min/max values (avoids O(n) scans) pub(crate) minmax_cache: HashMap, + /// Last X-axis bounds shown by each plot area. Used to slice raw data to + /// the visible viewport before LTTB-downsampling, so chart detail scales + /// with zoom level instead of being fixed at MAX_CHART_POINTS over the + /// full log range. Keyed by plot_area_id (0 in single-plot mode). + pub(crate) chart_last_x_bounds: HashMap, /// Current cursor position in seconds (timeline feature) pub(crate) cursor_time: Option, /// Total time range across all loaded files (min, max) @@ -175,6 +180,7 @@ impl Default for UltraLogApp { loading_state: LoadingState::Idle, downsample_cache: HashMap::new(), minmax_cache: HashMap::new(), + chart_last_x_bounds: HashMap::new(), cursor_time: None, time_range: None, cursor_record: None, @@ -919,6 +925,10 @@ impl UltraLogApp { } self.minmax_cache = new_minmax_cache; + // Reset viewport-bounds memory so the next frame after a file is + // removed picks fresh bounds from whatever data remains. + self.chart_last_x_bounds.clear(); + // Clear computed channels for this file and update indices self.file_computed_channels.remove(&index); let mut new_computed_channels = HashMap::new(); diff --git a/src/parsers/speeduino.rs b/src/parsers/speeduino.rs index 9911fd7..ece77dc 100644 --- a/src/parsers/speeduino.rs +++ b/src/parsers/speeduino.rs @@ -285,11 +285,16 @@ impl Speeduino { let mut times: Vec = Vec::with_capacity(estimated_records); let mut data_records: Vec> = Vec::with_capacity(estimated_records); - // Track timestamp wraparound (u16 wraps at 65535ms = 65.535 seconds) + // Track u16 timestamp wraparound. Per the EFI Analytics MLG Binary + // Log Format spec, each tick is 10 µs, so the u16 wraps every + // 65536 × 10 µs = 0.65536 s of wall-clock time. + const MLG_TICK_SECONDS: f64 = 1e-5; + const MLG_WRAP_TICKS: f64 = 65_536.0; let mut prev_raw_timestamp: u16 = 0; let mut wrap_count: u64 = 0; - // If timestamp drops by more than 30 seconds, it definitely wrapped - // (actual wraparounds show ~58.7s drop when going from ~65s to ~6s) + // A drop > 30000 raw ticks (= 0.3 s) is far above the per-record + // increment at any realistic ECU sample rate (20–1000 Hz), so it + // reliably distinguishes a real u16 wraparound from sample jitter. const WRAP_THRESHOLD: u16 = 30000; while offset + 4 <= data.len() { @@ -316,7 +321,8 @@ impl Speeduino { prev_raw_timestamp = raw_timestamp; // Calculate actual timestamp with wraparound compensation - let timestamp = (raw_timestamp as f64 / 1000.0) + (wrap_count as f64 * 65.536); + let timestamp = + (raw_timestamp as f64 + wrap_count as f64 * MLG_WRAP_TICKS) * MLG_TICK_SECONDS; if block_type == 0 { // Data record - calculate required bytes for all channels @@ -819,4 +825,50 @@ mod tests { eprintln!("Parsed {} channels from rusEFI log", log.channels.len()); eprintln!("Parsed {} data records", log.data.len()); } + + #[test] + fn test_mlg_timestamp_scale() { + // Regression guard for the 10 µs/bit timestamp unit. A previous + // version of the parser treated the u16 tick as milliseconds, which + // multiplied wall-clock time by ~100×. The bounds below are wide + // enough to allow any realistic ECU sample rate (1 ms .. 1 s per + // record) and tight enough to fail loudly on a ×10 or ×100 drift. + for file_path in [ + "exampleLogs/rusefi/rusefilog.mlg", + "exampleLogs/rusefi/Log1.mlg", + "exampleLogs/speeduino/speeduino.mlg", + ] { + let data = match std::fs::read(file_path) { + Ok(d) => d, + Err(_) => { + eprintln!("Skipping {}: file not found", file_path); + continue; + } + }; + + let log = Speeduino::parse_binary(&data) + .unwrap_or_else(|e| panic!("parse {}: {}", file_path, e)); + assert!( + log.times.len() > 1, + "{}: expected multiple records", + file_path + ); + + let total = *log.times.last().unwrap() - log.times[0]; + let avg_dt = total / (log.times.len() - 1) as f64; + assert!( + (1e-3..=1.0).contains(&avg_dt), + "{}: average sample interval {:.6}s outside 1ms..1s — units regression?", + file_path, + avg_dt + ); + eprintln!( + "{}: {} records, total {:.3}s, avg dt {:.4}s", + file_path, + log.times.len(), + total, + avg_dt + ); + } + } } diff --git a/src/ui/chart.rs b/src/ui/chart.rs index b300e3b..d061084 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -7,8 +7,8 @@ use rust_i18n::t; use crate::app::UltraLogApp; use crate::normalize::normalize_channel_name_with_custom; use crate::state::{ - CacheKey, PlotArea, SelectedChannel, CHART_COLORS, COLORBLIND_COLORS, MAX_CHART_POINTS, - MIN_PLOT_HEIGHT, PLOT_RESIZE_HANDLE_HEIGHT, + PlotArea, SelectedChannel, CHART_COLORS, COLORBLIND_COLORS, MAX_CHART_POINTS, MIN_PLOT_HEIGHT, + PLOT_RESIZE_HANDLE_HEIGHT, }; /// Sensitivity multiplier for scroll-to-zoom (higher = faster zoom per scroll tick). @@ -76,32 +76,16 @@ impl UltraLogApp { return; } - // Pre-compute and cache downsampled + normalized data for all selected channels - for selected in &selected_channels { - if selected.file_index >= self.files.len() { - continue; - } - - let cache_key = CacheKey { - file_index: selected.file_index, - channel_index: selected.channel_index, - plot_area_id: 0, // Single-plot mode uses plot_area_id 0 - }; - - if !self.downsample_cache.contains_key(&cache_key) { - let file = &self.files[selected.file_index]; - let times = file.log.get_times_as_f64(); - // Use app method to get channel data (handles both regular and computed channels) - let data = self.get_channel_data(selected.file_index, selected.channel_index); - - if times.len() == data.len() && !times.is_empty() { - let downsampled = Self::downsample_lttb(times, &data, MAX_CHART_POINTS); - // Normalize Y values to 0-1 range so all channels overlay - let normalized = Self::normalize_points(&downsampled); - self.downsample_cache.insert(cache_key, normalized); - } - } - } + // Compute downsampled + normalized data sliced to the current viewport. + // Detail scales with zoom level: a 1% viewport gets MAX_CHART_POINTS + // over that 1%, not over the whole log. + let viewport = self.chart_last_x_bounds.get(&0).copied(); + let chart_points: Vec>> = selected_channels + .iter() + .map(|selected| { + self.compute_viewport_points(selected.file_index, selected.channel_index, viewport) + }) + .collect(); // Pre-compute legend names with current values at cursor position let use_normalization = self.field_normalization; @@ -139,7 +123,7 @@ impl UltraLogApp { .collect(); // Prepare data for the plot closure (can't borrow self mutably inside) - let cache = &self.downsample_cache; + let chart_points = &chart_points; let files = &self.files; // selected_channels already defined at top of function from get_selected_channels() let cursor_time = self.get_cursor_time(); @@ -275,13 +259,7 @@ impl UltraLogApp { continue; } - let cache_key = CacheKey { - file_index: selected.file_index, - channel_index: selected.channel_index, - plot_area_id: 0, // Single-plot mode uses plot_area_id 0 - }; - - if let Some(points) = cache.get(&cache_key) { + if let Some(points) = chart_points.get(i).and_then(|p| p.as_ref()) { let plot_points: PlotPoints = points.iter().copied().collect(); let palette = if color_blind_mode { COLORBLIND_COLORS @@ -314,6 +292,12 @@ impl UltraLogApp { plot_ui.pointer_coordinate() }); + // Remember the X-axis bounds we just rendered so the next frame can + // slice raw data to this viewport before LTTB-downsampling. + let final_bounds = response.transform.bounds(); + self.chart_last_x_bounds + .insert(0, (final_bounds.min()[0], final_bounds.max()[0])); + // Detect user interaction with chart (drag, zoom, scroll) // This marks the chart as "interacted" so we stop using the initial zoomed view if response.response.dragged() @@ -506,30 +490,14 @@ impl UltraLogApp { plot_area_id: usize, height: f32, ) { - // Pre-compute and cache data for these channels - for selected in channels { - if selected.file_index >= self.files.len() { - continue; - } - - let cache_key = CacheKey { - file_index: selected.file_index, - channel_index: selected.channel_index, - plot_area_id, - }; - - if !self.downsample_cache.contains_key(&cache_key) { - let file = &self.files[selected.file_index]; - let times = file.log.get_times_as_f64(); - let data = self.get_channel_data(selected.file_index, selected.channel_index); - - if times.len() == data.len() && !times.is_empty() { - let downsampled = Self::downsample_lttb(times, &data, MAX_CHART_POINTS); - let normalized = Self::normalize_points(&downsampled); - self.downsample_cache.insert(cache_key, normalized); - } - } - } + // Compute viewport-aware downsampled + normalized points for this plot area. + let viewport = self.chart_last_x_bounds.get(&plot_area_id).copied(); + let chart_points: Vec>> = channels + .iter() + .map(|selected| { + self.compute_viewport_points(selected.file_index, selected.channel_index, viewport) + }) + .collect(); // Build legend names with values let use_normalization = self.field_normalization; @@ -567,7 +535,7 @@ impl UltraLogApp { .collect(); // Prepare data for plot - let cache = &self.downsample_cache; + let chart_points = &chart_points; let files = &self.files; let cursor_time = self.get_cursor_time(); let cursor_tracking = self.cursor_tracking; @@ -652,13 +620,7 @@ impl UltraLogApp { continue; } - let cache_key = CacheKey { - file_index: selected.file_index, - channel_index: selected.channel_index, - plot_area_id, - }; - - if let Some(points) = cache.get(&cache_key) { + if let Some(points) = chart_points.get(i).and_then(|p| p.as_ref()) { let plot_points: PlotPoints = points.iter().copied().collect(); let palette = if color_blind_mode { COLORBLIND_COLORS @@ -688,6 +650,12 @@ impl UltraLogApp { plot_ui.pointer_coordinate() }); + // Save the bounds we just rendered so the next frame's downsample + // matches the visible viewport. + let final_bounds = response.transform.bounds(); + self.chart_last_x_bounds + .insert(plot_area_id, (final_bounds.min()[0], final_bounds.max()[0])); + // Detect interaction if response.response.dragged() || response.response.drag_started() @@ -872,6 +840,61 @@ impl UltraLogApp { } } + /// Compute the points to plot for one channel, sliced to the currently + /// visible viewport before LTTB-downsampling. Y is normalized to [0, 1] + /// against the channel's full-range min/max so heights stay stable when + /// the user pans or zooms. `viewport` is the previous frame's X bounds; + /// when `None` (e.g., first frame after load) the full data range is used. + fn compute_viewport_points( + &mut self, + file_index: usize, + channel_index: usize, + viewport: Option<(f64, f64)>, + ) -> Option> { + let file = self.files.get(file_index)?; + let times = file.log.get_times_as_f64(); + let data = self.get_channel_data(file_index, channel_index); + if times.is_empty() || times.len() != data.len() { + return None; + } + + let (lo, hi) = match viewport { + Some((vmin, vmax)) if vmax > vmin => { + let pad = (vmax - vmin) * 0.1; + let lo_t = vmin - pad; + let hi_t = vmax + pad; + let lo_i = times.partition_point(|&t| t < lo_t).saturating_sub(1); + let hi_i = times + .partition_point(|&t| t <= hi_t) + .saturating_add(1) + .min(times.len()); + (lo_i, hi_i.max(lo_i + 1)) + } + _ => (0, times.len()), + }; + + let times_slice = ×[lo..hi]; + let data_slice = &data[lo..hi]; + let downsampled = Self::downsample_lttb(times_slice, data_slice, MAX_CHART_POINTS); + + let (min_y, max_y) = self + .get_channel_min_max(file_index, channel_index) + .unwrap_or((0.0, 1.0)); + let range = (max_y - min_y).abs(); + // Constant channels (range ≈ 0) get parked at the middle of the + // overlay strip so they remain visible instead of pinning to the + // bottom edge — matches the prior `normalize_points` behavior. + if range < f64::EPSILON { + return Some(downsampled.into_iter().map(|p| [p[0], 0.5]).collect()); + } + Some( + downsampled + .into_iter() + .map(|p| [p[0], (p[1] - min_y) / range]) + .collect(), + ) + } + /// Normalize values to 0-1 range for overlay display pub fn normalize_points(points: &[[f64; 2]]) -> Vec<[f64; 2]> { if points.is_empty() { diff --git a/tests/parsers/speeduino_tests.rs b/tests/parsers/speeduino_tests.rs index 193d259..eb609b4 100644 --- a/tests/parsers/speeduino_tests.rs +++ b/tests/parsers/speeduino_tests.rs @@ -290,6 +290,55 @@ fn test_speeduino_timestamp_monotonicity() { assert_monotonic_times(&log); } +/// Regression guard for the MLG raw u16 timestamp unit (10 µs/tick per the +/// EFI Analytics MLG spec). A previous version of the parser used 1 ms/tick, +/// which compounded with the wraparound logic to inflate wall-clock time +/// ~100×. We bound the total parsed duration to a value that all bundled +/// sample logs comfortably satisfy and that any 10× or 100× drift would +/// blow past. +fn assert_mlg_total_duration_under(file_path: &str, max_seconds: f64) { + if !example_file_exists(file_path) { + eprintln!("Skipping {}: file not found", file_path); + return; + } + + let data = read_example_binary(file_path); + let log = Speeduino::parse_binary(&data) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", file_path, e)); + + assert!( + log.times.len() > 1, + "{}: expected multiple records", + file_path + ); + + let total = *log.times.last().unwrap() - log.times[0]; + assert!( + total > 0.0 && total < max_seconds, + "{}: total duration {:.3}s outside (0, {})s — timestamp unit regression?", + file_path, + total, + max_seconds + ); +} + +#[test] +fn test_speeduino_mlg_duration_bounded() { + // ~30 minutes is well above any of the bundled speeduino samples but + // far below the ~50 hour figure the old 1ms-tick interpretation produced. + assert_mlg_total_duration_under(SPEEDUINO_MLG, 30.0 * 60.0); +} + +#[test] +fn test_rusefi_mlg_duration_bounded() { + assert_mlg_total_duration_under(RUSEFI_MLG, 30.0 * 60.0); +} + +#[test] +fn test_rusefi_log1_duration_bounded() { + assert_mlg_total_duration_under(RUSEFI_LOG1, 30.0 * 60.0); +} + #[test] fn test_speeduino_timestamp_range() { if !example_file_exists(SPEEDUINO_MLG) { From cb4141e87a6edc876f104a5c62181d9e33dcc9ab Mon Sep 17 00:00:00 2001 From: Ivan Rudoy Date: Sun, 26 Apr 2026 04:17:33 +1000 Subject: [PATCH 3/8] fix(ui): stop chart jitter on play with anchored bucket downsampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LTTB partitions a slice into N equal buckets *by index* and picks the "best" point per bucket. During cursor-tracked playback the slice itself shifts a few samples each frame, so each bucket gets a slightly different sample set and the chosen peak flickers — visibly so when zoomed far out, where peaks are flattened to single buckets. Replace LTTB in compute_viewport_points with min/max-per-bucket, where bucket boundaries are anchored to absolute time (k × bucket_size from t = 0) instead of the viewport. As the cursor advances, samples slide through a fixed grid and each bucket's contents are invariant to the viewport offset, so peaks stay put. Two points per bucket (min, max) preserve the envelope; the bucket count is halved so total output stays under MAX_CHART_POINTS. --- src/app.rs | 70 +++++++++++++++++++++------------------------ src/state.rs | 18 ++++++++++++ src/ui/chart.rs | 76 +++++++++++++++++++++++++++++++++++++------------ 3 files changed, 108 insertions(+), 56 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3f70c65..3336778 100644 --- a/src/app.rs +++ b/src/app.rs @@ -51,8 +51,6 @@ pub struct UltraLogApp { load_receiver: Option>, /// Current loading state pub(crate) loading_state: LoadingState, - /// Cache for downsampled chart data - pub(crate) downsample_cache: HashMap>, /// Cache for channel min/max values (avoids O(n) scans) pub(crate) minmax_cache: HashMap, /// Last X-axis bounds shown by each plot area. Used to slice raw data to @@ -178,7 +176,6 @@ impl Default for UltraLogApp { last_drop_time: None, load_receiver: None, loading_state: LoadingState::Idle, - downsample_cache: HashMap::new(), minmax_cache: HashMap::new(), chart_last_x_bounds: HashMap::new(), cursor_time: None, @@ -812,6 +809,26 @@ impl UltraLogApp { } } + /// Borrow channel data without copying. Returns an empty slice for invalid + /// indices or computed channels that haven't been evaluated yet. Used in + /// per-frame chart paths where cloning the full channel would be wasteful. + pub fn get_channel_data_ref(&self, file_index: usize, channel_index: usize) -> &[f64] { + let Some(file) = self.files.get(file_index) else { + return &[]; + }; + let regular_count = file.log.channels.len(); + if channel_index < regular_count { + file.get_channel_column(channel_index).unwrap_or(&[]) + } else { + let computed_idx = channel_index - regular_count; + self.file_computed_channels + .get(&file_index) + .and_then(|c| c.get(computed_idx)) + .and_then(|c| c.cached_data.as_deref()) + .unwrap_or(&[]) + } + } + /// Get the display name of a channel by index (handles both regular and computed channels) pub fn get_channel_name(&self, file_index: usize, channel_index: usize) -> String { if file_index >= self.files.len() { @@ -853,20 +870,19 @@ impl UltraLogApp { return Some(cached); } - // Compute min/max (handles both regular and computed channels) - let data = self.get_channel_data(file_index, channel_index); - - if data.is_empty() { - return None; - } - - let (min_val, max_val) = data - .iter() - .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &v| { - (min.min(v), max.max(v)) - }); + // Compute min/max (handles both regular and computed channels). + // Scoped to release the borrow before mutating the cache below. + let (min_val, max_val) = { + let data = self.get_channel_data_ref(file_index, channel_index); + if data.is_empty() { + return None; + } + data.iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &v| { + (min.min(v), max.max(v)) + }) + }; - // Cache the result self.minmax_cache.insert(cache_key, (min_val, max_val)); Some((min_val, max_val)) } @@ -883,28 +899,6 @@ impl UltraLogApp { self.close_tab(tab_idx); } - // Clear downsample cache entries for this file and update indices - let mut new_cache = HashMap::new(); - for (key, value) in self.downsample_cache.drain() { - if key.file_index == index { - // Skip entries for removed file - continue; - } else if key.file_index > index { - // Update indices for files after the removed one - new_cache.insert( - CacheKey { - file_index: key.file_index - 1, - channel_index: key.channel_index, - plot_area_id: key.plot_area_id, - }, - value, - ); - } else { - new_cache.insert(key, value); - } - } - self.downsample_cache = new_cache; - // Clear minmax cache entries for this file and update indices let mut new_minmax_cache = HashMap::new(); for (key, value) in self.minmax_cache.drain() { diff --git a/src/state.rs b/src/state.rs index 1953d3a..2c43b85 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,7 @@ //! the application, including loaded files, selected channels, and color palettes. use std::path::PathBuf; +use std::sync::OnceLock; use crate::parsers::{Channel, EcuType, Log}; @@ -84,6 +85,11 @@ pub struct LoadedFile { /// Cached flag for each channel: true if channel has non-zero data /// Computed once on load for UI performance pub channels_with_data: Vec, + /// Lazy column-major view of `log.data` as `Vec>`. Built on first + /// access so the chart hot path can borrow `&[f64]` for a channel instead + /// of re-collecting an owned `Vec` from the row-major store on every + /// frame. + channel_columns: OnceLock>>, } impl LoadedFile { @@ -103,6 +109,7 @@ impl LoadedFile { ecu_type, log, channels_with_data, + channel_columns: OnceLock::new(), } } @@ -114,6 +121,17 @@ impl LoadedFile { .copied() .unwrap_or(false) } + + /// Borrow a regular channel's f64 data without copying. Lazily transposes + /// `log.data` into column-major form on first call. + pub fn get_channel_column(&self, channel_index: usize) -> Option<&[f64]> { + let cols = self.channel_columns.get_or_init(|| { + (0..self.log.channels.len()) + .map(|i| self.log.get_channel_data(i)) + .collect() + }); + cols.get(channel_index).map(Vec::as_slice) + } } /// A channel selected for visualization on the chart diff --git a/src/ui/chart.rs b/src/ui/chart.rs index d061084..8601100 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -851,35 +851,75 @@ impl UltraLogApp { channel_index: usize, viewport: Option<(f64, f64)>, ) -> Option> { + // Resolve min/max first so the mutable borrow on the cache ends before + // we take immutable borrows on the channel data below. + let (min_y, max_y) = self + .get_channel_min_max(file_index, channel_index) + .unwrap_or((0.0, 1.0)); + let file = self.files.get(file_index)?; let times = file.log.get_times_as_f64(); - let data = self.get_channel_data(file_index, channel_index); + let data = self.get_channel_data_ref(file_index, channel_index); if times.is_empty() || times.len() != data.len() { return None; } - let (lo, hi) = match viewport { + let full_lttb = || Self::downsample_lttb(times, data, MAX_CHART_POINTS); + let downsampled = match viewport { Some((vmin, vmax)) if vmax > vmin => { + // Anchored min/max-per-bucket downsampling. Bucket + // boundaries are at multiples of `bucket_size` from t=0, + // so during cursor-tracked playback samples slide through + // a fixed grid instead of being re-bucketed every frame. + // Without this anchoring, LTTB-by-index re-selects a + // different "best peak" per frame and the curve jitters + // at far zoom-out. let pad = (vmax - vmin) * 0.1; - let lo_t = vmin - pad; - let hi_t = vmax + pad; - let lo_i = times.partition_point(|&t| t < lo_t).saturating_sub(1); - let hi_i = times - .partition_point(|&t| t <= hi_t) - .saturating_add(1) - .min(times.len()); - (lo_i, hi_i.max(lo_i + 1)) + let padded_span = (vmax - vmin) + 2.0 * pad; + let n_buckets = (MAX_CHART_POINTS / 2).max(1); + let bucket_size = padded_span / n_buckets as f64; + if bucket_size <= 0.0 { + full_lttb() + } else { + let raw_lo = vmin - pad; + let k_lo = (raw_lo / bucket_size).floor() as i64; + let mut points: Vec<[f64; 2]> = Vec::with_capacity(MAX_CHART_POINTS); + let mut idx = times.partition_point(|&t| t < k_lo as f64 * bucket_size); + for k in 0..n_buckets as i64 { + let bucket_end = (k_lo + k + 1) as f64 * bucket_size; + let mut end_idx = idx; + while end_idx < times.len() && times[end_idx] < bucket_end { + end_idx += 1; + } + if end_idx > idx { + let mut min_i = idx; + let mut max_i = idx; + for i in idx..end_idx { + if data[i] < data[min_i] { + min_i = i; + } + if data[i] > data[max_i] { + max_i = i; + } + } + if min_i == max_i { + points.push([times[min_i], data[min_i]]); + } else if min_i < max_i { + points.push([times[min_i], data[min_i]]); + points.push([times[max_i], data[max_i]]); + } else { + points.push([times[max_i], data[max_i]]); + points.push([times[min_i], data[min_i]]); + } + } + idx = end_idx; + } + points + } } - _ => (0, times.len()), + _ => full_lttb(), }; - let times_slice = ×[lo..hi]; - let data_slice = &data[lo..hi]; - let downsampled = Self::downsample_lttb(times_slice, data_slice, MAX_CHART_POINTS); - - let (min_y, max_y) = self - .get_channel_min_max(file_index, channel_index) - .unwrap_or((0.0, 1.0)); let range = (max_y - min_y).abs(); // Constant channels (range ≈ 0) get parked at the middle of the // overlay strip so they remain visible instead of pinning to the From 65cb2c0f88916536e4a310fca33312910d2141e3 Mon Sep 17 00:00:00 2001 From: Ivan Rudoy Date: Sun, 26 Apr 2026 04:31:19 +1000 Subject: [PATCH 4/8] feat(ui): grid toggle and opacity control for chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Show Grid checkbox in the View menu and a Show Grid / Opacity slider in Settings → Display. Both knobs persist in UserSettings so the choice survives across sessions. Wire egui_plot's show_grid([x, y]) and grid_color() into both Plot::new chains (single and multi-area). The opacity slider overrides only the alpha of the base grid color, leaving the distance-based fade and theme RGB intact. Adds en/ru translations for menu.show_grid, settings.show_grid, settings.show_grid_desc, settings.grid_opacity. --- i18n/ar.yaml | 4 +++ i18n/bn.yaml | 4 +++ i18n/de.yaml | 4 +++ i18n/en.yaml | 4 +++ i18n/es.yaml | 4 +++ i18n/fr.yaml | 4 +++ i18n/hi.yaml | 4 +++ i18n/id.yaml | 4 +++ i18n/it.yaml | 4 +++ i18n/ja.yaml | 4 +++ i18n/pt-BR.yaml | 4 +++ i18n/pt-PT.yaml | 4 +++ i18n/ru.yaml | 4 +++ i18n/ur.yaml | 4 +++ i18n/zh-CN.yaml | 4 +++ src/app.rs | 8 ++++++ src/settings.rs | 17 +++++++++++ src/ui/chart.rs | 18 ++++++++++++ src/ui/menu.rs | 15 ++++++++++ src/ui/settings_panel.rs | 42 +++++++++++++++++++++++++++ tests/core/settings_tests.rs | 55 ++++++++++++++++++++++++++++++++++++ 21 files changed, 215 insertions(+) diff --git a/i18n/ar.yaml b/i18n/ar.yaml index 62a6f85..3abf937 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "تصدير كـ PDF..." export_histogram_pdf: "تصدير المدرج التكراري كـ PDF..." view: "عرض" + show_grid: "إظهار الشبكة" tool_mode: "وضع الأداة" log_viewer: "عارض السجل" scatter_plots: "المخططات المبعثرة" @@ -61,6 +62,9 @@ settings: window: "النافذة:" scroll_to_zoom: "التمرير للتكبير" scroll_to_zoom_desc: "عجلة الماوس تكبر المخطط مباشرة بدلاً من الإزاحة" + show_grid: "إظهار الشبكة" + show_grid_desc: "رسم شبكة الخلفية للرسم البياني" + grid_opacity: "الشفافية:" field_names: "أسماء الحقول" field_normalization: "توحيد الحقول" field_normalization_desc: "توحيد أسماء القنوات عبر أنواع ECU" diff --git a/i18n/bn.yaml b/i18n/bn.yaml index f353251..356a5d5 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "PDF হিসেবে রপ্তানি..." export_histogram_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি..." view: "দৃশ্য" + show_grid: "গ্রিড দেখান" tool_mode: "টুল মোড" log_viewer: "লগ ভিউয়ার" scatter_plots: "স্ক্যাটার প্লট" @@ -61,6 +62,9 @@ settings: window: "উইন্ডো:" scroll_to_zoom: "স্ক্রল করে জুম করুন" scroll_to_zoom_desc: "মাউস হুইল সরাসরি চার্টকে জুম করে প্যানিং এর পরিবর্তে" + show_grid: "গ্রিড দেখান" + show_grid_desc: "চার্ট পটভূমি গ্রিড আঁকুন" + grid_opacity: "স্বচ্ছতা:" field_names: "ফিল্ড নাম" field_normalization: "ফিল্ড নরমালাইজেশন" field_normalization_desc: "ECU টাইপ জুড়ে চ্যানেল নাম মানসম্মত করুন" diff --git a/i18n/de.yaml b/i18n/de.yaml index 0269a91..78ce786 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Als PDF exportieren..." export_histogram_pdf: "Histogramm als PDF exportieren..." view: "Ansicht" + show_grid: "Gitter anzeigen" tool_mode: "Werkzeugmodus" log_viewer: "Log-Betrachter" scatter_plots: "Streudiagramme" @@ -61,6 +62,9 @@ settings: window: "Fenster:" scroll_to_zoom: "Scrollen zum Zoomen" scroll_to_zoom_desc: "Mausrad zoomt direkt in das Diagramm, anstatt es zu verschieben" + show_grid: "Gitter anzeigen" + show_grid_desc: "Gitter im Hintergrund anzeichnen" + grid_opacity: "Deckkraft:" field_names: "Feldnamen" field_normalization: "Feldnormalisierung" field_normalization_desc: "Kanalnamen über ECU-Typen hinweg standardisieren" diff --git a/i18n/en.yaml b/i18n/en.yaml index 382bed1..723744a 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Export as PDF..." export_histogram_pdf: "Export Histogram as PDF..." view: "View" + show_grid: "Show Grid" tool_mode: "Tool Mode" log_viewer: "Log Viewer" scatter_plots: "Scatter Plots" @@ -61,6 +62,9 @@ settings: window: "Window:" scroll_to_zoom: "Scroll to Zoom" scroll_to_zoom_desc: "Scroll wheel zooms the chart directly instead of panning" + show_grid: "Show Grid" + show_grid_desc: "Draw the chart background grid" + grid_opacity: "Opacity:" field_names: "Field Names" field_normalization: "Field Normalization" field_normalization_desc: "Standardize channel names across ECU types" diff --git a/i18n/es.yaml b/i18n/es.yaml index df088cb..f1ab5db 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." view: "Vista" + show_grid: "Mostrar cuadrícula" tool_mode: "Modo de Herramienta" log_viewer: "Visor de Log" scatter_plots: "Graficos de Dispersion" @@ -61,6 +62,9 @@ settings: window: "Ventana:" scroll_to_zoom: "Desplazar para Zoom" scroll_to_zoom_desc: "La rueda de desplazamiento amplía el gráfico directamente en lugar de desplazarse" + show_grid: "Mostrar cuadrícula" + show_grid_desc: "Mostrar la cuadrícula de fondo del gráfico" + grid_opacity: "Opacidad:" field_names: "Nombres de Campos" field_normalization: "Normalizacion de Campos" field_normalization_desc: "Estandarizar nombres de canales entre tipos de ECU" diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 883e49a..7ab6290 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Exporter en PDF..." export_histogram_pdf: "Exporter l'histogramme en PDF..." view: "Affichage" + show_grid: "Afficher la grille" tool_mode: "Mode outil" log_viewer: "Visionneuse de journaux" scatter_plots: "Nuages de points" @@ -61,6 +62,9 @@ settings: window: "Fenetre :" scroll_to_zoom: "Scroll pour Zoomer" scroll_to_zoom_desc: "La molette de la souris zoome le graphique directement au lieu de faire defiler" + show_grid: "Afficher la grille" + show_grid_desc: "Afficher la grille de fond du graphique" + grid_opacity: "Opacité :" field_names: "Noms des champs" field_normalization: "Normalisation des champs" field_normalization_desc: "Standardiser les noms de canaux entre les types d'ECU" diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 99f9391..e40a9c4 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "PDF के रूप में निर्यात करें..." export_histogram_pdf: "हिस्टोग्राम PDF के रूप में निर्यात करें..." view: "दृश्य" + show_grid: "ग्रिड दिखाएं" tool_mode: "टूल मोड" log_viewer: "लॉग व्यूअर" scatter_plots: "स्कैटर प्लॉट" @@ -61,6 +62,9 @@ settings: window: "विंडो:" scroll_to_zoom: "स्क्रॉल करके ज़ूम करें" scroll_to_zoom_desc: "माउस व्हील सीधे चार्ट को ज़ूम करता है, पैन करने के बजाय" + show_grid: "ग्रिड दिखाएं" + show_grid_desc: "चार्ट पृष्ठभूमि ग्रिड बनाएं" + grid_opacity: "अस्पष्टता:" field_names: "फ़ील्ड नाम" field_normalization: "फ़ील्ड नॉर्मलाइज़ेशन" field_normalization_desc: "ECU प्रकारों में चैनल नामों को मानकीकृत करें" diff --git a/i18n/id.yaml b/i18n/id.yaml index 0d03be3..a4a4f38 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Ekspor sebagai PDF..." export_histogram_pdf: "Ekspor Histogram sebagai PDF..." view: "Tampilan" + show_grid: "Tampilkan Kisi" tool_mode: "Mode Alat" log_viewer: "Penampil Log" scatter_plots: "Diagram Sebar" @@ -61,6 +62,9 @@ settings: window: "Jendela:" scroll_to_zoom: "Gulir untuk Zoom" scroll_to_zoom_desc: "Roda mouse memperbesar grafik secara langsung daripada menggeser" + show_grid: "Tampilkan Kisi" + show_grid_desc: "Gambar kisi latar belakang bagan" + grid_opacity: "Opacity:" field_names: "Nama Field" field_normalization: "Normalisasi Field" field_normalization_desc: "Standarisasi nama kanal antar jenis ECU" diff --git a/i18n/it.yaml b/i18n/it.yaml index 3fe8bb4..63a0100 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Esporta come PDF..." export_histogram_pdf: "Esporta Istogramma come PDF..." view: "Visualizza" + show_grid: "Mostra griglia" tool_mode: "Modalita' Strumento" log_viewer: "Visualizzatore Log" scatter_plots: "Grafici a Dispersione" @@ -61,6 +62,9 @@ settings: window: "Finestra:" scroll_to_zoom: "Scroll per Zoom" scroll_to_zoom_desc: "La rotella del mouse ingrandisce il grafico direttamente invece di scorrere" + show_grid: "Mostra griglia" + show_grid_desc: "Disegna la griglia di sfondo del grafico" + grid_opacity: "Opacità:" field_names: "Nomi dei Campi" field_normalization: "Normalizzazione Campi" field_normalization_desc: "Standardizza i nomi dei canali tra diversi tipi di ECU" diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 1417578..f2fe0d3 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "PDFとしてエクスポート..." export_histogram_pdf: "ヒストグラムをPDFでエクスポート..." view: "表示" + show_grid: "グリッドを表示" tool_mode: "ツールモード" log_viewer: "ログビューア" scatter_plots: "散布図" @@ -61,6 +62,9 @@ settings: window: "ウィンドウ:" scroll_to_zoom: "スクロールしてズーム" scroll_to_zoom_desc: "マウスホイールでチャートを直接ズームします(パンの代わりに)" + show_grid: "グリッドを表示" + show_grid_desc: "チャートの背景グリッドを描画" + grid_opacity: "不透明度:" field_names: "フィールド名" field_normalization: "フィールド正規化" field_normalization_desc: "ECUタイプ間でチャンネル名を標準化" diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index 32d6083..a5797d3 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." view: "Visualizar" + show_grid: "Mostrar Grade" tool_mode: "Modo de Ferramenta" log_viewer: "Visualizador de Logs" scatter_plots: "Gráficos de Dispersão" @@ -61,6 +62,9 @@ settings: window: "Janela:" scroll_to_zoom: "Scroll para Ampliar" scroll_to_zoom_desc: "A roda do mouse amplia o gráfico diretamente em vez de fazer pan" + show_grid: "Mostrar Grade" + show_grid_desc: "Desenhar a grade de fundo do gráfico" + grid_opacity: "Opacidade:" field_names: "Nomes dos Campos" field_normalization: "Normalização de Campos" field_normalization_desc: "Padronizar nomes de canais entre tipos de ECU" diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index 2c6ab6e..e70ee42 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." view: "Ver" + show_grid: "Mostrar Grelha" tool_mode: "Modo de Ferramenta" log_viewer: "Visualizador de Registos" scatter_plots: "Gráficos de Dispersão" @@ -61,6 +62,9 @@ settings: window: "Janela:" scroll_to_zoom: "Scroll para Ampliação" scroll_to_zoom_desc: "A roda do rato amplia o gráfico diretamente em vez de fazer pan" + show_grid: "Mostrar Grelha" + show_grid_desc: "Desenhar a grelha de fundo do gráfico" + grid_opacity: "Opacidade:" field_names: "Nomes dos Campos" field_normalization: "Normalização de Campos" field_normalization_desc: "Uniformizar nomes de canais entre tipos de ECU" diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 9635f5c..4c1c92f 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "Экспортировать в PDF..." export_histogram_pdf: "Экспортировать гистограмму в PDF..." view: "Вид" + show_grid: "Показывать сетку" tool_mode: "Режим инструмента" log_viewer: "Просмотр логов" scatter_plots: "Диаграммы рассеяния" @@ -61,6 +62,9 @@ settings: window: "Окно:" scroll_to_zoom: "Прокрутка для увеличения" scroll_to_zoom_desc: "Колесико мыши увеличивает график напрямую вместо панорамирования" + show_grid: "Показывать сетку" + show_grid_desc: "Отображать координатную сетку на графике" + grid_opacity: "Прозрачность:" field_names: "Имена полей" field_normalization: "Нормализация полей" field_normalization_desc: "Стандартизировать имена каналов для разных типов ECU" diff --git a/i18n/ur.yaml b/i18n/ur.yaml index b22c9e0..bb8dbbb 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -11,6 +11,7 @@ menu: export_pdf: "PDF کے طور پر برآمد کریں..." export_histogram_pdf: "ہسٹوگرام PDF کے طور پر برآمد کریں..." view: "منظر" + show_grid: "گرڈ دکھائیں" tool_mode: "ٹول موڈ" log_viewer: "لاگ ویور" scatter_plots: "سکیٹر پلاٹس" @@ -62,6 +63,9 @@ settings: window: "ونڈو:" scroll_to_zoom: "سکرول کریں تاکہ تقریب ہو" scroll_to_zoom_desc: "ماؤس وہیل براہ راست چارٹ کو بڑھاتا ہے بجائے پیننگ کے" + show_grid: "گرڈ دکھائیں" + show_grid_desc: "چار्ट کی پس منظر کی شبکہ کھینچیں" + grid_opacity: "شفافیت:" field_names: "فیلڈ کے نام" field_normalization: "فیلڈ نارملائزیشن" field_normalization_desc: "ECU اقسام میں چینل ناموں کو معیاری بنائیں" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index cbbd38e..45508d0 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -10,6 +10,7 @@ menu: export_pdf: "导出为 PDF..." export_histogram_pdf: "导出直方图为 PDF..." view: "视图" + show_grid: "显示网格" tool_mode: "工具模式" log_viewer: "日志查看器" scatter_plots: "散点图" @@ -61,6 +62,9 @@ settings: window: "窗口:" scroll_to_zoom: "滚动以缩放" scroll_to_zoom_desc: "鼠标滚轮直接缩放图表,而不是平移" + show_grid: "显示网格" + show_grid_desc: "绘制图表背景网格" + grid_opacity: "不透明度:" field_names: "字段名称" field_normalization: "字段标准化" field_normalization_desc: "统一不同 ECU 类型的通道名称" diff --git a/src/app.rs b/src/app.rs index 3336778..c698f95 100644 --- a/src/app.rs +++ b/src/app.rs @@ -86,6 +86,10 @@ pub struct UltraLogApp { pub(crate) initial_view_seconds: f64, /// When true, scroll wheel zooms chart directly instead of panning pub(crate) scroll_to_zoom: bool, + /// When true, draw the chart background grid + pub(crate) show_grid: bool, + /// Grid line opacity (0..=255) used as the alpha of the base grid color + pub(crate) grid_opacity: u8, // === Unit Preferences === /// User preferences for display units pub(crate) unit_preferences: UnitPreferences, @@ -190,6 +194,8 @@ impl Default for UltraLogApp { field_normalization: true, // Enabled by default for better readability initial_view_seconds: 60.0, // Start with 60 second view scroll_to_zoom: false, + show_grid: true, + grid_opacity: 255, unit_preferences: UnitPreferences::default(), font_scale: FontScale::default(), custom_normalizations: HashMap::new(), @@ -271,6 +277,8 @@ impl UltraLogApp { user_settings: user_settings.clone(), language: user_settings.language, scroll_to_zoom: user_settings.scroll_to_zoom, + show_grid: user_settings.show_grid, + grid_opacity: user_settings.grid_opacity, ..Self::default() }; diff --git a/src/settings.rs b/src/settings.rs index f1f31cc..0c1d51a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -19,18 +19,35 @@ pub struct UserSettings { /// When true, scroll wheel zooms chart directly instead of panning #[serde(default)] pub scroll_to_zoom: bool, + /// When true, draw the chart background grid + #[serde(default = "default_show_grid")] + pub show_grid: bool, + /// Grid line opacity, 0..=255. Modulates the base grid color's alpha + /// before egui_plot's distance-based fade + #[serde(default = "default_grid_opacity")] + pub grid_opacity: u8, } fn default_version() -> u32 { 1 } +fn default_show_grid() -> bool { + true +} + +fn default_grid_opacity() -> u8 { + 255 +} + impl Default for UserSettings { fn default() -> Self { Self { version: 1, language: Language::default(), scroll_to_zoom: false, + show_grid: default_show_grid(), + grid_opacity: default_grid_opacity(), } } } diff --git a/src/ui/chart.rs b/src/ui/chart.rs index 8601100..9d58df2 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -135,6 +135,8 @@ impl UltraLogApp { let initial_view_seconds = self.initial_view_seconds; let jump_to_time = self.get_jump_to_time(); let scroll_to_zoom = self.scroll_to_zoom; + let show_grid = self.show_grid; + let grid_color = grid_color_with_opacity(ui, self.grid_opacity); // Read scroll input before plot consumes it (for scroll-to-zoom mode) let scroll_delta_y = if scroll_to_zoom && !cursor_tracking { @@ -153,6 +155,8 @@ impl UltraLogApp { .legend(egui_plot::Legend::default()) .y_axis_label("") // Hide Y axis label since values are normalized .show_axes([true, false]) // Show X axis (time), hide Y axis (normalized 0-1) + .show_grid([show_grid, show_grid]) + .grid_color(grid_color) .allow_zoom([true, false]) // Only allow X-axis zoom .allow_drag([!cursor_tracking, false]) // Only allow X-axis drag, never Y .allow_scroll([!cursor_tracking && !scroll_to_zoom, false]); // Disable scroll-pan when scroll-to-zoom enabled @@ -550,12 +554,17 @@ impl UltraLogApp { const Y_MIN: f64 = -0.05; const Y_MAX: f64 = 1.05; + let show_grid = self.show_grid; + let grid_color = grid_color_with_opacity(ui, self.grid_opacity); + // Build plot with fixed height let plot = Plot::new(format!("plot_{}", plot_area_id)) .height(height) .legend(egui_plot::Legend::default()) .y_axis_label("") .show_axes([true, false]) + .show_grid([show_grid, show_grid]) + .grid_color(grid_color) .allow_zoom([true, false]) .allow_drag([!cursor_tracking, false]) .allow_scroll([!cursor_tracking, false]); @@ -1035,3 +1044,12 @@ impl UltraLogApp { result } } + +/// Build a grid color matching the active theme but with a user-controlled +/// alpha override. The base RGB comes from `Visuals::text_color`, which is +/// what egui_plot uses by default; we just substitute the alpha so the +/// distance-based fade still applies on top. +fn grid_color_with_opacity(ui: &egui::Ui, alpha: u8) -> egui::Color32 { + let c = ui.visuals().text_color(); + egui::Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha) +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 136e5d9..e69ac55 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -117,6 +117,21 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); + // Chart grid toggle + let old_show_grid = self.show_grid; + ui.checkbox( + &mut self.show_grid, + egui::RichText::new(t!("menu.show_grid")).size(font_14), + ); + if self.show_grid != old_show_grid { + self.user_settings.show_grid = self.show_grid; + if let Err(e) = self.user_settings.save() { + self.show_toast_error(&t!("toast.failed_to_save", error = e)); + } + } + + ui.separator(); + // Tool modes ui.label( egui::RichText::new(t!("menu.tool_mode")) diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs index caa49cd..b74a892 100644 --- a/src/ui/settings_panel.rs +++ b/src/ui/settings_panel.rs @@ -179,6 +179,48 @@ impl UltraLogApp { self.show_toast_error(&t!("toast.failed_to_save", error = e)); } } + + ui.add_space(8.0); + + // Chart grid + let old_show_grid = self.show_grid; + ui.checkbox( + &mut self.show_grid, + egui::RichText::new(t!("settings.show_grid")).size(font_14), + ); + ui.label( + egui::RichText::new(t!("settings.show_grid_desc")) + .size(font_12) + .color(egui::Color32::GRAY), + ); + if self.show_grid != old_show_grid { + self.user_settings.show_grid = self.show_grid; + if let Err(e) = self.user_settings.save() { + self.show_toast_error(&t!("toast.failed_to_save", error = e)); + } + } + + if self.show_grid { + ui.add_space(4.0); + let mut slider_response = None; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(t!("settings.grid_opacity")).size(font_12)); + slider_response = + Some(ui.add(egui::Slider::new(&mut self.grid_opacity, 0..=255))); + }); + // Persist only when the user releases the slider, otherwise + // every drag pixel rewrites settings.json. + if let Some(resp) = slider_response { + if resp.drag_stopped() || resp.lost_focus() { + if self.user_settings.grid_opacity != self.grid_opacity { + self.user_settings.grid_opacity = self.grid_opacity; + if let Err(e) = self.user_settings.save() { + self.show_toast_error(&t!("toast.failed_to_save", error = e)); + } + } + } + } + } }); } diff --git a/tests/core/settings_tests.rs b/tests/core/settings_tests.rs index ac64ac5..49e038b 100644 --- a/tests/core/settings_tests.rs +++ b/tests/core/settings_tests.rs @@ -34,6 +34,18 @@ fn test_settings_default_is_consistent() { assert_eq!(settings1.language, settings2.language); } +#[test] +fn test_settings_default_show_grid() { + let settings = UserSettings::default(); + assert!(settings.show_grid); +} + +#[test] +fn test_settings_default_grid_opacity() { + let settings = UserSettings::default(); + assert_eq!(settings.grid_opacity, 255); +} + // ============================================ // Serialization Tests // ============================================ @@ -105,12 +117,51 @@ fn test_settings_deserialize_empty_object() { assert_eq!(settings.language, Language::English); } +#[test] +fn test_settings_deserialize_legacy_without_grid_fields() { + // Settings persisted before the grid feature must still load and pick up + // sensible defaults for show_grid/grid_opacity + let json = r#"{"version":1,"language":"English","scroll_to_zoom":false}"#; + let settings: UserSettings = serde_json::from_str(json).unwrap(); + + assert!(settings.show_grid); + assert_eq!(settings.grid_opacity, 255); +} + +#[test] +fn test_settings_deserialize_grid_disabled() { + let json = r#"{"version":1,"show_grid":false,"grid_opacity":128}"#; + let settings: UserSettings = serde_json::from_str(json).unwrap(); + + assert!(!settings.show_grid); + assert_eq!(settings.grid_opacity, 128); +} + +#[test] +fn test_settings_grid_fields_roundtrip() { + let original = UserSettings { + version: 1, + language: Language::English, + scroll_to_zoom: false, + show_grid: false, + grid_opacity: 64, + }; + + let json = serde_json::to_string(&original).unwrap(); + let restored: UserSettings = serde_json::from_str(&json).unwrap(); + + assert_eq!(original.show_grid, restored.show_grid); + assert_eq!(original.grid_opacity, restored.grid_opacity); +} + #[test] fn test_settings_roundtrip() { let original = UserSettings { version: 1, language: Language::Spanish, scroll_to_zoom: false, + show_grid: true, + grid_opacity: 255, }; let json = serde_json::to_string(&original).unwrap(); @@ -127,6 +178,8 @@ fn test_settings_roundtrip_all_languages() { version: 1, language: *lang, scroll_to_zoom: false, + show_grid: true, + grid_opacity: 255, }; let json = serde_json::to_string(&settings).unwrap(); @@ -226,6 +279,8 @@ fn test_settings_clone() { version: 1, language: Language::Spanish, scroll_to_zoom: false, + show_grid: true, + grid_opacity: 255, }; let cloned = original.clone(); From d0cbc1b498ec9f077a659b14b85c70d6ce256319 Mon Sep 17 00:00:00 2001 From: Ivan Rudoy Date: Sun, 26 Apr 2026 05:57:07 +1000 Subject: [PATCH 5/8] fix(ui): decouple chart zoom from the Window-size setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinch / cmd+wheel / scroll-to-zoom in cursor-tracking mode used to write straight into view_window_seconds — the same field bound to the Window slider in Settings. Scrolling the chart visibly dragged the slider, and the zoomed value got persisted as the new default. Split the field in two: - `view_window_seconds` stays the user-set persistent default (Slider in Settings / Sidebar). The slider's write-path also resets the live render width. - `current_view_window` is the live render width used by the chart in cursor-tracking mode. Zoom interactions update only this field, leaving the slider and persisted value alone. Also gate the zoom helper on `ui.rect_contains_pointer(ui.max_rect())` so wheel/pinch events that land over the side panel (Settings, etc.) no longer reach the chart at all. --- src/app.rs | 10 +++++++++- src/ui/chart.rs | 22 +++++++++++++++++----- src/ui/settings_panel.rs | 16 +++++++++++----- src/ui/sidebar.rs | 5 ++++- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index c698f95..6860a9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,8 +67,15 @@ pub struct UltraLogApp { // === View Options === /// When true, keep cursor centered and pan graph during scrubbing pub(crate) cursor_tracking: bool, - /// Visible time window width in seconds (for cursor tracking mode) + /// Visible time window width in seconds (for cursor tracking mode). + /// This is the user-set value bound to the Window slider in Settings — + /// transient zoom interactions don't touch it. pub(crate) view_window_seconds: f64, + /// Live render width used by the chart in cursor-tracking mode. Starts + /// at `view_window_seconds` and is updated by zoom interactions + /// (pinch, cmd+wheel, scroll-to-zoom) without persisting back to the + /// slider; the slider write-path resets it back to the new setting. + pub(crate) current_view_window: f64, // === Playback === /// Whether playback is active pub(crate) is_playing: bool, @@ -187,6 +194,7 @@ impl Default for UltraLogApp { cursor_record: None, cursor_tracking: true, view_window_seconds: 30.0, // Default 30 second window + current_view_window: 30.0, is_playing: false, last_frame_time: None, playback_speed: 1.0, diff --git a/src/ui/chart.rs b/src/ui/chart.rs index 9d58df2..a8d6b98 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -32,18 +32,30 @@ impl UltraLogApp { } /// Translate pinch / cmd+wheel (and scroll if `scroll_to_zoom` is on) into - /// changes of `view_window_seconds` while in cursor-tracking mode. Without + /// changes of `current_view_window` while in cursor-tracking mode. Without /// this, the cursor-tracking branch in the plot closure forces bounds to a /// fixed-width window every frame and any zoom is immediately overridden. + /// The persistent `view_window_seconds` (Settings slider) is intentionally + /// not touched here — that's the user-set default; the slider write-path + /// resets `current_view_window` back to it on every change. fn apply_zoom_to_view_window_if_tracking(&mut self, ui: &egui::Ui) { if !self.cursor_tracking { return; } + // Don't react to scroll/pinch happening over other UI (e.g. the + // Settings panel) — `ui.input` is global, so without this guard a + // wheel event over the side panel would still resize the cursor + // tracking window. `min_rect()` is empty before any chart content + // is drawn, so we use `max_rect()` (the full available area of the + // central panel) instead. + if !ui.rect_contains_pointer(ui.max_rect()) { + return; + } let Some((min_t, max_t)) = self.get_time_range() else { return; }; let zoom_delta = ui.input(|i| i.zoom_delta()) as f64; - let mut new_window = self.view_window_seconds; + let mut new_window = self.current_view_window; if zoom_delta != 1.0 { new_window /= zoom_delta; } @@ -55,7 +67,7 @@ impl UltraLogApp { } } let max_window = (max_t - min_t).max(0.1); - self.view_window_seconds = new_window.clamp(0.1, max_window); + self.current_view_window = new_window.clamp(0.1, max_window); } /// Render single-plot mode chart (original implementation) @@ -128,7 +140,7 @@ impl UltraLogApp { // selected_channels already defined at top of function from get_selected_channels() let cursor_time = self.get_cursor_time(); let cursor_tracking = self.cursor_tracking; - let view_window = self.view_window_seconds; + let view_window = self.current_view_window; let time_range = self.get_time_range(); let color_blind_mode = self.color_blind_mode; let chart_interacted = self.get_chart_interacted(); @@ -543,7 +555,7 @@ impl UltraLogApp { let files = &self.files; let cursor_time = self.get_cursor_time(); let cursor_tracking = self.cursor_tracking; - let view_window = self.view_window_seconds; + let view_window = self.current_view_window; let time_range = self.get_time_range(); let color_blind_mode = self.color_blind_mode; let chart_interacted = self.get_chart_interacted(); diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs index b74a892..990aba0 100644 --- a/src/ui/settings_panel.rs +++ b/src/ui/settings_panel.rs @@ -149,15 +149,21 @@ impl UltraLogApp { if self.cursor_tracking { ui.add_space(4.0); + let mut window_resp = None; ui.horizontal(|ui| { ui.label(egui::RichText::new(t!("settings.window")).size(font_12)); - ui.add( - egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) - .suffix("s") - .logarithmic(true) - .text(""), + window_resp = Some( + ui.add( + egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) + .suffix("s") + .logarithmic(true) + .text(""), + ), ); }); + if window_resp.is_some_and(|r| r.changed()) { + self.current_view_window = self.view_window_seconds; + } } ui.add_space(8.0); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 9453cf1..9d90d0c 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -255,11 +255,14 @@ impl UltraLogApp { if self.cursor_tracking { ui.add_space(8.0); ui.label(egui::RichText::new("View Window:").size(font_14)); - ui.add( + let resp = ui.add( egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) .suffix("s") .logarithmic(true), ); + if resp.changed() { + self.current_view_window = self.view_window_seconds; + } } ui.add_space(8.0); From a84caf7a95024f3134bfd0aa54aa12469bb9a8c3 Mon Sep 17 00:00:00 2001 From: Ivan Rudoy Date: Sun, 26 Apr 2026 08:14:28 +1000 Subject: [PATCH 6/8] fix(ipc): drop non-blocking accept busy-poll in listener loop The IPC server set the listener to non-blocking and slept 100 ms between WouldBlock errors. There is no shutdown signal feeding the loop, so the non-blocking pattern only added up to ~100 ms of latency before each accepted connection plus continuous busy-poll wakeups. Switching to blocking accept removes the latency and the wakeups, and restores deterministic ordering for the in-tree IPC integration tests that previously flaked under cargo test parallelism (the listener could be mid-sleep when a connection arrived, pushing command delivery past the test's 2 s polling window). --- src/ipc/server.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/ipc/server.rs b/src/ipc/server.rs index efaf52f..15d9e7d 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -32,11 +32,6 @@ impl IpcServer { let listener = TcpListener::bind(format!("127.0.0.1:{}", port)) .map_err(|e| format!("Failed to bind to port {}: {}", port, e))?; - // Set non-blocking so we can check for shutdown - listener - .set_nonblocking(true) - .map_err(|e| format!("Failed to set non-blocking: {}", e))?; - let (command_tx, command_rx) = mpsc::channel(); // Spawn the listener thread @@ -79,10 +74,6 @@ impl IpcServer { Self::handle_connection(stream, tx); }); } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // No connection available, sleep briefly - thread::sleep(std::time::Duration::from_millis(100)); - } Err(e) => { tracing::error!("Error accepting connection: {}", e); thread::sleep(std::time::Duration::from_millis(100)); From 03035d4d7e8fa5ac580ec381a443910e765aa1d5 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sat, 25 Apr 2026 18:51:45 -0400 Subject: [PATCH 7/8] chore: fix clippy collapsible_if and add column-accessor tests Flatten the nested grid_opacity persistence guards in the settings panel to satisfy clippy::collapsible_if (edition 2021, no let_chains). Cover the new LoadedFile::get_channel_column lazy column-major accessor with unit tests for hot, out-of-bounds, idempotent, and empty-log paths. --- src/ui/settings_panel.rs | 15 ++++----- tests/core/state_tests.rs | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs index 990aba0..898d3bd 100644 --- a/src/ui/settings_panel.rs +++ b/src/ui/settings_panel.rs @@ -216,14 +216,13 @@ impl UltraLogApp { }); // Persist only when the user releases the slider, otherwise // every drag pixel rewrites settings.json. - if let Some(resp) = slider_response { - if resp.drag_stopped() || resp.lost_focus() { - if self.user_settings.grid_opacity != self.grid_opacity { - self.user_settings.grid_opacity = self.grid_opacity; - if let Err(e) = self.user_settings.save() { - self.show_toast_error(&t!("toast.failed_to_save", error = e)); - } - } + let committed = slider_response + .map(|r| r.drag_stopped() || r.lost_focus()) + .unwrap_or(false); + if committed && self.user_settings.grid_opacity != self.grid_opacity { + self.user_settings.grid_opacity = self.grid_opacity; + if let Err(e) = self.user_settings.save() { + self.show_toast_error(&t!("toast.failed_to_save", error = e)); } } } diff --git a/tests/core/state_tests.rs b/tests/core/state_tests.rs index 9779799..6063cf4 100644 --- a/tests/core/state_tests.rs +++ b/tests/core/state_tests.rs @@ -608,6 +608,73 @@ fn test_loaded_file_clone() { assert_eq!(cloned.channels_with_data, file.channels_with_data); } +#[test] +fn test_loaded_file_get_channel_column_returns_data() { + let log = create_test_log(); + let file = LoadedFile::new( + PathBuf::from("/test/path.csv"), + "path.csv".to_string(), + EcuType::Haltech, + log, + ); + + // Channel 0: Engine Speed values 5000, 5100, 0 + let col0 = file.get_channel_column(0).expect("channel 0 column"); + assert_eq!(col0, &[5000.0, 5100.0, 0.0]); + + // Channel 1: TPS values 50, 0, 0 + let col1 = file.get_channel_column(1).expect("channel 1 column"); + assert_eq!(col1, &[50.0, 0.0, 0.0]); +} + +#[test] +fn test_loaded_file_get_channel_column_out_of_bounds() { + let log = create_test_log(); + let file = LoadedFile::new( + PathBuf::from("/test/path.csv"), + "path.csv".to_string(), + EcuType::Haltech, + log, + ); + + assert!(file.get_channel_column(999).is_none()); +} + +#[test] +fn test_loaded_file_get_channel_column_idempotent() { + // Second call should return the same lazily-built columns and produce + // identical slices — exercises the OnceLock memoization path. + let log = create_test_log(); + let file = LoadedFile::new( + PathBuf::from("/test/path.csv"), + "path.csv".to_string(), + EcuType::Haltech, + log, + ); + + let first = file.get_channel_column(0).unwrap().to_vec(); + let second = file.get_channel_column(0).unwrap().to_vec(); + assert_eq!(first, second); +} + +#[test] +fn test_loaded_file_get_channel_column_empty_log() { + let log = Log { + meta: ultralog::parsers::types::Meta::Empty, + channels: vec![], + times: vec![], + data: vec![], + }; + let file = LoadedFile::new( + PathBuf::from("/test/path.csv"), + "path.csv".to_string(), + EcuType::Haltech, + log, + ); + + assert!(file.get_channel_column(0).is_none()); +} + // ============================================ // HistogramMode Tests // ============================================ From fded50b17775401cb83a157c1a2dc65dd95be6ec Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Sat, 25 Apr 2026 18:52:16 -0400 Subject: [PATCH 8/8] chore: bump version to 2.9.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5c9cf0..2660ea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4958,7 +4958,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "2.8.2" +version = "2.9.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 9a7e9e0..0365c72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "2.8.2" +version = "2.9.0" edition = "2021" description = "A high-performance ECU log viewer written in Rust" authors = ["Cole Gentry"] diff --git a/README.md b/README.md index 7942edd..5d243b6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust. ![CI](https://github.com/ClassicMiniDIY/UltraLog/actions/workflows/ci.yml/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -![Version](https://img.shields.io/badge/version-2.8.2-green.svg) +![Version](https://img.shields.io/badge/version-2.9.0-green.svg) ---