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) --- 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 a18ed14..6860a9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -51,10 +51,13 @@ 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 + /// 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) @@ -64,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, @@ -83,6 +93,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, @@ -173,13 +187,14 @@ 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, time_range: None, 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, @@ -187,6 +202,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(), @@ -268,6 +285,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() }; @@ -806,6 +825,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() { @@ -847,20 +886,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)) } @@ -877,28 +915,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() { @@ -919,6 +935,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/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)); 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/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/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 e4f2f0a..a8d6b98 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -7,10 +7,14 @@ 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). +/// 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,49 @@ impl UltraLogApp { } } + /// Translate pinch / cmd+wheel (and scroll if `scroll_to_zoom` is on) into + /// 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.current_view_window; + 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.current_view_window = 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(); @@ -43,32 +88,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; @@ -106,18 +135,20 @@ 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(); 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(); 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 { @@ -129,8 +160,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 @@ -138,6 +167,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 @@ -244,13 +275,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 @@ -283,6 +308,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() @@ -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( @@ -473,30 +506,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; @@ -534,11 +551,11 @@ 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; - 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(); @@ -549,12 +566,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]); @@ -619,13 +641,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 @@ -655,6 +671,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() @@ -839,6 +861,101 @@ 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> { + // 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_ref(file_index, channel_index); + if times.is_empty() || times.len() != data.len() { + return None; + } + + 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 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 + } + } + _ => full_lttb(), + }; + + 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() { @@ -939,3 +1056,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..898d3bd 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); @@ -179,6 +185,47 @@ 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. + 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/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); 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(); 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 // ============================================ 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) {