# HG changeset patch # User Raphaël Gomès <rgomes@octobus.net> # Date 1736946916 -3600 # Wed Jan 15 14:15:16 2025 +0100 # Node ID 873064788c76071c5857f6d4dd7359532f34a901 # Parent f73642c9af9944864b1728abe812b7a2a8324314 # EXP-Topic dashboard WIP results tab diff --git a/rust/poulpe-core/Cargo.lock b/rust/poulpe-core/Cargo.lock --- a/rust/poulpe-core/Cargo.lock +++ b/rust/poulpe-core/Cargo.lock @@ -1,6 +1,15 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] [[package]] name = "equivalent" @@ -42,6 +51,7 @@ version = "0.1.0" dependencies = [ "indexmap", + "regex", "serde", "serde_json", "toml", @@ -67,6 +77,35 @@ ] [[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/rust/poulpe-core/Cargo.toml b/rust/poulpe-core/Cargo.toml --- a/rust/poulpe-core/Cargo.toml +++ b/rust/poulpe-core/Cargo.toml @@ -5,6 +5,7 @@ [dependencies] indexmap = { version = "2.7.0", features = ["serde"] } +regex = "1.11.1" serde = {version = "1", features = ["derive"]} serde_json = {version = "1.0", features = ["preserve_order"]} toml = {version = "0.8", features = ["parse"]} diff --git a/rust/poulpe-core/src/compare.rs b/rust/poulpe-core/src/compare.rs new file mode 100644 --- /dev/null +++ b/rust/poulpe-core/src/compare.rs @@ -0,0 +1,269 @@ +use std::{collections::HashMap, sync::LazyLock}; + +use indexmap::IndexMap; + +use crate::{result::BenchmarkResult, utils::get_toml_value}; + +static LEGACY_RESULTS_COMPARISON: LazyLock<toml::Value> = LazyLock::new(|| { + toml::from_str( + r#" +default-compare-key = "bin-env-vars.hg.changeset.node" +header-keys = [ + "data-env-vars.name", + "benchmark.name", + "bin-env-vars.hg.flavor", + "bin-env-vars.hg.py-re2-module", + "bin-env-vars.hg.changeset.node", +] +sort-keys."bin-env-vars.hg.changeset.node" = [ + "bin-env-vars.hg.changeset.rank", + "bin-env-vars.hg.changeset.node", +] +"#, + ) + .unwrap() +}); + +pub type ComparedGroupedResults = IndexMap<ResultGroupKey, ComparedResults>; + +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct ResultGroupKey { + pub headers: Vec<(String, String)>, + pub compare_key: String, +} + +#[derive(Debug, Clone)] +pub struct ComparedResults { + pub compared: Vec<ComparedResult>, + pub base: Option<usize>, +} + +#[derive(Debug, Clone)] +pub struct ComparedResult { + pub result: BenchmarkResult, + pub time: f64, + pub max_abs_change: f64, + pub max_abs_diff: f64, +} + +pub fn compare_results( + results: &[BenchmarkResult], + minimal_relative_change: Option<f64>, + minimal_real_change: Option<f64>, +) -> (ComparedGroupedResults, bool) { + let grouped = group_results(results, None, &[]); + compare_and_filter_results( + grouped, + minimal_relative_change, + minimal_real_change, + ) +} + +pub type DispatchedResults = HashMap<ResultGroupKey, Vec<BenchmarkResult>>; + +fn group_results( + results: &[BenchmarkResult], + dimension: Option<String>, + ignored_dimensions: &[String], +) -> DispatchedResults { + // TODO remove the unwrap'ing by doing more in the deserialization step + let mut dispatched: DispatchedResults = HashMap::new(); + let mut sort_keys: HashMap<&String, Vec<String>> = HashMap::new(); + let mut compare_key: String = dimension.clone().unwrap_or_default(); + for result in results { + let data = &result.data; + let comparison = + if let Some(c) = data["bin-env-vars"].get("results-comparison") { + c + } else { + &LEGACY_RESULTS_COMPARISON + }; + if dimension.is_none() { + compare_key = comparison["default-compare-key"] + .as_str() + .unwrap() + .to_string(); + } + sort_keys.extend( + comparison["sort-keys"] + .as_table() + .unwrap() + .iter() + .map(|(k, v)| { + ( + k, + v.as_array() + .unwrap() + .iter() + .map(|v1| v1.as_str().unwrap().to_string()) + .collect(), + ) + }), + ); + let header_keys: Vec<_> = comparison["header-keys"] + .as_array() + .unwrap() + .iter() + .map(|h| h.as_str().unwrap().to_string()) + .collect(); + + let key_func = + make_result_key(&header_keys, &compare_key, ignored_dimensions); + let headers = key_func(data); + dispatched + .entry(ResultGroupKey { + headers, + compare_key: compare_key.clone(), + }) + .and_modify(|e| e.push(result.to_owned())) + .or_default(); + } + + for (ResultGroupKey { compare_key, .. }, results) in dispatched.iter_mut() { + match sort_keys.get(&compare_key) { + Some(sort_key) => { + results.sort_by_key(|res| { + sort_key + .iter() + .filter_map(|k| { + get_toml_value(k, &res.data).map(|v| v.to_string()) + }) + .collect::<Vec<String>>() + }); + } + None => { + results.sort_by_key(|res| { + get_toml_value(compare_key, &res.data) + .map(ToString::to_string) + .unwrap_or("".to_string()) + }); + } + } + } + dispatched +} + +fn make_result_key<'a>( + header_keys: &'a [String], + compare_key: &'a str, + ignored_dimensions: &'a [String], +) -> impl FnOnce(&toml::Value) -> Vec<(String, String)> + use<'a> { + let header_keys = header_keys.iter().filter(move |k| *k != compare_key); + + move |result: &toml::Value| { + let mut headers = vec![]; + for key in header_keys { + if let Some(value) = get_toml_value(key, result) { + if ignored_dimensions.contains(key) { + continue; + } + headers.push(( + key.to_owned(), + match value { + toml::Value::String(s) => s.to_owned(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + _ => { + // TODO log this? + continue; + } + }, + )); + } + } + let Some(variants) = result["benchmark"] + .get("variants") + .and_then(|v| v.as_table()) + else { + return headers; + }; + let mut variants: Vec<(&String, &toml::Value)> = + variants.into_iter().collect(); + variants.sort_by_key(|v| v.0); + for (key, value) in variants { + let key = format!("benchmark.variants.{key}"); + if key == compare_key { + continue; + } + if ignored_dimensions.contains(&key) { + continue; + } + headers.push(( + key, + match value { + toml::Value::String(s) => s.to_owned(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + _ => { + // TODO log this? + continue; + } + }, + )); + } + headers + } +} + +fn compare_and_filter_results( + results: DispatchedResults, + minimal_relative_change: Option<f64>, + minimal_real_change: Option<f64>, +) -> (ComparedGroupedResults, bool) { + // TODO make that a parameter + const VALUE_TYPE: &str = "mean"; + + let mut used_compare_key = false; + let mut compared = IndexMap::new(); + for (key, group) in results { + let mut base = None; + let mut base_idx = None; + let mut max_abs_change = 0.0; + let mut max_abs_diff = 0.0; + let mut compared_group = vec![]; + for (idx, result) in group.into_iter().enumerate() { + let key = get_toml_value(&key.compare_key, &result.data); + used_compare_key |= key.is_some(); + let times = &result.data["result"]["time"]; + let value = ×.get(VALUE_TYPE).and_then(|v| v.as_float()); + if base.is_none() && value.is_some() { + base = value.to_owned(); + base_idx = Some(idx); + } + if let (Some(value), Some(base)) = (value, base) { + let change = value / base; + let diff = value - base; + max_abs_change = (change - 1.0).abs().max(max_abs_change); + max_abs_diff = diff.abs().max(max_abs_change); + compared_group.push(ComparedResult { + time: *value, + max_abs_change, + max_abs_diff, + result, + }); + } + } + if let Some(minimal_relative_change) = minimal_relative_change { + if max_abs_change < minimal_relative_change { + continue; + } + } + if let Some(minimal_real_change) = minimal_real_change { + if max_abs_diff < minimal_real_change { + continue; + } + } + if !compared_group.is_empty() { + compared.insert( + key, + ComparedResults { + base: base_idx, + compared: compared_group, + }, + ); + } + } + (compared, used_compare_key) +} diff --git a/rust/poulpe-core/src/data_environment.rs b/rust/poulpe-core/src/data_environment.rs --- a/rust/poulpe-core/src/data_environment.rs +++ b/rust/poulpe-core/src/data_environment.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use crate::utils::split_unescape; +use crate::utils::get_toml_value; #[derive(Clone)] pub struct DataEnvironment { @@ -12,12 +12,8 @@ impl DataEnvironment { pub fn get_bench_input_var(&self, var: &str) -> Option<String> { - let mut current = self.metadata.get("bench-input-vars")?; - - for var in split_unescape(var, '.', '\\') { - current = current.get(var)?; - } - - current.as_str().map(ToOwned::to_owned) + get_toml_value(var, self.metadata.get("bench-input-vars")?)? + .as_str() + .map(ToOwned::to_owned) } } diff --git a/rust/poulpe-core/src/den.rs b/rust/poulpe-core/src/den.rs --- a/rust/poulpe-core/src/den.rs +++ b/rust/poulpe-core/src/den.rs @@ -97,7 +97,6 @@ Some(BenchmarkResult { path: dir_entry.into_path(), - contents: data.clone().try_into().ok()?, data, }) }) diff --git a/rust/poulpe-core/src/lib.rs b/rust/poulpe-core/src/lib.rs --- a/rust/poulpe-core/src/lib.rs +++ b/rust/poulpe-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod benchmark; pub mod binary_environment; +pub mod compare; pub mod data_environment; pub mod den; pub mod result; diff --git a/rust/poulpe-core/src/result.rs b/rust/poulpe-core/src/result.rs --- a/rust/poulpe-core/src/result.rs +++ b/rust/poulpe-core/src/result.rs @@ -1,57 +1,125 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{error::Error, path::PathBuf}; +use regex::Regex; use serde::Deserialize; -use toml::Value; -#[derive(Deserialize)] +use crate::utils::split_unescape; + +#[derive(Deserialize, Clone, Debug)] pub struct BenchmarkResult { pub path: PathBuf, - pub contents: BenchmarkResultContents, /// Corresponds to the full deserialized results file pub data: toml::Value, } -#[derive(Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct BenchmarkResultContents { - pub run: RunSection, - pub benchmark: BenchmarkSection, - pub data_env_vars: HashMap<String, Value>, - pub bin_env_vars: HashMap<String, Value>, - pub result: ResultSection, +#[derive(Debug)] +pub enum FilterValue { + Raw(String), + Regex(Regex), } -#[derive(Deserialize)] -pub struct RunSection { - // TODO use jiffy - pub timestamp: f64, - pub duration: f64, +impl TryFrom<&str> for FilterValue { + type Error = Box<dyn Error>; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Ok(match value.strip_prefix("re:") { + Some(re) => FilterValue::Regex(Regex::new(®ex::escape(re))?), + None => FilterValue::Raw(value.to_owned()), + }) + } } -#[derive(Deserialize)] -pub struct BenchmarkSection { - pub name: String, +#[derive(Debug)] +pub struct Filter { + key: String, + value: FilterValue, +} + +#[derive(Debug)] +/// Represents a query to filter a set of [`BenchmarkResult`] +pub struct Query { + filters: Vec<Filter>, } -#[derive(Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct ResultSection { - pub time: TimeSection, - /// Mercurial perf extension - pub sys_time: Option<TimeSection>, - /// Mercurial perf extension - pub comb_time: Option<TimeSection>, - /// Mercurial perf extension - pub run_count: Option<usize>, +impl Query { + pub fn new(filters_strings: &[String]) -> Self { + let mut filters = vec![]; + for filter_string in filters_strings { + if let Ok(filter) = Self::parse_filter_string(filter_string) { + filters.push(filter); + } + } + Self { filters } + } + + pub fn new_from_parsed(filters_strings: &[(String, String)]) -> Self { + Self { + filters: filters_strings + .iter() + .filter_map(|(key, value)| { + Some(Filter { + key: key.clone(), + value: value.as_str().try_into().ok()?, + }) + }) + .collect(), + } + } + + pub fn query_results( + &self, + results: &[BenchmarkResult], + ) -> Vec<BenchmarkResult> { + if self.filters.is_empty() { + return results.to_vec(); + } + let mut answer = vec![]; + for result in results { + if data_matches_filters(&result.data, &self.filters) { + answer.push(result.clone()); + } + } + answer.sort_unstable_by_key(|res| { + res.data + .get("run") + .and_then(|r| r.get("timestamp").and_then(|t| t.as_integer())) + }); + answer + } + + fn parse_filter_string( + filter_string: &str, + ) -> Result<Filter, Box<dyn Error>> { + let mut split = filter_string.splitn(2, '='); + let (key, value) = ( + split.next().ok_or("no filter key")?.to_owned(), + split.next().ok_or("no value key")?, + ); + Ok(Filter { + key, + value: value.try_into()?, + }) + } } -#[derive(Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct TimeSection { - pub median: f64, - pub mean: f64, - pub standard_deviation: Option<f64>, - /// Mercurial perf extension does not provide a min - pub min: Option<f64>, - pub max: f64, +fn data_matches_filters(data: &toml::Value, filters: &[Filter]) -> bool { + for filter in filters { + let split = split_unescape(&filter.key, '.', '\\'); + let mut split = split.iter(); + let mut current = Some(data); + while let (Some(level), Some(curr)) = (split.next(), current) { + current = curr.get(level) + } + let Some(current) = current.and_then(|c| c.as_str()) else { + return false; + }; + let is_match = match &filter.value { + FilterValue::Raw(val) => val == current, + FilterValue::Regex(re) => re.is_match(current), + }; + if !is_match { + return false; + } + } + true } diff --git a/rust/poulpe-core/src/utils.rs b/rust/poulpe-core/src/utils.rs --- a/rust/poulpe-core/src/utils.rs +++ b/rust/poulpe-core/src/utils.rs @@ -133,3 +133,24 @@ ret.push(current); ret } + +pub fn get_toml_value<'a>( + key: &str, + value: &'a toml::Value, +) -> Option<&'a toml::Value> { + let mut current = value; + + for var in split_unescape(key, '.', '\\') { + current = current.get(var)?; + } + Some(current) +} + +pub fn join_escaped(to_join: &[String], delimiters: &str) -> String { + let mut joined = String::new(); + for s in to_join { + joined.push_str(&escape_string(s, delimiters)); + joined.push_str(delimiters); + } + joined +} diff --git a/rust/poulpe-dashboard/Cargo.lock b/rust/poulpe-dashboard/Cargo.lock --- a/rust/poulpe-dashboard/Cargo.lock +++ b/rust/poulpe-dashboard/Cargo.lock @@ -1940,6 +1940,7 @@ version = "0.1.0" dependencies = [ "indexmap", + "regex", "serde", "serde_json", "toml", @@ -2051,9 +2052,9 @@ [[package]] name = "regex" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -2063,9 +2064,9 @@ [[package]] name = "regex-automata" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -2074,9 +2075,9 @@ [[package]] name = "regex-syntax" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "renderdoc-sys" diff --git a/rust/poulpe-dashboard/src/app.rs b/rust/poulpe-dashboard/src/app.rs --- a/rust/poulpe-dashboard/src/app.rs +++ b/rust/poulpe-dashboard/src/app.rs @@ -1,6 +1,8 @@ use std::error::Error; -use egui::{Align, Context, Key, Layout, RichText, TopBottomPanel, Ui}; +use egui::{ + Align, CentralPanel, Context, Key, Layout, RichText, TopBottomPanel, Ui, +}; use egui_commonmark::CommonMarkCache; use include_dir::{include_dir, Dir}; use poulpe_core::{den::Den, gather_den}; @@ -9,7 +11,7 @@ elements::{benchmark_form::RunBenchmarkForm, menu::render_menu}, tabs::{ den_tab::render_den_tab, docs_tab::render_docs_tab, - run_tab::render_run_tab, + results_tab::CompareResultsForm, run_tab::render_run_tab, }, IS_WEB, WARNING_COLOR, }; @@ -22,6 +24,7 @@ Docs, Den, Run, + Results, } pub struct Dashboard { @@ -34,6 +37,7 @@ pub last_update: f64, /// Cache for the rendered markdown docs pub commonmark_cache: CommonMarkCache, + pub compare_results_form: CompareResultsForm, } impl Dashboard { @@ -66,6 +70,7 @@ } else { DashboardMode::Docs }; + let compare_results_form = CompareResultsForm::new(den.as_ref()); Self { den, den_path_input: root, @@ -74,12 +79,14 @@ commonmark_cache, current_doc_page: "poulpe-den.md".to_string(), mode, + compare_results_form, } } pub fn reload_den(&mut self) -> Result<(), Box<dyn Error>> { self.den = Some(gather_den(&self.den_path_input)?); self.benchmark_form = Default::default(); + self.compare_results_form = CompareResultsForm::new(self.den.as_ref()); Ok(()) } @@ -151,6 +158,15 @@ DashboardMode::Docs => render_docs_tab(self, ctx), DashboardMode::Den => render_den_tab(self, ctx), DashboardMode::Run => render_run_tab(self, ctx), + DashboardMode::Results => { + if let Some(den) = &self.den { + self.compare_results_form.render_results_tab(den, ctx) + } else { + CentralPanel::default().show(ctx, |ui| { + self.no_den_message(ui); + }); + } + } } } } diff --git a/rust/poulpe-dashboard/src/tabs/results_tab.rs b/rust/poulpe-dashboard/src/tabs/results_tab.rs --- a/rust/poulpe-dashboard/src/tabs/results_tab.rs +++ b/rust/poulpe-dashboard/src/tabs/results_tab.rs @@ -1,46 +1,236 @@ -use crate::{app::DashboardMode, Dashboard, IS_WEB, WARNING_COLOR}; -use egui::{CentralPanel, Context, SidePanel}; -use egui_taffy::{taffy::prelude::*, TuiBuilderLogic}; +use std::collections::{HashMap, HashSet}; + +use egui::{CentralPanel, Context}; +use egui_taffy::{ + taffy::{prelude::*, Overflow, Point}, + TuiBuilderLogic, +}; +use poulpe_core::{ + compare::{compare_results, ComparedGroupedResults}, + den::Den, + result::Query, + utils::get_toml_value, +}; + +#[derive(Default)] +pub struct CompareResultsForm { + pub current_results: ComparedGroupedResults, + pub used_compare_key: bool, + pub checkboxes: HashMap<(String, String), bool>, + pub headers: Vec<(String, Vec<String>)>, +} + +impl CompareResultsForm { + pub fn new(den: Option<&Den>) -> Self { + let mut slf = Self::default(); + if let Some(den) = den { + slf.run_query(den); + }; + slf + } + pub fn render_results_tab(&mut self, den: &Den, ctx: &Context) { + ctx.style_mut(|style| { + // Disable text wrapping + style.wrap_mode = Some(egui::TextWrapMode::Extend); + }); + + let query_form_outer_style = Style { + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::Stretch), + gap: length(10.0), + overflow: Point { + x: Default::default(), + y: Overflow::Scroll, + }, + max_size: Size { + width: auto(), + height: percent(1.0), + }, + min_size: Size { + width: length(200.0), + height: percent(1.0), + }, + padding: length(10.0), + ..Default::default() + }; + + let results_outer_style = Style { + display: egui_taffy::taffy::Display::Grid, + grid_template_columns: vec![ + minmax(length(300.0), fr(1.0)), + minmax(length(300.0), fr(1.0)), + minmax(length(300.0), fr(1.0)), + ], + flex_grow: 1.0, + min_size: Size { + width: auto(), + height: percent(1.0), + }, + gap: length(5.0), + padding: length(5.0), + overflow: Point { + x: Default::default(), + y: Overflow::Scroll, + }, + ..Default::default() + }; + + CentralPanel::default().show(ctx, |ui| { + egui_taffy::tui(ui, ui.id().with("results-grid")) + .reserve_available_space() + .style(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0), + height: percent(1.0), + }, + gap: length(10.0), + ..Default::default() + }) + .show(|tui| { + tui.style(query_form_outer_style).add_with_border(|tui| { + self.render_query_form(den, tui); + }); + tui.style(results_outer_style).add_with_border(|tui| { + tui.egui_style_mut().wrap_mode = + Some(egui::TextWrapMode::Wrap); + self.render_results_inner(tui); + }); + }) + }); + } -pub fn render_results_tab(dashboard: &mut Dashboard, ctx: &Context) { - ctx.style_mut(|style| { - // Disable text wrapping - style.wrap_mode = Some(egui::TextWrapMode::Truncate); - }); - SidePanel::left("results-left-panel").show(ctx, |ui| { - if let Some(_den) = &dashboard.den { - ui.label("TODO"); - } else { - if !IS_WEB { - ui.label("Use the top bar to point the dashboard to a den"); - } else { - ui.colored_label( - WARNING_COLOR, - "Remote loading of a den is not available yet", - ); + fn render_query_form(&mut self, den: &Den, tui: &mut egui_taffy::Tui) { + if tui.ui_add(egui::Button::new("Reset")).clicked() { + self.checkboxes.clear(); + self.run_query(den); + } + let mut any_filter_changed = false; + for (name, values) in &mut self.headers { + tui.heading(name.clone()); + tui.style(Style { + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .add(|tui| { + for value in values { + let checked = + self.checkboxes.get_mut(&(name.clone(), value.clone())); + let checked = checked.expect("checkboxes should be set"); + any_filter_changed |= tui + .ui_add(egui::Checkbox::new(checked, value.clone())) + .changed(); + } + }) + } + if !self.used_compare_key { + // TODO dimension field + // tui.colored_label( + // WARNING_COLOR, + // format!("No results have the dimension {}", dimension), + // ); + } + if self.current_results.is_empty() { + // XXX Not sure how this would happen since it's AND filtering + // and the filters are based on the results. + for ((header, value), checked) in &mut self.checkboxes { + tui.heading(header.clone()); + any_filter_changed |= tui + .ui_add(egui::Checkbox::new(checked, value.clone())) + .changed(); } - if ui.button("Open the docs").clicked() { - dashboard.mode = DashboardMode::Docs; + tui.label("Query returned no results"); + } + if any_filter_changed { + self.run_query(den); + }; + } + + fn render_results_inner(&mut self, tui: &mut egui_taffy::Tui) { + for (key, group) in &self.current_results { + tui.style(Style { + flex_direction: FlexDirection::Column, + padding: length(10.0), + ..Default::default() + }) + .add_with_border(|tui| { + for (name, value) in &key.headers { + tui.label(format!("# {name} = {value}")); + } + for (idx, result) in group.compared.iter().enumerate() { + let is_base = group.base.map(|b| b == idx).unwrap_or(false); + let key = + get_toml_value(&key.compare_key, &result.result.data) + .map(|k| k.to_string()) + .unwrap_or("None".to_string()); + let diff = if !is_base { + format!( + " ({}, {})", + result.max_abs_change, result.max_abs_diff + ) + } else { + "".to_string() + }; + + tui.label(format!("{}: {}{}", key, result.time, diff)); + } + }) + } + } + + fn run_query(&mut self, den: &Den) { + // TODO do this in a separate thread or query server if web + let filters: Vec<_> = self + .checkboxes + .iter() + .filter_map(|((header, value), checked)| { + if *checked { + Some((header.clone(), value.to_string())) + } else { + None + } + }) + .collect(); + + let queried = Query::new_from_parsed(filters.as_slice()) + .query_results(den.results.as_ref()); + let (mut current_results, used_compare_key) = + compare_results(&queried, None, None); + current_results.sort_unstable_keys(); + + self.current_results = current_results; + self.used_compare_key = used_compare_key; + + let mut headers: HashMap<String, HashSet<String>> = HashMap::new(); + for group in self.current_results.keys() { + for (header, value) in &group.headers { + headers + .entry(header.clone()) + .or_default() + .insert(value.clone()); } } - }); - ctx.style_mut(|style| { - // Disable text wrapping - style.wrap_mode = Some(egui::TextWrapMode::Extend); - }); - CentralPanel::default().show(ctx, |ui| { - let Some(_den) = &dashboard.den else { - dashboard.no_den_message(ui); - return; - }; - egui_taffy::tui(ui, ui.id().with("results-grid")) - .reserve_available_space() - .style(Style { - // display: Display::Grid, - ..Default::default() + let mut checkboxes = HashMap::new(); + for (header, values) in headers.iter() { + for value in values { + let checked = self + .checkboxes + .get(&(header.clone(), value.clone())) + .unwrap_or(&false); + checkboxes.insert((header.clone(), value.clone()), *checked); + } + } + let mut headers: Vec<(String, Vec<String>)> = headers + .into_iter() + .map(|(name, set)| { + let mut set = Vec::from_iter(set); + set.sort_unstable(); + (name, set) }) - .show(|tui| { - tui.heading("TODO"); - }) - }); + .collect(); + headers.sort_unstable(); + + self.headers = headers; + self.checkboxes = checkboxes; + } }