From 60ac3a2db96541df02936281aac69215bdaa9121 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 30 Dec 2025 22:48:44 -0500 Subject: [PATCH] Initial restructure of UI to make it more clear and easy to use Signed-off-by: Cole Gentry --- src/app.rs | 129 ++++++-- src/state.rs | 36 +++ src/ui/activity_bar.rs | 225 ++++++++++++++ src/ui/channels.rs | 172 ++++++----- src/ui/channels_panel.rs | 295 ++++++++++++++++++ src/ui/computed_channels_manager.rs | 4 +- src/ui/files_panel.rs | 282 +++++++++++++++++ src/ui/menu.rs | 447 +++++---------------------- src/ui/mod.rs | 30 +- src/ui/settings_panel.rs | 460 ++++++++++++++++++++++++++++ src/ui/side_panel.rs | 35 +++ src/ui/tools_panel.rs | 356 +++++++++++++++++++++ 12 files changed, 1980 insertions(+), 491 deletions(-) create mode 100644 src/ui/activity_bar.rs create mode 100644 src/ui/channels_panel.rs create mode 100644 src/ui/files_panel.rs create mode 100644 src/ui/settings_panel.rs create mode 100644 src/ui/side_panel.rs create mode 100644 src/ui/tools_panel.rs diff --git a/src/app.rs b/src/app.rs index 5c6efd4..765808b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,9 +16,9 @@ use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; use crate::parsers::{Aim, EcuMaster, EcuType, Haltech, Link, Parseable, RomRaider, Speeduino}; use crate::state::{ - ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, - ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, COLORBLIND_COLORS, - MAX_CHANNELS, + ActivePanel, ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, + ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, + COLORBLIND_COLORS, MAX_CHANNELS, }; use crate::units::UnitPreferences; use crate::updater::{DownloadResult, UpdateCheckResult, UpdateState}; @@ -92,6 +92,9 @@ pub struct UltraLogApp { // === Tool/View Selection === /// Currently active tool/view pub(crate) active_tool: ActiveTool, + // === Panel Selection === + /// Currently active side panel (activity bar selection) + pub(crate) active_panel: ActivePanel, // === Tab Management === /// Open tabs (one per log file being viewed) pub(crate) tabs: Vec, @@ -161,6 +164,7 @@ impl Default for UltraLogApp { norm_editor_custom_source: String::new(), norm_editor_custom_target: String::new(), active_tool: ActiveTool::default(), + active_panel: ActivePanel::default(), tabs: Vec::new(), active_tab: None, update_state: UpdateState::default(), @@ -1233,18 +1237,78 @@ impl UltraLogApp { /// Handle keyboard shortcuts fn handle_keyboard_shortcuts(&mut self, ctx: &egui::Context) { - // Only handle shortcuts when we have data loaded - if self.files.is_empty() || self.get_selected_channels().is_empty() { - return; - } - // Don't handle shortcuts when a text field or other widget has keyboard focus if ctx.memory(|m| m.focused().is_some()) { return; } - // Spacebar to toggle play/pause ctx.input(|i| { + let cmd = i.modifiers.command; + let shift = i.modifiers.shift; + + // ⌘O - Open file + if cmd && i.key_pressed(egui::Key::O) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + return; + } + + // ⌘W - Close current tab + if cmd && i.key_pressed(egui::Key::W) { + if let Some(tab_idx) = self.active_tab { + self.close_tab(tab_idx); + } + return; + } + + // ⌘, - Open Settings panel + if cmd && i.key_pressed(egui::Key::Comma) { + self.active_panel = crate::state::ActivePanel::Settings; + return; + } + + // ⌘1/2/3 - Switch tool modes + if cmd && !shift { + if i.key_pressed(egui::Key::Num1) { + self.active_tool = crate::state::ActiveTool::LogViewer; + return; + } + if i.key_pressed(egui::Key::Num2) { + self.active_tool = crate::state::ActiveTool::ScatterPlot; + return; + } + if i.key_pressed(egui::Key::Num3) { + self.active_tool = crate::state::ActiveTool::Histogram; + return; + } + } + + // ⌘⇧F/C/T - Switch panels + if cmd && shift { + if i.key_pressed(egui::Key::F) { + self.active_panel = crate::state::ActivePanel::Files; + return; + } + if i.key_pressed(egui::Key::C) { + self.active_panel = crate::state::ActivePanel::Channels; + return; + } + if i.key_pressed(egui::Key::T) { + self.active_panel = crate::state::ActivePanel::Tools; + return; + } + } + + // Playback shortcuts (require file loaded with channels selected) + if self.files.is_empty() || self.get_selected_channels().is_empty() { + return; + } + + // Spacebar to toggle play/pause if i.key_pressed(egui::Key::Space) { self.is_playing = !self.is_playing; if self.is_playing { @@ -1347,41 +1411,38 @@ impl eframe::App for UltraLogApp { self.render_tool_switcher(ui); }); - // Panel background color (matches drop zone card) + // Panel background color let panel_bg = egui::Color32::from_rgb(45, 45, 45); let panel_frame = egui::Frame::NONE .fill(panel_bg) .inner_margin(egui::Margin::symmetric(10, 10)); - // Left sidebar panel (always visible) - egui::SidePanel::left("files_panel") - .default_width(200.0) + // Activity bar (far left, narrow icon strip) + let activity_bar_bg = egui::Color32::from_rgb(35, 35, 35); + let activity_bar_frame = egui::Frame::NONE + .fill(activity_bar_bg) + .inner_margin(egui::Margin::symmetric(4, 8)); + + egui::SidePanel::left("activity_bar") + .exact_width(crate::ui::activity_bar::ACTIVITY_BAR_WIDTH) + .resizable(false) + .frame(activity_bar_frame) + .show(ctx, |ui| { + self.render_activity_bar(ui); + }); + + // Side panel (context-sensitive based on activity bar selection) + egui::SidePanel::left("side_panel") + .default_width(crate::ui::side_panel::SIDE_PANEL_WIDTH) + .min_width(crate::ui::side_panel::SIDE_PANEL_MIN_WIDTH) .resizable(true) .frame(panel_frame) .show(ctx, |ui| { - self.render_sidebar(ui); + self.render_side_panel(ui); }); - // Right panel for channel selection (only in Log Viewer mode) - if self.active_tool == ActiveTool::LogViewer { - egui::SidePanel::right("channels_panel") - .default_width(300.0) - .min_width(200.0) - .resizable(true) - .frame(panel_frame) - .show(ctx, |ui| { - self.render_channel_selection(ui); - }); - } - - // Bottom panel for timeline scrubber (Log Viewer and Histogram modes) - let show_timeline = match self.active_tool { - ActiveTool::LogViewer => { - self.get_time_range().is_some() && !self.get_selected_channels().is_empty() - } - ActiveTool::Histogram => self.get_time_range().is_some(), - ActiveTool::ScatterPlot => false, - }; + // Bottom panel for timeline scrubber (always visible when file loaded) + let show_timeline = self.get_time_range().is_some(); if show_timeline { egui::TopBottomPanel::bottom("timeline_panel") diff --git a/src/state.rs b/src/state.rs index f9fdb23..e8fa623 100644 --- a/src/state.rs +++ b/src/state.rs @@ -195,6 +195,42 @@ impl ActiveTool { } } +/// The currently active side panel in the activity bar +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum ActivePanel { + /// Files panel - file management, loading, file list + #[default] + Files, + /// Channels panel - channel selection and selected channels + Channels, + /// Tools panel - analysis tools, computed channels, export + Tools, + /// Settings panel - all preferences consolidated + Settings, +} + +impl ActivePanel { + /// Get the display name for this panel + pub fn name(&self) -> &'static str { + match self { + ActivePanel::Files => "Files", + ActivePanel::Channels => "Channels", + ActivePanel::Tools => "Tools", + ActivePanel::Settings => "Settings", + } + } + + /// Get the icon character for this panel (using Unicode symbols) + pub fn icon(&self) -> &'static str { + match self { + ActivePanel::Files => "\u{1F4C1}", // Folder icon + ActivePanel::Channels => "\u{1F4CA}", // Chart icon + ActivePanel::Tools => "\u{1F527}", // Wrench icon + ActivePanel::Settings => "\u{2699}", // Gear icon + } + } +} + /// Font scale preference for UI elements #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum FontScale { diff --git a/src/ui/activity_bar.rs b/src/ui/activity_bar.rs new file mode 100644 index 0000000..b4b7c94 --- /dev/null +++ b/src/ui/activity_bar.rs @@ -0,0 +1,225 @@ +//! Activity bar component - VS Code-style vertical icon strip for panel navigation. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActivePanel; + +/// Width of the activity bar in pixels +pub const ACTIVITY_BAR_WIDTH: f32 = 48.0; + +/// Size of icons in the activity bar +const ICON_SIZE: f32 = 24.0; + +/// Padding around icons (reserved for future use) +#[allow(dead_code)] +const ICON_PADDING: f32 = 12.0; + +impl UltraLogApp { + /// Render the activity bar (vertical icon strip on the far left) + pub fn render_activity_bar(&mut self, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.add_space(8.0); + + // Render each panel icon + for panel in [ + ActivePanel::Files, + ActivePanel::Channels, + ActivePanel::Tools, + ActivePanel::Settings, + ] { + let is_selected = self.active_panel == panel; + self.render_activity_icon(ui, panel, is_selected); + ui.add_space(4.0); + } + }); + } + + /// Render a single activity bar icon button + fn render_activity_icon(&mut self, ui: &mut egui::Ui, panel: ActivePanel, is_selected: bool) { + let button_size = egui::vec2(ACTIVITY_BAR_WIDTH - 8.0, ACTIVITY_BAR_WIDTH - 8.0); + + // Colors + let bg_color = if is_selected { + egui::Color32::from_rgb(60, 60, 60) + } else { + egui::Color32::TRANSPARENT + }; + + let icon_color = if is_selected { + egui::Color32::WHITE + } else { + egui::Color32::from_rgb(150, 150, 150) + }; + + let hover_bg = egui::Color32::from_rgb(50, 50, 50); + + // Selected indicator bar on the left + let indicator_color = egui::Color32::from_rgb(113, 120, 78); // Olive green accent + + let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); + + if response.clicked() { + self.active_panel = panel; + } + + let is_hovered = response.hovered(); + + // Draw background + let final_bg = if is_hovered && !is_selected { + hover_bg + } else { + bg_color + }; + + if final_bg != egui::Color32::TRANSPARENT { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(4), final_bg); + } + + // Draw selection indicator bar on left edge + if is_selected { + let indicator_rect = + egui::Rect::from_min_size(rect.left_top(), egui::vec2(3.0, rect.height())); + ui.painter() + .rect_filled(indicator_rect, egui::CornerRadius::ZERO, indicator_color); + } + + // Draw the icon + let center = rect.center(); + self.draw_panel_icon(ui, center, ICON_SIZE, icon_color, panel); + + // Tooltip on hover + if is_hovered { + response.on_hover_text(panel.name()); + } + } + + /// Draw the icon for a specific panel type + fn draw_panel_icon( + &self, + ui: &egui::Ui, + center: egui::Pos2, + size: f32, + color: egui::Color32, + panel: ActivePanel, + ) { + let painter = ui.painter(); + let half = size / 2.0; + + match panel { + ActivePanel::Files => { + // Folder icon + let folder_width = size * 0.9; + let folder_height = size * 0.7; + let tab_width = folder_width * 0.4; + let tab_height = folder_height * 0.15; + + // Main folder body + let body_rect = egui::Rect::from_center_size( + egui::pos2(center.x, center.y + tab_height / 2.0), + egui::vec2(folder_width, folder_height - tab_height), + ); + painter.rect_stroke( + body_rect, + egui::CornerRadius::same(2), + egui::Stroke::new(1.5, color), + egui::StrokeKind::Outside, + ); + + // Folder tab + let tab_rect = egui::Rect::from_min_size( + egui::pos2(body_rect.left() + 2.0, body_rect.top() - tab_height), + egui::vec2(tab_width, tab_height + 2.0), + ); + painter.rect_filled(tab_rect, egui::CornerRadius::same(1), color); + } + ActivePanel::Channels => { + // Bar chart icon + let bar_width = size * 0.15; + let spacing = size * 0.22; + let base_y = center.y + half * 0.6; + + // Three bars of different heights + let heights = [size * 0.5, size * 0.8, size * 0.35]; + let start_x = center.x - spacing; + + for (i, &height) in heights.iter().enumerate() { + let x = start_x + (i as f32) * spacing; + let bar_rect = egui::Rect::from_min_max( + egui::pos2(x - bar_width / 2.0, base_y - height), + egui::pos2(x + bar_width / 2.0, base_y), + ); + painter.rect_filled(bar_rect, egui::CornerRadius::same(1), color); + } + } + ActivePanel::Tools => { + // Wrench icon + let stroke = egui::Stroke::new(2.0, color); + + // Handle (diagonal line) + let handle_start = egui::pos2(center.x - half * 0.5, center.y + half * 0.5); + let handle_end = egui::pos2(center.x + half * 0.1, center.y - half * 0.1); + painter.line_segment([handle_start, handle_end], stroke); + + // Wrench head (arc-like shape using lines) + let head_center = egui::pos2(center.x + half * 0.25, center.y - half * 0.25); + let head_radius = half * 0.45; + + // Draw wrench head as a partial circle with opening + let segments = 8; + let start_angle = std::f32::consts::PI * 0.25; + let end_angle = std::f32::consts::PI * 1.75; + let angle_step = (end_angle - start_angle) / segments as f32; + + for i in 0..segments { + let a1 = start_angle + (i as f32) * angle_step; + let a2 = start_angle + ((i + 1) as f32) * angle_step; + let p1 = egui::pos2( + head_center.x + head_radius * a1.cos(), + head_center.y + head_radius * a1.sin(), + ); + let p2 = egui::pos2( + head_center.x + head_radius * a2.cos(), + head_center.y + head_radius * a2.sin(), + ); + painter.line_segment([p1, p2], stroke); + } + } + ActivePanel::Settings => { + // Gear icon + let outer_radius = half * 0.85; + let inner_radius = half * 0.45; + let teeth = 8; + let tooth_depth = half * 0.2; + + // Draw gear teeth + for i in 0..teeth { + let angle = (i as f32) * std::f32::consts::TAU / teeth as f32; + let next_angle = ((i as f32) + 0.5) * std::f32::consts::TAU / teeth as f32; + + let outer_point = egui::pos2( + center.x + outer_radius * angle.cos(), + center.y + outer_radius * angle.sin(), + ); + let inner_point = egui::pos2( + center.x + (outer_radius - tooth_depth) * next_angle.cos(), + center.y + (outer_radius - tooth_depth) * next_angle.sin(), + ); + + painter.line_segment([outer_point, inner_point], egui::Stroke::new(2.0, color)); + } + + // Draw outer circle + painter.circle_stroke( + center, + outer_radius - tooth_depth / 2.0, + egui::Stroke::new(2.0, color), + ); + + // Draw inner circle (hole) + painter.circle_stroke(center, inner_radius, egui::Stroke::new(1.5, color)); + } + } + } +} diff --git a/src/ui/channels.rs b/src/ui/channels.rs index 2fde154..b0e4a7a 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -398,24 +398,104 @@ impl UltraLogApp { .corner_radius(5) .inner_margin(10.0) .show(ui, |ui| { - ui.vertical(|ui| { - ui.horizontal(|ui| { - // Show computed channel indicator - if card.is_computed { + // Use horizontal layout with content on left, close button on right + ui.horizontal(|ui| { + // Main content column + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Show computed channel indicator + if card.is_computed { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::from_rgb(150, 200, 255)) + .strong(), + ) + .on_hover_text("Computed channel (formula-based)"); + } ui.label( - egui::RichText::new("ƒ") - .color(egui::Color32::from_rgb(150, 200, 255)) - .strong(), - ) - .on_hover_text("Computed channel (formula-based)"); + egui::RichText::new(&card.display_name) + .strong() + .color(card.color) + .size(font_14), + ); + }); + + // Show min with jump button + if let Some(min_str) = &card.min_str { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Min:") + .color(egui::Color32::GRAY) + .size(font_12), + ); + ui.label( + egui::RichText::new(min_str) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + if let (Some(record), Some(time)) = + (card.min_record, card.min_time) + { + let btn = ui + .small_button("⏵") + .on_hover_text("Jump to minimum"); + if btn.clicked() { + jump_to = Some((record, time)); + } + if btn.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + } + }); } - ui.label( - egui::RichText::new(&card.display_name) - .strong() - .color(card.color) - .size(font_14), + + // Show max with jump button + if let Some(max_str) = &card.max_str { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Max:") + .color(egui::Color32::GRAY) + .size(font_12), + ); + ui.label( + egui::RichText::new(max_str) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + if let (Some(record), Some(time)) = + (card.max_record, card.max_time) + { + let btn = ui + .small_button("⏵") + .on_hover_text("Jump to maximum"); + if btn.clicked() { + jump_to = Some((record, time)); + } + if btn.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + } + }); + } + }); + + // Close button in top right + ui.add_space(8.0); + ui.vertical(|ui| { + let close_btn = ui.add( + egui::Button::new( + egui::RichText::new("✕") + .size(font_12) + .color(egui::Color32::from_rgb(150, 150, 150)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .corner_radius(2.0), ); - let close_btn = ui.small_button("x"); if close_btn.clicked() { channel_to_remove = Some(i); } @@ -423,68 +503,6 @@ impl UltraLogApp { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } }); - - // Show min with jump button - if let Some(min_str) = &card.min_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Min:") - .color(egui::Color32::GRAY) - .size(font_12), - ); - ui.label( - egui::RichText::new(min_str) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), - ); - if let (Some(record), Some(time)) = - (card.min_record, card.min_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to minimum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } - - // Show max with jump button - if let Some(max_str) = &card.max_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Max:") - .color(egui::Color32::GRAY) - .size(font_12), - ); - ui.label( - egui::RichText::new(max_str) - .color(egui::Color32::LIGHT_GRAY) - .size(font_14), - ); - if let (Some(record), Some(time)) = - (card.max_record, card.max_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to maximum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } }); }); diff --git a/src/ui/channels_panel.rs b/src/ui/channels_panel.rs new file mode 100644 index 0000000..18b43b8 --- /dev/null +++ b/src/ui/channels_panel.rs @@ -0,0 +1,295 @@ +//! Channels panel - channel selection and selected channel cards. +//! +//! This panel provides channel selection functionality that works across all tool modes. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::normalize::sort_channels_by_priority; +use crate::state::MAX_CHANNELS; + +impl UltraLogApp { + /// Render the channels panel content (called from side_panel.rs) + pub fn render_channels_panel_content(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let _font_16 = self.scaled_font(16.0); + + // Get active tab info + let tab_info = self.active_tab.and_then(|tab_idx| { + let tab = &self.tabs[tab_idx]; + if tab.file_index < self.files.len() { + Some(( + tab.file_index, + tab.channel_search.clone(), + tab.selected_channels.len(), + )) + } else { + None + } + }); + + if let Some((file_index, current_search, selected_count)) = tab_info { + let channel_count = self.files[file_index].log.channels.len(); + + // Computed Channels button + let primary_color = egui::Color32::from_rgb(113, 120, 78); + let computed_btn = egui::Frame::NONE + .fill(primary_color) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::WHITE) + .size(font_14), + ); + ui.label( + egui::RichText::new("Computed Channels") + .color(egui::Color32::WHITE) + .size(font_14), + ); + }); + }); + + if computed_btn + .response + .interact(egui::Sense::click()) + .on_hover_text("Create virtual channels from mathematical formulas") + .clicked() + { + self.show_computed_channels_manager = true; + } + + if computed_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + ui.add_space(8.0); + + // Search box + let mut search_text = current_search; + let mut search_changed = false; + egui::Frame::NONE + .fill(egui::Color32::from_rgb(50, 50, 50)) + .corner_radius(4) + .inner_margin(egui::vec2(8.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("🔍") + .size(font_14) + .color(egui::Color32::GRAY), + ); + let response = ui.add( + egui::TextEdit::singleline(&mut search_text) + .hint_text("Search channels...") + .desired_width(f32::INFINITY) + .frame(false), + ); + search_changed = response.changed(); + }); + }); + + if search_changed { + self.set_channel_search(search_text.clone()); + } + + ui.add_space(4.0); + + // Channel count + ui.label( + egui::RichText::new(format!( + "Selected: {} / {} | Total: {}", + selected_count, MAX_CHANNELS, channel_count + )) + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + + // Channel list + self.render_channel_list_compact(ui, file_index, &search_text); + } else { + // No file selected + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("📊") + .size(32.0) + .color(egui::Color32::from_rgb(100, 100, 100)), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("No file selected") + .size(font_14) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("Load a file to view channels") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)), + ); + }); + } + } + + /// Render the channel list in compact form + fn render_channel_list_compact(&mut self, ui: &mut egui::Ui, file_index: usize, search: &str) { + let font_14 = self.scaled_font(14.0); + let search_lower = search.to_lowercase(); + + let mut channel_to_add: Option<(usize, usize)> = None; + let mut channel_to_remove: Option = None; + + let file = &self.files[file_index]; + let sorted_channels = sort_channels_by_priority( + file.log.channels.len(), + |idx| file.log.channels[idx].name(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + let channel_names: Vec = (0..file.log.channels.len()) + .map(|idx| file.log.channels[idx].name()) + .collect(); + + let channels_with_data = &file.channels_with_data; + let (channels_with, channels_without): (Vec<_>, Vec<_>) = sorted_channels + .into_iter() + .partition(|(idx, _, _)| channels_with_data[*idx]); + + let selected_channels = self.get_selected_channels().to_vec(); + + let render_channel = |ui: &mut egui::Ui, + channel_index: usize, + display_name: &str, + is_empty: bool, + channel_to_add: &mut Option<(usize, usize)>, + channel_to_remove: &mut Option| { + let original_name = &channel_names[channel_index]; + + if !search_lower.is_empty() + && !original_name.to_lowercase().contains(&search_lower) + && !display_name.to_lowercase().contains(&search_lower) + { + return; + } + + let selected_idx = selected_channels + .iter() + .position(|c| c.file_index == file_index && c.channel_index == channel_index); + let is_selected = selected_idx.is_some(); + + let text_color = if is_empty { + egui::Color32::from_rgb(100, 100, 100) + } else if is_selected { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }; + + let bg_color = if is_selected { + egui::Color32::from_rgb(55, 60, 50) + } else { + egui::Color32::TRANSPARENT + }; + + let frame = egui::Frame::NONE + .fill(bg_color) + .corner_radius(3) + .inner_margin(egui::Margin::symmetric(6, 3)); + + let response = frame + .show(ui, |ui| { + ui.horizontal(|ui| { + let check = if is_selected { "☑" } else { "☐" }; + ui.label(egui::RichText::new(check).size(font_14).color(text_color)); + ui.label( + egui::RichText::new(display_name) + .size(font_14) + .color(text_color), + ); + }); + }) + .response + .interact(egui::Sense::click()); + + if response.clicked() { + if let Some(idx) = selected_idx { + *channel_to_remove = Some(idx); + } else { + *channel_to_add = Some((file_index, channel_index)); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }; + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Channels with data + if !channels_with.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("📊 With Data ({})", channels_with.len())) + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + for (channel_index, display_name, _) in &channels_with { + render_channel( + ui, + *channel_index, + display_name, + false, + &mut channel_to_add, + &mut channel_to_remove, + ); + } + }); + } + + ui.add_space(4.0); + + // Empty channels + if !channels_without.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("📭 Empty ({})", channels_without.len())) + .size(font_14) + .color(egui::Color32::GRAY), + ) + .default_open(false) + .show(ui, |ui| { + for (channel_index, display_name, _) in &channels_without { + render_channel( + ui, + *channel_index, + display_name, + true, + &mut channel_to_add, + &mut channel_to_remove, + ); + } + }); + } + }); + + if let Some(idx) = channel_to_remove { + self.remove_channel(idx); + } + + if let Some((file_idx, channel_idx)) = channel_to_add { + self.add_channel(file_idx, channel_idx); + } + } +} diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 30b411b..6f5084d 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -257,7 +257,7 @@ impl UltraLogApp { let _ = self.computed_library.save(); } - if let Some(template) = template_to_apply { + if let Some(ref template) = template_to_apply { self.apply_computed_channel_template(template); } } @@ -530,7 +530,7 @@ impl UltraLogApp { } /// Apply a computed channel template to the current file - fn apply_computed_channel_template(&mut self, template: ComputedChannelTemplate) { + pub fn apply_computed_channel_template(&mut self, template: &ComputedChannelTemplate) { let Some(tab_idx) = self.active_tab else { self.show_toast_warning("No active tab"); return; diff --git a/src/ui/files_panel.rs b/src/ui/files_panel.rs new file mode 100644 index 0000000..0eeb695 --- /dev/null +++ b/src/ui/files_panel.rs @@ -0,0 +1,282 @@ +//! Files panel - file management, loading, and file list. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::LoadingState; +use crate::ui::icons::draw_upload_icon; + +impl UltraLogApp { + /// Render the files panel content (called from side_panel.rs) + pub fn render_files_panel_content(&mut self, ui: &mut egui::Ui) { + // Show loading indicator + if let LoadingState::Loading(filename) = &self.loading_state { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("Loading {}...", filename)); + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + } + + let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); + + // File list (if any files loaded) + if !self.files.is_empty() { + self.render_file_list(ui); + + ui.add_space(10.0); + + // Add more files button + ui.add_enabled_ui(!is_loading, |ui| { + self.render_add_file_button(ui); + }); + } else if !is_loading { + // Nice drop zone when no files loaded + self.render_drop_zone_card(ui); + } + } + + /// Render the list of loaded files + fn render_file_list(&mut self, ui: &mut egui::Ui) { + let mut file_to_remove: Option = None; + let mut file_to_switch: Option = None; + + // Collect file info upfront to avoid borrow issues + let file_info: Vec<(String, bool, String, usize, usize)> = self + .files + .iter() + .enumerate() + .map(|(i, file)| { + ( + file.name.clone(), + self.selected_file == Some(i), + file.ecu_type.name().to_string(), + file.log.channels.len(), + file.log.data.len(), + ) + }) + .collect(); + + ui.label( + egui::RichText::new(format!("Loaded Files ({})", file_info.len())) + .size(self.scaled_font(13.0)) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + for (i, (file_name, is_selected, ecu_name, channel_count, data_count)) in + file_info.iter().enumerate() + { + // File card with selection highlight + let card_bg = if *is_selected { + egui::Color32::from_rgb(50, 55, 45) // Subtle olive tint for selected + } else { + egui::Color32::from_rgb(40, 40, 40) + }; + + let card_border = if *is_selected { + egui::Color32::from_rgb(113, 120, 78) // Olive green for selected + } else { + egui::Color32::from_rgb(55, 55, 55) + }; + + egui::Frame::NONE + .fill(card_bg) + .stroke(egui::Stroke::new(1.0, card_border)) + .corner_radius(6) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // File name (clickable) + let response = ui.add( + egui::Label::new( + egui::RichText::new(file_name) + .size(self.scaled_font(14.0)) + .color(if *is_selected { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }), + ) + .sense(egui::Sense::click()), + ); + + if response.clicked() { + file_to_switch = Some(i); + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Delete button + let close_btn = ui.add( + egui::Label::new( + egui::RichText::new("×") + .size(self.scaled_font(16.0)) + .color(egui::Color32::from_rgb(150, 150, 150)), + ) + .sense(egui::Sense::click()), + ); + + if close_btn.clicked() { + file_to_remove = Some(i); + } + + if close_btn.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + + // ECU type and data info + ui.label( + egui::RichText::new(format!( + "{} • {} ch • {} pts", + ecu_name, channel_count, data_count + )) + .size(self.scaled_font(11.0)) + .color(egui::Color32::GRAY), + ); + }); + + ui.add_space(4.0); + } + + // Handle deferred file switching + if let Some(index) = file_to_switch { + self.switch_to_file_tab(index); + } + + if let Some(index) = file_to_remove { + self.remove_file(index); + } + } + + /// Render the "Add File" button + fn render_add_file_button(&mut self, ui: &mut egui::Ui) { + let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green + + let button_response = egui::Frame::NONE + .fill(primary_color) + .corner_radius(6) + .inner_margin(egui::vec2(16.0, 8.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("+") + .color(egui::Color32::WHITE) + .size(self.scaled_font(16.0)), + ); + ui.label( + egui::RichText::new("Add File") + .color(egui::Color32::WHITE) + .size(self.scaled_font(14.0)), + ); + }); + }); + + if button_response + .response + .interact(egui::Sense::click()) + .clicked() + { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + } + + if button_response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + /// Render the drop zone card for when no files are loaded + fn render_drop_zone_card(&mut self, ui: &mut egui::Ui) { + let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green + let card_bg = egui::Color32::from_rgb(45, 45, 45); + let text_gray = egui::Color32::from_rgb(150, 150, 150); + + ui.add_space(20.0); + + // Drop zone card + egui::Frame::NONE + .fill(card_bg) + .corner_radius(12) + .inner_margin(20.0) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + // Upload icon + let icon_size = 32.0; + let (icon_rect, _) = ui.allocate_exact_size( + egui::vec2(icon_size, icon_size), + egui::Sense::hover(), + ); + draw_upload_icon(ui, icon_rect.center(), icon_size, primary_color); + + ui.add_space(12.0); + + // Select file button + let button_response = egui::Frame::NONE + .fill(primary_color) + .corner_radius(6) + .inner_margin(egui::vec2(16.0, 8.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Select a file") + .color(egui::Color32::WHITE) + .size(self.scaled_font(14.0)), + ); + }); + + if button_response + .response + .interact(egui::Sense::click()) + .clicked() + { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + } + + if button_response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + ui.add_space(12.0); + + ui.label( + egui::RichText::new("or") + .color(text_gray) + .size(self.scaled_font(12.0)), + ); + + ui.add_space(8.0); + + ui.label( + egui::RichText::new("Drop file here") + .color(egui::Color32::LIGHT_GRAY) + .size(self.scaled_font(13.0)), + ); + + ui.add_space(12.0); + + ui.label( + egui::RichText::new("CSV • LOG • TXT • MLG") + .color(text_gray) + .size(self.scaled_font(11.0)), + ); + }); + }); + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index ab5bda3..1bf10b7 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,14 +1,11 @@ -//! Menu bar UI components (File, View, Units, Help menus). +//! Menu bar UI components (File, View, Help menus). +//! +//! Simplified menu structure - settings moved to Settings panel. use eframe::egui; -use crate::analytics; use crate::app::UltraLogApp; -use crate::state::{ActiveTool, FontScale, LoadingState}; -use crate::units::{ - AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, - TemperatureUnit, VolumeUnit, -}; +use crate::state::{ActivePanel, ActiveTool, LoadingState}; impl UltraLogApp { /// Render the application menu bar @@ -39,7 +36,8 @@ impl UltraLogApp { // Open file option if ui - .add_enabled(!is_loading, egui::Button::new("📂 Open Log File...")) + .add_enabled(!is_loading, egui::Button::new("Open Log File...")) + .on_hover_text("⌘O") .clicked() { if let Some(path) = rfd::FileDialog::new() @@ -53,6 +51,21 @@ impl UltraLogApp { ui.separator(); + // Close current tab + let has_tabs = !self.tabs.is_empty(); + if ui + .add_enabled(has_tabs, egui::Button::new("Close Tab")) + .on_hover_text("⌘W") + .clicked() + { + if let Some(tab_idx) = self.active_tab { + self.close_tab(tab_idx); + } + ui.close(); + } + + ui.separator(); + // Export submenu - context-aware based on active tool let has_chart_data = !self.files.is_empty() && !self.get_selected_channels().is_empty(); @@ -68,20 +81,17 @@ impl UltraLogApp { let can_export = has_chart_data || has_histogram_data; ui.add_enabled_ui(can_export, |ui| { - ui.menu_button("📤 Export", |ui| { - // Increase font size for submenu items + ui.menu_button("Export", |ui| { ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); if self.active_tool == ActiveTool::Histogram && has_histogram_data { - // Histogram export options if ui.button("Export Histogram as PDF...").clicked() { self.export_histogram_pdf(); ui.close(); } } else if has_chart_data { - // Chart export options if ui.button("Export as PNG...").clicked() { self.export_chart_png(); ui.close(); @@ -95,11 +105,10 @@ impl UltraLogApp { }); }); - // View menu + // View menu - tool modes and panels ui.menu_button("View", |ui| { - ui.set_min_width(180.0); + ui.set_min_width(200.0); - // Increase font size for dropdown items ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); @@ -107,392 +116,82 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - // Cursor Tracking toggle + // Tool modes + ui.label( + egui::RichText::new("Tool Mode") + .size(font_14) + .color(egui::Color32::GRAY), + ); + if ui - .checkbox(&mut self.cursor_tracking, "🎯 Cursor Tracking") + .radio_value(&mut self.active_tool, ActiveTool::LogViewer, "Log Viewer") + .on_hover_text("⌘1") .clicked() { ui.close(); } - - // Color Blind Mode toggle - let old_color_blind_mode = self.color_blind_mode; if ui - .checkbox(&mut self.color_blind_mode, "👁 Color Blind Mode") + .radio_value( + &mut self.active_tool, + ActiveTool::ScatterPlot, + "Scatter Plots", + ) + .on_hover_text("⌘2") .clicked() { - if self.color_blind_mode != old_color_blind_mode { - analytics::track_colorblind_mode_toggled(self.color_blind_mode); - } ui.close(); } - - ui.separator(); - - // Field Normalization toggle if ui - .checkbox(&mut self.field_normalization, "📝 Field Normalization") - .on_hover_text("Standardize channel names across different ECU types") + .radio_value(&mut self.active_tool, ActiveTool::Histogram, "Histogram") + .on_hover_text("⌘3") .clicked() { ui.close(); } - // Edit mappings button - if ui.button(" Edit Mappings...").clicked() { - self.show_normalization_editor = true; - ui.close(); - } - ui.separator(); - // Auto-update preference + // Panel navigation + ui.label( + egui::RichText::new("Side Panel") + .size(font_14) + .color(egui::Color32::GRAY), + ); + if ui - .checkbox( - &mut self.auto_check_updates, - "🔄 Check for Updates on Startup", - ) - .on_hover_text("Automatically check for new versions when the app starts") + .radio_value(&mut self.active_panel, ActivePanel::Files, "Files") + .on_hover_text("⌘⇧F") .clicked() { ui.close(); } - - ui.separator(); - - // Font Size submenu - ui.menu_button("🔠 Font Size", |ui| { - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - - if ui - .radio_value(&mut self.font_scale, FontScale::Small, "Small (85%)") - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.font_scale, FontScale::Medium, "Medium (100%)") - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.font_scale, FontScale::Large, "Large (120%)") - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.font_scale, - FontScale::ExtraLarge, - "Extra Large (140%)", - ) - .clicked() - { - ui.close(); - } - }); - }); - - // Units menu - ui.menu_button("Units", |ui| { - ui.set_min_width(180.0); - - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - - // Temperature submenu - ui.menu_button("°C Temperature", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Celsius, - "Celsius (°C)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Fahrenheit, - "Fahrenheit (°F)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Kelvin, - "Kelvin (K)", - ) - .clicked() - { - ui.close(); - } - }); - - // Pressure submenu - ui.menu_button("💨 Pressure", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::KPa, - "Kilopascal (kPa)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::PSI, - "PSI", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::Bar, - "Bar", - ) - .clicked() - { - ui.close(); - } - }); - - // Speed submenu - ui.menu_button("🚗 Speed", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.speed, - SpeedUnit::KmH, - "Kilometers/hour (km/h)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.speed, - SpeedUnit::Mph, - "Miles/hour (mph)", - ) - .clicked() - { - ui.close(); - } - }); - - // Distance submenu - ui.menu_button("📏 Distance", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.distance, - DistanceUnit::Kilometers, - "Kilometers (km)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.distance, - DistanceUnit::Miles, - "Miles (mi)", - ) - .clicked() - { - ui.close(); - } - }); - - ui.separator(); - - // Fuel Economy submenu - ui.menu_button("⛽ Fuel Economy", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::LPer100Km, - "Liters/100km (L/100km)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::Mpg, - "Miles/gallon (mpg)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::KmPerL, - "Kilometers/liter (km/L)", - ) - .clicked() - { - ui.close(); - } - }); - - // Volume submenu - ui.menu_button("📊 Volume", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.volume, - VolumeUnit::Liters, - "Liters (L)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.volume, - VolumeUnit::Gallons, - "Gallons (gal)", - ) - .clicked() - { - ui.close(); - } - }); - - // Flow submenu - ui.menu_button("💧 Flow Rate", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.flow, - FlowUnit::CcPerMin, - "cc/min", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.unit_preferences.flow, FlowUnit::LbPerHr, "lb/hr") - .clicked() - { - ui.close(); - } - }); - - ui.separator(); - - // Acceleration submenu - ui.menu_button("📈 Acceleration", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if ui - .radio_value( - &mut self.unit_preferences.acceleration, - AccelerationUnit::MPerS2, - "m/s²", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.acceleration, - AccelerationUnit::G, - "g-force (g)", - ) - .clicked() - { - ui.close(); - } - }); - }); - - // Channels menu - ui.menu_button("Channels", |ui| { - ui.set_min_width(200.0); - - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - - if ui.button("ƒ(x) Computed Channels...").clicked() { - self.show_computed_channels_manager = true; + if ui + .radio_value(&mut self.active_panel, ActivePanel::Channels, "Channels") + .on_hover_text("⌘⇧C") + .clicked() + { ui.close(); } - - ui.separator(); - - if ui.button("📊 Analysis Tools...").clicked() { - self.show_analysis_panel = true; + if ui + .radio_value(&mut self.active_panel, ActivePanel::Tools, "Tools") + .on_hover_text("⌘⇧T") + .clicked() + { + ui.close(); + } + if ui + .radio_value(&mut self.active_panel, ActivePanel::Settings, "Settings") + .on_hover_text("⌘,") + .clicked() + { ui.close(); } }); + // Help menu ui.menu_button("Help", |ui| { ui.set_min_width(200.0); - // Increase font size for dropdown items ui.style_mut() .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); @@ -500,19 +199,19 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - if ui.button("📖 Documentation").clicked() { + if ui.button("Documentation").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/wiki"); ui.close(); } - if ui.button("🐛 Report Issue").clicked() { + if ui.button("Report Issue").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/issues"); ui.close(); } ui.separator(); - if ui.button("💝 Support Development").clicked() { + if ui.button("Support Development").clicked() { let _ = open::that("https://github.com/sponsors/SomethingNew71"); ui.close(); } @@ -526,9 +225,9 @@ impl UltraLogApp { | crate::updater::UpdateState::Downloading ); let button_text = if is_checking { - "🔄 Checking for Updates..." + "Checking for Updates..." } else { - "🔄 Check for Updates" + "Check for Updates" }; if ui diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a55d2f4..84ba92a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,21 +1,43 @@ //! UI rendering modules for the UltraLog application. //! //! This module organizes the various UI components into logical submodules: -//! - `sidebar` - Files panel and view options -//! - `channels` - Channel selection and display +//! +//! ## New Activity Bar Architecture +//! - `activity_bar` - VS Code-style vertical icon strip for panel navigation +//! - `side_panel` - Container that routes to the appropriate panel +//! - `files_panel` - File management, loading, and file list +//! - `channels_panel` - Channel selection (works in all modes) +//! - `tools_panel` - Analysis tools, computed channels, export +//! - `settings_panel` - Consolidated settings (display, units, normalization, updates) +//! +//! ## Core UI Components +//! - `sidebar` - Legacy files panel (being replaced by files_panel) +//! - `channels` - Legacy channel selection (being replaced by channels_panel) //! - `chart` - Main chart rendering and legends //! - `timeline` - Timeline scrubber and playback controls -//! - `menu` - Menu bar (File, Units, Help) +//! - `menu` - Menu bar (File, Edit, View, Help) //! - `toast` - Toast notification system //! - `icons` - Custom icon drawing utilities //! - `export` - Chart export functionality (PNG, PDF) //! - `normalization_editor` - Field normalization customization window -//! - `tool_switcher` - Pill-style tab navigation between tools +//! - `tool_switcher` - Tool mode selection (Log Viewer, Scatter Plot, Histogram) //! - `scatter_plot` - Scatter plot visualization view +//! - `histogram` - Histogram visualization view //! - `tab_bar` - Chrome-style tabs for managing multiple log files //! - `update_dialog` - Auto-update dialog window //! - `analysis_panel` - Signal analysis tools window +//! - `computed_channels_manager` - Computed channels library manager +//! - `formula_editor` - Formula creation and editing + +// New activity bar architecture +pub mod activity_bar; +pub mod channels_panel; +pub mod files_panel; +pub mod settings_panel; +pub mod side_panel; +pub mod tools_panel; +// Core UI components pub mod analysis_panel; pub mod channels; pub mod chart; diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs new file mode 100644 index 0000000..8d8fbc5 --- /dev/null +++ b/src/ui/settings_panel.rs @@ -0,0 +1,460 @@ +//! Settings panel - consolidated settings for display, units, normalization, and updates. +//! +//! This panel provides a single location for all user preferences. + +use eframe::egui; + +use crate::analytics; +use crate::app::UltraLogApp; +use crate::state::FontScale; +use crate::units::{ + AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, + TemperatureUnit, VolumeUnit, +}; +use crate::updater::UpdateState; + +impl UltraLogApp { + /// Render the settings panel content (called from side_panel.rs) + pub fn render_settings_panel_content(&mut self, ui: &mut egui::Ui) { + // Display settings section + self.render_display_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Field normalization settings + self.render_normalization_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Unit preferences + self.render_unit_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Update settings + self.render_update_settings(ui); + } + + /// Render display settings section + fn render_display_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("🖥 Display").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + // Colorblind mode + let old_color_blind_mode = self.color_blind_mode; + ui.checkbox( + &mut self.color_blind_mode, + egui::RichText::new("Color Blind Mode").size(font_14), + ); + if self.color_blind_mode != old_color_blind_mode { + analytics::track_colorblind_mode_toggled(self.color_blind_mode); + } + ui.label( + egui::RichText::new("Use accessible color palette (Wong's palette)") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Font size + ui.label(egui::RichText::new("Font Size:").size(font_14)); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.font_scale, FontScale::Small, "S"); + ui.selectable_value(&mut self.font_scale, FontScale::Medium, "M"); + ui.selectable_value(&mut self.font_scale, FontScale::Large, "L"); + ui.selectable_value(&mut self.font_scale, FontScale::ExtraLarge, "XL"); + }); + + ui.add_space(8.0); + + // Cursor tracking + ui.checkbox( + &mut self.cursor_tracking, + egui::RichText::new("Cursor Tracking").size(font_14), + ); + ui.label( + egui::RichText::new("Keep cursor centered while scrubbing") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + if self.cursor_tracking { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Window:").size(font_12)); + ui.add( + egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) + .suffix("s") + .logarithmic(true) + .text(""), + ); + }); + } + }); + } + + /// Render field normalization settings + fn render_normalization_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📝 Field Names").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.checkbox( + &mut self.field_normalization, + egui::RichText::new("Field Normalization").size(font_14), + ); + ui.label( + egui::RichText::new("Standardize channel names across ECU types") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Custom mappings count + let custom_count = self.custom_normalizations.len(); + if custom_count > 0 { + ui.label( + egui::RichText::new(format!("{} custom mappings", custom_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + } + + // Edit mappings button + let btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Edit Custom Mappings") + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + }); + + if btn.response.interact(egui::Sense::click()).clicked() { + self.show_normalization_editor = true; + } + + if btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + } + + /// Render unit preferences + fn render_unit_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📐 Units").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Select preferred units for display") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + // Create a grid for unit selections + egui::Grid::new("unit_settings_grid") + .num_columns(2) + .spacing([8.0, 6.0]) + .show(ui, |ui| { + // Temperature + ui.label(egui::RichText::new("Temperature:").size(font_12)); + egui::ComboBox::from_id_salt("temp_unit") + .selected_text(self.unit_preferences.temperature.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Celsius, + "°C", + ); + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Fahrenheit, + "°F", + ); + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Kelvin, + "K", + ); + }); + ui.end_row(); + + // Pressure + ui.label(egui::RichText::new("Pressure:").size(font_12)); + egui::ComboBox::from_id_salt("pressure_unit") + .selected_text(self.unit_preferences.pressure.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::KPa, + "kPa", + ); + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::PSI, + "psi", + ); + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::Bar, + "bar", + ); + }); + ui.end_row(); + + // Speed + ui.label(egui::RichText::new("Speed:").size(font_12)); + egui::ComboBox::from_id_salt("speed_unit") + .selected_text(self.unit_preferences.speed.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.speed, + SpeedUnit::KmH, + "km/h", + ); + ui.selectable_value( + &mut self.unit_preferences.speed, + SpeedUnit::Mph, + "mph", + ); + }); + ui.end_row(); + + // Distance + ui.label(egui::RichText::new("Distance:").size(font_12)); + egui::ComboBox::from_id_salt("distance_unit") + .selected_text(self.unit_preferences.distance.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.distance, + DistanceUnit::Kilometers, + "km", + ); + ui.selectable_value( + &mut self.unit_preferences.distance, + DistanceUnit::Miles, + "mi", + ); + }); + ui.end_row(); + + // Fuel Economy + ui.label(egui::RichText::new("Fuel Economy:").size(font_12)); + egui::ComboBox::from_id_salt("fuel_unit") + .selected_text(self.unit_preferences.fuel_economy.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::LPer100Km, + "L/100km", + ); + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::Mpg, + "mpg", + ); + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::KmPerL, + "km/L", + ); + }); + ui.end_row(); + + // Volume + ui.label(egui::RichText::new("Volume:").size(font_12)); + egui::ComboBox::from_id_salt("volume_unit") + .selected_text(self.unit_preferences.volume.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.volume, + VolumeUnit::Liters, + "L", + ); + ui.selectable_value( + &mut self.unit_preferences.volume, + VolumeUnit::Gallons, + "gal", + ); + }); + ui.end_row(); + + // Flow Rate + ui.label(egui::RichText::new("Flow Rate:").size(font_12)); + egui::ComboBox::from_id_salt("flow_unit") + .selected_text(self.unit_preferences.flow.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.flow, + FlowUnit::CcPerMin, + "cc/min", + ); + ui.selectable_value( + &mut self.unit_preferences.flow, + FlowUnit::LbPerHr, + "lb/hr", + ); + }); + ui.end_row(); + + // Acceleration + ui.label(egui::RichText::new("Acceleration:").size(font_12)); + egui::ComboBox::from_id_salt("accel_unit") + .selected_text(self.unit_preferences.acceleration.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.acceleration, + AccelerationUnit::MPerS2, + "m/s²", + ); + ui.selectable_value( + &mut self.unit_preferences.acceleration, + AccelerationUnit::G, + "g", + ); + }); + ui.end_row(); + }); + }); + } + + /// Render update settings + fn render_update_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("🔄 Updates").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + // Auto-check preference + ui.checkbox( + &mut self.auto_check_updates, + egui::RichText::new("Check on startup").size(font_14), + ); + ui.label( + egui::RichText::new("Automatically check for new versions") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Check now button + let is_checking = matches!(self.update_state, UpdateState::Checking); + ui.add_enabled_ui(!is_checking, |ui| { + let btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + let text = if is_checking { + "Checking..." + } else { + "Check for Updates" + }; + ui.label( + egui::RichText::new(text) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + }); + + if !is_checking && btn.response.interact(egui::Sense::click()).clicked() { + self.start_update_check(); + } + + if btn.response.hovered() && !is_checking { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + ui.add_space(8.0); + + // Version info + let version = env!("CARGO_PKG_VERSION"); + ui.label( + egui::RichText::new(format!("Current version: {}", version)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + + // Show update status + match &self.update_state { + UpdateState::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label( + egui::RichText::new("Checking...") + .size(font_12) + .color(egui::Color32::GRAY), + ); + }); + } + UpdateState::UpdateAvailable(info) => { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "✓ Update available: v{}", + info.new_version + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + + let view_btn = ui.add( + egui::Label::new( + egui::RichText::new("View Details →") + .size(font_12) + .color(egui::Color32::from_rgb(150, 180, 220)), + ) + .sense(egui::Sense::click()), + ); + + if view_btn.clicked() { + self.show_update_dialog = true; + } + + if view_btn.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + UpdateState::Error(msg) => { + ui.label( + egui::RichText::new(format!("⚠ {}", msg)) + .size(font_12) + .color(egui::Color32::from_rgb(200, 150, 100)), + ); + } + _ => {} + } + }); + } +} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs new file mode 100644 index 0000000..671e60a --- /dev/null +++ b/src/ui/side_panel.rs @@ -0,0 +1,35 @@ +//! Side panel container - routes to the appropriate panel based on activity bar selection. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActivePanel; + +/// Default width of the side panel in pixels +pub const SIDE_PANEL_WIDTH: f32 = 280.0; + +/// Minimum width of the side panel +pub const SIDE_PANEL_MIN_WIDTH: f32 = 200.0; + +impl UltraLogApp { + /// Render the side panel content based on the active panel selection + pub fn render_side_panel(&mut self, ui: &mut egui::Ui) { + // Panel header with title + ui.horizontal(|ui| { + ui.heading(self.active_panel.name()); + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Route to the appropriate panel content + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.active_panel { + ActivePanel::Files => self.render_files_panel_content(ui), + ActivePanel::Channels => self.render_channels_panel_content(ui), + ActivePanel::Tools => self.render_tools_panel_content(ui), + ActivePanel::Settings => self.render_settings_panel_content(ui), + }); + } +} diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs new file mode 100644 index 0000000..f01c352 --- /dev/null +++ b/src/ui/tools_panel.rs @@ -0,0 +1,356 @@ +//! Tools panel - analysis tools, computed channels library, and export options. +//! +//! Provides quick access to analysis and export functionality inline in the side panel. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActiveTool; + +impl UltraLogApp { + /// Render the tools panel content (called from side_panel.rs) + pub fn render_tools_panel_content(&mut self, ui: &mut egui::Ui) { + let _font_12 = self.scaled_font(12.0); + let _font_14 = self.scaled_font(14.0); + + // Analysis Tools Section + self.render_tools_analysis_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Computed Channels Section + self.render_tools_computed_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Export Section + self.render_tools_export_section(ui); + } + + /// Render the analysis tools section + fn render_tools_analysis_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new( + egui::RichText::new("📈 Analysis Tools") + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Run signal processing and statistical analysis on log data.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + + if has_file { + // Show available analyzers count + let analyzer_count = self.analyzer_registry.all().len(); + ui.label( + egui::RichText::new(format!("{} analyzers available", analyzer_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + // Open full analysis panel button + let primary_color = egui::Color32::from_rgb(113, 120, 78); + let btn = egui::Frame::NONE + .fill(primary_color) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Open Analysis Panel") + .color(egui::Color32::WHITE) + .size(font_14), + ); + }); + + if btn.response.interact(egui::Sense::click()).clicked() { + self.show_analysis_panel = true; + } + + if btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Show results count if any + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get(&file_idx) { + if !results.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "✓ {} analysis results for current file", + results.len() + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + } + } + } else { + ui.label( + egui::RichText::new("Load a file to access analysis tools") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } + }); + } + + /// Render the computed channels section + fn render_tools_computed_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new( + egui::RichText::new("ƒ Computed Channels") + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Create virtual channels from mathematical formulas.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + // Library count + let template_count = self.computed_library.templates.len(); + ui.label( + egui::RichText::new(format!("{} templates in library", template_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + // Buttons row + ui.horizontal(|ui| { + // New Channel button + let accent_color = egui::Color32::from_rgb(113, 120, 78); + let new_btn = egui::Frame::NONE + .fill(accent_color) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 5.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("+ New") + .color(egui::Color32::WHITE) + .size(font_12), + ); + }); + + if new_btn.response.interact(egui::Sense::click()).clicked() { + self.formula_editor_state.open_new(); + } + + if new_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Manage Library button + let manage_btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 5.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Manage Library") + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), + ); + }); + + if manage_btn.response.interact(egui::Sense::click()).clicked() { + self.show_computed_channels_manager = true; + } + + if manage_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + // Show applied channels for current file + if let Some(file_idx) = self.selected_file { + if let Some(channels) = self.file_computed_channels.get(&file_idx) { + if !channels.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "✓ {} computed channels on current file", + channels.len() + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + } + } + + // Quick apply section + if !self.computed_library.templates.is_empty() && self.selected_file.is_some() { + ui.add_space(8.0); + ui.label( + egui::RichText::new("Quick Apply:") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + // Show first few templates as quick apply buttons + let templates: Vec<_> = self + .computed_library + .templates + .iter() + .take(5) + .map(|t| (t.id.clone(), t.name.clone())) + .collect(); + + for (id, name) in templates { + let response = ui.add( + egui::Label::new( + egui::RichText::new(format!(" • {}", name)) + .size(font_12) + .color(egui::Color32::from_rgb(150, 180, 220)), + ) + .sense(egui::Sense::click()), + ); + + if response.clicked() { + // Find and apply the template + if let Some(template) = + self.computed_library.templates.iter().find(|t| t.id == id) + { + let template_clone = template.clone(); + self.apply_computed_channel_template(&template_clone); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + } + }); + } + + /// Render the export section + fn render_tools_export_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📤 Export").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Export visualizations as images or documents.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_data = self.selected_file.is_some() + && !self.files.is_empty() + && !self.get_selected_channels().is_empty(); + + let can_export_chart = has_data && self.active_tool == ActiveTool::LogViewer; + let can_export_histogram = self.selected_file.is_some() + && !self.files.is_empty() + && self.active_tool == ActiveTool::Histogram; + + ui.horizontal(|ui| { + // PNG Export + ui.add_enabled_ui(can_export_chart, |ui| { + let btn = egui::Frame::NONE + .fill(if can_export_chart { + egui::Color32::from_rgb(71, 108, 155) + } else { + egui::Color32::from_rgb(50, 50, 50) + }) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PNG") + .color(if can_export_chart { + egui::Color32::WHITE + } else { + egui::Color32::GRAY + }) + .size(font_14), + ); + }); + + if can_export_chart && btn.response.interact(egui::Sense::click()).clicked() + { + self.export_chart_png(); + } + + if btn.response.hovered() && can_export_chart { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + // PDF Export + let can_export_pdf = can_export_chart || can_export_histogram; + ui.add_enabled_ui(can_export_pdf, |ui| { + let btn = egui::Frame::NONE + .fill(if can_export_pdf { + egui::Color32::from_rgb(155, 71, 71) + } else { + egui::Color32::from_rgb(50, 50, 50) + }) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PDF") + .color(if can_export_pdf { + egui::Color32::WHITE + } else { + egui::Color32::GRAY + }) + .size(font_14), + ); + }); + + if can_export_pdf && btn.response.interact(egui::Sense::click()).clicked() { + if can_export_chart { + self.export_chart_pdf(); + } else if can_export_histogram { + self.export_histogram_pdf(); + } + } + + if btn.response.hovered() && can_export_pdf { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + + if !has_data { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Select channels to enable export") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } + }); + } +}