# 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 = &times.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(&regex::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;
+    }
 }