use crate::icon_text::IconText;
use eframe::egui;
pub const INDENT: f32 = 16.0;
const BOX: f32 = 13.0;
const GAP: f32 = 4.0;
const RIGHT_PAD: f32 = 10.0;
pub struct TreeRow<'a> {
pub guides: &'a [bool],
pub is_last: bool,
pub expandable: bool,
pub expanded: bool,
pub root: bool,
pub glyph: Option<&'a str>,
pub label: &'a str,
pub selected: bool,
pub draggable: bool,
}
impl<'a> TreeRow<'a> {
pub fn branch(guides: &'a [bool], is_last: bool, expanded: bool, label: &'a str) -> Self {
Self {
guides,
is_last,
expandable: true,
expanded,
root: false,
glyph: None,
label,
selected: false,
draggable: false,
}
}
pub fn leaf(guides: &'a [bool], is_last: bool, label: &'a str) -> Self {
Self {
guides,
is_last,
expandable: false,
expanded: false,
root: false,
glyph: None,
label,
selected: false,
draggable: false,
}
}
pub fn glyph(mut self, glyph: Option<&'a str>) -> Self {
self.glyph = glyph;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn draggable(mut self, draggable: bool) -> Self {
self.draggable = draggable;
self
}
}
pub struct NodeResponse {
pub row_rect: egui::Rect,
pub box_rect: egui::Rect,
pub toggled: bool,
pub label: egui::Response,
}
pub fn child_guides(guides: &[bool], is_last: bool) -> Vec<bool> {
let mut next = guides.to_vec();
next.push(!is_last);
next
}
pub fn node(
ui: &mut egui::Ui,
row: TreeRow,
add_right: impl FnOnce(&mut egui::Ui),
) -> NodeResponse {
let connector_col = if row.root { 0 } else { row.guides.len() };
let indent = if row.root {
0.0
} else {
(connector_col as f32 + 1.0) * INDENT
};
let mut box_rect = egui::Rect::NOTHING;
let mut toggled = false;
let mut label_resp: Option<egui::Response> = None;
let inner = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if indent > 0.0 {
ui.add_space(indent);
}
let (brect, bresp) = ui.allocate_exact_size(
egui::vec2(BOX, BOX),
if row.expandable {
egui::Sense::click()
} else {
egui::Sense::hover()
},
);
box_rect = brect;
if row.expandable {
paint_box(ui, brect, row.expanded);
if bresp.clicked() {
toggled = true;
}
}
ui.add_space(GAP);
if let Some(glyph) = row.glyph {
match crate::icons::artwork(glyph) {
Some(icon) => {
egui_extras::install_image_loaders(ui.ctx());
let height = ui.text_style_height(&egui::TextStyle::Body);
let mut art = crate::icon_text::image(icon, height);
if icon.mono {
art = art.tint(ui.visuals().text_color());
}
ui.add(art);
}
None => {
ui.label(egui::RichText::new(glyph).color(ui.visuals().text_color()));
}
}
ui.add_space(GAP);
}
let mut text = egui::RichText::new(row.label);
if row.selected {
text = text.strong();
}
let sense = if row.draggable {
egui::Sense::click_and_drag()
} else {
egui::Sense::click()
};
let has_icon = row.label.chars().any(crate::icons::has);
label_resp = Some(if has_icon && !row.draggable {
IconText::new(text).sense(sense).selectable(false).show(ui)
} else {
ui.add(egui::Label::new(text).sense(sense).selectable(!row.draggable))
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(RIGHT_PAD);
add_right(ui);
});
});
let row_rect = inner.response.rect;
if !row.root {
paint_connectors(ui, row_rect, row.guides, connector_col, row.is_last);
}
NodeResponse {
row_rect,
box_rect,
toggled,
label: label_resp.expect("label always drawn"),
}
}
pub fn message_leaf(
ui: &mut egui::Ui,
guides: &[bool],
is_last: bool,
text: &str,
color: egui::Color32,
) -> egui::Rect {
let connector_col = guides.len();
let indent = (connector_col as f32 + 1.0) * INDENT + BOX + GAP;
let line_h = ui.text_style_height(&egui::TextStyle::Body);
let inner = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(indent);
ui.vertical(|ui| {
ui.set_max_width((ui.available_width() - RIGHT_PAD).max(40.0));
ui.add(
egui::Label::new(egui::RichText::new(text).color(color))
.wrap_mode(egui::TextWrapMode::Wrap),
);
});
});
let row_rect = inner.response.rect;
let tick_y = row_rect.top() + line_h * 0.5;
paint_connectors_at(ui, row_rect, guides, connector_col, is_last, tick_y);
row_rect
}
fn paint_box(ui: &egui::Ui, rect: egui::Rect, expanded: bool) {
let painter = ui.painter();
let stroke = egui::Stroke::new(1.0, line_color(ui));
painter.rect(
rect,
egui::CornerRadius::same(2),
egui::Color32::TRANSPARENT,
stroke,
egui::StrokeKind::Inside,
);
let c = rect.center();
let arm = rect.width() * 0.28;
let sign = egui::Stroke::new(1.4, ui.visuals().text_color());
painter.line_segment(
[egui::pos2(c.x - arm, c.y), egui::pos2(c.x + arm, c.y)],
sign,
);
if !expanded {
painter.line_segment(
[egui::pos2(c.x, c.y - arm), egui::pos2(c.x, c.y + arm)],
sign,
);
}
}
fn paint_connectors(
ui: &egui::Ui,
row: egui::Rect,
guides: &[bool],
connector_col: usize,
is_last: bool,
) {
paint_connectors_at(ui, row, guides, connector_col, is_last, row.center().y);
}
fn paint_connectors_at(
ui: &egui::Ui,
row: egui::Rect,
guides: &[bool],
connector_col: usize,
is_last: bool,
mid: f32,
) {
let painter = ui.painter();
let stroke = egui::Stroke::new(1.0, line_color(ui));
let sp = ui.spacing().item_spacing.y + 1.0;
let left = row.left();
let top = row.top() - sp;
let bottom = row.bottom() + sp;
let col_x = |c: usize| left + c as f32 * INDENT + INDENT * 0.5;
for (i, on) in guides.iter().enumerate() {
if *on {
let x = col_x(i);
painter.line_segment([egui::pos2(x, top), egui::pos2(x, bottom)], stroke);
}
}
let x = col_x(connector_col);
let v_bottom = if is_last { mid } else { bottom };
painter.line_segment([egui::pos2(x, top), egui::pos2(x, v_bottom)], stroke);
painter.line_segment(
[egui::pos2(x, mid), egui::pos2(x + INDENT * 0.5, mid)],
stroke,
);
}
fn line_color(ui: &egui::Ui) -> egui::Color32 {
ui.visuals()
.widgets
.noninteractive
.fg_stroke
.color
.gamma_multiply(0.7)
}
#[cfg(test)]
mod tests {
use super::*;
use eframe::egui;
use std::cell::RefCell;
use std::rc::Rc;
fn frame(
ctx: &egui::Context,
rows: &[(&str, bool)],
events: Vec<egui::Event>,
rects: &Rc<RefCell<Vec<egui::Rect>>>,
) {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 300.0),
)),
events,
..Default::default()
};
let _ = ctx.run_ui(raw, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
let mut rs = rects.borrow_mut();
rs.clear();
let n = rows.len();
for (i, (label, draggable)) in rows.iter().enumerate() {
let resp = node(
ui,
TreeRow::branch(&[], i + 1 == n, false, label).draggable(*draggable),
|_| {},
);
rs.push(resp.label.rect);
}
});
}
fn has_label_selection(ctx: &egui::Context) -> bool {
ctx.plugin::<egui::text_selection::LabelSelectionState>()
.lock()
.has_selection()
}
fn press_drag_from_row0(ctx: &egui::Context, rows: &[(&str, bool)]) -> bool {
let rects = Rc::new(RefCell::new(Vec::new()));
frame(ctx, rows, vec![], &rects); let (start, end_y) = {
let rs = rects.borrow();
(rs[0].center(), rs[rs.len() - 1].center().y)
};
frame(
ctx,
rows,
vec![
egui::Event::PointerMoved(start),
egui::Event::PointerButton {
pos: start,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
&rects,
);
for k in 1..=8 {
let y = start.y + (end_y - start.y) * (k as f32 / 8.0);
frame(ctx, rows, vec![egui::Event::PointerMoved(egui::pos2(start.x, y))], &rects);
}
has_label_selection(ctx)
}
#[test]
fn reorder_handle_press_drag_does_not_text_select_passed_over_rows() {
let ctx = egui::Context::default();
let rows = [("FeatureA", true), ("field one", false), ("field two", false)];
assert!(
!press_drag_from_row0(&ctx, &rows),
"dragging a reorder handle must not create/ sweep a label text selection"
);
}
#[test]
fn selectable_non_drag_row_still_text_selects_on_press_drag() {
let ctx = egui::Context::default();
let rows = [("field one", false), ("field two", false), ("field three", false)];
assert!(
press_drag_from_row0(&ctx, &rows),
"a normal selectable tree row must still support text selection"
);
}
}