use crate::color::parse_hex_color;
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 hovered: Option<String>,
pub layout_changed: bool,
}
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 !root && over_row.is_positive() && ui.rect_contains_pointer(over_row) {
out.hovered = Some(row.id.clone());
}
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,
tint: None,
};
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 color = badge
.get("color")
.and_then(Value::as_str)
.and_then(parse_hex_color)
.unwrap_or_else(|| ui.visuals().text_color());
let label = match crate::icon_text::glyph(ui, glyph, color) {
Some(art) => ui.add(art),
None => ui.label(egui::RichText::new(glyph).color(color)),
};
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 | CellKind::Numeric { .. } | CellKind::Choice { .. } => {
let salt = egui::Id::new(("column-tree-cell", spec.id, &row.id, &column.key));
let size = egui::vec2(width, ui.available_height());
let (edited, rect) = value_editor(ui, salt, &column.kind, value, size);
if let Some(new_value) = edited {
emit(new_value);
}
rect
}
}
}
pub fn value_editor(
ui: &mut egui::Ui,
salt: egui::Id,
kind: &CellKind,
value: Option<&Value>,
size: egui::Vec2,
) -> (Option<Value>, egui::Rect) {
match kind {
CellKind::Text => {
let buffer_id = salt.with("text");
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(size, egui::TextEdit::singleline(&mut buffer));
let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mut committed = None;
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 {
committed = Some(Value::String(buffer));
}
} else if !edit.has_focus() {
ui.data_mut(|d| d.remove::<String>(buffer_id));
}
(committed, edit.rect)
}
CellKind::Numeric { step } => {
let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
let drag = ui.add_sized(size, egui::DragValue::new(&mut number).speed(*step));
let committed = drag.changed().then(|| serde_json::json!(number));
(committed, drag.rect)
}
CellKind::Choice { options } => {
let current = display_text(value);
let mut chosen = current.clone();
let combo = egui::ComboBox::from_id_salt(salt.with("combo"))
.width(size.x)
.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);
}
});
let committed = (chosen != current).then(|| Value::String(chosen));
(committed, combo.response.rect)
}
_ => {
let label = ui.add(egui::Label::new(display_text(value)).truncate());
(None, label.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 color =
action.destructive.then(|| ui.visuals().error_fg_color);
let button =
crate::icon_text::icon_button_colored(ui, &action.label, color);
let entry = ui.add_enabled(action.enabled, button);
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);
}
}