From e9c505b625b1182215d20e36359caca024146a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stefa=C5=84ski?= Date: Sat, 23 May 2026 17:04:18 +0200 Subject: [PATCH] GUI Show post decoding info --- Cargo.lock | 1 + svldgui/Cargo.toml | 1 + svldgui/src/main.rs | 245 ++++++++++++++++++++++++++++++++++++++++-- svldgui/ui/main.slint | 189 +++++++++++++++++++++++++------- 4 files changed, 388 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee00941..c03f65c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4768,6 +4768,7 @@ name = "svldgui" version = "0.1.0" dependencies = [ "can-dbc 9.1.0", + "chrono", "libsvld", "rfd", "slint", diff --git a/svldgui/Cargo.toml b/svldgui/Cargo.toml index 3153e16..38ed004 100644 --- a/svldgui/Cargo.toml +++ b/svldgui/Cargo.toml @@ -13,6 +13,7 @@ libsvld = { path = "../libsvld" } can-dbc = "9.1.0" slint = "1.16.1" rfd = "0.17.2" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } [build-dependencies] slint-build = "1.16.1" diff --git a/svldgui/src/main.rs b/svldgui/src/main.rs index 2779f83..ecd0dd9 100644 --- a/svldgui/src/main.rs +++ b/svldgui/src/main.rs @@ -9,7 +9,8 @@ use std::{ }; use can_dbc::Dbc; -use slint::ComponentHandle; +use chrono::Local; +use slint::{ComponentHandle, ModelRc, StandardListViewItem, VecModel}; slint::include_modules!(); @@ -20,10 +21,213 @@ struct State { output_dir: Option, } +struct FileResult { + file_name: String, + date: String, + unknown_count: i32, + error_count: i32, + unknown_ids: String, + error_rows: Vec<(f64, u8, u8)>, + runtime_stats: String, +} + +struct Run { + label: String, + files: Vec, +} + +#[derive(Default)] +struct Results { + runs: Vec, + window: Option, + run_counter: usize, +} + +thread_local! { + static RESULTS: RefCell = RefCell::new(Results::default()); +} + +fn build_file_result( + path: &PathBuf, + info: libsvld::DecodeProcessInfo, +) -> FileResult { + let file_name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + let date = info + .date + .and_utc() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + + let unknown_count = info.unknown_ids.len() as i32; + let error_count = info.error_frames.len() as i32; + + let mut unknown_sorted: Vec = info.unknown_ids.iter().copied().collect(); + unknown_sorted.sort(); + let unknown_ids = if unknown_sorted.is_empty() { + "(none)".to_string() + } else { + unknown_sorted + .iter() + .map(|id| format!("0x{:X}", id)) + .collect::>() + .join(", ") + }; + + let error_rows: Vec<(f64, u8, u8)> = info + .error_frames + .iter() + .map(|f| (f.timestamp, f.channel, f.data)) + .collect(); + + let runtime_stats = format!( + "threads: {} · page size: {} · pages/chunk: {} · chunk size: {}", + info.thread_count, info.page_size, info.pages_per_chunk, info.chunk_size + ); + + FileResult { + file_name, + date, + unknown_count, + error_count, + unknown_ids, + error_rows, + runtime_stats, + } +} + +fn rebuild_entries(results: &Results) -> Vec { + let last_idx = results.runs.len().saturating_sub(1); + let mut entries = Vec::new(); + for (run_idx, run) in results.runs.iter().enumerate() { + let is_new = run_idx == last_idx; + entries.push(ResultEntry { + is_header: true, + is_new, + label: run.label.clone().into(), + }); + for f in &run.files { + entries.push(ResultEntry { + is_header: false, + is_new: false, + label: f.file_name.clone().into(), + }); + } + } + entries +} + +fn find_file(results: &Results, target: usize) -> Option<&FileResult> { + let mut i = 0; + for run in &results.runs { + if i == target { + return None; + } + i += 1; + for f in &run.files { + if i == target { + return Some(f); + } + i += 1; + } + } + None +} + +fn apply_selection(window: &ResultsWindow, results: &Results, idx: i32) { + if idx < 0 { + window.set_selected_index(-1); + return; + } + let Some(f) = find_file(results, idx as usize) else { + return; + }; + window.set_selected_index(idx); + window.set_sel_label(f.file_name.clone().into()); + window.set_sel_date(f.date.clone().into()); + window.set_sel_unknown_count(f.unknown_count); + window.set_sel_error_count(f.error_count); + window.set_sel_unknown_ids(f.unknown_ids.clone().into()); + window.set_sel_runtime_stats(f.runtime_stats.clone().into()); + + let rows: Vec> = f + .error_rows + .iter() + .map(|(t, ch, d)| { + let cells: Vec = vec![ + slint::format!("{}", t).into(), + slint::format!("{}", ch).into(), + slint::format!("0b{:08b}", d).into(), + ]; + ModelRc::new(VecModel::from(cells)) + }) + .collect(); + window.set_sel_error_rows(ModelRc::new(VecModel::from(rows))); +} + +fn show_results(results: &mut Results, new_run: Run) { + results.runs.push(new_run); + let entries = rebuild_entries(results); + + let new_first_file_idx = entries + .iter() + .enumerate() + .rev() + .find(|(_, e)| !e.is_header) + .map(|(i, _)| i as i32) + .unwrap_or(-1); + let new_first_file_idx = { + // We want the first non-header entry of the last run, not the very last. + let last_header_idx = entries + .iter() + .enumerate() + .rev() + .find(|(_, e)| e.is_header) + .map(|(i, _)| i) + .unwrap_or(0); + entries + .iter() + .enumerate() + .skip(last_header_idx + 1) + .find(|(_, e)| !e.is_header) + .map(|(i, _)| i as i32) + .unwrap_or(new_first_file_idx) + }; + + if results.window.is_none() { + let win = ResultsWindow::new().unwrap(); + let win_weak = win.as_weak(); + win.on_select(move |i| { + if let Some(w) = win_weak.upgrade() { + RESULTS.with(|cell| { + apply_selection(&w, &cell.borrow(), i); + }); + } + }); + results.window = Some(win); + } + + let win = results.window.as_ref().unwrap(); + let model = ModelRc::new(VecModel::from(entries)); + win.set_entries(model); + apply_selection(win, results, new_first_file_idx); + win.show().unwrap(); +} + fn main() { let window = MainWindow::new().unwrap(); let state = Rc::new(RefCell::new(State::default())); + window.on_open_results(|| { + RESULTS.with(|cell| { + if let Some(w) = cell.borrow().window.as_ref() { + w.show().ok(); + } + }); + }); + window.on_browse_dbc({ let window_weak = window.as_weak(); let state = state.clone(); @@ -105,29 +309,48 @@ fn main() { let window_weak = window_weak.clone(); thread::spawn(move || { - let result = (|| -> Result<(), String> { + let result = (|| -> Result, String> { let data = fs::read_to_string(&dbc_path) .map_err(|e| format!("Failed to read DBC: {}", e))?; let dbc = Dbc::try_from(data.as_str()) .map_err(|e| format!("Failed to parse DBC: {:?}", e))?; + let mut out = Vec::with_capacity(asc_paths.len()); for asc in &asc_paths { - libsvld::decode(&dbc, asc, &output_dir); + let info = libsvld::decode(&dbc, asc, &output_dir); + out.push(build_file_result(asc, info)); } - Ok(()) + Ok(out) })(); - let message = match result { - Ok(()) => "Done.".to_string(), - Err(e) => format!("Error: {}", e), - }; - slint::invoke_from_event_loop(move || { if let Some(w) = window_weak.upgrade() { w.set_busy(false); - w.set_status(message.into()); + } + match result { + Ok(files) => { + if let Some(w) = window_weak.upgrade() { + w.set_status("Done.".into()); + w.set_has_results(true); + } + RESULTS.with(|cell| { + let mut r = cell.borrow_mut(); + r.run_counter += 1; + let label = format!( + "Run {} — {}", + r.run_counter, + Local::now().format("%H:%M:%S") + ); + show_results(&mut r, Run { label, files }); + }); + } + Err(e) => { + if let Some(w) = window_weak.upgrade() { + w.set_status(format!("Error: {}", e).into()); + } + } } }) - .ok(); + .ok(); }); } }); diff --git a/svldgui/ui/main.slint b/svldgui/ui/main.slint index 16e6448..10aca61 100644 --- a/svldgui/ui/main.slint +++ b/svldgui/ui/main.slint @@ -1,4 +1,10 @@ -import { Button, LineEdit, CheckBox, Spinner, GridBox, VerticalBox, HorizontalBox } from "std-widgets.slint"; +import { Button, LineEdit, CheckBox, Spinner, GridBox, VerticalBox, HorizontalBox, ListView, ScrollView, StandardTableView } from "std-widgets.slint"; + +export struct ResultEntry { + is-header: bool, + is-new: bool, + label: string, +} export component MainWindow inherits Window { title: "Sane Vector Log Decoder"; @@ -12,11 +18,13 @@ export component MainWindow inherits Window { in-out property laps; in-out property busy; in-out property status; + in-out property has-results; callback browse-dbc(); callback browse-asc(); callback browse-output(); callback decode(); + callback open-results(); VerticalBox { padding: 12px; @@ -26,48 +34,21 @@ export component MainWindow inherits Window { spacing: 8px; Row { - Text { - text: "DBC file:"; - vertical-alignment: center; - } - LineEdit { - read-only: true; - text: root.dbc-path; - } - Button { - text: "Browse..."; - clicked => { root.browse-dbc(); } - } + Text { text: "DBC file:"; vertical-alignment: center; } + LineEdit { read-only: true; text: root.dbc-path; } + Button { text: "Browse..."; clicked => { root.browse-dbc(); } } } Row { - Text { - text: "ASC file(s):"; - vertical-alignment: center; - } - LineEdit { - read-only: true; - text: root.asc-label; - } - Button { - text: "Browse..."; - clicked => { root.browse-asc(); } - } + Text { text: "ASC file(s):"; vertical-alignment: center; } + LineEdit { read-only: true; text: root.asc-label; } + Button { text: "Browse..."; clicked => { root.browse-asc(); } } } Row { - Text { - text: "Output dir:"; - vertical-alignment: center; - } - LineEdit { - read-only: true; - text: root.output-dir; - } - Button { - text: "Browse..."; - clicked => { root.browse-output(); } - } + Text { text: "Output dir:"; vertical-alignment: center; } + LineEdit { read-only: true; text: root.output-dir; } + Button { text: "Browse..."; clicked => { root.browse-output(); } } } } @@ -95,6 +76,11 @@ export component MainWindow inherits Window { overflow: elide; } + if root.has-results: Button { + text: "Show results"; + clicked => { root.open-results(); } + } + Button { text: "Decode!"; enabled: !root.busy @@ -106,3 +92,132 @@ export component MainWindow inherits Window { } } } + +export component ResultsWindow inherits Window { + title: "Decode results"; + preferred-width: 900px; + preferred-height: 520px; + + in property <[ResultEntry]> entries; + in-out property selected-index: -1; + + in property sel-label; + in property sel-date; + in property sel-unknown-count; + in property sel-error-count; + in property sel-unknown-ids; + in property <[[StandardListViewItem]]> sel-error-rows; + in property sel-runtime-stats; + + callback select(int); + + HorizontalBox { + padding: 8px; + spacing: 8px; + + Rectangle { + width: 280px; + border-width: 1px; + border-color: #cccccc; + + ListView { + for entry[i] in root.entries: Rectangle { + height: 26px; + background: i == root.selected-index ? #3a78d8 : transparent; + + TouchArea { + clicked => { root.select(i); } + + HorizontalLayout { + padding-left: entry.is-header ? 6px : 24px; + padding-right: 6px; + spacing: 6px; + + Text { + text: entry.label; + font-weight: entry.is-header ? 700 : 400; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + + if entry.is-header && entry.is-new: Rectangle { + background: #2ecc71; + border-radius: 3px; + width: 38px; + height: 16px; + y: (parent.height - self.height) / 2; + Text { + text: "NEW"; + color: white; + font-size: 10px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } + } + } + + VerticalBox { + padding: 0px; + spacing: 6px; + horizontal-stretch: 1; + + Text { + text: root.selected-index >= 0 ? root.sel-label : "Select an ASC file on the left."; + font-size: 14px; + font-weight: 700; + } + + if root.selected-index >= 0: Text { + text: "Date: " + root.sel-date; + } + + if root.selected-index >= 0: Text { + text: "Unknown IDs: " + root.sel-unknown-count + + " Error frames: " + root.sel-error-count; + } + + if root.selected-index >= 0: Text { + text: "Unknown CAN IDs"; + font-weight: 700; + } + if root.selected-index >= 0: Rectangle { + border-width: 1px; + border-color: #cccccc; + height: 90px; + ScrollView { + Text { + text: root.sel-unknown-ids; + wrap: word-wrap; + } + } + } + + if root.selected-index >= 0: Text { + text: "Error frames"; + font-weight: 700; + } + if root.selected-index >= 0: StandardTableView { + vertical-stretch: 1; + columns: [ + { title: "Timestamp" }, + { title: "Channel" }, + { title: "Data (binary)" }, + ]; + rows: root.sel-error-rows; + } + + if root.selected-index >= 0: Text { + text: "Runtime stats"; + font-weight: 700; + } + if root.selected-index >= 0: Text { + text: root.sel-runtime-stats; + } + } + } +}