use std::{borrow::Cow, collections::HashMap};
use egui::{Align2, Color32, CornerRadius, FontId, Pos2, Rect, Stroke, Vec2};
use super::{
layout::{
MEMBER_GAP, NodeLayout, PORT_RADIUS, PORT_ROW_H, STACK_HEADER_H, STACK_PAD, StackLayout,
},
spline,
state::{GraphView, ReorderDrag},
transform::{Transform, WorldPos, WorldRect},
viewer::{Link, NodeId, PortAddr, PortSide, StackId, StackLink},
};
pub struct Palette {
pub grid_minor: Color32,
pub grid_major: Color32,
pub node_bg: Color32,
pub node_header: Color32,
pub node_stroke: Color32,
pub selected: Color32,
pub text: Color32,
pub port: Color32,
pub link: Color32,
pub stack_bg: Color32,
pub stack_header: Color32,
pub stack_stroke: Color32,
pub section_header: Color32,
pub value_bg: Color32,
}
impl Palette {
pub fn from_visuals(v: &egui::Visuals) -> Self {
let accent = v.selection.bg_fill;
let node_bg = blend(v.extreme_bg_color, v.widgets.inactive.bg_fill, 0.45);
Self {
grid_minor: v.extreme_bg_color.linear_multiply(2.2),
grid_major: v.extreme_bg_color.linear_multiply(3.4),
node_bg,
node_header: v.widgets.active.bg_fill,
node_stroke: v.widgets.noninteractive.bg_stroke.color,
selected: accent,
text: v.text_color(),
port: v.widgets.active.fg_stroke.color,
link: v.widgets.active.fg_stroke.color,
stack_bg: node_bg,
stack_header: v.widgets.open.bg_fill,
stack_stroke: v.widgets.noninteractive.bg_stroke.color,
section_header: v.widgets.active.bg_fill,
value_bg: v.extreme_bg_color,
}
}
}
fn blend(a: Color32, b: Color32, t: f32) -> Color32 {
let lerp = |x: u8, y: u8| {
(x as f32 + (y as f32 - x as f32) * t)
.round()
.clamp(0.0, 255.0) as u8
};
Color32::from_rgb(lerp(a.r(), b.r()), lerp(a.g(), b.g()), lerp(a.b(), b.b()))
}
fn contrast_text(bg: Color32) -> Color32 {
let l = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
if l > 140.0 {
Color32::from_gray(20)
} else {
Color32::from_gray(240)
}
}
fn header_corners(rounding: f32) -> CornerRadius {
let r = rounding.round().clamp(0.0, 255.0) as u8;
CornerRadius {
nw: r,
ne: r,
sw: 0,
se: 0,
}
}
fn draw_header_title(
painter: &egui::Painter,
header: Rect,
pad: f32,
text: &str,
size: f32,
color: Color32,
) {
let text_rect = Rect::from_min_max(header.min, Pos2::new(header.max.x - 4.0, header.max.y));
if text_rect.width() < 12.0 || size < 2.0 {
return;
}
painter.with_clip_rect(text_rect).text(
Pos2::new(header.min.x + pad, header.center().y),
Align2::LEFT_CENTER,
text,
FontId::proportional(size),
color,
);
}
pub fn draw_grid(painter: &egui::Painter, t: &Transform, rect: Rect, view: &GraphView) {
if !view.grid.enabled {
return;
}
let palette_minor = Palette::from_visuals(&painter.ctx().global_style().visuals).grid_minor;
let palette_major = Palette::from_visuals(&painter.ctx().global_style().visuals).grid_major;
let spacing = view.grid.spacing.max(f64::EPSILON);
let major_every = view.grid.major_every.max(1) as f64;
let minor_px = t.world_len_to_screen(spacing);
let major_px = t.world_len_to_screen(spacing * major_every);
if major_px < 6.0 {
return;
}
let draw_minor = minor_px >= 6.0;
let top_left = t.screen_to_world(rect.min);
let bottom_right = t.screen_to_world(rect.max);
let start_i = (top_left.x / spacing).floor() as i64;
let end_i = (bottom_right.x / spacing).ceil() as i64;
for i in start_i..=end_i {
let is_major = i.rem_euclid(major_every as i64) == 0;
if !is_major && !draw_minor {
continue;
}
let x = t.world_to_screen(WorldPos::new(i as f64 * spacing, 0.0)).x;
let color = if is_major {
palette_major
} else {
palette_minor
};
painter.line_segment(
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
Stroke::new(1.0, color),
);
}
let start_j = (top_left.y / spacing).floor() as i64;
let end_j = (bottom_right.y / spacing).ceil() as i64;
for j in start_j..=end_j {
let is_major = j.rem_euclid(major_every as i64) == 0;
if !is_major && !draw_minor {
continue;
}
let y = t.world_to_screen(WorldPos::new(0.0, j as f64 * spacing)).y;
let color = if is_major {
palette_major
} else {
palette_minor
};
painter.line_segment(
[Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)],
Stroke::new(1.0, color),
);
}
}
pub fn draw_links(
painter: &egui::Painter,
t: &Transform,
layouts: &[NodeLayout],
links: &[Link],
selected: &std::collections::HashSet<Link>,
palette: &Palette,
) {
let by_id: HashMap<NodeId, &NodeLayout> = layouts.iter().map(|n| (n.id, n)).collect();
let base_width = (t.world_len_to_screen(2.0)).clamp(1.0, 4.0);
for link in links {
let (Some(from_node), Some(to_node)) =
(by_id.get(&link.from.node), by_id.get(&link.to.node))
else {
continue;
};
let (Some(from_w), Some(to_w)) = (
from_node.port_center(link.from.port),
to_node.port_center(link.to.port),
) else {
continue;
};
let from_c = from_node.port_color(link.from.port).unwrap_or(palette.link);
let to_c = to_node.port_color(link.to.port).unwrap_or(palette.link);
let from_s = t.world_to_screen(from_w);
let to_s = t.world_to_screen(to_w);
let is_selected = selected.contains(link);
if is_selected {
let halo = blend(palette.selected, Color32::WHITE, 0.55).gamma_multiply(0.5);
painter.add(spline::link_curve(
from_s,
to_s,
t.zoom as f32,
Stroke::new(base_width + 5.0, halo),
));
}
let width = if is_selected {
base_width + 1.0
} else {
base_width
};
let curve = if from_c == to_c {
spline::link_curve(from_s, to_s, t.zoom as f32, Stroke::new(width, from_c))
} else {
spline::link_curve_grad(from_s, to_s, t.zoom as f32, width, from_c, to_c)
};
painter.add(curve);
}
}
pub fn draw_stack_links(
painter: &egui::Painter,
t: &Transform,
stacks: &[StackLayout],
links: &[StackLink],
palette: &Palette,
) {
let by_id: HashMap<StackId, &StackLayout> = stacks.iter().map(|s| (s.id, s)).collect();
let width = (t.world_len_to_screen(2.0)).clamp(1.0, 4.0);
let pin_r = (t.world_len_to_screen(PORT_RADIUS)).clamp(2.0, 9.0);
for link in links {
let (Some(from), Some(to)) = (by_id.get(&link.from), by_id.get(&link.to)) else {
continue;
};
let from_w = from.bottom_pin();
let to_w = to.top_pin();
let from_s = t.world_to_screen(from_w);
let to_s = t.world_to_screen(to_w);
let curve = spline::link_curve_vertical(
from_s,
to_s,
t.zoom as f32,
Stroke::new(width, palette.link),
);
painter.add(curve);
for c in [from_s, to_s] {
painter.circle_filled(c, pin_r, palette.port);
painter.circle_stroke(c, pin_r, Stroke::new(1.0, palette.node_stroke));
}
}
}
pub fn draw_port_hover(painter: &egui::Painter, t: &Transform, center: WorldPos) {
let r = super::layout::port_grab_radius_screen(t);
let c = t.world_to_screen(center);
painter.circle_filled(c, r, Color32::from_white_alpha(140));
}
fn draw_callout(
painter: &egui::Painter,
pin: Pos2,
text: &str,
accent: Color32,
border: Color32,
icon: char,
) {
let clip = painter.clip_rect();
let font = FontId::proportional(13.0);
let text_color = Color32::from_rgb(0xF5, 0xF5, 0xF5);
let bg = Color32::from_rgb(0x1E, 0x1E, 0x1E);
let stroke = Stroke::new(1.0, border);
let radius = 4.0;
let icon_galley = painter.layout_no_wrap(icon.to_string(), font.clone(), accent);
let icon_gap = 6.0;
let icon_w = icon_galley.size().x;
let icon_h = icon_galley.size().y;
let pad = Vec2::new(7.0, 4.0);
let bar = 4.0; let margin = 8.0;
let fixed_w = bar + pad.x * 2.0 + icon_w + icon_gap;
let max_text_w = (clip.width() - margin * 2.0 - fixed_w).max(60.0);
let galley = painter.layout(text.to_owned(), font, text_color, max_text_w);
let content_h = galley.size().y.max(icon_h);
let size = Vec2::new(fixed_w + galley.size().x, content_h + pad.y * 2.0);
let ch = 5.0; let cw = 6.0; let gap = 6.0;
let above_top = pin.y - gap - ch - size.y;
let room_below = pin.y + gap + ch + size.y <= clip.max.y - margin;
let place_above = above_top >= clip.min.y + margin || !room_below;
let min_x = (pin.x - 14.0)
.min(clip.max.x - margin - size.x)
.max(clip.min.x + margin);
let tip_x = pin.x.clamp(min_x + cw + 2.0, min_x + size.x - cw - 2.0);
let (min_y, mouth_y, tip_y, fill_y) = if place_above {
let tip_y = pin.y - gap;
let mouth_y = tip_y - ch; (mouth_y - size.y, mouth_y, tip_y, mouth_y - 1.5)
} else {
let tip_y = pin.y + gap;
let mouth_y = tip_y + ch; (mouth_y, mouth_y, tip_y, mouth_y + 1.5)
};
let rect = Rect::from_min_size(Pos2::new(min_x, min_y), size);
let tip = Pos2::new(tip_x, tip_y);
let base_l = Pos2::new(tip_x - cw, mouth_y);
let base_r = Pos2::new(tip_x + cw, mouth_y);
painter.rect_filled(rect, radius, bg);
painter.rect_stroke(rect, radius, stroke, egui::StrokeKind::Inside);
let bar_rect = Rect::from_min_size(rect.min, Vec2::new(bar, rect.height()));
let r = radius.round() as u8;
painter.rect_filled(
bar_rect,
CornerRadius {
nw: r,
ne: 0,
sw: r,
se: 0,
},
accent,
);
painter.add(egui::Shape::convex_polygon(
vec![
tip,
Pos2::new(base_l.x, fill_y),
Pos2::new(base_r.x, fill_y),
],
bg,
Stroke::NONE,
));
painter.line_segment([base_l, tip], stroke);
painter.line_segment([tip, base_r], stroke);
let cy = rect.center().y;
let content_left = rect.min.x + bar + pad.x;
painter.galley(
Pos2::new(content_left, cy - icon_h * 0.5),
icon_galley,
accent,
);
painter.galley(
Pos2::new(content_left + icon_w + icon_gap, cy - galley.size().y * 0.5),
galley,
text_color,
);
}
pub fn draw_tooltip(painter: &egui::Painter, pin: Pos2, text: &str) {
draw_callout(
painter,
pin,
text,
Color32::from_rgb(0xE5, 0x48, 0x48),
Color32::from_rgb(0x7A, 0x1F, 0x1F),
crate::icons::ICON_CIRCLE_EXCLAMATION,
);
}
pub fn draw_warning(painter: &egui::Painter, pin: Pos2, text: &str) {
draw_callout(
painter,
pin,
text,
Color32::from_rgb(0xFF, 0xB4, 0x32),
Color32::from_rgb(0x7A, 0x5A, 0x1F),
crate::icons::ICON_TRIANGLE_EXCLAMATION,
);
}
pub fn draw_pending_link(
painter: &egui::Painter,
t: &Transform,
from_world: WorldPos,
cursor: Pos2,
anchor_is_input: bool,
anchor_color: Color32,
target_color: Color32,
) {
let width = (t.world_len_to_screen(2.0)).clamp(1.0, 4.0);
let anchor = t.world_to_screen(from_world);
let (a_col, t_col) = (anchor_color, target_color);
let curve = if anchor_is_input {
spline::link_curve_grad(cursor, anchor, t.zoom as f32, width, t_col, a_col)
} else {
spline::link_curve_grad(anchor, cursor, t.zoom as f32, width, a_col, t_col)
};
painter.add(curve);
}
pub fn draw_stacks(
painter: &egui::Painter,
t: &Transform,
stacks: &[StackLayout],
selected: &std::collections::HashSet<super::viewer::StackId>,
hovered: Option<super::viewer::StackId>,
hovered_add: Option<super::viewer::StackId>,
hovered_collapse_all: Option<super::viewer::StackId>,
palette: &Palette,
) {
let canvas = painter.clip_rect();
let title_size = (t.world_len_to_screen(12.0)).clamp(2.0, 24.0);
let rounding = (t.world_len_to_screen(6.0)).clamp(1.0, 10.0);
for s in stacks {
let screen = t.world_rect_to_screen(s.rect);
if !canvas.intersects(screen) {
continue;
}
let is_hovered = hovered == Some(s.id);
let lift = |c: Color32| if is_hovered { c.gamma_multiply(1.4) } else { c };
painter.rect_filled(screen, rounding, lift(palette.stack_bg));
let header_h = t.world_len_to_screen(STACK_HEADER_H);
let header = Rect::from_min_max(
screen.min,
Pos2::new(screen.max.x, (screen.min.y + header_h).min(screen.max.y)),
);
let header_color = s.accent.unwrap_or(palette.stack_header);
painter.rect_filled(header, header_corners(rounding), lift(header_color));
let stroke = if selected.contains(&s.id) {
Stroke::new(2.0, palette.selected)
} else {
Stroke::new(1.0, palette.stack_stroke)
};
painter.rect_stroke(screen, rounding, stroke, egui::StrokeKind::Inside);
let cab = t.world_rect_to_screen(s.collapse_all_button);
let title_header = Rect::from_min_max(header.min, Pos2::new(cab.min.x - 4.0, header.max.y));
draw_header_title(
painter,
title_header,
t.world_len_to_screen(6.0),
&s.title,
title_size,
contrast_text(header_color),
);
if cab.width() >= 6.0 && canvas.intersects(cab) {
let icon_color = contrast_text(header_color);
if hovered_collapse_all == Some(s.id) {
painter.rect_filled(
cab,
(rounding * 0.5).clamp(1.0, 4.0),
icon_color.gamma_multiply(0.25),
);
}
draw_double_chevron(painter, cab, s.all_collapsed, icon_color);
}
let button = t.world_rect_to_screen(s.add_button);
let btn_rounding = (rounding * 0.6).clamp(1.0, 6.0);
let is_hovered_add = hovered_add == Some(s.id);
let btn_fill = if is_hovered_add {
palette.stack_header.gamma_multiply(1.4)
} else {
palette.stack_header
};
painter.rect_filled(button, btn_rounding, btn_fill);
painter.rect_stroke(
button,
btn_rounding,
Stroke::new(1.0, palette.stack_stroke),
egui::StrokeKind::Inside,
);
let label_size = (button.height() * 0.62).clamp(0.0, 16.0);
if label_size >= 7.0 {
painter.text(
button.center(),
egui::Align2::CENTER_CENTER,
"+ Add",
egui::FontId::proportional(label_size),
contrast_text(btn_fill),
);
}
}
}
#[derive(Default)]
pub struct NodePaint {
pub warning_tooltip: Option<(Pos2, Cow<'static, str>)>,
pub chips: Vec<super::response::ChipHit>,
}
pub fn draw_nodes(
painter: &egui::Painter,
t: &Transform,
layouts: &[NodeLayout],
selected: &std::collections::HashSet<NodeId>,
hovered: Option<NodeId>,
hovered_port: Option<WorldPos>,
hovered_close: Option<NodeId>,
hover_pos: Option<Pos2>,
palette: &Palette,
) -> NodePaint {
let canvas = painter.clip_rect();
let title_size = (t.world_len_to_screen(13.0)).clamp(2.0, 26.0);
let stack_title_size = (t.world_len_to_screen(12.0)).clamp(2.0, 24.0);
let label_size = (t.world_len_to_screen(11.0)).clamp(2.0, 22.0);
let port_r = (t.world_len_to_screen(PORT_RADIUS)).clamp(2.0, 9.0);
let rounding = (t.world_len_to_screen(5.0)).clamp(1.0, 8.0);
let show_labels = t.zoom >= 0.25;
let mut result = NodePaint::default();
for node in layouts {
let screen = t.world_rect_to_screen(node.rect);
if !canvas.intersects(screen) {
continue;
}
let is_selected = selected.contains(&node.id);
let is_hovered = hovered == Some(node.id);
let stacked = node.stack.is_some();
let corner = if stacked { 0.0 } else { rounding };
let lift = |c: Color32| if is_hovered { c.gamma_multiply(1.4) } else { c };
let header_color = if stacked {
palette.section_header
} else {
node.accent.unwrap_or(palette.node_header)
};
let header_h = t.world_len_to_screen(super::layout::HEADER_H);
let header;
if stacked {
let edge = (t.world_len_to_screen(2.0)).clamp(2.0, 6.0);
let min_x = screen.min.x + edge;
let max_x = screen.max.x - edge;
header = Rect::from_min_max(
Pos2::new(min_x, screen.min.y),
Pos2::new(max_x, (screen.min.y + header_h).min(screen.max.y)),
);
if is_hovered {
let body = Rect::from_min_max(
Pos2::new(min_x, header.max.y),
Pos2::new(max_x, screen.max.y),
);
painter.rect_filled(body, 0.0, palette.node_bg.gamma_multiply(1.4));
}
painter.rect_filled(header, header_corners(corner), lift(header_color));
} else {
header = Rect::from_min_max(
screen.min,
Pos2::new(screen.max.x, (screen.min.y + header_h).min(screen.max.y)),
);
painter.rect_filled(screen, corner, lift(palette.node_bg));
painter.rect_filled(header, header_corners(corner), lift(header_color));
let stroke = if is_selected {
Stroke::new(2.0, palette.selected)
} else {
Stroke::new(1.0, palette.node_stroke)
};
painter.rect_stroke(screen, corner, stroke, egui::StrokeKind::Inside);
}
let title_color = contrast_text(header_color);
let title_pad = match node.collapse_toggle {
Some(tr) => {
let r = t.world_rect_to_screen(tr);
if r.width() >= 4.0 {
draw_chevron(painter, r, !node.collapsed, title_color);
}
(r.max.x - header.min.x + 4.0).max(t.world_len_to_screen(6.0))
}
None => t.world_len_to_screen(6.0),
};
let close_screen = node
.close_button
.map(|r| t.world_rect_to_screen(r))
.filter(|r| r.width() >= 7.0 && canvas.intersects(*r));
let title_limit = match close_screen {
Some(r) => r.min.x - 4.0,
None => header.max.x - 4.0,
};
let title_end = draw_node_title(
painter,
header,
title_limit,
title_pad,
&node.title,
if stacked {
stack_title_size
} else {
title_size
},
title_color,
);
if let Some(text) = &node.warning {
let icon_size = if stacked {
stack_title_size
} else {
title_size
};
let gap = 5.0;
let icon_x = (title_end + gap).min(title_limit - icon_size);
if icon_size >= 7.0 && icon_x >= header.min.x {
let amber = Color32::from_rgb(0xFF, 0xB4, 0x32);
let g = painter.layout_no_wrap(
crate::icons::ICON_TRIANGLE_EXCLAMATION.to_string(),
FontId::proportional(icon_size),
amber,
);
let pos = Pos2::new(icon_x, header.center().y - g.size().y * 0.5);
let icon_rect = Rect::from_min_size(pos, g.size());
painter.galley(pos, g, amber);
if hover_pos.is_some_and(|p| icon_rect.contains(p)) {
result.warning_tooltip = Some((
Pos2::new(icon_rect.center().x, icon_rect.min.y),
text.clone(),
));
}
}
}
if let Some(close_screen) = close_screen {
let is_hovered_close = hovered_close == Some(node.id);
if is_hovered_close {
painter.rect_filled(
close_screen,
(rounding * 0.6).clamp(1.0, 4.0),
Color32::from_rgb(0xC0, 0x39, 0x2B),
);
}
let glyph_color = if is_hovered_close {
Color32::WHITE
} else {
title_color
};
let glyph_size = (close_screen.height() * 0.8).clamp(7.0, 18.0);
painter.text(
close_screen.center(),
Align2::CENTER_CENTER,
crate::icons::ICON_XMARK.to_string(),
FontId::proportional(glyph_size),
glyph_color,
);
}
if node.collapsed {
let mid_y = node.rect.min.y + super::layout::HEADER_H * 0.5;
if node.inputs.iter().any(|p| p.connectable) {
let c = t.world_to_screen(WorldPos::new(node.rect.min.x, mid_y));
painter.circle_filled(c, port_r, palette.port);
painter.circle_stroke(c, port_r, Stroke::new(1.0, palette.node_stroke));
}
if node.outputs.iter().any(|p| p.connectable) {
let c = t.world_to_screen(WorldPos::new(node.rect.max().x, mid_y));
painter.circle_filled(c, port_r, palette.port);
painter.circle_stroke(c, port_r, Stroke::new(1.0, palette.node_stroke));
}
continue;
}
for (port, side) in node
.inputs
.iter()
.map(|p| (p, PortSide::Input))
.chain(node.outputs.iter().map(|p| (p, PortSide::Output)))
{
let c = t.world_to_screen(port.center);
if port.connectable {
if hovered_port == Some(port.center) {
draw_port_hover(painter, t, port.center);
}
let color = port.color.unwrap_or(palette.port);
painter.circle_filled(c, port_r, color);
painter.circle_stroke(c, port_r, Stroke::new(1.0, palette.node_stroke));
if port.arity >= 1 {
draw_arity_pips(
painter,
c,
port_r,
port.arity,
side,
color,
palette.node_stroke,
);
}
}
match side {
PortSide::Output => {
if show_labels && !port.label.is_empty() {
let anchor_x = if port.arity >= 1 {
c.x - arity_pip_inset(port_r, port.arity) - t.world_len_to_screen(3.0)
} else {
c.x - t.world_len_to_screen(PORT_RADIUS + 3.0)
};
painter.text(
Pos2::new(anchor_x, c.y),
Align2::RIGHT_CENTER,
&port.label,
FontId::proportional(label_size),
palette.text,
);
}
}
PortSide::Input => {
let mut x = if port.arity >= 1 {
c.x + arity_pip_inset(port_r, port.arity) + t.world_len_to_screen(3.0)
} else {
c.x + t.world_len_to_screen(PORT_RADIUS + 3.0)
};
let expanded = port.row_height > PORT_ROW_H + 0.5;
let mut chevron_rect = None;
if show_labels && port.collapsible {
let s = label_size;
let r = Rect::from_center_size(Pos2::new(x + s * 0.5, c.y), Vec2::splat(s));
draw_chevron(painter, r, expanded, palette.text);
chevron_rect = Some(r);
x = r.max.x + t.world_len_to_screen(3.0);
}
if show_labels && !port.label.is_empty() {
let g = painter.layout_no_wrap(
port.label.to_string(),
FontId::proportional(label_size),
palette.text,
);
let w = g.size().x;
painter.galley(Pos2::new(x, c.y - g.size().y * 0.5), g, palette.text);
x += w + t.world_len_to_screen(5.0);
}
if show_labels {
if let Some(val) = &port.value {
let pad = (t.world_len_to_screen(3.0)).clamp(1.5, 5.0);
let node_clip = Rect::from_min_max(
screen.min,
Pos2::new(screen.max.x - pad * 2.0, screen.max.y),
);
let rr = (t.world_len_to_screen(3.0)).clamp(1.0, 5.0);
let mut value_text = None;
let chip_rect = if expanded {
let row_h = t.world_len_to_screen(port.row_height) as f32;
let line_h = t.world_len_to_screen(PORT_ROW_H) as f32;
let top = c.y - line_h * 0.5 + line_h;
let left = screen.min.x + pad * 2.0;
Rect::from_min_max(
Pos2::new(left, top),
Pos2::new(node_clip.max.x, c.y - line_h * 0.5 + row_h),
)
} else if port.collapsible {
let chip_h = label_size + pad;
let chip_min = Pos2::new(x, c.y - chip_h * 0.5);
Rect::from_min_max(
chip_min,
Pos2::new(node_clip.max.x, chip_min.y + chip_h),
)
} else {
let g = painter.layout_no_wrap(
val.to_string(),
FontId::monospace(label_size),
palette.text,
);
let chip_h = g.size().y + pad;
let chip_min = Pos2::new(x, c.y - chip_h * 0.5);
let full_w = g.size().x + pad * 2.0;
let chip_w = full_w.min((node_clip.max.x - chip_min.x).max(0.0));
let rect = Rect::from_min_size(chip_min, Vec2::new(chip_w, chip_h));
value_text =
Some((g, Pos2::new(chip_min.x + pad, chip_min.y + pad * 0.5)));
rect
};
let host_editor = expanded && !port.collapsible;
if !host_editor {
painter.rect_filled(chip_rect, rr, palette.value_bg);
painter.rect_stroke(
chip_rect,
rr,
Stroke::new(1.0, palette.node_stroke),
egui::StrokeKind::Inside,
);
}
if let Some((g, pos)) = value_text {
painter.with_clip_rect(chip_rect.intersect(canvas)).galley(
pos,
g,
palette.text,
);
}
result.chips.push(super::response::ChipHit {
port: PortAddr::new(node.id, port.id),
rect: chip_rect,
font_size: label_size,
pad,
clip: node_clip,
chevron: chevron_rect,
expanded,
});
}
}
}
}
}
}
result
}
const PIP_SQ_FRAC: f32 = 0.6;
const PIP_GAP_X_FRAC: f32 = 0.8;
fn arity_pip_inset(pin_r: f32, arity: u8) -> f32 {
if arity == 0 {
return 0.0;
}
let sq = (pin_r * PIP_SQ_FRAC).max(1.5);
pin_r + pin_r * PIP_GAP_X_FRAC + sq
}
fn draw_arity_pips(
painter: &egui::Painter,
center: Pos2,
pin_r: f32,
arity: u8,
side: PortSide,
fill: Color32,
stroke: Color32,
) {
let sq = (pin_r * PIP_SQ_FRAC).max(1.5);
let gap = (pin_r * 0.4).max(1.0);
let gap_x = pin_r * PIP_GAP_X_FRAC;
let (left, right) = match side {
PortSide::Input => (center.x + pin_r + gap_x, center.x + pin_r + gap_x + sq),
PortSide::Output => (center.x - pin_r - gap_x - sq, center.x - pin_r - gap_x),
};
let total_h = arity as f32 * sq + (arity as f32 - 1.0) * gap;
let rounding = (sq * 0.2).max(0.5);
let mut top = center.y - total_h * 0.5;
for _ in 0..arity {
let rect = Rect::from_min_max(Pos2::new(left, top), Pos2::new(right, top + sq));
painter.rect_filled(rect, rounding, fill);
painter.rect_stroke(
rect,
rounding,
Stroke::new(1.0, stroke),
egui::StrokeKind::Inside,
);
top += sq + gap;
}
}
fn draw_double_chevron(painter: &egui::Painter, rect: Rect, down: bool, color: Color32) {
let cx = rect.center().x;
let half = rect.width() * 0.30;
let amp = rect.height() * 0.15;
let stroke = Stroke::new((rect.width() * 0.11).max(1.0), color);
for k in 0..2 {
let oy = rect.min.y + rect.height() * (0.30 + 0.28 * k as f32);
let (outer, inner) = if down {
(oy - amp, oy + amp)
} else {
(oy + amp, oy - amp)
};
painter.line_segment([Pos2::new(cx - half, outer), Pos2::new(cx, inner)], stroke);
painter.line_segment([Pos2::new(cx + half, outer), Pos2::new(cx, inner)], stroke);
}
}
fn draw_chevron(painter: &egui::Painter, rect: Rect, expanded: bool, color: Color32) {
let r = rect.shrink(rect.width() * 0.22);
let pts = if expanded {
vec![
Pos2::new(r.min.x, r.min.y),
Pos2::new(r.max.x, r.min.y),
Pos2::new(r.center().x, r.max.y),
]
} else {
vec![
Pos2::new(r.min.x, r.min.y),
Pos2::new(r.max.x, r.center().y),
Pos2::new(r.min.x, r.max.y),
]
};
painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
}
fn draw_node_title(
painter: &egui::Painter,
header: Rect,
right_limit: f32,
pad: f32,
text: &str,
size: f32,
color: Color32,
) -> f32 {
let left = header.min.x + pad;
let text_rect = Rect::from_min_max(header.min, Pos2::new(right_limit, header.max.y));
if text_rect.width() < 12.0 || size < 2.0 {
return left;
}
let galley = painter.layout_no_wrap(text.to_owned(), FontId::proportional(size), color);
let w = galley.size().x;
let h = galley.size().y;
painter.with_clip_rect(text_rect).galley(
Pos2::new(left, header.center().y - h * 0.5),
galley,
color,
);
(left + w).min(right_limit)
}
pub fn draw_reorder_overlay(
painter: &egui::Painter,
t: &Transform,
layouts: &[NodeLayout],
stacks: &[StackLayout],
rd: &ReorderDrag,
cursor: Pos2,
palette: &Palette,
) {
let Some(stack) = stacks.iter().find(|s| s.id == rd.stack) else {
return;
};
let members: Vec<&NodeLayout> = layouts
.iter()
.filter(|n| n.stack == Some(rd.stack))
.collect();
let n = members.len();
let from = members.iter().position(|m| m.id == rd.node).unwrap_or(0);
let ti = rd.target_index.min(n.saturating_sub(1));
let gap = if ti <= from { ti } else { ti + 1 };
let indicator_y = if members.is_empty() {
stack.rect.min.y + STACK_HEADER_H + STACK_PAD
} else if gap == 0 {
members[0].rect.min.y - MEMBER_GAP * 0.5
} else if gap >= n {
members[n - 1].rect.max().y + MEMBER_GAP * 0.5
} else {
(members[gap - 1].rect.max().y + members[gap].rect.min.y) * 0.5
};
let y = t.world_to_screen(WorldPos::new(0.0, indicator_y)).y;
let x0 = t.world_to_screen(stack.rect.min).x;
let x1 = t.world_to_screen(stack.rect.max()).x;
painter.line_segment(
[Pos2::new(x0, y), Pos2::new(x1, y)],
Stroke::new(2.0, palette.selected),
);
if let Some(node) = layouts.iter().find(|n| n.id == rd.node) {
let min = t.screen_to_world(cursor) - rd.grab_offset;
let ghost = WorldRect::new(min, node.rect.width, node.rect.height);
let screen = t.world_rect_to_screen(ghost);
let rounding = (t.world_len_to_screen(5.0)).clamp(1.0, 8.0);
painter.rect_filled(screen, rounding, palette.node_bg.gamma_multiply(0.6));
painter.rect_stroke(
screen,
rounding,
Stroke::new(1.5, palette.selected),
egui::StrokeKind::Inside,
);
let title_size = (t.world_len_to_screen(12.0)).clamp(2.0, 24.0);
if title_size >= 2.0 {
painter.text(
Pos2::new(
screen.min.x + t.world_len_to_screen(6.0),
screen.min.y + title_size,
),
Align2::LEFT_CENTER,
&node.title,
FontId::proportional(title_size),
palette.text,
);
}
}
}