diff --git a/CLAUDE.md b/CLAUDE.md index fa1642f..2906c01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -672,7 +672,7 @@ preferences convert them. ### Settings Persistence Contract -`UserSettings` (`src/settings.rs`) is a `Serialize`/`Deserialize` struct persisted as JSON at `{app_data_dir}/UltraLog/settings.json` (or `.../ultralog/settings.json` on Linux). It currently covers: `language`, `scroll_to_zoom`, `show_grid`, `grid_opacity`, `unit_preferences`, `font_scale`, `color_blind_mode`, `ultra_intensity`, `high_contrast`, `field_normalization`, `cursor_tracking`, `auto_check_updates`, `custom_normalizations`, and the Track Map preferences (`tile_provider`, `tile_cache_max_mb`, `tiles_enabled`, `tile_opacity`, `tile_grayscale`, `tile_privacy_notice_seen`, `hidden_widgets`), and `export` (the ExportDialog's last format and theme per format). +`UserSettings` (`src/settings.rs`) is a `Serialize`/`Deserialize` struct persisted as JSON at `{app_data_dir}/UltraLog/settings.json` (or `.../ultralog/settings.json` on Linux). It currently covers: `language`, `scroll_to_zoom`, `show_grid`, `hover_readout` (default on), `grid_opacity`, `unit_preferences`, `font_scale`, `color_blind_mode`, `ultra_intensity`, `high_contrast`, `field_normalization`, `cursor_tracking`, `auto_check_updates`, `custom_normalizations`, and the Track Map preferences (`tile_provider`, `tile_cache_max_mb`, `tiles_enabled`, `tile_opacity`, `tile_grayscale`, `tile_privacy_notice_seen`, `hidden_widgets`), and `export` (the ExportDialog's last format and theme per format). - **Load** - `UltraLogApp::new` calls `UserSettings::load()` and copies each field into the corresponding live `UltraLogApp` field (e.g. `app.color_blind_mode = user_settings.color_blind_mode`). - **Save** - `UltraLogApp` implements `eframe::App::save`, which eframe calls on its auto-save interval (~30s) and again at shutdown. `save()` rebuilds a `UserSettings` from the current live fields, and only writes to disk if it differs from the last-loaded/saved value. @@ -877,6 +877,7 @@ ColorScale, and the footer (ultralog.co · version · export date). - **Playback** - Play through log data at 0.25x to 8x speed - **Cursor Tracking** - Lock view to follow cursor during playback/scrubbing - **Min/Max Legend** - Shows peak values for each channel +- **Hover Readout** - A hairline and a value box at the mouse pointer with every series' value there (issue #98 follow-up), beside the cursor legend; `plot_style::hover_readout`, rows from `series_rows_at`, so log B and R4 apply. It turns off egui_plot's crosshair (`show_crosshair(!hover_readout)`); Settings → Display → Values at the pointer - **Initial Zoom** - Charts start zoomed to first 60 seconds for better initial view - **Multi-File Tabs** - Chrome-style tabs for working with multiple log files simultaneously - **Internationalization** - `rust-i18n`-backed language selection (`src/i18n.rs`) diff --git a/Cargo.lock b/Cargo.lock index b71534d..92a40d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4587,7 +4587,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "3.0.0" +version = "3.1.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 8fe1f64..bdfb09b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "3.0.0" +version = "3.1.0" edition = "2024" # egui/eframe 0.36 is the binding constraint on the minimum supported Rust # version; edition 2024 itself only needs 1.85. diff --git a/README.md b/README.md index 2e18629..e0b594d 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-3.0.0-green.svg) +![Version](https://img.shields.io/badge/version-3.1.0-green.svg) --- @@ -47,6 +47,7 @@ UltraLog is an **independent, open-source** desktop application designed for aut - **Normalized display** - All channels scaled 0-1 for easy comparison regardless of original units - **Min/Max legend** - Peak values displayed for each channel at a glance - **Real-time cursor values** - Legend shows live values at cursor position with proper units +- **Values at the pointer** - Move the mouse over the chart for a line and a box with every channel's value at that point, without clicking (turn it off in Settings → Display) - **High-performance rendering** - LTTB (Largest Triangle Three Buckets) algorithm reduces millions of points to 2,000 while preserving visual fidelity - **Stacked plot areas** - Split the chart into up to 10 independent vertically-stacked panels, each with its own Y-axis and channel set — ideal for comparing channels with wildly different scales diff --git a/i18n/ar.yaml b/i18n/ar.yaml index 007dcfd..73d8e9d 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -92,6 +92,8 @@ settings: scroll_to_zoom_desc: "عجلة الماوس تكبر المخطط مباشرة بدلاً من الإزاحة" show_grid: "إظهار الشبكة" show_grid_desc: "رسم شبكة الخلفية للرسم البياني" + hover_readout: "القيم عند المؤشر" + hover_readout_desc: "اعرض خطاً وقيمة كل قناة حيث تشير الفأرة على الرسم البياني" grid_opacity: "الشفافية:" field_names: "أسماء الحقول" field_normalization: "توحيد الحقول" diff --git a/i18n/bn.yaml b/i18n/bn.yaml index 323e17a..72e8033 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -92,6 +92,8 @@ settings: scroll_to_zoom_desc: "মাউস হুইল সরাসরি চার্টকে জুম করে প্যানিং এর পরিবর্তে" show_grid: "গ্রিড দেখান" show_grid_desc: "চার্ট পটভূমি গ্রিড আঁকুন" + hover_readout: "পয়েন্টারে মান" + hover_readout_desc: "চার্টে মাউস যেখানে আছে সেখানে একটি রেখা এবং প্রতিটি চ্যানেলের মান দেখান" grid_opacity: "স্বচ্ছতা:" field_names: "ফিল্ড নাম" field_normalization: "ফিল্ড নরমালাইজেশন" diff --git a/i18n/de.yaml b/i18n/de.yaml index bb3fac5..43b8a9b 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -92,6 +92,8 @@ settings: 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" + hover_readout: "Werte am Mauszeiger" + hover_readout_desc: "Zeigt eine Linie und den Wert jedes Kanals dort, wo die Maus im Diagramm steht" grid_opacity: "Deckkraft:" field_names: "Feldnamen" field_normalization: "Feldnormalisierung" diff --git a/i18n/en.yaml b/i18n/en.yaml index bfe04d7..3a57c5b 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -92,6 +92,8 @@ settings: 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" + hover_readout: "Values at the pointer" + hover_readout_desc: "Show a line and every channel's value where the mouse points on the chart" grid_opacity: "Opacity" field_names: "Field Names" field_normalization: "Field normalization" diff --git a/i18n/es.yaml b/i18n/es.yaml index ee1c304..5efdf31 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -70,6 +70,8 @@ settings: 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" + hover_readout: "Valores en el puntero" + hover_readout_desc: "Muestra una línea y el valor de cada canal donde apunta el ratón en el gráfico" grid_opacity: "Opacidad:" field_names: "Nombres de Campos" field_normalization: "Normalizacion de Campos" diff --git a/i18n/fr.yaml b/i18n/fr.yaml index d1001d2..e463592 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -92,6 +92,8 @@ settings: 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" + hover_readout: "Valeurs sous le pointeur" + hover_readout_desc: "Affiche une ligne et la valeur de chaque canal là où pointe la souris sur le graphique" grid_opacity: "Opacité :" field_names: "Noms des champs" field_normalization: "Normalisation des champs" diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 8e8d0c0..2a09fdd 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -92,6 +92,8 @@ settings: scroll_to_zoom_desc: "माउस व्हील सीधे चार्ट को ज़ूम करता है, पैन करने के बजाय" show_grid: "ग्रिड दिखाएं" show_grid_desc: "चार्ट पृष्ठभूमि ग्रिड बनाएं" + hover_readout: "पॉइंटर पर मान" + hover_readout_desc: "चार्ट पर जहाँ माउस है वहाँ एक रेखा और हर चैनल का मान दिखाएँ" grid_opacity: "अस्पष्टता:" field_names: "फ़ील्ड नाम" field_normalization: "फ़ील्ड नॉर्मलाइज़ेशन" diff --git a/i18n/id.yaml b/i18n/id.yaml index 4dfda2d..59f79a6 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -92,6 +92,8 @@ settings: scroll_to_zoom_desc: "Roda mouse memperbesar grafik secara langsung daripada menggeser" show_grid: "Tampilkan Kisi" show_grid_desc: "Gambar kisi latar belakang bagan" + hover_readout: "Nilai di penunjuk" + hover_readout_desc: "Tampilkan garis dan nilai setiap kanal di tempat mouse menunjuk pada grafik" grid_opacity: "Opacity:" field_names: "Nama Field" field_normalization: "Normalisasi Field" diff --git a/i18n/it.yaml b/i18n/it.yaml index be20990..aecc116 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -92,6 +92,8 @@ settings: 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" + hover_readout: "Valori al puntatore" + hover_readout_desc: "Mostra una linea e il valore di ogni canale dove punta il mouse sul grafico" grid_opacity: "Opacità:" field_names: "Nomi dei Campi" field_normalization: "Normalizzazione Campi" diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 791a811..6ac32ad 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -92,6 +92,8 @@ settings: scroll_to_zoom_desc: "マウスホイールでチャートを直接ズームします(パンの代わりに)" show_grid: "グリッドを表示" show_grid_desc: "チャートの背景グリッドを描画" + hover_readout: "ポインター位置の値" + hover_readout_desc: "チャート上でマウスが指す位置に線と各チャンネルの値を表示します" grid_opacity: "不透明度:" field_names: "フィールド名" field_normalization: "フィールド正規化" diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index 67c07f0..9c66f5b 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -70,6 +70,8 @@ settings: 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" + hover_readout: "Valores no ponteiro" + hover_readout_desc: "Mostra uma linha e o valor de cada canal onde o mouse aponta no gráfico" grid_opacity: "Opacidade:" field_names: "Nomes dos Campos" field_normalization: "Normalização de Campos" diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index 5c1a11d..11e9982 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -92,6 +92,8 @@ settings: 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" + hover_readout: "Valores no ponteiro" + hover_readout_desc: "Mostra uma linha e o valor de cada canal onde o rato aponta no gráfico" grid_opacity: "Opacidade:" field_names: "Nomes dos Campos" field_normalization: "Normalização de Campos" diff --git a/i18n/ru.yaml b/i18n/ru.yaml index d05b753..06d2a33 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -92,6 +92,8 @@ settings: scroll_to_zoom_desc: "Колесико мыши увеличивает график напрямую вместо панорамирования" show_grid: "Показывать сетку" show_grid_desc: "Отображать координатную сетку на графике" + hover_readout: "Значения под указателем" + hover_readout_desc: "Показывает линию и значение каждого канала там, где на графике находится мышь" grid_opacity: "Прозрачность:" field_names: "Имена полей" field_normalization: "Нормализация полей" diff --git a/i18n/ur.yaml b/i18n/ur.yaml index 69ea4b7..29f99d0 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -93,6 +93,8 @@ settings: scroll_to_zoom_desc: "ماؤس وہیل براہ راست چارٹ کو بڑھاتا ہے بجائے پیننگ کے" show_grid: "گرڈ دکھائیں" show_grid_desc: "چار्ट کی پس منظر کی شبکہ کھینچیں" + hover_readout: "پوائنٹر پر قدریں" + hover_readout_desc: "چارٹ پر جہاں ماؤس ہے وہاں ایک لکیر اور ہر چینل کی قدر دکھائیں" grid_opacity: "شفافیت:" field_names: "فیلڈ کے نام" field_normalization: "فیلڈ نارملائزیشن" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index d0e5138..133abd6 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -70,6 +70,8 @@ settings: scroll_to_zoom_desc: "鼠标滚轮直接缩放图表,而不是平移" show_grid: "显示网格" show_grid_desc: "绘制图表背景网格" + hover_readout: "指针处的数值" + hover_readout_desc: "在图表上鼠标所指的位置显示一条线和每个通道的数值" grid_opacity: "不透明度:" field_names: "字段名称" field_normalization: "字段标准化" diff --git a/src/app.rs b/src/app.rs index 0510a48..a73d077 100644 --- a/src/app.rs +++ b/src/app.rs @@ -135,6 +135,8 @@ pub struct UltraLogApp { pub(crate) scroll_to_zoom: bool, /// When true, draw the chart background grid pub(crate) show_grid: bool, + /// When true, the chart shows a line and a value box at the mouse pointer + pub(crate) hover_readout: bool, /// Grid line opacity (0..=255) used as the alpha of the base grid color pub(crate) grid_opacity: u8, // === Unit Preferences === @@ -278,6 +280,7 @@ impl Default for UltraLogApp { initial_view_seconds: 60.0, // Start with 60 second view scroll_to_zoom: false, show_grid: true, + hover_readout: true, grid_opacity: 255, unit_preferences: UnitPreferences::default(), font_scale: FontScale::default(), @@ -340,6 +343,7 @@ impl UltraLogApp { language: user_settings.language, scroll_to_zoom: user_settings.scroll_to_zoom, show_grid: user_settings.show_grid, + hover_readout: user_settings.hover_readout, grid_opacity: user_settings.grid_opacity, unit_preferences: user_settings.unit_preferences.clone(), font_scale: user_settings.font_scale, @@ -2632,6 +2636,7 @@ impl eframe::App for UltraLogApp { language: self.language, scroll_to_zoom: self.scroll_to_zoom, show_grid: self.show_grid, + hover_readout: self.hover_readout, grid_opacity: self.grid_opacity, unit_preferences: self.unit_preferences.clone(), font_scale: self.font_scale, diff --git a/src/settings.rs b/src/settings.rs index c58f640..ee479f5 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -26,6 +26,10 @@ pub struct UserSettings { /// When true, draw the chart background grid #[serde(default = "default_show_grid")] pub show_grid: bool, + /// When true, the chart shows a line and a value box at the mouse pointer (issue #98). + /// On by default, also for settings files from before it existed. + #[serde(default = "default_hover_readout")] + pub hover_readout: 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")] @@ -106,6 +110,10 @@ fn default_show_grid() -> bool { true } +fn default_hover_readout() -> bool { + true +} + fn default_grid_opacity() -> u8 { 255 } @@ -129,6 +137,7 @@ impl Default for UserSettings { language: Language::default(), scroll_to_zoom: false, show_grid: default_show_grid(), + hover_readout: default_hover_readout(), grid_opacity: default_grid_opacity(), unit_preferences: UnitPreferences::default(), font_scale: FontScale::default(), diff --git a/src/state.rs b/src/state.rs index a39ce94..dd7234f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -646,11 +646,6 @@ impl PlotArea { collapsed: false, } } - - /// Get the number of channels in this plot area - pub fn channel_count(&self) -> usize { - self.channel_indices.len() - } } // ============================================================================ diff --git a/src/ui/chart.rs b/src/ui/chart.rs index af0e379..c6d328a 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -149,6 +149,13 @@ impl UltraLogApp { let plot = Plot::new("log_chart") .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) + // The hover readout draws its own time line; egui_plot's crosshair adds a y line, + // which means nothing on the normalized axis. + .show_crosshair(!self.hover_readout) + // egui_plot's hover dot and coordinate label snap to the nearest 2-D sample, at a + // different time than the readout line, and cost a scan of every point. + .show_x(!self.hover_readout) + .show_y(!self.hover_readout) .show_grid([show_grid, show_grid]) .grid_color(grid_color) .allow_zoom([true, false]) // Only allow X-axis zoom @@ -273,6 +280,7 @@ impl UltraLogApp { chart_points, cursor_time, plot_id, + &selected_channels, ); // Detect user interaction with chart (drag, zoom, scroll) @@ -528,6 +536,13 @@ impl UltraLogApp { .height(height) .y_axis_label("") .show_axes([true, false]) + // The hover readout draws its own time line; egui_plot's crosshair adds a y line, + // which means nothing on the normalized axis. + .show_crosshair(!self.hover_readout) + // egui_plot's hover dot and coordinate label snap to the nearest 2-D sample, at a + // different time than the readout line, and cost a scan of every point. + .show_x(!self.hover_readout) + .show_y(!self.hover_readout) .show_grid([show_grid, show_grid]) .grid_color(grid_color) .allow_zoom([true, false]) @@ -608,6 +623,7 @@ impl UltraLogApp { chart_points, cursor_time, plot_id, + channels, ); // Detect interaction @@ -698,15 +714,16 @@ impl UltraLogApp { .color(self.tokens.ink), ); - // Channel count + // Channel count: a compare pair counts as one (R2) + let units = self + .active_tab + .map_or(plot_area.channel_indices.len(), |idx| { + self.tabs[idx].channel_units(&plot_area.channel_indices) + }); ui.label( - egui::RichText::new(format!( - "({}/{})", - plot_area.channel_count(), - 10 // MAX_CHANNELS_PER_PLOT - )) - .text_style(fonts::text_style(style::DATA_SM)) - .color(self.tokens.ink_subtle), + egui::RichText::new(format!("({units}/{})", crate::state::MAX_CHANNELS_PER_PLOT)) + .text_style(fonts::text_style(style::DATA_SM)) + .color(self.tokens.ink_subtle), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -782,7 +799,13 @@ impl UltraLogApp { /// Legend / line rows for `channels`: normalized name, value at the cursor (converted to the /// user's units, mono column), and the series colour and stroke. fn series_rows(&self, channels: &[SelectedChannel]) -> Vec { - let cursor_time = self.get_cursor_time(); + self.series_rows_at(channels, self.get_cursor_time()) + } + + /// Legend rows with each channel's value at tab time `time` (the cursor for the legend, the + /// pointer for the hover readout). + fn series_rows_at(&self, channels: &[SelectedChannel], time: Option) -> Vec { + let cursor_time = time; channels .iter() .map(|selected| { @@ -835,12 +858,13 @@ impl UltraLogApp { points: &[Option>], cursor_time: Option, plot_id: egui::Id, + channels: &[SelectedChannel], ) { let plot_rect = *response.transform.frame(); if style.cvd_like { plot_style::direct_labels(ui, &response.transform, rows, points); } - let hovered = plot_style::legend_overlay( + let (hovered, legend_rect) = plot_style::legend_overlay( ui, plot_rect, style, @@ -848,6 +872,31 @@ impl UltraLogApp { cursor_time.map(Self::format_time), plot_id, ); + // The hover readout (issue #98): values at the pointer, beside the cursor legend. Off + // while panning and over the legend; only the line while picking an alignment point. + if self.hover_readout + && !response.response.dragged() + && response.response.hovered() + && let Some(pointer) = ui.ctx().pointer_hover_pos() + && !legend_rect.is_some_and(|r| r.contains(pointer)) + { + if self.picking_alignment() { + plot_style::hover_line(ui, plot_rect, style, pointer); + } else if hovered.is_none() { + // This frame's transform: `response.inner` (pointer_coordinate) uses last + // frame's, which lags during cursor-tracking playback and zoom. + let time = response.transform.value_from_position(pointer).x; + let hover_rows = self.series_rows_at(channels, Some(time)); + plot_style::hover_readout( + ui, + plot_rect, + style, + &hover_rows, + &Self::format_time(time), + pointer, + ); + } + } if hovered != style.solo { ui.ctx().request_repaint(); } @@ -1217,6 +1266,23 @@ mod tests { } } + #[test] + fn hover_rows_read_the_pointer_time_not_the_cursor() { + let mut app = comparing_app(); + app.set_compare_offset(0, -5.0); + app.tabs[0].cursor_time = Some(10.0); + let channels = app.tabs[0].selected_channels.clone(); + // The cursor legend stays at 10 s; the pointer at 4 s reads A at 4 s and B at 9 s. + let cursor = app.series_rows(&channels); + let hover = app.series_rows_at(&channels, Some(4.0)); + assert_eq!(cursor[0].value.as_deref(), Some("0.75")); + assert_eq!(hover[0].value.as_deref(), Some("0.30")); + assert_eq!(hover[1].value.as_deref(), Some("0.81")); + assert_eq!(hover[1].tag, Some("B")); + // Past log B's end (file time 25 s > 20 s): a dash, not a stale value. + assert_eq!(app.series_rows_at(&channels, Some(19.5))[1].value, None); + } + #[test] fn legend_values_come_from_each_logs_own_record() { let mut app = comparing_app(); diff --git a/src/ui/plot_style.rs b/src/ui/plot_style.rs index 149df0c..67d782b 100644 --- a/src/ui/plot_style.rs +++ b/src/ui/plot_style.rs @@ -8,6 +8,8 @@ //! - Legend: a floating `plot_legend_bg` box (top-right; top-left plus direct end labels when the //! palette is color-blind safe) with the Cursor row first, then swatch, name and live value in //! mono. Hovering a row solos that series. +//! - Hover readout: under the mouse pointer a hairline and a box with every series' value at that +//! time (issue #98), beside the cursor legend, which keeps the clicked point. use eframe::egui::{ self, Align2, Color32, CornerRadius, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2, @@ -175,7 +177,8 @@ fn legend_fit(max_rows: usize, series: usize, cursor_rows: usize) -> (usize, usi } /// Paint the floating legend over `plot_rect` and return the row the pointer is on, if any -/// (used to solo that series next frame). `cursor` is the formatted cursor time. +/// (used to solo that series next frame), and the legend's rect (the hover readout stays off +/// it). `cursor` is the formatted cursor time. pub fn legend_overlay( ui: &Ui, plot_rect: Rect, @@ -183,7 +186,7 @@ pub fn legend_overlay( rows: &[SeriesRow], cursor: Option, id: Id, -) -> Option { +) -> (Option, Option) { let t = style.tokens; let name_font = fonts::text_style(style::LABEL).resolve(ui.style()); let value_font = fonts::text_style(style::DATA).resolve(ui.style()); @@ -222,7 +225,7 @@ pub fn legend_overlay( let count = cursor_rows + shown + usize::from(hidden > 0); if count > max_rows { // Not even the cursor row and a "+N" row fit. - return None; + return (None, None); } let height = pad * 2.0 + row_h * count as f32 - space::SPACE_2; let origin = if style.cvd_like { @@ -232,7 +235,7 @@ pub fn legend_overlay( }; let rect = Rect::from_min_size(origin, vec2(width, height)); if !plot_rect.contains_rect(rect) { - return None; + return (None, None); } let painter = ui.painter().with_clip_rect(plot_rect); @@ -325,7 +328,7 @@ pub fn legend_overlay( t.ink_subtle, ); } - hovered + (hovered, Some(rect)) } /// The legend / channel-card swatch: a short line in the series colour, dashed or dotted to @@ -409,9 +412,198 @@ pub fn solo_memory(ctx: &egui::Context, id: Id, update: Option>) - } } +/// Gap between the mouse pointer and the hover readout box. +const READOUT_OFFSET: f32 = 14.0; + +/// Where the hover readout box of `size` goes: below-right of the pointer, flipped left or up +/// when it would leave the plot, and kept inside the plot. +fn readout_origin(pointer: Pos2, size: egui::Vec2, plot: Rect) -> Pos2 { + let mut x = pointer.x + READOUT_OFFSET; + if x + size.x > plot.right() { + x = pointer.x - READOUT_OFFSET - size.x; + } + let mut y = pointer.y + READOUT_OFFSET; + if y + size.y > plot.bottom() { + y = pointer.y - READOUT_OFFSET - size.y; + } + pos2( + x.clamp(plot.left(), (plot.right() - size.x).max(plot.left())), + y.clamp(plot.top(), (plot.bottom() - size.y).max(plot.top())), + ) +} + +/// The hover readout's hairline alone: a full-height line at the pointer. Also drawn while +/// Align at cursor waits for its click, when the box is off. +pub fn hover_line(ui: &Ui, plot_rect: Rect, style: &PlotStyle<'_>, pointer: Pos2) { + let area = plot_rect.intersect(ui.clip_rect()); + if !area.contains(pointer) { + return; + } + ui.painter().with_clip_rect(area).line_segment( + [ + pos2(pointer.x, plot_rect.top()), + pos2(pointer.x, plot_rect.bottom()), + ], + Stroke::new(stroke::STROKE_HAIRLINE, style.tokens.ink_subtle), + ); +} + +/// The hover readout (issue #98): a hairline at the pointer and, beside it, a box with the time +/// and every series' value there (`rows` come from `series_rows_at` for the hover time). A +/// series with no data at that time (log B outside its span) shows a dash. +pub fn hover_readout( + ui: &Ui, + plot_rect: Rect, + style: &PlotStyle<'_>, + rows: &[SeriesRow], + time: &str, + pointer: Pos2, +) { + // Place and flip inside the part of the plot that is on screen: a stacked plot can be + // partly scrolled out of its ScrollArea. + let area = plot_rect.intersect(ui.clip_rect()); + if rows.is_empty() || !area.contains(pointer) { + return; + } + let t = style.tokens; + let painter = ui.painter().with_clip_rect(area); + hover_line(ui, plot_rect, style, pointer); + + let name_font = fonts::text_style(style::LABEL).resolve(ui.style()); + let value_font = fonts::text_style(style::DATA).resolve(ui.style()); + let row_h = (name_font.size.max(value_font.size) + space::SPACE_2).round(); + let swatch_w = 14.0; + let measure = |text: &str, font: &FontId| { + ui.painter() + .layout_no_wrap(text.to_owned(), font.clone(), t.ink) + .size() + .x + }; + let dash = "\u{2013}"; + let mut name_w: f32 = 0.0; + let mut value_w = measure(time, &value_font); + let mut tag_w: f32 = 0.0; + for row in rows { + name_w = name_w.max(measure(&row.name, &name_font)); + value_w = value_w.max(measure(row.value.as_deref().unwrap_or(dash), &value_font)); + if let Some(tag) = row.tag { + tag_w = tag_w.max(measure(tag, &value_font) + space::SPACE_2); + } + } + let pad = space::SPACE_2 + 2.0; + let width = pad + swatch_w + space::SPACE_2 + tag_w + name_w + space::SPACE_6 + value_w + pad; + // A short plot lists the rows that fit and a "+N" row for the rest, like the legend. + let max_rows = ((area.height() - 2.0 * pad + space::SPACE_2) / row_h) + .floor() + .max(0.0) as usize; + let (shown, hidden) = legend_fit(max_rows, rows.len(), 1); + let count = 1 + shown + usize::from(hidden > 0); + if count > max_rows { + return; + } + let height = pad * 2.0 + row_h * count as f32 - space::SPACE_2; + let origin = readout_origin(pointer, vec2(width, height), area); + let rect = Rect::from_min_size(origin, vec2(width, height)); + painter.rect( + rect, + CornerRadius::same(radius::RADIUS_MD), + t.plot_legend_bg, + Stroke::new(stroke::STROKE_HAIRLINE, t.line), + egui::StrokeKind::Inside, + ); + + let swatch_x = rect.left() + pad; + let name_x = swatch_x + swatch_w + space::SPACE_2; + let value_x = rect.right() - pad; + let mut y = rect.top() + pad; + let cy = y + row_h / 2.0 - space::SPACE_1; + painter.text( + pos2(value_x, cy), + Align2::RIGHT_CENTER, + time, + value_font.clone(), + t.ink_muted, + ); + y += row_h; + for row in rows.iter().take(shown) { + let cy = y + row_h / 2.0 - space::SPACE_1; + swatch( + &painter, + pos2(swatch_x, cy), + swatch_w, + row.color, + row.stroke, + ); + if let Some(tag) = row.tag { + painter.text( + pos2(name_x, cy), + Align2::LEFT_CENTER, + tag, + value_font.clone(), + t.ink_muted, + ); + } + painter.text( + pos2(name_x + tag_w, cy), + Align2::LEFT_CENTER, + &row.name, + name_font.clone(), + t.ink, + ); + let (value, color) = match &row.value { + Some(v) => (v.as_str(), t.ink), + None => (dash, t.ink_subtle), + }; + painter.text( + pos2(value_x, cy), + Align2::RIGHT_CENTER, + value, + value_font.clone(), + color, + ); + y += row_h; + } + if hidden > 0 { + painter.text( + pos2(name_x, y + row_h / 2.0 - space::SPACE_1), + Align2::LEFT_CENTER, + format!("+{hidden}"), + value_font, + t.ink_subtle, + ); + } +} + #[cfg(test)] mod tests { - use super::legend_fit; + use super::{legend_fit, readout_origin}; + use eframe::egui::{Rect, pos2, vec2}; + + #[test] + fn the_readout_stays_inside_the_plot() { + let plot = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 400.0)); + let size = vec2(200.0, 100.0); + // Room below-right: there. + assert_eq!( + readout_origin(pos2(100.0, 100.0), size, plot), + pos2(114.0, 114.0) + ); + // Near the right edge: flips left of the pointer. + assert_eq!( + readout_origin(pos2(700.0, 100.0), size, plot), + pos2(486.0, 114.0) + ); + // Near the bottom: flips above the pointer. + assert_eq!( + readout_origin(pos2(100.0, 390.0), size, plot), + pos2(114.0, 276.0) + ); + // A box larger than the plot pins to the top-left. + assert_eq!( + readout_origin(pos2(10.0, 10.0), vec2(900.0, 500.0), plot), + pos2(0.0, 0.0) + ); + } #[test] fn legend_keeps_the_rows_that_fit_and_counts_the_rest() { diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs index ee442a1..feebc49 100644 --- a/src/ui/settings_panel.rs +++ b/src/ui/settings_panel.rs @@ -314,6 +314,23 @@ impl UltraLogApp { ui.add_space(8.0); + // Values at the mouse pointer (issue #98) + let old_hover_readout = self.hover_readout; + components::checkbox(ui, &mut self.hover_readout, t!("settings.hover_readout")); + ui.label( + egui::RichText::new(t!("settings.hover_readout_desc")) + .small() + .color(self.tokens.ink_subtle), + ); + if self.hover_readout != old_hover_readout { + self.user_settings.hover_readout = self.hover_readout; + if let Err(e) = self.user_settings.save() { + 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; components::checkbox(ui, &mut self.show_grid, t!("settings.show_grid")); diff --git a/tests/core/settings_tests.rs b/tests/core/settings_tests.rs index 201b86b..4cff135 100644 --- a/tests/core/settings_tests.rs +++ b/tests/core/settings_tests.rs @@ -345,6 +345,16 @@ fn test_settings_from_before_3_0_open_at_stealth() { assert!(settings.color_blind_mode); } +#[test] +fn test_hover_readout_is_on_by_default_and_for_old_files() { + assert!(UserSettings::default().hover_readout); + let json = r#"{"version":1,"language":"English"}"#; + let settings: UserSettings = serde_json::from_str(json).unwrap(); + assert!(settings.hover_readout); + let off: UserSettings = serde_json::from_str(r#"{"version":1,"hover_readout":false}"#).unwrap(); + assert!(!off.hover_readout); +} + #[test] fn test_settings_default_theme_fields() { let settings = UserSettings::default();