GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(widget): migrate Graph + the legacy dual-geometry escape hatch (Phase 5m)
Graph is the last leaf controller widget, with three hosts consuming
DIFFERENT legacy getters: the designer's raw render path draws extra_quads
as plain quads + extra_circles, cce-files' render_widget consumes the
rounded conversion in all_rounded_quads (with all_quads overridden to
highlight-only so geometry never doubles), and cce-graph's Phase 3 scene
path replays all_rounded_quads through the default paint_self. The model
keeps one geometry generator and serves both views: paint() emits the
rounded view (+ port circles + node labels), and two transitional Paint
hooks — serves_legacy_plain_quads/legacy_plain_quads (returned verbatim
from extra_quads, with the adapter emptying all_quads to preserve the
no-double-draw contract) and text_bounds (node names clip to the widget
rect; the adapter's new text_labels_with_[font_and_]bounds overrides honor
it, replicating the scroll-ancestor walk otherwise).
Ported machinery: the port/connection press cascade (start/complete/abort
a connection, node-body fallback), geometry toggles, double-click timer,
node selection + drag arming with grid snap and empty-cell fallback on
commit, ctrl-wheel zoom vs wheel pan (ctrl read via EventCtx.ui), zoom
keybindings, and the id-keyed state resync in set_nodes. Legacy hit_test
excluded right/bottom edges — Input::hit override keeps that. Dropped: the
Element::paint register_hovered pre-pass (the shared hover highlight is
suppressed for all adapted widgets).
Verification: cce-designer startup (its network pane IS a Graph with
nodes) pixel-diffs ZERO against the stashed legacy build; cce-graph idle +
hover diff to an empty bbox at the 2% threshold (sub-perceptual blend
noise only). cce-files' Graph view was verified visually (rounded nodes,
wires, grid, axes, edge-clipped labels) and its getter contract is pinned
by a unit test (plain view == rounded view minus the bg entry; node bodies
carry the node radius; all_quads empty); a pixel A/B for it was blocked by
the live session's terminal covering the capture region. 165 tests pass;
all 19 client crates build.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkfJazPs9bRchkcozmxXCX
src/widget/display/graph.rs | 1414 ++++++++++++++++++++++---------------------
src/widget/model.rs | 98 +++
2 files changed, 825 insertions(+), 687 deletions(-)
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 041c697..7e33a66 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -1,6 +1,23 @@
+//! Narrow-trait `Graph` (Phase 5m) — the node-network editor: a pannable/zoomable grid of
+//! draggable nodes with geometry toggles, input/output ports, wire routing, and interactive
+//! connection dragging. [`GraphController`] rides the `Input` capability hooks.
+//!
+//! Rendering serves the legacy dual-geometry contract through the adapter's escape hatch:
+//! [`Paint::paint`] emits the ROUNDED view (what `render_widget` hosts — cce-files — and the
+//! scene walk — cce-graph — consume), while [`Paint::legacy_plain_quads`] serves the same
+//! geometry as plain quads for raw `extra_quads` readers (the designer's render path), with
+//! `all_quads` emptied by the adapter so no host draws it twice. Node-name text is clipped to
+//! the widget rect via [`Paint::text_bounds`]. All grid geometry is in absolute screen space
+//! (hosts pan by moving `grid_origin`); the widget rect only culls and clips.
+
use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
use crate::widget::display::TextLabel;
+use crate::widget::{
+ Adapted, ElementState, Event, EventCtx, GraphController, Input, Key, Layout, MouseButton,
+ MouseScrollDelta, Paint,
+};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PortType {
@@ -26,9 +43,12 @@ pub struct GraphNode {
pub outputs: usize,
}
+/// The widget's own corner style: the legacy `Element` defaults it inherited
+/// (`corner_radius` 12.0, bottom corners rounded).
+const WIDGET_RADIUS: f32 = 12.0;
+const WIDGET_CORNERS: (bool, bool, bool, bool) = (false, false, true, true);
+
pub struct Graph {
- x: f32, y: f32, w: f32, h: f32,
- hovered: bool,
show_network_grid: bool,
grid_size_x: f32,
grid_size_y: f32,
@@ -67,32 +87,7 @@ pub struct Graph {
}
impl Graph {
- pub fn set_uniform_background(&mut self, uniform: bool) {
- self.uniform_background = uniform;
- }
- pub fn set_network_opacity(&mut self, opacity: f32) {
- self.network_opacity = opacity;
- }
- pub fn grid_sizes(&self) -> (f32, f32) {
- (self.grid_size_x, self.grid_size_y)
- }
- pub fn skipped_sizes(&self) -> (f32, f32) {
- (self.skipped_row_h, self.skipped_col_w)
- }
- pub fn grid_origin(&self) -> (f32, f32) {
- (self.grid_origin_x, self.grid_origin_y)
- }
- pub fn grid_snap_enabled(&self) -> bool {
- self.grid_snap_enabled
- }
- pub fn set_cell_color(&mut self, color: [f32; 3]) {
- self.cell_color = color;
- }
- pub fn set_gap_color(&mut self, color: [f32; 3]) {
- self.gap_color = color;
- }
-
- pub fn new() -> Self {
+ pub fn new() -> Adapted<Graph> {
crate::layout::lazy_init_style_registry();
let grid_size_x = crate::layout::graph_spacing_x();
@@ -102,9 +97,7 @@ impl Graph {
let cell_col = crate::color::graph_cell_color();
let gap_col = crate::color::graph_gap_color();
- Self {
- x: 0.0, y: 0.0, w: 0.0, h: 0.0,
- hovered: false,
+ Adapted::new(Graph {
show_network_grid: false,
grid_size_x,
grid_size_y,
@@ -133,7 +126,32 @@ impl Graph {
current_mouse_pos: (0.0, 0.0),
pending_connection: None,
hovered_port: None,
- }
+ })
+ }
+
+ pub fn set_uniform_background(&mut self, uniform: bool) {
+ self.uniform_background = uniform;
+ }
+ pub fn set_network_opacity(&mut self, opacity: f32) {
+ self.network_opacity = opacity;
+ }
+ pub fn grid_sizes(&self) -> (f32, f32) {
+ (self.grid_size_x, self.grid_size_y)
+ }
+ pub fn skipped_sizes(&self) -> (f32, f32) {
+ (self.skipped_row_h, self.skipped_col_w)
+ }
+ pub fn grid_origin(&self) -> (f32, f32) {
+ (self.grid_origin_x, self.grid_origin_y)
+ }
+ pub fn grid_snap_enabled(&self) -> bool {
+ self.grid_snap_enabled
+ }
+ pub fn set_cell_color(&mut self, color: [f32; 3]) {
+ self.cell_color = color;
+ }
+ pub fn set_gap_color(&mut self, color: [f32; 3]) {
+ self.gap_color = color;
}
pub fn node_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
@@ -222,99 +240,227 @@ impl Graph {
self.skipped_col_w *= factor;
}
}
-}
-fn read_zoom_bindings() -> (String, String) {
- let mut zoom_in_val = "=".to_string();
- let mut zoom_out_val = "-".to_string();
- let path = crate::config::get_config_path();
- if let Ok(content) = std::fs::read_to_string(&path) {
- if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
- if let Some(zoom_in) = val.pointer("/layout/zoom_in").and_then(|v| v.as_str()) {
- zoom_in_val = zoom_in.to_string();
- }
- if let Some(zoom_out) = val.pointer("/layout/zoom_out").and_then(|v| v.as_str()) {
- zoom_out_val = zoom_out.to_string();
- }
+ fn bg_color(&self) -> [f32; 4] {
+ let mut c = if self.uniform_background {
+ [self.cell_color[0], self.cell_color[1], self.cell_color[2], self.network_opacity]
+ } else {
+ [0.0, 0.0, 0.0, 0.01 * self.network_opacity]
+ };
+ let blur_val = crate::layout::graph_blur();
+ if blur_val > 0.0 {
+ c[3] = -blur_val.abs() * self.network_opacity;
}
+ c
}
- (zoom_in_val, zoom_out_val)
-}
-impl Element for Graph {
- fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
- if event.state == ElementState::Pressed {
- if let Key::Character(ref ch) = event.logical_key {
- let (zoom_in_binding, zoom_out_binding) = read_zoom_bindings();
- if ch == &zoom_in_binding {
- self.zoom_in();
- return true;
- } else if ch == &zoom_out_binding {
- self.zoom_out();
- return true;
+ /// The wires / connection preview / grid cells / axes / node bodies / toggles as plain
+ /// quads — the legacy `extra_quads` body, against `rect` instead of a stored rect.
+ fn geometry_quads(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ let min_x = rect.x;
+ let min_y = rect.y;
+ let max_x = rect.x + rect.width;
+ let max_y = rect.y + rect.height;
+ let push_clipped = |qx: f32, qy: f32, qw: f32, qh: f32, qc: [f32; 4], q: &mut Vec<(f32, f32, f32, f32, [f32; 4])>| {
+ let rx1 = qx.max(min_x);
+ let ry1 = qy.max(min_y);
+ let rx2 = (qx + qw).min(max_x);
+ let ry2 = (qy + qh).min(max_y);
+ let rw = rx2 - rx1;
+ let rh = ry2 - ry1;
+ if rw > 0.0 && rh > 0.0 {
+ q.push((rx1, ry1, rw, rh, qc));
+ }
+ };
+
+ // A three-segment orthogonal wire from (start_x, start_y) down/up to (end_x, end_y).
+ let scale_f = self.grid_size_x / 80.0;
+ let wire_thickness = (3.0 * scale_f).clamp(1.0, 15.0);
+ let push_wire = |start_x: f32, start_y: f32, end_x: f32, end_y: f32, color: [f32; 4], q: &mut Vec<(f32, f32, f32, f32, [f32; 4])>| {
+ let mid_y = start_y + (end_y - start_y) / 2.0;
+
+ let v1_min_y = start_y.min(mid_y);
+ let v1_max_y = start_y.max(mid_y);
+ push_clipped(start_x - wire_thickness / 2.0, v1_min_y, wire_thickness, v1_max_y - v1_min_y, color, q);
+
+ let h_min_x = start_x.min(end_x);
+ let h_max_x = start_x.max(end_x);
+ push_clipped(h_min_x, mid_y - wire_thickness / 2.0, h_max_x - h_min_x, wire_thickness, color, q);
+
+ let v2_min_y = mid_y.min(end_y);
+ let v2_max_y = mid_y.max(end_y);
+ push_clipped(end_x - wire_thickness / 2.0, v2_min_y, wire_thickness, v2_max_y - v2_min_y, color, q);
+ };
+
+ // Connection wires: each node with an "input" parameter draws a wire from that source
+ // node's first output port to its own first input port.
+ let wire_color = [0.0, 0.75, 1.0, 0.7 * self.network_opacity]; // Vibrant cyan glow
+ for i in 0..self.nodes.len() {
+ let node = &self.nodes[i];
+ if let Some((_, input_name, _)) = node.parameters.iter().find(|(name, _, _)| name.eq_ignore_ascii_case("input")) {
+ if let Some(src_idx) = self.nodes.iter().position(|n| n.name == *input_name) {
+ if let (Some((sx, sy, sw, sh)), Some((ex, ey, ew, _eh))) = (self.node_rect(src_idx), self.node_rect(i)) {
+ let src_outputs = self.nodes[src_idx].outputs;
+ let target_inputs = node.inputs;
+
+ let start_x = if src_outputs > 0 {
+ sx + sw * 1.0 / (src_outputs + 1) as f32
+ } else {
+ sx + sw / 2.0
+ };
+ let start_y = sy + sh;
+
+ let end_x = if target_inputs > 0 {
+ ex + ew * 1.0 / (target_inputs + 1) as f32
+ } else {
+ ex + ew / 2.0
+ };
+ let end_y = ey;
+
+ push_wire(start_x, start_y, end_x, end_y, wire_color, &mut quads);
+ }
}
}
}
- false
- }
- fn as_any(&self) -> &dyn std::any::Any { self }
- fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
- fn as_ptr(&self) -> *mut (dyn Element + 'static) {
- self as *const Self as *mut Self as *mut (dyn Element + 'static)
- }
- fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
- self as *mut Self as *mut (dyn Element + 'static)
- }
+ // Connection preview while dragging one out
+ if let Some((node_idx, port_type, port_idx)) = self.connecting_from {
+ if let Some((nx, ny, nw, nh)) = self.node_rect(node_idx) {
+ let start_x = match port_type {
+ PortType::Input => nx + nw * (port_idx + 1) as f32 / (self.nodes[node_idx].inputs + 1) as f32,
+ PortType::Output => nx + nw * (port_idx + 1) as f32 / (self.nodes[node_idx].outputs + 1) as f32,
+ };
+ let start_y = match port_type {
+ PortType::Input => ny,
+ PortType::Output => ny + nh,
+ };
+ let preview_color = [1.0, 0.6, 0.0, 0.8]; // Golden orange preview
+ push_wire(start_x, start_y, self.current_mouse_pos.0, self.current_mouse_pos.1, preview_color, &mut quads);
+ }
+ }
- fn rounded_corners(&self) -> (bool, bool, bool, bool) { (false, false, true, true) }
+ // Grid cells + gaps, on rounded pixel boundaries to prevent seams
+ if self.show_network_grid && self.grid_size_x > 0.0 && self.grid_size_y > 0.0 && !self.uniform_background {
+ let step_y = self.grid_size_y + self.skipped_row_h;
+ let step_x = self.grid_size_x + self.skipped_col_w;
- fn widget_font(&self) -> Option<String> {
- Some(crate::layout::graph_node_font())
- }
+ if step_y >= 4.0 && step_x >= 4.0 {
+ let ry_start = (((rect.y - self.grid_origin_y) / step_y).floor() as i32 - 1).max(-100_000);
+ let ry_end = (((rect.y + rect.height - self.grid_origin_y) / step_y).ceil() as i32 + 1).min(100_000);
+
+ let cx_start = (((rect.x - self.grid_origin_x) / step_x).floor() as i32 - 1).max(-100_000);
+ let cx_end = (((rect.x + rect.width - self.grid_origin_x) / step_x).ceil() as i32 + 1).min(100_000);
+
+ for r in ry_start..=ry_end {
+ let y_cell_start = (self.grid_origin_y + (r as f32) * step_y).round();
+ let y_cell_end = (self.grid_origin_y + (r as f32) * step_y + self.grid_size_y).round();
+ let y2 = (self.grid_origin_y + ((r + 1) as f32) * step_y).round();
+
+ let cell_h = y_cell_end - y_cell_start;
+ let gap_h = y2 - y_cell_end;
+ for c in cx_start..=cx_end {
+ let x_cell_start = (self.grid_origin_x + (c as f32) * step_x).round();
+ let x_cell_end = (self.grid_origin_x + (c as f32) * step_x + self.grid_size_x).round();
+ let x2 = (self.grid_origin_x + ((c + 1) as f32) * step_x).round();
+ let cell_w = x_cell_end - x_cell_start;
+ let gap_w = x2 - x_cell_end;
- fn paint(&mut self, ctx: &mut UiContext) {
- if let Some(hq) = self.highlight_quad(ctx) {
- if hq.4 == colors::HIGHLIGHT_SECONDARY {
- ctx.register_hovered(hq.0, hq.1, hq.2, hq.3, hq.4);
+ // Right gap (shares cell height), bottom gap (full step width), then the
+ // cell itself, all on identical rounded boundaries.
+ push_clipped(
+ x_cell_end,
+ y_cell_start,
+ gap_w,
+ cell_h,
+ [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity],
+ &mut quads,
+ );
+ push_clipped(
+ x_cell_start,
+ y_cell_end,
+ x2 - x_cell_start,
+ gap_h,
+ [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity],
+ &mut quads,
+ );
+ push_clipped(
+ x_cell_start,
+ y_cell_start,
+ cell_w,
+ cell_h,
+ [self.cell_color[0], self.cell_color[1], self.cell_color[2], self.network_opacity],
+ &mut quads,
+ );
+ }
+ }
}
}
- }
- fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
- fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+ // Origin axes, drawn in the gaps beside row/column 0
+ if self.grid_size_x > 0.0 && self.grid_size_y > 0.0 {
+ let thickness = 2.0;
+ let y_center = self.grid_origin_y + self.grid_size_y + self.skipped_row_h / 2.0;
+ push_clipped(rect.x, y_center - thickness / 2.0, rect.width, thickness, [0.0, 0.0, 0.0, 1.0 * self.network_opacity], &mut quads);
+
+ let x_center = self.grid_origin_x - self.skipped_col_w / 2.0;
+ push_clipped(x_center - thickness / 2.0, rect.y, thickness, rect.height, [0.0, 0.0, 0.0, 1.0 * self.network_opacity], &mut quads);
+ }
- fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- let mut quads = Vec::new();
- if let Some(hq) = self.highlight_quad(ctx) {
- if hq.4 != colors::HIGHLIGHT_SECONDARY {
- quads.push(hq);
+ // Node bodies (culled, not clipped — legacy) + geometry toggles (clipped)
+ for i in 0..self.nodes.len() {
+ if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+ let scale_f = nw / 80.0;
+ let mut bg_color = if self.dragging_idx == Some(i) {
+ colors::node_drag_color()
+ } else if self.selected_idx == Some(i) {
+ colors::node_selected_color()
+ } else {
+ colors::node_color()
+ };
+ bg_color[3] *= self.network_opacity;
+ if nx + nw > min_x && nx < max_x && ny + nh > min_y && ny < max_y {
+ quads.push((nx, ny, nw, nh, bg_color));
+ }
+
+ if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
+ let mut btn_color = if self.toggle_hovered_idx == Some(i) {
+ colors::TOGGLE_HOVER
+ } else {
+ colors::TOGGLE_OFF
+ };
+ btn_color[3] *= self.network_opacity;
+ push_clipped(tx, ty, tw, th, btn_color, &mut quads);
+
+ if self.nodes[i].geom_visible {
+ let inset = 3.0 * scale_f;
+ let mut toggle_on_color = colors::TOGGLE_ON;
+ toggle_on_color[3] *= self.network_opacity;
+ push_clipped(tx + inset, ty + inset, tw - inset * 2.0, th - inset * 2.0, toggle_on_color, &mut quads);
+ }
+ }
}
}
+
quads
}
- fn all_rounded_quads(&self, _ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
+ /// The rounded view of the same geometry — the legacy `all_rounded_quads` conversion: the
+ /// widget background, then each plain quad either as a node body (node corner radius, all
+ /// corners) or with the widget's edge-corner resolution.
+ fn rounded_geometry(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
let mut rounded = Vec::new();
-
- let (r1, r2, r3, r4) = self.rounded_corners();
- if r1 || r2 || r3 || r4 {
- let bg_color = self.color();
- let (x, y, w, h) = self.rect();
- let radius = self.corner_radius();
- rounded.push((x, y, w, h, radius, bg_color, (r1, r2, r3, r4)));
- }
+
+ let (w_tl, w_tr, w_br, w_bl) = WIDGET_CORNERS;
+ rounded.push((rect.x, rect.y, rect.width, rect.height, WIDGET_RADIUS, self.bg_color(), WIDGET_CORNERS));
let node_radius = crate::layout::graph_node_corner_radius();
- let widget_radius = self.corner_radius();
- let (w_tl, w_tr, w_br, w_bl) = self.rounded_corners();
- let (wx, wy, ww, wh) = self.rect();
+ let (wx, wy, ww, wh) = (rect.x, rect.y, rect.width, rect.height);
- for (qx, qy, qw, qh, qc) in self.extra_quads() {
- let is_node = self.is_node_rect(qx, qy, qw, qh);
- if is_node {
+ for (qx, qy, qw, qh, qc) in self.geometry_quads(rect) {
+ if self.is_node_rect(qx, qy, qw, qh) {
rounded.push((qx, qy, qw, qh, node_radius, qc, (true, true, true, true)));
} else {
let tl = w_tl && qx <= wx + 1.5 && qy <= wy + 1.5;
@@ -322,7 +468,7 @@ impl Element for Graph {
let br = w_br && qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5;
let bl = w_bl && qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5;
- let r = if tl || tr || br || bl { widget_radius } else { 0.0 };
+ let r = if tl || tr || br || bl { WIDGET_RADIUS } else { 0.0 };
rounded.push((qx, qy, qw, qh, r, qc, (tl, tr, br, bl)));
}
}
@@ -330,40 +476,80 @@ impl Element for Graph {
rounded
}
- fn color(&self) -> [f32; 4] {
- let mut c = if self.uniform_background {
- [self.cell_color[0], self.cell_color[1], self.cell_color[2], self.network_opacity]
- } else {
- [0.0, 0.0, 0.0, 0.01 * self.network_opacity]
+ /// Input/output port circles, culled to the widget rect (legacy `extra_circles`).
+ fn port_circles(&self, rect: Rect) -> Vec<(f32, f32, f32, [f32; 4])> {
+ let mut circles = Vec::new();
+ let min_x = rect.x;
+ let min_y = rect.y;
+ let max_x = rect.x + rect.width;
+ let max_y = rect.y + rect.height;
+
+ let mut push_circle_clipped = |cx: f32, cy: f32, r: f32, color: [f32; 4]| {
+ if cx >= min_x && cx <= max_x && cy >= min_y && cy <= max_y {
+ circles.push((cx, cy, r, color));
+ }
};
- let blur_val = crate::layout::graph_blur();
- if blur_val > 0.0 {
- c[3] = -blur_val.abs() * self.network_opacity;
- }
- c
- }
- fn set_hovered(&mut self, v: bool) { self.hovered = v; }
- fn hovered(&self) -> bool { self.hovered }
- fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
- if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
- return false;
- }
- px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
- }
- fn as_graph_controller(&self) -> Option<&dyn GraphController> { Some(self) }
- fn as_graph_controller_mut(&mut self) -> Option<&mut dyn GraphController> { Some(self) }
+ let conn_size = crate::layout::graph_connector_size();
+ let mut conn_color = colors::graph_connector_color();
+ let mut conn_hl_color = colors::graph_connector_highlight_color();
+ conn_color[3] *= self.network_opacity;
+ conn_hl_color[3] *= self.network_opacity;
- fn text_labels(&self) -> Vec<TextLabel> {
- let mut labels = Vec::new();
- for (i, node) in self.nodes.iter().enumerate() {
+ for i in 0..self.nodes.len() {
+ if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+ let scale_f = nw / 80.0;
+ let port_size = (conn_size * scale_f).max(2.0);
+ let base_r = port_size / 2.0;
+
+ let node = &self.nodes[i];
+
+ for k in 0..node.inputs {
+ let cx = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
+ let cy = ny;
+
+ let is_hovered = self.hovered_port == Some((i, PortType::Input, k));
+ let is_connecting = self.connecting_from == Some((i, PortType::Input, k));
+
+ let (r, color) = if is_hovered || is_connecting {
+ (base_r * 1.4, conn_hl_color)
+ } else {
+ (base_r, conn_color)
+ };
+ push_circle_clipped(cx, cy, r, color);
+ }
+
+ for k in 0..node.outputs {
+ let cx = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
+ let cy = ny + nh;
+
+ let is_hovered = self.hovered_port == Some((i, PortType::Output, k));
+ let is_connecting = self.connecting_from == Some((i, PortType::Output, k));
+
+ let (r, color) = if is_hovered || is_connecting {
+ (base_r * 1.4, conn_hl_color)
+ } else {
+ (base_r, conn_color)
+ };
+ push_circle_clipped(cx, cy, r, color);
+ }
+ }
+ }
+ circles
+ }
+
+ /// Node-name labels beside each node, scaled with the grid, included only when they
+ /// intersect the widget rect (legacy `text_labels`).
+ fn node_labels(&self, rect: Rect) -> Vec<TextLabel> {
+ let mut labels = Vec::new();
+ for (i, node) in self.nodes.iter().enumerate() {
if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
let scale_f = nw / 80.0;
let font_size = (14.0 * scale_f).clamp(6.0, 48.0);
let lx = nx + nw + 8.0 * scale_f;
let ly = crate::layout::align_text_y(ny, nh, font_size, 0.0);
let text_w = TextLabel::estimate_width(&node.name, font_size);
- if lx + text_w >= self.x && lx < self.x + self.w && ly + font_size >= self.y && ly < self.y + self.h {
+ if lx + text_w >= rect.x && lx < rect.x + rect.width && ly + font_size >= rect.y && ly < rect.y + rect.height {
labels.push(TextLabel {
text: node.name.clone(),
x: lx,
@@ -376,654 +562,404 @@ impl Element for Graph {
}
labels
}
+}
- fn text_labels_with_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
- let bounds = Some([self.x, self.y, self.x + self.w, self.y + self.h]);
- self.text_labels().into_iter().map(|l| (l, bounds)).collect()
+fn read_zoom_bindings() -> (String, String) {
+ let mut zoom_in_val = "=".to_string();
+ let mut zoom_out_val = "-".to_string();
+ let path = crate::config::get_config_path();
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
+ if let Some(zoom_in) = val.pointer("/layout/zoom_in").and_then(|v| v.as_str()) {
+ zoom_in_val = zoom_in.to_string();
+ }
+ if let Some(zoom_out) = val.pointer("/layout/zoom_out").and_then(|v| v.as_str()) {
+ zoom_out_val = zoom_out.to_string();
+ }
+ }
}
+ (zoom_in_val, zoom_out_val)
+}
+
+impl Layout for Graph {}
- fn text_labels_with_font_and_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
- let font = self.widget_font();
- let bounds = Some([self.x, self.y, self.x + self.w, self.y + self.h]);
- self.text_labels().into_iter().map(|l| (l, font.clone(), bounds)).collect()
+impl Paint for Graph {
+ fn color(&self) -> [f32; 4] {
+ self.bg_color()
}
- fn focus(&mut self) {
- focus::set_focused(self);
+ fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
+ Some((WIDGET_RADIUS, WIDGET_CORNERS))
}
- fn is_dragging(&self) -> bool { self.dragging_idx.is_some() }
- fn draggable(&self) -> bool { self.dragging_idx.is_some() }
+ fn widget_font(&self) -> Option<String> {
+ Some(crate::layout::graph_node_font())
+ }
- fn drag_begin(&mut self, px: f32, py: f32) {
- if let Some(idx) = self.dragging_idx {
- if let Some((nx, ny, _, _)) = self.node_rect(idx) {
- self.drag_ox = px - nx;
- self.drag_oy = py - ny;
- self.drag_node_pos = Some((nx, ny));
- }
+ fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+ for (qx, qy, qw, qh, r, c, corners) in self.rounded_geometry(rect) {
+ ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, r, corners, c);
+ }
+ for (cx, cy, r, c) in self.port_circles(rect) {
+ ctx.circle(cx, cy, r, c);
+ }
+ for l in self.node_labels(rect) {
+ ctx.text(l.text, l.x, l.y, l.font_size, l.color);
}
}
- fn drag_update(&mut self, px: f32, py: f32) -> bool {
- if self.dragging_idx.is_some() {
- let nx = px - self.drag_ox;
- let ny = py - self.drag_oy;
-
- let snap_x = if self.grid_snap_enabled { self.grid_size_x + self.skipped_col_w } else { 0.0 };
- let snap_y = if self.grid_snap_enabled { self.grid_size_y + self.skipped_row_h } else { 0.0 };
-
- let nx = if snap_x > 0.0 {
- let relative = nx - self.grid_origin_x;
- let snapped = (relative / snap_x).round() * snap_x;
- snapped + self.grid_origin_x
- } else { nx };
+ fn serves_legacy_plain_quads(&self) -> bool {
+ true
+ }
- let ny = if snap_y > 0.0 {
- let relative = ny - self.grid_origin_y;
- let snapped = (relative / snap_y).round() * snap_y;
- snapped + self.grid_origin_y
- } else { ny };
+ fn legacy_plain_quads(&self, rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ self.geometry_quads(rect)
+ }
- self.drag_node_pos = Some((nx, ny));
- return true;
- }
- false
+ fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height])
}
+}
- fn drag_end(&mut self) {
- if let Some((nx, ny)) = self.drag_node_pos.take() {
- let c = ((nx - self.grid_origin_x) / (self.grid_size_x + self.skipped_col_w)).round();
- let r = ((ny - self.grid_origin_y) / (self.grid_size_y + self.skipped_row_h)).round();
- if let Some(idx) = self.dragging_idx.take() {
- let (nx, ny) = self.find_empty_cell(c, r, Some(idx));
- self.nodes[idx].position = (nx, ny);
- }
- } else {
- self.dragging_idx = None;
- }
- self.dragging_id = None;
+impl Input for Graph {
+ /// Legacy hit test excluded the right/bottom edges.
+ fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+ x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
}
- fn handle_event(&mut self, event: &crate::widget::Event, _ctx: &mut UiContext) -> bool {
+ fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
match event {
- crate::widget::Event::MouseLeave => {
- let changed = self.hovered_port.is_some() || self.toggle_hovered_idx.is_some();
- self.hovered_port = None;
+ Event::PointerMove { x: px, y: py, .. } => {
+ let (px, py) = (*px, *py);
+ let mut changed = false;
+ if self.connecting_from.is_some() {
+ self.current_mouse_pos = (px, py);
+ changed = true;
+ }
+ let was_toggle_hovered = self.toggle_hovered_idx;
self.toggle_hovered_idx = None;
- return changed;
- }
- _ => {}
- }
- false
- }
-
- fn on_cursor_moved(&mut self, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
- let mut changed = false;
- if self.connecting_from.is_some() {
- self.current_mouse_pos = (px, py);
- changed = true;
- }
- let was_toggle_hovered = self.toggle_hovered_idx;
- self.toggle_hovered_idx = None;
-
- let was_hovered_port = self.hovered_port;
- self.hovered_port = None;
-
- let conn_act_r = crate::layout::graph_connector_activation_radius();
- for i in 0..self.nodes.len() {
- if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
- let scale_f = nw / 80.0;
- let hit_radius = (conn_act_r * scale_f).max(2.0);
- let node = &self.nodes[i];
-
- for k in 0..node.inputs {
- let cx = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
- let cy = ny;
- if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
- self.hovered_port = Some((i, PortType::Input, k));
- }
- }
-
- for k in 0..node.outputs {
- let cx = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
- let cy = ny + nh;
- if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
- self.hovered_port = Some((i, PortType::Output, k));
- }
- }
- }
+ let was_hovered_port = self.hovered_port;
+ self.hovered_port = None;
- if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
- if px >= tx && px < tx + tw && py >= ty && py < ty + th {
- self.toggle_hovered_idx = Some(i);
- }
- }
- }
-
- if was_toggle_hovered != self.toggle_hovered_idx || was_hovered_port != self.hovered_port {
- changed = true;
- }
- changed
- }
+ let conn_act_r = crate::layout::graph_connector_activation_radius();
- fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
- if button == MouseButton::Right && state == ElementState::Pressed {
- if self.connecting_from.is_some() {
- self.connecting_from = None;
- return true;
- }
- }
- if button != MouseButton::Left { return false; }
- match state {
- ElementState::Pressed => {
- log::debug!("Graph::mouse_input: Pressed px={}, py={}, connecting_from={:?}", px, py, self.connecting_from);
- for i in (0..self.nodes.len()).rev() {
+ for i in 0..self.nodes.len() {
if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
let scale_f = nw / 80.0;
- let conn_act_r = crate::layout::graph_connector_activation_radius();
- let port_click_radius = (conn_act_r * scale_f).max(2.0);
- let port_click_radius_sq = port_click_radius * port_click_radius;
-
+ let hit_radius = (conn_act_r * scale_f).max(2.0);
let node = &self.nodes[i];
- // Check inputs (top edge)
+
for k in 0..node.inputs {
- let port_x = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
- let port_y = ny;
- let dx = px - port_x;
- let dy = py - port_y;
- log::debug!(" Checking input node={} port={} port_x={} port_y={} dist_sq={}", node.name, k, port_x, port_y, dx*dx + dy*dy);
- if dx * dx + dy * dy <= port_click_radius_sq {
- if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
- if src_idx != i && src_port_type == PortType::Output {
- let output_node = &self.nodes[src_idx];
- let input_node = &self.nodes[i];
- self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
- self.connecting_from = None;
- log::debug!(" Port connection created: Input={} from Output={}", input_node.name, output_node.name);
- return true;
- } else {
- self.connecting_from = None;
- log::debug!(" Port connection aborted (same node or incompatible ports)");
- return true;
- }
- } else {
- self.connecting_from = Some((i, PortType::Input, k));
- self.current_mouse_pos = (px, py);
- log::debug!(" Start connecting from Input port of node={}", node.name);
- return true;
- }
+ let cx = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
+ let cy = ny;
+ if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
+ self.hovered_port = Some((i, PortType::Input, k));
}
}
- // Check outputs (bottom edge)
for k in 0..node.outputs {
- let port_x = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
- let port_y = ny + nh;
- let dx = px - port_x;
- let dy = py - port_y;
- log::debug!(" Checking output node={} port={} port_x={} port_y={} dist_sq={}", node.name, k, port_x, port_y, dx*dx + dy*dy);
- if dx * dx + dy * dy <= port_click_radius_sq {
- if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
- if src_idx != i && src_port_type == PortType::Input {
- let output_node = &self.nodes[i];
- let input_node = &self.nodes[src_idx];
- self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
- self.connecting_from = None;
- log::debug!(" Port connection created: Input={} from Output={}", input_node.name, output_node.name);
- return true;
- } else {
- self.connecting_from = None;
- log::debug!(" Port connection aborted (same node or incompatible ports)");
- return true;
- }
- } else {
- self.connecting_from = Some((i, PortType::Output, k));
- self.current_mouse_pos = (px, py);
- log::debug!(" Start connecting from Output port of node={}", node.name);
- return true;
- }
+ let cx = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
+ let cy = ny + nh;
+ if (px - cx).powi(2) + (py - cy).powi(2) <= hit_radius.powi(2) {
+ self.hovered_port = Some((i, PortType::Output, k));
}
}
}
- }
- // Fallback: If we are actively connecting and clicked on a target node body, connect to its closest compatible port
- if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
- for i in (0..self.nodes.len()).rev() {
- if src_idx != i {
- if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
- log::debug!(" Checking fallback body node={} nx={} ny={} nw={} nh={}", self.nodes[i].name, nx, ny, nw, nh);
- if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
- let node = &self.nodes[i];
- if src_port_type == PortType::Output && node.inputs > 0 {
- let output_node = &self.nodes[src_idx];
- let input_node = &self.nodes[i];
- self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
- self.connecting_from = None;
- log::debug!(" Fallback connection created: Input={} from Output={}", input_node.name, output_node.name);
- return true;
- } else if src_port_type == PortType::Input && node.outputs > 0 {
- let output_node = &self.nodes[i];
- let input_node = &self.nodes[src_idx];
- self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
- self.connecting_from = None;
- log::debug!(" Fallback connection created: Input={} from Output={}", input_node.name, output_node.name);
- return true;
- }
- }
- }
+ if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
+ if px >= tx && px < tx + tw && py >= ty && py < ty + th {
+ self.toggle_hovered_idx = Some(i);
}
}
}
+ if was_toggle_hovered != self.toggle_hovered_idx || was_hovered_port != self.hovered_port {
+ changed = true;
+ }
+ changed
+ }
+ Event::MouseLeave => {
+ let changed = self.hovered_port.is_some() || self.toggle_hovered_idx.is_some();
+ self.hovered_port = None;
+ self.toggle_hovered_idx = None;
+ changed
+ }
+ Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, .. } => {
if self.connecting_from.is_some() {
- log::debug!(" Clearing connecting_from because it didn't hit any ports or node bodies");
self.connecting_from = None;
+ true
+ } else {
+ false
}
-
- for i in (0..self.nodes.len()).rev() {
- if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
- if px >= tx && px < tx + tw && py >= ty && py < ty + th {
- self.nodes[i].geom_visible = !self.nodes[i].geom_visible;
- self.node_geom_toggled = Some((i, self.nodes[i].geom_visible));
- return true;
+ }
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+ self.on_left_press(*x, *y, ectx)
+ }
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
+ if self.dragging_idx.is_some() {
+ self.commit_drag();
+ true
+ } else {
+ false
+ }
+ }
+ Event::MouseWheel { delta, x: px, y: py, .. } => {
+ let _ = (px, py); // hit-gated by the adapter
+ let ctrl = ectx.ui.as_deref().map_or(false, |ui| ui.ctrl_pressed);
+ if ctrl {
+ match delta {
+ MouseScrollDelta::LineDelta(_x, y) => {
+ if *y > 0.0 {
+ self.zoom_by_factor(1.1);
+ } else if *y < 0.0 {
+ self.zoom_by_factor(1.0 / 1.1);
+ }
+ true
+ }
+ MouseScrollDelta::PixelDelta(pos) => {
+ let factor = 1.0 + (pos.y as f32 * 0.015);
+ self.zoom_by_factor(factor);
+ true
}
}
- if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
- if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
- let now = std::time::Instant::now();
- if let Some((prev_time, prev_idx)) = self.double_click_timer {
- if prev_idx == i && now.duration_since(prev_time) < std::time::Duration::from_millis(500) {
- self.double_clicked_idx = Some(i);
- }
- }
- self.double_click_timer = Some((now, i));
- self.selected_idx = Some(i);
- self.selected_id = Some(self.nodes[i].id.clone());
- self.dragging_idx = Some(i);
- self.dragging_id = Some(self.nodes[i].id.clone());
- self.drag_ox = px - nx;
- self.drag_oy = py - ny;
- self.drag_node_pos = Some((nx, ny));
- self.focus();
- return true;
+ } else {
+ match delta {
+ MouseScrollDelta::LineDelta(x, y) => {
+ self.grid_origin_x += *x * 15.0;
+ self.grid_origin_y += *y * 15.0;
+ true
+ }
+ MouseScrollDelta::PixelDelta(pos) => {
+ self.grid_origin_x += pos.x as f32;
+ self.grid_origin_y += pos.y as f32;
+ true
}
}
}
- self.selected_idx = None;
- self.selected_id = None;
- false
}
- ElementState::Released => {
- if self.dragging_idx.is_some() {
- self.drag_end();
- return true;
+ Event::KeyInput(key_event) => {
+ if key_event.state == ElementState::Pressed {
+ if let Key::Character(ref ch) = key_event.logical_key {
+ let (zoom_in_binding, zoom_out_binding) = read_zoom_bindings();
+ if ch == &zoom_in_binding {
+ self.zoom_in();
+ return true;
+ } else if ch == &zoom_out_binding {
+ self.zoom_out();
+ return true;
+ }
+ }
}
false
}
+ _ => false,
}
}
- fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- let mut quads = Vec::new();
- let min_x = self.x;
- let min_y = self.y;
- let max_x = self.x + self.w;
- let max_y = self.y + self.h;
- let push_clipped = |qx: f32, qy: f32, qw: f32, qh: f32, qc: [f32; 4], q: &mut Vec<(f32, f32, f32, f32, [f32; 4])>| {
- let rx1 = qx.max(min_x);
- let ry1 = qy.max(min_y);
- let rx2 = (qx + qw).min(max_x);
- let ry2 = (qy + qh).min(max_y);
- let rw = rx2 - rx1;
- let rh = ry2 - ry1;
- if rw > 0.0 && rh > 0.0 {
- q.push((rx1, ry1, rw, rh, qc));
- }
- };
-
- // Draw connection wires
- let scale_f = self.grid_size_x / 80.0;
- let wire_color = [0.0, 0.75, 1.0, 0.7 * self.network_opacity]; // Vibrant cyan glow
- let wire_thickness = (3.0 * scale_f).clamp(1.0, 15.0);
- for i in 0..self.nodes.len() {
- let node = &self.nodes[i];
- if let Some((_, input_name, _)) = node.parameters.iter().find(|(name, _, _)| name.eq_ignore_ascii_case("input")) {
- if let Some(src_idx) = self.nodes.iter().position(|n| n.name == *input_name) {
- if let (Some((sx, sy, sw, sh)), Some((ex, ey, ew, _eh))) = (self.node_rect(src_idx), self.node_rect(i)) {
- let src_outputs = self.nodes[src_idx].outputs;
- let target_inputs = node.inputs;
-
- let start_x = if src_outputs > 0 {
- sx + sw * 1.0 / (src_outputs + 1) as f32
- } else {
- sx + sw / 2.0
- };
- let start_y = sy + sh;
-
- let end_x = if target_inputs > 0 {
- ex + ew * 1.0 / (target_inputs + 1) as f32
- } else {
- ex + ew / 2.0
- };
- let end_y = ey;
-
- let mid_y = start_y + (end_y - start_y) / 2.0;
-
- // Vertical segment 1
- let v1_min_y = start_y.min(mid_y);
- let v1_max_y = start_y.max(mid_y);
- push_clipped(
- start_x - wire_thickness / 2.0,
- v1_min_y,
- wire_thickness,
- v1_max_y - v1_min_y,
- wire_color,
- &mut quads,
- );
-
- // Horizontal segment
- let h_min_x = start_x.min(end_x);
- let h_max_x = start_x.max(end_x);
- push_clipped(
- h_min_x,
- mid_y - wire_thickness / 2.0,
- h_max_x - h_min_x,
- wire_thickness,
- wire_color,
- &mut quads,
- );
-
- // Vertical segment 2
- let v2_min_y = mid_y.min(end_y);
- let v2_max_y = mid_y.max(end_y);
- push_clipped(
- end_x - wire_thickness / 2.0,
- v2_min_y,
- wire_thickness,
- v2_max_y - v2_min_y,
- wire_color,
- &mut quads,
- );
- }
- }
- }
- }
-
- // Draw connection wire preview if in progress
- if let Some((node_idx, port_type, port_idx)) = self.connecting_from {
- if let Some((nx, ny, nw, nh)) = self.node_rect(node_idx) {
- let start_x = match port_type {
- PortType::Input => nx + nw * (port_idx + 1) as f32 / (self.nodes[node_idx].inputs + 1) as f32,
- PortType::Output => nx + nw * (port_idx + 1) as f32 / (self.nodes[node_idx].outputs + 1) as f32,
- };
- let start_y = match port_type {
- PortType::Input => ny,
- PortType::Output => ny + nh,
- };
-
- let end_x = self.current_mouse_pos.0;
- let end_y = self.current_mouse_pos.1;
-
- let preview_color = [1.0, 0.6, 0.0, 0.8]; // Golden orange preview
- let mid_y = start_y + (end_y - start_y) / 2.0;
-
- // Vertical segment 1
- let v1_min_y = start_y.min(mid_y);
- let v1_max_y = start_y.max(mid_y);
- push_clipped(
- start_x - wire_thickness / 2.0,
- v1_min_y,
- wire_thickness,
- v1_max_y - v1_min_y,
- preview_color,
- &mut quads,
- );
-
- // Horizontal segment
- let h_min_x = start_x.min(end_x);
- let h_max_x = start_x.max(end_x);
- push_clipped(
- h_min_x,
- mid_y - wire_thickness / 2.0,
- h_max_x - h_min_x,
- wire_thickness,
- preview_color,
- &mut quads,
- );
-
- // Vertical segment 2
- let v2_min_y = mid_y.min(end_y);
- let v2_max_y = mid_y.max(end_y);
- push_clipped(
- end_x - wire_thickness / 2.0,
- v2_min_y,
- wire_thickness,
- v2_max_y - v2_min_y,
- preview_color,
- &mut quads,
- );
- }
- }
-
- if self.show_network_grid && self.grid_size_x > 0.0 && self.grid_size_y > 0.0 && !self.uniform_background {
- let step_y = self.grid_size_y + self.skipped_row_h;
- let step_x = self.grid_size_x + self.skipped_col_w;
-
- if step_y >= 4.0 && step_x >= 4.0 {
- let ry_start = (((self.y - self.grid_origin_y) / step_y).floor() as i32 - 1).max(-100_000);
- let ry_end = (((self.y + self.h - self.grid_origin_y) / step_y).ceil() as i32 + 1).min(100_000);
-
- let cx_start = (((self.x - self.grid_origin_x) / step_x).floor() as i32 - 1).max(-100_000);
- let cx_end = (((self.x + self.w - self.grid_origin_x) / step_x).ceil() as i32 + 1).min(100_000);
-
- // Draw gap rectangles and cells in a single loop with matching rounded coordinate boundaries to prevent seams
- for r in ry_start..=ry_end {
- let y_cell_start = (self.grid_origin_y + (r as f32) * step_y).round();
- let y_cell_end = (self.grid_origin_y + (r as f32) * step_y + self.grid_size_y).round();
- let y2 = (self.grid_origin_y + ((r + 1) as f32) * step_y).round();
-
- let cell_h = y_cell_end - y_cell_start;
- let gap_h = y2 - y_cell_end;
-
- for c in cx_start..=cx_end {
- let x_cell_start = (self.grid_origin_x + (c as f32) * step_x).round();
- let x_cell_end = (self.grid_origin_x + (c as f32) * step_x + self.grid_size_x).round();
- let x2 = (self.grid_origin_x + ((c + 1) as f32) * step_x).round();
-
- let cell_w = x_cell_end - x_cell_start;
- let gap_w = x2 - x_cell_end;
-
- // Draw right gap rectangle (shares cell height, sits to the right of the cell)
- push_clipped(
- x_cell_end,
- y_cell_start,
- gap_w,
- cell_h,
- [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity],
- &mut quads,
- );
+ fn scrollable(&self) -> bool {
+ false
+ }
- // Draw bottom gap rectangle (shares the full step width, sits below the cell/right gap)
- push_clipped(
- x_cell_start,
- y_cell_end,
- x2 - x_cell_start,
- gap_h,
- [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity],
- &mut quads,
- );
+ // The graph is "draggable" only once a left press landed on a node body (`on_left_press`
+ // sets `dragging_idx`); hosts then re-init via drag_begin and stream drag_update.
+ fn draggable(&self, _rect: Rect) -> bool {
+ self.dragging_idx.is_some()
+ }
+ fn is_dragging(&self) -> bool {
+ self.dragging_idx.is_some()
+ }
- // Draw cell rectangle with the exact same bounding boundaries
- push_clipped(
- x_cell_start,
- y_cell_start,
- cell_w,
- cell_h,
- [self.cell_color[0], self.cell_color[1], self.cell_color[2], self.network_opacity],
- &mut quads,
- );
- }
- }
+ fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
+ if let Some(idx) = self.dragging_idx {
+ if let Some((nx, ny, _, _)) = self.node_rect(idx) {
+ self.drag_ox = px - nx;
+ self.drag_oy = py - ny;
+ self.drag_node_pos = Some((nx, ny));
}
}
+ }
- if self.grid_size_x > 0.0 && self.grid_size_y > 0.0 {
- let thickness = 2.0;
- // X axis (horizontal) in the gap below row 0
- let y_center = self.grid_origin_y + self.grid_size_y + self.skipped_row_h / 2.0;
- push_clipped(self.x, y_center - thickness / 2.0, self.w, thickness, [0.0, 0.0, 0.0, 1.0 * self.network_opacity], &mut quads);
+ fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
+ if self.dragging_idx.is_some() {
+ let nx = px - self.drag_ox;
+ let ny = py - self.drag_oy;
- // Y axis (vertical) in the gap to the left of col 0
- let x_center = self.grid_origin_x - self.skipped_col_w / 2.0;
- push_clipped(x_center - thickness / 2.0, self.y, thickness, self.h, [0.0, 0.0, 0.0, 1.0 * self.network_opacity], &mut quads);
- }
+ let snap_x = if self.grid_snap_enabled { self.grid_size_x + self.skipped_col_w } else { 0.0 };
+ let snap_y = if self.grid_snap_enabled { self.grid_size_y + self.skipped_row_h } else { 0.0 };
- for i in 0..self.nodes.len() {
- if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
- let scale_f = nw / 80.0;
- let mut bg_color = if self.dragging_idx == Some(i) {
- colors::node_drag_color()
- } else if self.selected_idx == Some(i) {
- colors::node_selected_color()
- } else {
- colors::node_color()
- };
- bg_color[3] *= self.network_opacity;
- if nx + nw > min_x && nx < max_x && ny + nh > min_y && ny < max_y {
- quads.push((nx, ny, nw, nh, bg_color));
- }
+ let nx = if snap_x > 0.0 {
+ let relative = nx - self.grid_origin_x;
+ (relative / snap_x).round() * snap_x + self.grid_origin_x
+ } else {
+ nx
+ };
- if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
- let mut btn_color = if self.toggle_hovered_idx == Some(i) {
- colors::TOGGLE_HOVER
- } else {
- colors::TOGGLE_OFF
- };
- btn_color[3] *= self.network_opacity;
- push_clipped(tx, ty, tw, th, btn_color, &mut quads);
+ let ny = if snap_y > 0.0 {
+ let relative = ny - self.grid_origin_y;
+ (relative / snap_y).round() * snap_y + self.grid_origin_y
+ } else {
+ ny
+ };
- if self.nodes[i].geom_visible {
- let inset = 3.0 * scale_f;
- let mut toggle_on_color = colors::TOGGLE_ON;
- toggle_on_color[3] *= self.network_opacity;
- push_clipped(tx + inset, ty + inset, tw - inset * 2.0, th - inset * 2.0, toggle_on_color, &mut quads);
- }
- }
- }
+ self.drag_node_pos = Some((nx, ny));
+ return true;
}
-
- quads
+ false
}
- fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
- let mut circles = Vec::new();
- let min_x = self.x;
- let min_y = self.y;
- let max_x = self.x + self.w;
- let max_y = self.y + self.h;
-
- let mut push_circle_clipped = |cx: f32, cy: f32, r: f32, color: [f32; 4]| {
- if cx >= min_x && cx <= max_x && cy >= min_y && cy <= max_y {
- circles.push((cx, cy, r, color));
- }
- };
+ fn drag_end(&mut self) {
+ self.commit_drag();
+ }
- let conn_size = crate::layout::graph_connector_size();
- let mut conn_color = colors::graph_connector_color();
- let mut conn_hl_color = colors::graph_connector_highlight_color();
- conn_color[3] *= self.network_opacity;
- conn_hl_color[3] *= self.network_opacity;
+ fn graph_controller(&self) -> Option<&dyn GraphController> {
+ Some(self)
+ }
+ fn graph_controller_mut(&mut self) -> Option<&mut dyn GraphController> {
+ Some(self)
+ }
+}
- for i in 0..self.nodes.len() {
+impl Graph {
+ /// The legacy left-press cascade: ports (start/complete a connection), a node-body
+ /// fallback for an in-flight connection, geometry toggles, then node selection + drag
+ /// arming; an empty-space press clears the selection and stays unconsumed.
+ fn on_left_press(&mut self, px: f32, py: f32, ectx: &mut EventCtx) -> bool {
+ for i in (0..self.nodes.len()).rev() {
if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
let scale_f = nw / 80.0;
- let port_size = (conn_size * scale_f).max(2.0);
- let base_r = port_size / 2.0;
+ let conn_act_r = crate::layout::graph_connector_activation_radius();
+ let port_click_radius = (conn_act_r * scale_f).max(2.0);
+ let port_click_radius_sq = port_click_radius * port_click_radius;
let node = &self.nodes[i];
-
- // Input ports (top edge)
+ // Inputs (top edge)
for k in 0..node.inputs {
- let cx = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
- let cy = ny;
-
- let is_hovered = self.hovered_port == Some((i, PortType::Input, k));
- let is_connecting = self.connecting_from == Some((i, PortType::Input, k));
-
- let (r, color) = if is_hovered || is_connecting {
- (base_r * 1.4, conn_hl_color)
- } else {
- (base_r, conn_color)
- };
- push_circle_clipped(cx, cy, r, color);
+ let port_x = nx + nw * (k + 1) as f32 / (node.inputs + 1) as f32;
+ let port_y = ny;
+ let dx = px - port_x;
+ let dy = py - port_y;
+ if dx * dx + dy * dy <= port_click_radius_sq {
+ if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
+ if src_idx != i && src_port_type == PortType::Output {
+ let output_node = &self.nodes[src_idx];
+ let input_node = &self.nodes[i];
+ self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
+ self.connecting_from = None;
+ } else {
+ self.connecting_from = None;
+ }
+ } else {
+ self.connecting_from = Some((i, PortType::Input, k));
+ self.current_mouse_pos = (px, py);
+ }
+ return true;
+ }
}
- // Output ports (bottom edge)
+ // Outputs (bottom edge)
for k in 0..node.outputs {
- let cx = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
- let cy = ny + nh;
-
- let is_hovered = self.hovered_port == Some((i, PortType::Output, k));
- let is_connecting = self.connecting_from == Some((i, PortType::Output, k));
-
- let (r, color) = if is_hovered || is_connecting {
- (base_r * 1.4, conn_hl_color)
- } else {
- (base_r, conn_color)
- };
- push_circle_clipped(cx, cy, r, color);
+ let port_x = nx + nw * (k + 1) as f32 / (node.outputs + 1) as f32;
+ let port_y = ny + nh;
+ let dx = px - port_x;
+ let dy = py - port_y;
+ if dx * dx + dy * dy <= port_click_radius_sq {
+ if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
+ if src_idx != i && src_port_type == PortType::Input {
+ let output_node = &self.nodes[i];
+ let input_node = &self.nodes[src_idx];
+ self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
+ self.connecting_from = None;
+ } else {
+ self.connecting_from = None;
+ }
+ } else {
+ self.connecting_from = Some((i, PortType::Output, k));
+ self.current_mouse_pos = (px, py);
+ }
+ return true;
+ }
}
}
}
- circles
- }
-
-
- fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
- if self.hit_test(px, py, ctx) {
- if ctx.ctrl_pressed {
- match delta {
- MouseScrollDelta::LineDelta(_x, y) => {
- if *y > 0.0 {
- self.zoom_by_factor(1.1);
- } else if *y < 0.0 {
- self.zoom_by_factor(1.0 / 1.1);
+ // Actively connecting + clicked a target node body: connect to its closest compatible port
+ if let Some((src_idx, src_port_type, _src_port_idx)) = self.connecting_from {
+ for i in (0..self.nodes.len()).rev() {
+ if src_idx != i {
+ if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+ if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
+ let node = &self.nodes[i];
+ if src_port_type == PortType::Output && node.inputs > 0 {
+ let output_node = &self.nodes[src_idx];
+ let input_node = &self.nodes[i];
+ self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
+ self.connecting_from = None;
+ return true;
+ } else if src_port_type == PortType::Input && node.outputs > 0 {
+ let output_node = &self.nodes[i];
+ let input_node = &self.nodes[src_idx];
+ self.pending_connection = Some((input_node.id.clone(), output_node.name.clone()));
+ self.connecting_from = None;
+ return true;
+ }
}
- true
- }
- MouseScrollDelta::PixelDelta(pos) => {
- let factor = 1.0 + (pos.y as f32 * 0.015);
- self.zoom_by_factor(factor);
- true
}
}
- } else {
- match delta {
- MouseScrollDelta::LineDelta(x, y) => {
- self.grid_origin_x += *x * 15.0;
- self.grid_origin_y += *y * 15.0;
- true
- }
- MouseScrollDelta::PixelDelta(pos) => {
- self.grid_origin_x += pos.x as f32;
- self.grid_origin_y += pos.y as f32;
- true
+ }
+ }
+
+ if self.connecting_from.is_some() {
+ self.connecting_from = None;
+ }
+
+ for i in (0..self.nodes.len()).rev() {
+ if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
+ if px >= tx && px < tx + tw && py >= ty && py < ty + th {
+ self.nodes[i].geom_visible = !self.nodes[i].geom_visible;
+ self.node_geom_toggled = Some((i, self.nodes[i].geom_visible));
+ return true;
+ }
+ }
+ if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+ if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
+ let now = std::time::Instant::now();
+ if let Some((prev_time, prev_idx)) = self.double_click_timer {
+ if prev_idx == i && now.duration_since(prev_time) < std::time::Duration::from_millis(500) {
+ self.double_clicked_idx = Some(i);
+ }
}
+ self.double_click_timer = Some((now, i));
+ self.selected_idx = Some(i);
+ self.selected_id = Some(self.nodes[i].id.clone());
+ self.dragging_idx = Some(i);
+ self.dragging_id = Some(self.nodes[i].id.clone());
+ self.drag_ox = px - nx;
+ self.drag_oy = py - ny;
+ self.drag_node_pos = Some((nx, ny));
+ ectx.request_focus();
+ return true;
}
}
- } else {
- false
}
+ self.selected_idx = None;
+ self.selected_id = None;
+ false
}
-}
-impl Drop for Graph {
- fn drop(&mut self) {
- clear_widget_references(self);
+ /// Drop the in-flight node drag onto the nearest free grid cell (legacy `drag_end`).
+ fn commit_drag(&mut self) {
+ if let Some((nx, ny)) = self.drag_node_pos.take() {
+ let c = ((nx - self.grid_origin_x) / (self.grid_size_x + self.skipped_col_w)).round();
+ let r = ((ny - self.grid_origin_y) / (self.grid_size_y + self.skipped_row_h)).round();
+ if let Some(idx) = self.dragging_idx.take() {
+ let (nx, ny) = self.find_empty_cell(c, r, Some(idx));
+ self.nodes[idx].position = (nx, ny);
+ }
+ } else {
+ self.dragging_idx = None;
+ }
+ self.dragging_id = None;
}
}
@@ -1031,7 +967,7 @@ impl GraphController for Graph {
fn set_nodes(&mut self, nodes: &[GraphNode]) {
self.nodes = nodes.to_vec();
self.hovered_port = None;
-
+
// Sync selected_idx from selected_id
if let Some(ref id) = self.selected_id {
self.selected_idx = self.nodes.iter().position(|n| n.id == *id);
@@ -1085,3 +1021,107 @@ impl GraphController for Graph {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::context::UiContext;
+ use crate::widget::Element;
+
+ fn two_nodes() -> Adapted<Graph> {
+ let mut g = Graph::new();
+ Element::set_rect(&mut g, 0.0, 0.0, 800.0, 600.0);
+ g.set_grid_sizes(80.0, 40.0);
+ g.set_skipped_sizes(20.0, 20.0);
+ g.set_grid_origin(100.0, 100.0);
+ g.set_grid_snap_enabled(true);
+ let node = |id: &str, name: &str, col: f32, row: f32| GraphNode {
+ id: id.into(),
+ name: name.into(),
+ position: (col, row),
+ parameters: Vec::new(),
+ geom_visible: true,
+ node_type: String::new(),
+ inputs: 1,
+ outputs: 1,
+ };
+ g.set_nodes(&[node("a", "alpha", 0.0, 0.0), node("b", "beta", 1.0, 1.0)]);
+ g
+ }
+
+ #[test]
+ fn node_press_selects_arms_drag_and_commit_snaps_to_grid() {
+ let mut ctx = UiContext::new();
+ let mut g = two_nodes();
+ let (id, ptr) = (g.id(), g.as_ptr_mut());
+ ctx.register_widget(id, ptr);
+
+ // Node a occupies (100, 100, 80, 40). Press its body (away from ports/toggle).
+ assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
+ assert_eq!(g.selected_node(), Some(0));
+ assert!(Element::is_dragging(&g) && Element::draggable(&g));
+
+ // Drag one grid step right (step_x = 100): snap puts the node at column 1, but cell
+ // (1, 0) is free so it lands there.
+ Element::drag_begin(&mut g, 110.0, 120.0);
+ assert!(Element::drag_update(&mut g, 210.0, 120.0));
+ assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 120.0, &mut ctx));
+ assert!(!Element::is_dragging(&g));
+ assert_eq!(g.get_nodes()[0].position, (1.0, 0.0));
+
+ // An empty-space press clears the selection and is NOT consumed (legacy contract).
+ assert!(!g.mouse_input(MouseButton::Left, ElementState::Pressed, 700.0, 550.0, &mut ctx));
+ assert_eq!(g.selected_node(), None);
+ }
+
+ #[test]
+ fn port_click_starts_and_completes_a_connection() {
+ let mut ctx = UiContext::new();
+ let mut g = two_nodes();
+ let (id, ptr) = (g.id(), g.as_ptr_mut());
+ ctx.register_widget(id, ptr);
+
+ // Node a's output port sits at the bottom-center of (100,100,80,40) => (140, 140).
+ assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 140.0, 140.0, &mut ctx));
+ // Node b's input port: node b at (200, 160, 80, 40) => top-center (240, 160).
+ assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 240.0, 160.0, &mut ctx));
+
+ let elem: &mut dyn Element = &mut g;
+ let pending = elem
+ .as_graph_controller_mut()
+ .expect("Graph exposes GraphController")
+ .take_pending_connection();
+ assert_eq!(pending, Some(("b".to_string(), "alpha".to_string())));
+ }
+
+ #[test]
+ fn dual_geometry_views_stay_consistent() {
+ let mut g = two_nodes();
+ let ctx = UiContext::new();
+
+ // The plain view (designer path) and the rounded view (render_widget path) describe
+ // the same quads: the rounded view adds only the widget background entry up front.
+ let plain = Element::extra_quads(&g);
+ let rounded = Element::all_rounded_quads(&g, &ctx);
+ assert!(!plain.is_empty());
+ assert_eq!(rounded.len(), plain.len() + 1);
+ for ((px, py, pw, ph, pc), (rx, ry, rw, rh, _, rc, _)) in plain.iter().zip(rounded.iter().skip(1)) {
+ assert_eq!((px, py, pw, ph, pc), (rx, ry, rw, rh, rc));
+ }
+
+ // Node bodies carry the node corner radius in the rounded view.
+ let node_radius = crate::layout::graph_node_corner_radius();
+ let node_entries: Vec<_> = rounded
+ .iter()
+ .filter(|(qx, qy, qw, qh, ..)| g.is_node_rect(*qx, *qy, *qw, *qh))
+ .collect();
+ assert_eq!(node_entries.len(), 2, "both node bodies present");
+ for entry in node_entries {
+ assert_eq!(entry.4, node_radius);
+ assert_eq!(entry.6, (true, true, true, true));
+ }
+
+ // And `all_quads` stays empty so render_widget hosts (reading BOTH getters) never
+ // draw the geometry twice — the legacy Graph override's contract.
+ assert!(Element::all_quads(&g, &ctx).is_empty());
+ }
+}
diff --git a/src/widget/model.rs b/src/widget/model.rs
index bce2514..d1a4395 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -137,6 +137,33 @@ pub trait Paint {
/// widgets) store it here; the default discards it, leaving label drawing to the adapter's
/// base-label machinery.
fn sync_label(&mut self, _label: &str) {}
+
+ // --- Legacy dual-geometry escape hatch (transitional; Graph is the only user). Legacy
+ // hosts read DIFFERENT getters: the designer's raw render path draws `extra_quads` as
+ // PLAIN quads, while `render_widget` and the scene walk consume the rounded view
+ // (`all_rounded_quads` / `paint_self`). Legacy Graph served both by overriding all three
+ // getters. A migrated widget emits the rounded view from `paint`; when it also serves a
+ // plain view, the adapter returns it verbatim from `extra_quads` and empties `all_quads`
+ // (mirroring legacy Graph's highlight-only override) so render_widget-style hosts that
+ // read BOTH getters never draw the geometry twice. Dies with `Element`.
+
+ /// Whether this widget serves [`legacy_plain_quads`](Paint::legacy_plain_quads).
+ fn serves_legacy_plain_quads(&self) -> bool {
+ false
+ }
+
+ /// The plain-quad view of this widget's geometry for legacy `extra_quads` readers.
+ fn legacy_plain_quads(&self, _rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ Vec::new()
+ }
+
+ /// Clip rect `[x1, y1, x2, y2]` for this widget's text on the legacy bounded-text paths
+ /// (`text_labels_with_bounds` / `text_labels_with_font_and_bounds`). `None` (default) keeps
+ /// the legacy behavior: unbounded, except inside a scroll ancestor. Graph clips its node
+ /// names to its own rect.
+ fn text_bounds(&self, _rect: Rect) -> Option<[f32; 4]> {
+ None
+ }
}
/// What an event handler may reach beyond its own state — the RFC §3.5 `EventCtx`, grown as
@@ -599,6 +626,62 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
/// Text derived from the [`Paint::paint`] `Text` prims (one source of truth for what the
/// widget draws — inline labels, readouts), plus the base-label text for detached-label
/// widgets (drawn by the adapter, since the label lives on the base).
+ /// The legacy bounded-text getters, honoring [`Paint::text_bounds`] (Graph clips node
+ /// names to its own rect). Without it, `text_labels_with_bounds` matches the unbounded
+ /// `Element` default, and `text_labels_with_font_and_bounds` replicates the default's
+ /// scroll-ancestor walk (an overriding impl can no longer call it).
+ fn text_labels_with_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
+ let bounds = Paint::text_bounds(&self.inner, self.content_rect());
+ self.text_labels().into_iter().map(|l| (l, bounds)).collect()
+ }
+
+ fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+ let font = Paint::widget_font(&self.inner);
+ if let Some(bounds) = Paint::text_bounds(&self.inner, self.content_rect()) {
+ return self
+ .text_labels()
+ .into_iter()
+ .map(|l| (l, font.clone(), Some(bounds)))
+ .collect();
+ }
+
+ // Replica of the `Element` default: clip to the viewport of a ScrollBox/List ancestor.
+ let mut labels =
+ self.text_labels().into_iter().map(|l| (l, font.clone(), None::<[f32; 4]>)).collect::<Vec<_>>();
+ let mut curr = self.parent(ctx);
+ let mut scroll_box_bounds = None;
+ while let Some(parent_ptr) = curr {
+ let parent = unsafe { &*parent_ptr };
+ if let Some(scroll_box) = parent.as_any().downcast_ref::<crate::widget::ScrollBox>() {
+ let (sb_x, _, sb_w, _) = parent.rect();
+ let view_min = scroll_box.viewport_y + 4.0;
+ let view_max = scroll_box.viewport_y + scroll_box.viewport_h - 4.0;
+ scroll_box_bounds = Some([sb_x, view_min, sb_x + sb_w, view_max]);
+ break;
+ } else if let Some(list) = parent.as_any().downcast_ref::<crate::widget::List>() {
+ let (sb_x, _, sb_w, _) = parent.rect();
+ let view_min = list.scroll_box.viewport_y + 4.0;
+ let view_max = list.scroll_box.viewport_y + list.scroll_box.viewport_h - 4.0;
+ scroll_box_bounds = Some([sb_x, view_min, sb_x + sb_w, view_max]);
+ break;
+ }
+ curr = parent.parent(ctx);
+ }
+ if let Some(sb_bounds) = scroll_box_bounds {
+ for item in &mut labels {
+ if let Some(ref mut b) = item.2 {
+ b[0] = b[0].max(sb_bounds[0]);
+ b[1] = b[1].max(sb_bounds[1]);
+ b[2] = b[2].min(sb_bounds[2]);
+ b[3] = b[3].min(sb_bounds[3]);
+ } else {
+ item.2 = Some(sb_bounds);
+ }
+ }
+ }
+ labels
+ }
+
fn text_labels(&self) -> Vec<TextLabel> {
if !self.visible() {
return Vec::new();
@@ -646,6 +729,9 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
if !self.visible() {
return Vec::new();
}
+ if Paint::serves_legacy_plain_quads(&self.inner) {
+ return Paint::legacy_plain_quads(&self.inner, self.content_rect());
+ }
self.painted_prims()
.into_iter()
.filter_map(|prim| match prim {
@@ -655,6 +741,18 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
.collect()
}
+ /// When the widget serves a legacy plain-quad view, its geometry reaches
+ /// `render_widget`-style hosts (which read BOTH quad getters) through `all_rounded_quads`
+ /// only — `all_quads` must stay empty or they draw it twice. Mirrors legacy Graph's
+ /// highlight-only `all_quads` override. Otherwise: the `Element` default minus the shared
+ /// highlight (suppressed for all adapted widgets via `highlight_quad -> None`).
+ fn all_quads(&self, _ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ if Paint::serves_legacy_plain_quads(&self.inner) {
+ return Vec::new();
+ }
+ self.extra_quads()
+ }
+
fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
if !self.visible() {
return Vec::new();