use crate::panels::tree::{self, TreeRow};
use eframe::egui;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
pub const MIN_COLUMN_WIDTH: f32 = 28.0;
pub const DEFAULT_COLUMN_WIDTH: f32 = 110.0;
const GRIP: f32 = 5.0;
const CELL_PAD: f32 = 3.0;
const FREEZE_GAP: f32 = 4.0;
#[derive(Debug, Clone, PartialEq)]
pub enum CellKind {
Text,
Numeric { step: f64 },
Choice { options: Vec<String> },
Button { label: String },
Actions { label: String },
Badges,
Toggle,
ReadOnly,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColumnSpec {
pub key: String,
pub label: String,
pub kind: CellKind,
pub default_width: f32,
}
impl ColumnSpec {
pub fn new(key: impl Into<String>, label: impl Into<String>, kind: CellKind) -> Self {
Self {
key: key.into(),
label: label.into(),
kind,
default_width: DEFAULT_COLUMN_WIDTH,
}
}
pub fn width(mut self, width: f32) -> Self {
self.default_width = width;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowAction {
pub id: String,
pub label: String,
pub tooltip: String,
pub enabled: bool,
pub separator_above: bool,
pub destructive: bool,
}
impl RowAction {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
tooltip: String::new(),
enabled: true,
separator_above: false,
destructive: false,
}
}
pub fn tooltip(mut self, text: impl Into<String>) -> Self {
self.tooltip = text.into();
self
}
pub fn disabled(mut self, why: impl Into<String>) -> Self {
self.enabled = false;
self.tooltip = why.into();
self
}
pub fn separator_above(mut self) -> Self {
self.separator_above = true;
self
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RowNode {
pub id: String,
pub cells: HashMap<String, Value>,
pub editable: bool,
pub selected: bool,
pub expanded: bool,
pub actions: Vec<RowAction>,
pub children: Vec<RowNode>,
}
impl RowNode {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
editable: true,
..Default::default()
}
}
pub fn cell(mut self, key: impl Into<String>, value: Value) -> Self {
self.cells.insert(key.into(), value);
self
}
pub fn actions(mut self, actions: Vec<RowAction>) -> Self {
self.actions = actions;
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ColumnLayout {
pub order: Vec<String>,
pub hidden: HashSet<String>,
pub widths: HashMap<String, f32>,
pub sort: Option<(String, bool)>,
pub frozen: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CellEdit {
pub row_id: String,
pub column: String,
pub value: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CellClick {
pub row_id: String,
pub column: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowActionClick {
pub row_id: String,
pub action: String,
}
pub struct ColumnTreeSpec<'a> {
pub id: &'a str,
pub columns: &'a [ColumnSpec],
pub root_label: Option<&'a str>,
pub root_cells: Option<&'a HashMap<String, Value>>,
pub empty_hint: Option<&'a str>,
pub hits_prefix: &'a str,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ColumnTreeOut {
pub edits: Vec<CellEdit>,
pub buttons: Vec<CellClick>,
pub actions: Vec<RowActionClick>,
pub toggled: Option<String>,
pub clicked: Option<String>,
pub layout_changed: bool,
}
fn parse_hex_color(hex: &str) -> Option<egui::Color32> {
let digits = hex.strip_prefix('#')?;
if digits.len() != 6 {
return None;
}
let byte = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
Some(egui::Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
}
pub fn column_tree(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
layout: &mut ColumnLayout,
rows: &[RowNode],
mut hits: Option<&mut HashMap<String, egui::Rect>>,
) -> ColumnTreeOut {
let mut out = ColumnTreeOut::default();
let arranged = arranged_columns(spec, layout);
let visible: Vec<&ColumnSpec> = arranged
.iter()
.copied()
.filter(|column| !layout.hidden.contains(&column.key))
.collect();
if visible.is_empty() {
ui.label(egui::RichText::new("(every column is hidden)").weak());
return out;
}
let widths: Vec<f32> = visible
.iter()
.map(|column| column_width(layout, column))
.collect();
let mut frozen = arranged
.iter()
.take(layout.frozen)
.filter(|column| !layout.hidden.contains(&column.key))
.count();
if frozen >= visible.len() {
frozen = 0;
}
ui.spacing_mut().item_spacing.y = 2.0;
let full = ui.available_rect_before_wrap();
let frozen_width: f32 = widths[..frozen]
.iter()
.sum::<f32>()
.min((full.width() - MIN_COLUMN_WIDTH).max(0.0));
let mut pending: Option<MenuOpen> = None;
let mut bounds: Vec<(String, f32, f32)> = Vec::new();
let mut bottom = full.top();
if frozen > 0 {
let rect = egui::Rect::from_min_max(
full.min,
egui::pos2(full.min.x + frozen_width, full.max.y),
);
let mut pane = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::top_down(egui::Align::Min))
.id_salt((spec.id, "column-tree-frozen")),
);
pane.set_clip_rect(pane.clip_rect().intersect(egui::Rect::from_x_y_ranges(
rect.x_range(),
ui.clip_rect().y_range(),
)));
pane.spacing_mut().item_spacing.y = 2.0;
draw_pane(
&mut pane,
spec,
layout,
&visible[..frozen],
&widths[..frozen],
0,
frozen_width,
rows,
&mut hits,
&mut out,
&mut pending,
&mut bounds,
);
bottom = bottom.max(pane.min_rect().bottom());
}
let scroll_left = full.min.x + if frozen > 0 { frozen_width + FREEZE_GAP } else { 0.0 };
let scroll_rect = egui::Rect::from_min_max(egui::pos2(scroll_left, full.min.y), full.max);
let viewport = scroll_rect.width();
let mut pane = ui.new_child(
egui::UiBuilder::new()
.max_rect(scroll_rect)
.layout(egui::Layout::top_down(egui::Align::Min))
.id_salt((spec.id, "column-tree-scrolling")),
);
let rest: f32 = widths[frozen..].iter().sum();
egui::ScrollArea::horizontal()
.id_salt((spec.id, "column-tree-hscroll"))
.show(&mut pane, |ui: &mut egui::Ui| {
ui.spacing_mut().item_spacing.y = 2.0;
draw_pane(
ui,
spec,
layout,
&visible[frozen..],
&widths[frozen..],
frozen,
rest.max(viewport),
rows,
&mut hits,
&mut out,
&mut pending,
&mut bounds,
);
if rest > viewport {
let scroll = ui.spacing().scroll;
ui.add_space(scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin);
}
});
bottom = bottom.max(pane.min_rect().bottom());
let used = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, bottom));
ui.advance_cursor_after_rect(used);
if frozen > 0 {
let x = full.min.x + frozen_width + FREEZE_GAP * 0.5;
let divider = egui::Rect::from_min_max(
egui::pos2(x - 1.0, used.top()),
egui::pos2(x + 1.0, used.bottom()),
);
ui.painter()
.rect_filled(divider, 0.0, ui.visuals().widgets.active.bg_fill);
publish(&mut hits, spec, "freeze:divider", divider);
}
finish_reorder(ui, spec, layout, &arranged, &bounds, &mut out);
row_action_menu(ui, spec, rows, &mut hits, &mut out, &mut pending);
out
}
#[allow(clippy::too_many_arguments)]
fn draw_pane(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
layout: &mut ColumnLayout,
columns: &[&ColumnSpec],
widths: &[f32],
offset: usize,
band: f32,
rows: &[RowNode],
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut ColumnTreeOut,
pending: &mut Option<MenuOpen>,
bounds: &mut Vec<(String, f32, f32)>,
) {
header(ui, spec, layout, columns, widths, band, hits, out, bounds);
ui.separator();
if let Some(label) = spec.root_label {
let mut cells = spec.root_cells.cloned().unwrap_or_default();
if offset == 0 {
cells.insert(columns[0].key.clone(), Value::String(label.to_string()));
}
let root = RowNode {
id: format!("{}__root", spec.id),
cells,
editable: false,
selected: false,
expanded: true,
actions: Vec::new(),
children: Vec::new(),
};
draw_row(
ui, spec, columns, widths, offset, band, &root, &[], true, true, hits, out, pending,
);
}
if rows.is_empty() {
if let Some(hint) = spec.empty_hint {
if offset == 0 {
let guides = tree::child_guides(&[], true);
tree::node(ui, TreeRow::leaf(&guides, true, hint), |_| {});
} else {
ui.allocate_exact_size(
egui::vec2(band, ui.spacing().interact_size.y),
egui::Sense::hover(),
);
}
}
return;
}
let ordered = sorted_siblings(rows, layout);
let last = ordered.len();
for (index, row) in ordered.iter().enumerate() {
draw_subtree(
ui,
spec,
layout,
columns,
widths,
offset,
band,
row,
&[],
index + 1 == last,
hits,
out,
pending,
);
}
}
fn arranged_columns<'a>(
spec: &'a ColumnTreeSpec<'_>,
layout: &ColumnLayout,
) -> Vec<&'a ColumnSpec> {
let mut out: Vec<&ColumnSpec> = Vec::new();
for key in &layout.order {
if let Some(column) = spec.columns.iter().find(|c| &c.key == key) {
if !out.iter().any(|c| c.key == column.key) {
out.push(column);
}
}
}
for column in spec.columns {
if !out.iter().any(|c| c.key == column.key) {
out.push(column);
}
}
out
}
fn column_width(layout: &ColumnLayout, column: &ColumnSpec) -> f32 {
layout
.widths
.get(&column.key)
.copied()
.unwrap_or(column.default_width)
.max(MIN_COLUMN_WIDTH)
}
#[allow(clippy::too_many_arguments)]
fn header(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
layout: &mut ColumnLayout,
visible: &[&ColumnSpec],
widths: &[f32],
width: f32,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut ColumnTreeOut,
bounds: &mut Vec<(String, f32, f32)>,
) {
let height = ui.spacing().interact_size.y;
let (band, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
let dragging: Option<String> = ui.data(|d| d.get_temp(drag_key));
let mut x = band.left();
let first = bounds.len();
for (column, width) in visible.iter().zip(widths) {
let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
bounds.push((column.key.clone(), cell.left(), cell.right()));
let resp = ui.interact(
cell,
ui.id().with(("column-tree-head", spec.id, &column.key)),
egui::Sense::click_and_drag(),
);
let held = dragging.as_deref() == Some(column.key.as_str());
let fill = if held {
ui.visuals().selection.bg_fill.gamma_multiply(0.45)
} else if resp.hovered() {
ui.visuals().widgets.hovered.bg_fill
} else {
ui.visuals().widgets.noninteractive.bg_fill
};
ui.painter().rect_filled(cell, 0.0, fill);
let marker = match &layout.sort {
Some((key, true)) if key == &column.key => " \u{25B2}",
Some((key, false)) if key == &column.key => " \u{25BC}",
_ => "",
};
let text = format!("{}{marker}", column.label);
ui.painter().text(
egui::pos2(cell.left() + CELL_PAD, cell.center().y),
egui::Align2::LEFT_CENTER,
elide(ui, &text, *width - 2.0 * CELL_PAD),
egui::TextStyle::Body.resolve(ui.style()),
ui.visuals().strong_text_color(),
);
publish(hits, spec, &format!("col:{}", column.key), cell);
resp.context_menu(|ui| {
ui.label(egui::RichText::new("Columns").strong());
for candidate in spec.columns {
let mut shown = !layout.hidden.contains(&candidate.key);
if ui.checkbox(&mut shown, &candidate.label).changed() {
if shown {
layout.hidden.remove(&candidate.key);
} else {
layout.hidden.insert(candidate.key.clone());
}
out.layout_changed = true;
}
}
});
if resp.clicked() {
layout.sort = match &layout.sort {
Some((key, true)) if key == &column.key => Some((column.key.clone(), false)),
Some((key, false)) if key == &column.key => None,
_ => Some((column.key.clone(), true)),
};
out.layout_changed = true;
}
if resp.drag_started() {
ui.data_mut(|d| d.insert_temp(drag_key, column.key.clone()));
}
ui.painter().line_segment(
[
egui::pos2(cell.right(), band.top()),
egui::pos2(cell.right(), band.bottom()),
],
ui.visuals().widgets.noninteractive.bg_stroke,
);
x = cell.right();
}
for (column, (_, _, right)) in visible.iter().zip(&bounds[first..]) {
let grip_rect = egui::Rect::from_min_max(
egui::pos2(right - GRIP * 0.5, band.top()),
egui::pos2(right + GRIP * 0.5, band.bottom()),
);
let grip = ui.interact(
grip_rect,
ui.id().with(("column-tree-grip", spec.id, &column.key)),
egui::Sense::drag(),
);
if grip.hovered() || grip.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if grip.dragged() {
let next = (column_width(layout, column) + grip.drag_delta().x).max(MIN_COLUMN_WIDTH);
layout.widths.insert(column.key.clone(), next);
out.layout_changed = true;
}
publish(hits, spec, &format!("grip:{}", column.key), grip_rect);
}
}
fn finish_reorder(
ui: &egui::Ui,
spec: &ColumnTreeSpec<'_>,
layout: &mut ColumnLayout,
arranged: &[&ColumnSpec],
bounds: &[(String, f32, f32)],
out: &mut ColumnTreeOut,
) {
let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
let Some(held) = ui.data(|d| d.get_temp::<String>(drag_key)) else {
return;
};
if ui.input(|i| i.pointer.any_down()) {
return;
}
ui.data_mut(|d| d.remove::<String>(drag_key));
let Some(pos) = ui.input(|i| i.pointer.latest_pos()) else {
return;
};
if let Some((target, _, _)) = bounds
.iter()
.find(|(_, left, right)| pos.x >= *left && pos.x < *right)
{
if *target != held && move_column(layout, arranged, &held, target) {
out.layout_changed = true;
}
}
}
fn move_column(
layout: &mut ColumnLayout,
arranged: &[&ColumnSpec],
held: &str,
target: &str,
) -> bool {
let mut order: Vec<String> = layout.order.clone();
for column in arranged {
if !order.iter().any(|key| key == &column.key) {
order.push(column.key.clone());
}
}
let Some(from) = order.iter().position(|key| key == held) else {
return false;
};
let key = order.remove(from);
let Some(to) = order.iter().position(|k| k == target) else {
order.insert(from.min(order.len()), key);
return false;
};
order.insert(to, key);
layout.order = order;
true
}
#[allow(clippy::too_many_arguments)]
fn draw_subtree(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
layout: &ColumnLayout,
visible: &[&ColumnSpec],
widths: &[f32],
offset: usize,
band: f32,
row: &RowNode,
guides: &[bool],
is_last: bool,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut ColumnTreeOut,
pending: &mut Option<MenuOpen>,
) {
draw_row(
ui, spec, visible, widths, offset, band, row, guides, is_last, false, hits, out, pending,
);
if !row.expanded || row.children.is_empty() {
return;
}
let child_guides = tree::child_guides(guides, is_last);
let ordered = sorted_siblings(&row.children, layout);
let last = ordered.len();
for (index, child) in ordered.iter().enumerate() {
draw_subtree(
ui,
spec,
layout,
visible,
widths,
offset,
band,
child,
&child_guides,
index + 1 == last,
hits,
out,
pending,
);
}
}
fn sorted_siblings<'a>(rows: &'a [RowNode], layout: &ColumnLayout) -> Vec<&'a RowNode> {
let mut out: Vec<&RowNode> = rows.iter().collect();
if let Some((key, ascending)) = &layout.sort {
out.sort_by(|a, b| {
let ordering = compare_cells(a.cells.get(key), b.cells.get(key));
if *ascending {
ordering
} else {
ordering.reverse()
}
});
}
out
}
fn compare_cells(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
use std::cmp::Ordering;
let empty = |value: Option<&Value>| match value {
None | Some(Value::Null) => true,
Some(Value::String(text)) => text.is_empty(),
_ => false,
};
match (empty(a), empty(b)) {
(true, true) => return Ordering::Equal,
(true, false) => return Ordering::Greater,
(false, true) => return Ordering::Less,
(false, false) => {}
}
if let (Some(Value::Number(x)), Some(Value::Number(y))) = (a, b) {
if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
}
}
display_text(a).to_lowercase().cmp(&display_text(b).to_lowercase())
}
fn display_text(value: Option<&Value>) -> String {
match value {
None | Some(Value::Null) => String::new(),
Some(Value::String(text)) => text.clone(),
Some(other) => other.to_string(),
}
}
#[allow(clippy::too_many_arguments)]
fn draw_row(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
visible: &[&ColumnSpec],
widths: &[f32],
offset: usize,
band_width: f32,
row: &RowNode,
guides: &[bool],
is_last: bool,
root: bool,
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut ColumnTreeOut,
pending: &mut Option<MenuOpen>,
) {
let height = ui.spacing().interact_size.y;
let (band, _) = ui.allocate_exact_size(egui::vec2(band_width, height), egui::Sense::hover());
let clip = ui.clip_rect();
let over_row = band.intersect(clip);
if !row.actions.is_empty()
&& over_row.is_positive()
&& ui.input(|i| i.pointer.secondary_clicked())
{
if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
let above = ui.ctx().layer_id_at(pos);
let ours = above.is_none() || above == Some(ui.layer_id());
if over_row.contains(pos) && ours {
*pending = Some(MenuOpen {
row: row.id.clone(),
pos,
});
}
}
}
if row.selected && !root {
let visible_band = band.intersect(clip);
if visible_band.is_positive() {
ui.painter().rect_filled(
visible_band,
0.0,
ui.visuals().selection.bg_fill.gamma_multiply(0.35),
);
}
}
let mut x = band.left();
for (index, (column, width)) in visible.iter().zip(widths).enumerate() {
let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
x = cell.right();
let Some(cell_clip) = cell.intersect(clip).is_positive().then_some(cell.intersect(clip))
else {
continue;
};
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(cell.shrink2(egui::vec2(CELL_PAD, 0.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center))
.id_salt(("column-tree-cell", spec.id, &row.id, &column.key)),
);
child.set_clip_rect(cell_clip);
if offset + index == 0 {
let label = display_text(row.cells.get(&column.key));
let expandable = !row.children.is_empty();
let mut tree_row = TreeRow {
guides,
is_last,
expandable,
expanded: row.expanded,
root,
glyph: None,
label: &label,
selected: row.selected,
draggable: false,
};
if root {
tree_row.expandable = true;
tree_row.expanded = true;
}
let resp = tree::node(&mut child, tree_row, |_| {});
publish(hits, spec, &format!("row:{}", row.id), resp.label.rect);
publish(hits, spec, &format!("box:{}", row.id), resp.box_rect);
if resp.toggled {
out.toggled = Some(row.id.clone());
}
if resp.label.clicked() {
out.clicked = Some(row.id.clone());
}
} else {
let rect = cell_editor(&mut child, spec, row, column, out, pending);
publish(
hits,
spec,
&format!("cell:{}:{}", row.id, column.key),
rect,
);
if matches!(column.kind, CellKind::Actions { .. }) {
publish(hits, spec, &format!("menu:{}", row.id), rect);
}
}
}
}
fn cell_editor(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
row: &RowNode,
column: &ColumnSpec,
out: &mut ColumnTreeOut,
pending: &mut Option<MenuOpen>,
) -> egui::Rect {
let value = row.cells.get(&column.key);
let width = ui.available_width();
let mut emit = |new_value: Value| {
out.edits.push(CellEdit {
row_id: row.id.clone(),
column: column.key.clone(),
value: new_value,
});
};
match &column.kind {
CellKind::Button { label } => {
let button = ui.add_sized([width, ui.available_height()], egui::Button::new(label));
if button.clicked() {
out.buttons.push(CellClick {
row_id: row.id.clone(),
column: column.key.clone(),
});
}
button.rect
}
CellKind::Actions { label } => {
let offered = !row.actions.is_empty();
let button = ui
.add_enabled_ui(offered, |ui| {
ui.add_sized([width, ui.available_height()], egui::Button::new(label))
})
.inner;
if button.clicked() {
*pending = Some(MenuOpen {
row: row.id.clone(),
pos: button.rect.left_bottom(),
});
}
button.rect
}
CellKind::ReadOnly => ui.add(egui::Label::new(display_text(value)).truncate()).rect,
CellKind::Badges => {
let badges = value.and_then(Value::as_array).cloned().unwrap_or_default();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for badge in &badges {
let glyph = badge.get("glyph").and_then(Value::as_str).unwrap_or("");
if glyph.is_empty() {
continue;
}
let mut text = egui::RichText::new(glyph);
if let Some(color) = badge
.get("color")
.and_then(Value::as_str)
.and_then(parse_hex_color)
{
text = text.color(color);
}
let label = ui.label(text);
if let Some(tip) = badge.get("tooltip").and_then(Value::as_str) {
label.on_hover_text(tip);
}
}
})
.response
.rect
}
CellKind::Toggle => {
let mut on = value.and_then(Value::as_bool).unwrap_or(false);
let box_ = ui
.add_enabled_ui(row.editable, |ui| ui.checkbox(&mut on, ""))
.inner;
if box_.changed() {
emit(Value::Bool(on));
}
box_.rect
}
_ if !row.editable => ui
.add(
egui::Label::new(egui::RichText::new(display_text(value)).weak())
.truncate(),
)
.rect,
CellKind::Text => {
let buffer_id = egui::Id::new(("column-tree-text", spec.id, &row.id, &column.key));
let stored = display_text(value);
let mut buffer: String =
ui.data(|d| d.get_temp(buffer_id)).unwrap_or_else(|| stored.clone());
let edit = ui.add_sized(
[width, ui.available_height()],
egui::TextEdit::singleline(&mut buffer),
);
let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if edit.has_focus() || edit.changed() {
ui.data_mut(|d| d.insert_temp(buffer_id, buffer.clone()));
}
if edit.lost_focus() || entered {
ui.data_mut(|d| d.remove::<String>(buffer_id));
if buffer != stored {
emit(Value::String(buffer));
}
} else if !edit.has_focus() {
ui.data_mut(|d| d.remove::<String>(buffer_id));
}
edit.rect
}
CellKind::Numeric { step } => {
let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
let drag = ui.add_sized(
[width, ui.available_height()],
egui::DragValue::new(&mut number).speed(*step),
);
if drag.changed() {
emit(serde_json::json!(number));
}
drag.rect
}
CellKind::Choice { options } => {
let current = display_text(value);
let mut chosen = current.clone();
let combo = egui::ComboBox::from_id_salt((
"column-tree-combo",
spec.id,
&row.id,
&column.key,
))
.width(width)
.selected_text(if current.is_empty() { "—" } else { ¤t })
.show_ui(ui, |ui| {
ui.selectable_value(&mut chosen, String::new(), "—");
for option in options {
ui.selectable_value(&mut chosen, option.clone(), option);
}
});
if chosen != current {
emit(Value::String(chosen));
}
combo.response.rect
}
}
}
#[derive(Clone)]
struct MenuOpen {
row: String,
pos: egui::Pos2,
}
fn row_action_menu(
ui: &mut egui::Ui,
spec: &ColumnTreeSpec<'_>,
rows: &[RowNode],
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
out: &mut ColumnTreeOut,
pending: &mut Option<MenuOpen>,
) {
let key = egui::Id::new((spec.id, "column-tree-menu"));
let mut open: Option<MenuOpen> = ui.data(|d| d.get_temp(key));
let was_open = open.as_ref().map(|state| state.row.clone());
if let Some(state) = open.clone() {
match find_row(rows, &state.row) {
Some(row) if !row.actions.is_empty() => {
let mut still_open = true;
egui::Popup::new(
key.with("popup"),
ui.ctx().clone(),
egui::PopupAnchor::Position(state.pos),
ui.layer_id(),
)
.open_bool(&mut still_open)
.kind(egui::PopupKind::Menu)
.layout(egui::Layout::top_down_justified(egui::Align::Min))
.width(160.0)
.show(|ui| {
for action in &row.actions {
if action.separator_above {
ui.separator();
}
let mut text = egui::RichText::new(&action.label);
if action.destructive {
text = text.color(ui.visuals().error_fg_color);
}
let entry = ui.add_enabled(action.enabled, egui::Button::new(text));
publish(
hits,
spec,
&format!("menuitem:{}:{}", row.id, action.id),
entry.rect,
);
if !action.tooltip.is_empty() {
if action.enabled {
entry.clone().on_hover_text(&action.tooltip);
} else {
entry.clone().on_disabled_hover_text(&action.tooltip);
}
}
if entry.clicked() {
out.actions.push(RowActionClick {
row_id: row.id.clone(),
action: action.id.clone(),
});
}
}
});
if !still_open {
open = None;
}
}
_ => open = None,
}
}
if let Some(next) = pending.take() {
let toggled_off = open.is_none() && was_open.as_deref() == Some(next.row.as_str());
open = (!toggled_off).then_some(next);
}
match &open {
Some(state) => ui.data_mut(|d| {
d.insert_temp(key, state.clone());
}),
None => ui.data_mut(|d| d.remove::<MenuOpen>(key)),
}
}
fn find_row<'a>(rows: &'a [RowNode], id: &str) -> Option<&'a RowNode> {
for row in rows {
if row.id == id {
return Some(row);
}
if let Some(found) = find_row(&row.children, id) {
return Some(found);
}
}
None
}
fn elide(ui: &egui::Ui, text: &str, width: f32) -> String {
let font = egui::TextStyle::Body.resolve(ui.style());
let measure = |candidate: &str| {
ui.painter()
.layout_no_wrap(candidate.to_string(), font.clone(), egui::Color32::WHITE)
.rect
.width()
};
if width <= 0.0 || measure(text) <= width {
return text.to_string();
}
let mut cut: Vec<char> = text.chars().collect();
while !cut.is_empty() {
cut.pop();
let candidate: String = cut.iter().collect::<String>() + "\u{2026}";
if measure(&candidate) <= width {
return candidate;
}
}
String::new()
}
fn publish(
hits: &mut Option<&mut HashMap<String, egui::Rect>>,
spec: &ColumnTreeSpec<'_>,
key: &str,
rect: egui::Rect,
) {
if let Some(map) = hits.as_deref_mut() {
map.insert(format!("{}{key}", spec.hits_prefix), rect);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn columns() -> Vec<ColumnSpec> {
vec![
ColumnSpec::new("name", "Name", CellKind::Text).width(120.0),
ColumnSpec::new("qty", "Qty", CellKind::ReadOnly).width(40.0),
ColumnSpec::new("note", "Note", CellKind::Text),
ColumnSpec::new(
"grade",
"Grade",
CellKind::Choice {
options: vec!["A".into(), "B".into()],
},
),
ColumnSpec::new("del", "", CellKind::Button { label: "x".into() }).width(30.0),
ColumnSpec::new("on", "", CellKind::Toggle).width(26.0),
ColumnSpec::new("flags", "", CellKind::Badges).width(52.0),
ColumnSpec::new(
"act",
"",
CellKind::Actions {
label: "\u{22EF}".into(),
},
)
.width(30.0),
]
}
fn actions(open_allowed: bool) -> Vec<RowAction> {
vec![
RowAction::new("open", "Open").tooltip("Open it"),
RowAction::new("rename", "Rename").tooltip("Rename it"),
RowAction::new("drop", "Drop")
.separator_above()
.destructive(),
]
.into_iter()
.map(|action| {
if action.id == "open" && !open_allowed {
action.disabled("This one has nothing to open")
} else {
action
}
})
.collect()
}
fn rows() -> Vec<RowNode> {
vec![
RowNode::new("J1")
.cell("name", Value::String("J1".into()))
.cell("qty", serde_json::json!(2))
.cell("note", Value::String("main".into()))
.cell("on", Value::Bool(true))
.cell(
"flags",
serde_json::json!([
{ "glyph": "A", "color": "#ff9f0a", "tooltip": "amber" },
{ "glyph": "B" }
]),
)
.actions(actions(true)),
{
let mut parent = RowNode::new("J2")
.cell("name", Value::String("J2".into()))
.cell("qty", serde_json::json!(1))
.actions(actions(false));
parent.expanded = true;
parent.children = vec![
RowNode::new("J2-P2").cell("name", Value::String("pin2".into())),
RowNode::new("J2-P1").cell("name", Value::String("pin1".into())),
];
parent
},
]
}
fn spec<'a>(columns: &'a [ColumnSpec]) -> ColumnTreeSpec<'a> {
ColumnTreeSpec {
id: "test-tree",
columns,
root_label: None,
root_cells: None,
empty_hint: Some("(nothing)"),
hits_prefix: "",
}
}
fn frame(
ctx: &egui::Context,
layout: &mut ColumnLayout,
rows: &[RowNode],
events: Vec<egui::Event>,
) -> (ColumnTreeOut, HashMap<String, egui::Rect>) {
let cols = columns();
let spec = spec(&cols);
let mut hits = HashMap::new();
let mut out = ColumnTreeOut::default();
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(700.0, 500.0),
)),
events,
..Default::default()
};
let _ = ctx.run_ui(raw, |ui| {
out = column_tree(ui, &spec, layout, rows, Some(&mut hits));
});
(out, hits)
}
fn click_at(
ctx: &egui::Context,
layout: &mut ColumnLayout,
rows: &[RowNode],
pos: egui::Pos2,
button: egui::PointerButton,
) -> ColumnTreeOut {
frame(
ctx,
layout,
rows,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
frame(
ctx,
layout,
rows,
vec![egui::Event::PointerButton {
pos,
button,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
)
.0
}
fn right_click_at(
ctx: &egui::Context,
layout: &mut ColumnLayout,
rows: &[RowNode],
pos: egui::Pos2,
) -> ColumnTreeOut {
click_at(ctx, layout, rows, pos, egui::PointerButton::Secondary)
}
fn settle(ctx: &egui::Context, layout: &mut ColumnLayout, rows: &[RowNode])
-> HashMap<String, egui::Rect>
{
frame(ctx, layout, rows, vec![]);
frame(ctx, layout, rows, vec![]).1
}
fn menu_entries(hits: &HashMap<String, egui::Rect>, row: &str) -> Vec<String> {
let prefix = format!("menuitem:{row}:");
let mut keys: Vec<String> = hits
.keys()
.filter_map(|key| key.strip_prefix(&prefix).map(str::to_string))
.collect();
keys.sort();
keys
}
#[test]
fn every_column_and_every_row_cell_is_drawn() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
for key in ["name", "qty", "note", "grade", "del", "act"] {
assert!(hits.contains_key(&format!("col:{key}")), "heading {key}");
}
for row in ["J1", "J2", "J2-P1", "J2-P2"] {
assert!(hits.contains_key(&format!("row:{row}")), "tree cell {row}");
assert!(
hits.contains_key(&format!("cell:{row}:note")),
"editor cell {row}"
);
}
assert!(
hits["row:J2-P1"].left() > hits["row:J2"].left(),
"a child indents"
);
}
#[test]
fn heading_click_cycles_sort_and_sorts_within_each_level() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
let head = hits["col:name"].center();
let out = click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
assert!(out.layout_changed, "a sort click is a layout change");
assert_eq!(layout.sort, Some(("name".into(), true)));
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(hits["row:J2-P1"].top() < hits["row:J2-P2"].top(), "sorted");
assert!(hits["row:J2"].top() < hits["row:J2-P1"].top(), "still nested");
click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
assert_eq!(layout.sort, Some(("name".into(), false)), "then descending");
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(hits["row:J2-P2"].top() < hits["row:J2-P1"].top(), "reversed");
click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
assert_eq!(layout.sort, None, "a third click clears the sort");
}
#[test]
fn empty_cells_sort_last_and_numbers_compare_numerically() {
use std::cmp::Ordering;
let ten = serde_json::json!(10);
let nine = serde_json::json!(9);
assert_eq!(compare_cells(Some(&ten), Some(&nine)), Ordering::Greater);
let ten_text = Value::String("10".into());
let nine_text = Value::String("9".into());
assert_eq!(
compare_cells(Some(&ten_text), Some(&nine_text)),
Ordering::Less
);
let filled = Value::String("a".into());
assert_eq!(compare_cells(None, Some(&filled)), Ordering::Greater);
assert_eq!(compare_cells(Some(&Value::Null), Some(&filled)), Ordering::Greater);
assert_eq!(compare_cells(None, None), Ordering::Equal);
}
#[test]
fn dragging_a_divider_resizes_the_column() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
let grip = hits["grip:name"].center();
frame(
&ctx,
&mut layout,
&rows(),
vec![
egui::Event::PointerMoved(grip),
egui::Event::PointerButton {
pos: grip,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
let mut out = ColumnTreeOut::default();
for step in 1..=4 {
out = frame(
&ctx,
&mut layout,
&rows(),
vec![egui::Event::PointerMoved(egui::pos2(
grip.x + 10.0 * step as f32,
grip.y,
))],
)
.0;
}
assert!(out.layout_changed, "a resize is a layout change");
assert!(
layout.widths["name"] > 120.0,
"widened past its default: {:?}",
layout.widths
);
layout.widths.insert("name".into(), 1.0);
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(hits["col:name"].width() >= MIN_COLUMN_WIDTH);
}
#[test]
fn a_hidden_column_disappears_and_the_rest_close_up() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, before) = frame(&ctx, &mut layout, &rows(), vec![]);
let note_left = before["col:note"].left();
layout.hidden.insert("qty".into());
let (_, after) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(!after.contains_key("col:qty"), "no heading");
assert!(!after.contains_key("cell:J1:qty"), "no cell");
assert!(
after["col:note"].left() < note_left,
"the columns to its right close up"
);
}
#[test]
fn layout_order_reorders_and_unnamed_columns_still_appear() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout {
order: vec!["note".into(), "name".into()],
..Default::default()
};
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(hits["col:note"].left() < hits["col:name"].left(), "reordered");
for key in ["qty", "grade", "del"] {
assert!(
hits.contains_key(&format!("col:{key}")),
"unnamed column {key} still drawn"
);
}
assert!(hits.contains_key("row:J1"), "the tree cell moved with it");
}
#[test]
fn move_column_seeds_from_the_drawn_order() {
let cols = columns();
let visible: Vec<&ColumnSpec> = cols.iter().collect();
let mut layout = ColumnLayout::default();
assert!(move_column(&mut layout, &visible, "grade", "name"));
assert_eq!(
layout.order,
vec!["grade", "name", "qty", "note", "del", "on", "flags", "act"],
"the held column takes the target's slot"
);
}
#[test]
fn a_button_cell_reports_the_click_and_edits_nothing() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
let out = click_at(
&ctx,
&mut layout,
&rows(),
hits["cell:J1:del"].center(),
egui::PointerButton::Primary,
);
assert_eq!(
out.buttons,
vec![CellClick {
row_id: "J1".into(),
column: "del".into()
}]
);
assert!(out.edits.is_empty(), "a button never writes a cell");
}
#[test]
fn a_toggle_cell_reports_the_flipped_boolean() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
let out = click_at(
&ctx,
&mut layout,
&rows(),
hits["cell:J1:on"].center(),
egui::PointerButton::Primary,
);
assert_eq!(
out.edits,
vec![CellEdit {
row_id: "J1".into(),
column: "on".into(),
value: Value::Bool(false),
}],
"the cell was true, so the click writes false"
);
}
#[test]
fn a_toggle_on_a_read_only_row_draws_but_does_not_write() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let mut rows = rows();
rows[0].editable = false;
let (_, hits) = frame(&ctx, &mut layout, &rows, vec![]);
let cell = *hits.get("cell:J1:on").expect("the box is still DRAWN");
let out = click_at(&ctx, &mut layout, &rows, cell.center(), egui::PointerButton::Primary);
assert!(out.edits.is_empty(), "but it does not write");
}
#[test]
fn a_badges_cell_draws_glyphs_and_never_edits() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
let cell = *hits.get("cell:J1:flags").expect("a badges rect");
let out = click_at(&ctx, &mut layout, &rows(), cell.center(), egui::PointerButton::Primary);
assert!(out.edits.is_empty(), "badges are read-only");
assert!(out.buttons.is_empty());
assert_eq!(parse_hex_color("#ff9f0a"), Some(egui::Color32::from_rgb(0xff, 0x9f, 0x0a)));
assert_eq!(parse_hex_color("nonsense"), None, "ignored, not guessed");
assert_eq!(parse_hex_color("#fff"), None, "a short form is not a colour");
}
#[test]
fn a_collapse_box_click_reports_a_toggle_not_a_state_change() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let mut data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
let out = click_at(
&ctx,
&mut layout,
&data,
hits["box:J2"].center(),
egui::PointerButton::Primary,
);
assert_eq!(out.toggled.as_deref(), Some("J2"));
assert!(data[1].expanded, "the widget did not touch the caller's state");
data[1].expanded = false;
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
assert!(!hits.contains_key("row:J2-P1"), "collapsed children are gone");
}
#[test]
fn a_text_cell_commits_on_focus_loss_not_per_keystroke() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
let cell = hits["cell:J1:note"].center();
click_at(&ctx, &mut layout, &data, cell, egui::PointerButton::Primary);
let out = frame(
&ctx,
&mut layout,
&data,
vec![egui::Event::Text("XY".into())],
)
.0;
assert!(out.edits.is_empty(), "typing alone commits nothing");
let out = frame(
&ctx,
&mut layout,
&data,
vec![
egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
},
egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: false,
repeat: false,
modifiers: egui::Modifiers::default(),
},
],
)
.0;
assert_eq!(
out.edits,
vec![CellEdit {
row_id: "J1".into(),
column: "note".into(),
value: Value::String("mainXY".into()),
}],
"one commit carrying the finished text"
);
}
#[test]
fn a_read_only_row_shows_values_but_takes_no_edit() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let mut data = rows();
data[0].editable = false;
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
assert!(hits.contains_key("cell:J1:note"), "the cell is still drawn");
let cell = hits["cell:J1:note"].center();
click_at(&ctx, &mut layout, &data, cell, egui::PointerButton::Primary);
let out = frame(&ctx, &mut layout, &data, vec![egui::Event::Text("Z".into())]).0;
assert!(out.edits.is_empty(), "a read-only row takes no text");
let out = click_at(
&ctx,
&mut layout,
&data,
hits["cell:J1:del"].center(),
egui::PointerButton::Primary,
);
assert_eq!(out.buttons.len(), 1, "buttons stay live on a read-only row");
}
#[test]
fn an_empty_tree_draws_the_hint() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let (out, hits) = frame(&ctx, &mut layout, &[], vec![]);
assert!(hits.contains_key("col:name"), "the header still stands");
assert!(out.edits.is_empty());
}
#[test]
fn hiding_every_column_says_so() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
for column in columns() {
layout.hidden.insert(column.key);
}
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(hits.is_empty(), "nothing to publish, and no panic");
}
#[test]
fn a_right_click_on_a_text_cell_opens_the_row_menu() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
let cell = hits["cell:J1:note"].center();
let out = right_click_at(&ctx, &mut layout, &data, cell);
assert!(out.clicked.is_none(), "a right-click never selects");
assert_eq!(layout.sort, None, "and never sorts");
assert!(!out.layout_changed);
let hits = settle(&ctx, &mut layout, &data);
assert_eq!(
menu_entries(&hits, "J1"),
vec!["drop", "open", "rename"],
"the row's declared entries, from a right-click over a text cell"
);
}
#[test]
fn both_triggers_open_one_and_the_same_menu() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
click_at(
&ctx,
&mut layout,
&data,
hits["menu:J1"].center(),
egui::PointerButton::Primary,
);
let from_cell = settle(&ctx, &mut layout, &data);
let by_cell = menu_entries(&from_cell, "J1");
assert_eq!(by_cell, vec!["drop", "open", "rename"]);
click_at(
&ctx,
&mut layout,
&data,
hits["menu:J1"].center(),
egui::PointerButton::Primary,
);
let closed = settle(&ctx, &mut layout, &data);
assert!(
menu_entries(&closed, "J1").is_empty(),
"a second click on the trigger shuts it"
);
right_click_at(&ctx, &mut layout, &data, hits["row:J1"].center());
let from_row = settle(&ctx, &mut layout, &data);
assert_eq!(menu_entries(&from_row, "J1"), by_cell, "the same menu");
}
#[test]
fn an_entry_reports_its_row_and_a_disabled_one_reports_nothing() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
click_at(
&ctx,
&mut layout,
&data,
hits["menu:J2"].center(),
egui::PointerButton::Primary,
);
let open = settle(&ctx, &mut layout, &data);
assert_eq!(
menu_entries(&open, "J2"),
vec!["drop", "open", "rename"],
"a refused entry is GREYED, not hidden"
);
let out = click_at(
&ctx,
&mut layout,
&data,
open["menuitem:J2:open"].center(),
egui::PointerButton::Primary,
);
assert!(out.actions.is_empty(), "a disabled entry fires nothing");
click_at(
&ctx,
&mut layout,
&data,
hits["menu:J2"].center(),
egui::PointerButton::Primary,
);
let open = settle(&ctx, &mut layout, &data);
let out = click_at(
&ctx,
&mut layout,
&data,
open["menuitem:J2:rename"].center(),
egui::PointerButton::Primary,
);
assert_eq!(
out.actions,
vec![RowActionClick {
row_id: "J2".into(),
action: "rename".into()
}]
);
assert!(out.edits.is_empty(), "a menu never writes a cell");
let after = settle(&ctx, &mut layout, &data);
assert!(
menu_entries(&after, "J2").is_empty(),
"choosing an entry closes the menu"
);
}
#[test]
fn a_row_that_declares_nothing_opens_nothing() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
let data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
assert!(hits.contains_key("menu:J2-P1"), "the trigger cell is drawn");
click_at(
&ctx,
&mut layout,
&data,
hits["menu:J2-P1"].center(),
egui::PointerButton::Primary,
);
right_click_at(&ctx, &mut layout, &data, hits["cell:J2-P1:note"].center());
let after = settle(&ctx, &mut layout, &data);
assert!(
after.keys().all(|key| !key.starts_with("menuitem:")),
"no menu from either trigger: {:?}",
after.keys().collect::<Vec<_>>()
);
}
#[test]
fn frozen_columns_hold_still_while_the_rest_scroll() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout {
frozen: 2,
..Default::default()
};
layout.widths.insert("note".into(), 400.0);
layout.widths.insert("grade".into(), 400.0);
let data = rows();
let (_, before) = frame(&ctx, &mut layout, &data, vec![]);
assert!(before.contains_key("freeze:divider"), "the boundary is drawn");
let divider = before["freeze:divider"];
assert!(before["col:qty"].right() <= divider.left() + 1.0, "qty is pinned");
assert!(before["col:note"].left() >= divider.left(), "note scrolls");
let over = before["col:note"].center();
for _ in 0..8 {
frame(
&ctx,
&mut layout,
&data,
vec![
egui::Event::PointerMoved(over),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-60.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
},
],
);
}
let (_, after) = frame(&ctx, &mut layout, &data, vec![]);
assert_eq!(
after["col:name"], before["col:name"],
"a frozen heading does not move"
);
assert_eq!(after["col:qty"], before["col:qty"], "nor the second one");
assert_eq!(after["row:J1"], before["row:J1"], "nor the frozen tree cell");
assert!(
after["col:note"].left() < before["col:note"].left() - 20.0,
"and the scrolling side did scroll: {:?} -> {:?}",
before["col:note"],
after["col:note"]
);
}
#[test]
fn freezing_every_column_reads_as_none() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout {
frozen: 99,
..Default::default()
};
let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(!hits.contains_key("freeze:divider"), "no boundary is drawn");
assert!(hits.contains_key("col:act"), "and the last column is still there");
}
#[test]
fn hiding_a_frozen_column_does_not_promote_the_next_one() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout {
frozen: 2,
..Default::default()
};
layout.widths.insert("note".into(), 400.0);
layout.widths.insert("grade".into(), 400.0);
let (_, before) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(before["col:note"].left() >= before["freeze:divider"].left());
layout.hidden.insert("qty".into());
let (_, after) = frame(&ctx, &mut layout, &rows(), vec![]);
assert!(
after["col:note"].left() >= after["freeze:divider"].left(),
"note stayed on the scrolling side rather than being promoted"
);
assert!(
after["col:name"].right() <= after["freeze:divider"].left() + 1.0,
"and the surviving frozen column is still frozen"
);
}
#[test]
fn the_horizontal_scrollbar_does_not_eat_the_last_rows_clicks() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout::default();
layout.widths.insert("note".into(), 300.0);
layout.widths.insert("grade".into(), 300.0);
let mut data = rows();
data[1].expanded = false;
let mut hits = frame(&ctx, &mut layout, &data, vec![]).1;
for _ in 0..12 {
hits = frame(
&ctx,
&mut layout,
&data,
vec![
egui::Event::PointerMoved(egui::pos2(350.0, 40.0)),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-60.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
},
],
)
.1;
}
let trigger = *hits
.get("menu:J2")
.expect("the trailing action column scrolled into view");
click_at(
&ctx,
&mut layout,
&data,
trigger.center(),
egui::PointerButton::Primary,
);
let hits = settle(&ctx, &mut layout, &data);
assert!(
!menu_entries(&hits, "J2").is_empty(),
"the CENTRE of the last row's trigger opened nothing: {trigger:?}"
);
}
#[test]
fn it_survives_being_nested_in_a_vertical_scroll_area() {
let ctx = egui::Context::default();
let cols = columns();
let spec = spec(&cols);
let data = rows();
let mut layout = ColumnLayout {
frozen: 2,
..Default::default()
};
let mut draw = |events: Vec<egui::Event>| {
let mut hits = HashMap::new();
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(700.0, 500.0),
)),
events,
..Default::default()
};
let _ = ctx.run_ui(raw, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
column_tree(ui, &spec, &mut layout, &data, Some(&mut hits));
});
});
hits
};
let hits = draw(vec![]);
for (key, rect) in &hits {
assert!(rect.is_finite(), "{key} has an infinite rect {rect:?}");
if key.starts_with("row:")
|| key.starts_with("col:")
|| key.starts_with("menu")
|| key.starts_with("freeze:")
{
assert!(rect.is_positive(), "{key} is unclickable: {rect:?}");
}
}
assert!(hits.contains_key("freeze:divider"), "still frozen in there");
assert!(hits["row:J1"].top() < 500.0, "and drawn on screen");
let trigger = hits["menu:J1"].center();
draw(vec![
egui::Event::PointerMoved(trigger),
egui::Event::PointerButton {
pos: trigger,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
]);
draw(vec![egui::Event::PointerButton {
pos: trigger,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}]);
draw(vec![]);
let hits = draw(vec![]);
assert_eq!(menu_entries(&hits, "J1"), vec!["drop", "open", "rename"]);
}
#[test]
fn a_heading_drags_across_the_freeze_boundary() {
let ctx = egui::Context::default();
let mut layout = ColumnLayout {
order: vec![
"name".into(),
"qty".into(),
"note".into(),
"grade".into(),
"del".into(),
"act".into(),
],
frozen: 2,
..Default::default()
};
let data = rows();
let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
let from = hits["col:note"].center();
let onto = hits["col:name"].center();
frame(
&ctx,
&mut layout,
&data,
vec![
egui::Event::PointerMoved(from),
egui::Event::PointerButton {
pos: from,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
for step in 1..=4 {
let at = egui::pos2(from.x + (onto.x - from.x) * step as f32 / 4.0, from.y);
frame(&ctx, &mut layout, &data, vec![egui::Event::PointerMoved(at)]);
}
let out = frame(
&ctx,
&mut layout,
&data,
vec![egui::Event::PointerButton {
pos: onto,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
)
.0;
assert!(out.layout_changed, "the drop is a layout change");
assert_eq!(
layout.order.first().map(String::as_str),
Some("note"),
"the scrolling column took the frozen one's slot: {:?}",
layout.order
);
}
}