GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Update system configuration and interface modules
Cargo.lock | 1 +
Cargo.toml | 1 +
protocol/clear-inspector-v1.xml | 6 +-
src/color.rs | 31 ++
src/engine.rs | 8 +-
src/layout.rs | 48 +-
src/main.rs | 72 ++-
src/shader.wgsl | 24 +-
src/widget.rs | 1146 +++++++++++++++++++++++++++++++++++----
9 files changed, 1227 insertions(+), 110 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 8d0aa75..4286121 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -174,6 +174,7 @@ dependencies = [
"calloop",
"calloop-wayland-source",
"glyphon",
+ "libc",
"pollster",
"raw-window-handle",
"resvg",
diff --git a/Cargo.toml b/Cargo.toml
index c106194..6607100 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,4 +23,5 @@ wayland-scanner = "0.31"
xkeysym = "0.2"
resvg = "0.41.0"
serde = { version = "1.0", features = ["derive"] }
+libc = "0.2"
diff --git a/protocol/clear-inspector-v1.xml b/protocol/clear-inspector-v1.xml
index b321965..4dca611 100644
--- a/protocol/clear-inspector-v1.xml
+++ b/protocol/clear-inspector-v1.xml
@@ -27,7 +27,8 @@
The state is serialized as a JSON string containing widget layout and state data.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
- <arg name="state" type="string" summary="JSON serialized state of the application"/>
+ <arg name="fd" type="fd" summary="JSON state file descriptor"/>
+ <arg name="len" type="uint" summary="JSON state length in bytes"/>
</request>
<request name="get_inspected_surfaces">
@@ -42,7 +43,8 @@
<arg name="y" type="int" summary="absolute screen Y coordinate"/>
<arg name="width" type="int" summary="surface width"/>
<arg name="height" type="int" summary="surface height"/>
- <arg name="state" type="string" summary="JSON state of the application"/>
+ <arg name="fd" type="fd" summary="JSON state file descriptor"/>
+ <arg name="len" type="uint" summary="JSON state length in bytes"/>
</event>
<event name="inspected_surface_done">
diff --git a/src/color.rs b/src/color.rs
index 26fd7e6..80ebfe8 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -28,6 +28,37 @@ use std::sync::RwLock;
static SLIDER_TRACK_COLOR: RwLock<[f32; 4]> = RwLock::new(SLIDER_TRACK);
static PAGE_LOW_COLOR: RwLock<[f32; 4]> = RwLock::new([0.0600316, 0.0600316, 0.080219, 1.0]);
static COLOR_BORDERS_COLOR: RwLock<[f32; 4]> = RwLock::new([0.2039, 0.2039, 0.2530, 1.0]);
+static NODE_COLOR: RwLock<[f32; 4]> = RwLock::new(NODE_IDLE);
+
+pub fn node_color() -> [f32; 4] {
+ *NODE_COLOR.read().unwrap()
+}
+
+pub fn set_node_color(color: [f32; 4]) {
+ if let Ok(mut lock) = NODE_COLOR.write() {
+ *lock = color;
+ }
+}
+
+pub fn node_selected_color() -> [f32; 4] {
+ let base = node_color();
+ [
+ (base[0] + 0.10).min(1.0),
+ (base[1] + 0.20).min(1.0),
+ (base[2] + 0.20).min(1.0),
+ base[3]
+ ]
+}
+
+pub fn node_drag_color() -> [f32; 4] {
+ let base = node_color();
+ [
+ (base[0] + 0.20).min(1.0),
+ (base[1] + 0.35).min(1.0),
+ (base[2] + 0.30).min(1.0),
+ base[3]
+ ]
+}
pub fn page_low_color() -> [f32; 4] {
use std::sync::Once;
diff --git a/src/engine.rs b/src/engine.rs
index a269f5b..1547e01 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -735,7 +735,11 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
_keyboard: &wl_keyboard::WlKeyboard,
_surface: &wl_surface::WlSurface,
_serial: u32,
- ) {}
+ ) {
+ self.pressed_key = None;
+ self.ctrl_pressed = false;
+ self.shift_pressed = false;
+ }
fn press_key(
&mut self,
@@ -789,6 +793,8 @@ impl<A: Application> EngineState<A> {
_ => {
if let Some(ref text) = event.utf8 {
Key::Character(text.clone())
+ } else if let Some(ch) = event.keysym.key_char() {
+ Key::Character(ch.to_string())
} else {
return;
}
diff --git a/src/layout.rs b/src/layout.rs
index 8e0c5ff..4a9dc49 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -8,7 +8,32 @@ pub trait RenderTarget {
}
}
-pub fn render_widget<T: Widget>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32) {
+pub struct PopoverCollector {
+ pub rects: Vec<([f32; 4], f32, f32, f32, f32)>,
+ pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>)>,
+}
+
+impl PopoverCollector {
+ pub fn new() -> Self {
+ Self { rects: Vec::new(), texts: Vec::new() }
+ }
+}
+
+impl RenderTarget for PopoverCollector {
+ fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
+ self.rects.push((color, x, y, w, h));
+ }
+
+ fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
+ self.texts.push((content.to_string(), size, x, y, color, None));
+ }
+
+ fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
+ self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string())));
+ }
+}
+
+pub fn render_widget<T: Widget + 'static>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32) {
w.set_rect(x, y, ww, wh);
for (qx, qy, qw, qh, qc) in w.all_quads() {
pc.rect(qc, qx, qy, qw, qh);
@@ -27,6 +52,17 @@ pub fn render_widget<T: Widget>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y:
pc.text(&label.text, label.x, label.y, label.font_size, color_f32);
}
}
+ if w.popover_rect().is_some() {
+ crate::widget::popovers::register(w);
+ }
+}
+
+pub fn render_popovers(pc: &mut dyn RenderTarget) {
+ for popover_ptr in crate::widget::popovers::get_active() {
+ unsafe {
+ (*popover_ptr).render_popover(pc);
+ }
+ }
}
pub struct UiFrame;
@@ -35,6 +71,7 @@ impl UiFrame {
pub fn start(scroll_offset: f32) -> Self {
crate::widget::hover_animation::reset_frame_registration();
crate::widget::hover_animation::set_scroll_offset(scroll_offset);
+ crate::widget::popovers::clear();
Self
}
@@ -43,6 +80,7 @@ impl UiFrame {
if let Some((qx, qy, qw, qh, qc)) = crate::widget::hover_animation::get_quad() {
pc.rect(qc, qx, qy, qw, qh);
}
+ render_popovers(pc);
}
}
@@ -100,7 +138,7 @@ impl Column {
pc.text(text, x, y, font_size, color);
}
- pub fn widget<T: Widget>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, wh: f32) {
+ pub fn widget<T: Widget + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, wh: f32) {
let x = self.ax(x_off);
let y = self.ay();
w.set_row_rect(self.ox + self.cx + 8.0, self.cw - 16.0);
@@ -145,7 +183,7 @@ impl<'a> Row<'a> {
self.cursor_x += width + self.spacing;
}
- pub fn widget<T: Widget>(&mut self, w: &mut T, ww: f32, wh: f32) {
+ pub fn widget<T: Widget + 'static>(&mut self, w: &mut T, ww: f32, wh: f32) {
render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh);
self.cursor_x += ww + self.spacing;
}
@@ -182,7 +220,7 @@ impl Section {
pc.text(text, self.ax(x_off), self.ay() + y_off, font_size, color);
}
- pub fn widget<T: Widget>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, wh: f32) {
+ pub fn widget<T: Widget + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, wh: f32) {
w.set_row_rect(self.left + Self::ROW_PADDING_X, self.cw - 2.0 * Self::ROW_PADDING_X);
let top_room = w.top_room();
self.content_y += top_room;
@@ -267,7 +305,7 @@ pub struct VStack<'a> {
}
impl<'a> VStack<'a> {
- pub fn add_widget<T: Widget>(&mut self, w: &mut T, ww: f32, wh: f32) {
+ pub fn add_widget<T: Widget + 'static>(&mut self, w: &mut T, ww: f32, wh: f32) {
self.section.widget(self.pc, w, Section::DEFAULT_MARGIN_X, ww, wh);
self.section.spacing(self.spacing);
}
diff --git a/src/main.rs b/src/main.rs
index 841006f..1a5de51 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -321,6 +321,19 @@ impl State {
verts.extend(quad_vertices(qx, qy, qw, qh, sw, sh, qc));
}
}
+
+ // Draw popover quads on top
+ let mut popover_pc = clear_ui::layout::PopoverCollector::new();
+ for &i in &draw_order {
+ let w = &self.widgets[i];
+ if w.popover_rect().is_some() {
+ w.render_popover(&mut popover_pc);
+ }
+ }
+ for (qc, qx, qy, qw, qh) in popover_pc.rects {
+ verts.extend(quad_vertices(qx, qy, qw, qh, sw, sh, qc));
+ }
+
if clear_ui::widget::context_menu::is_visible() {
for (qx, qy, qw, qh, qc) in clear_ui::widget::context_menu::extra_quads() {
verts.extend(quad_vertices(qx, qy, qw, qh, sw, sh, qc));
@@ -451,6 +464,38 @@ impl State {
});
}
+ // Draw popover texts on top
+ let mut popover_pc = clear_ui::layout::PopoverCollector::new();
+ for w in &self.widgets {
+ if w.popover_rect().is_some() {
+ w.render_popover(&mut popover_pc);
+ }
+ }
+ let mut popover_buffers = Vec::new();
+ for (t, size, _x, _y, _tc, _font_opt) in &popover_pc.texts {
+ popover_buffers.push(make_text_buffer(font_system, t, *size));
+ }
+ for (buf, (_, size, x, y, tc, _font_opt)) in popover_buffers.iter().zip(popover_pc.texts.iter()) {
+ areas.push(TextArea {
+ buffer: buf,
+ left: *x * scale_f32,
+ top: *y * scale_f32,
+ scale: scale_f32,
+ bounds: TextBounds {
+ left: 0,
+ top: 0,
+ right: *physical_width as i32,
+ bottom: *physical_height as i32,
+ },
+ default_color: glyphon::Color::rgb(
+ (tc[0] * 255.0) as u8,
+ (tc[1] * 255.0) as u8,
+ (tc[2] * 255.0) as u8,
+ ),
+ custom_glyphs: &[],
+ });
+ }
+
text_renderer
.prepare(device, queue, font_system, text_atlas, text_viewport, areas, swash_cache)
.unwrap();
@@ -917,6 +962,9 @@ impl KeyboardHandler for AppState {
_surface: &wl_surface::WlSurface,
_serial: u32,
) {
+ self.pressed_key = None;
+ self.ctrl_pressed = false;
+ self.shift_pressed = false;
}
fn press_key(
@@ -972,6 +1020,8 @@ impl AppState {
_ => {
if let Some(ref text) = event.utf8 {
Key::Character(text.clone())
+ } else if let Some(ch) = event.keysym.key_char() {
+ Key::Character(ch.to_string())
} else {
return;
}
@@ -1203,6 +1253,22 @@ fn main() {
}
}
+fn create_memfd_with_data(name: &str, data: &[u8]) -> std::io::Result<std::os::unix::io::RawFd> {
+ use std::io::{Seek, Write};
+ use std::os::unix::io::FromRawFd;
+ use std::os::unix::io::IntoRawFd;
+
+ let c_name = std::ffi::CString::new(name).unwrap();
+ let fd = unsafe { libc::memfd_create(c_name.as_ptr(), libc::MFD_CLOEXEC) };
+ if fd < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
+ file.write_all(data)?;
+ file.seek(std::io::SeekFrom::Start(0))?;
+ Ok(file.into_raw_fd())
+}
+
if app.redraw {
app.redraw = false;
if let Some(state) = &mut app.state {
@@ -1210,7 +1276,11 @@ fn main() {
if let Some(ref inspector) = app.inspector {
if let Some(ref surface) = app.surface {
let json = clear_ui::widget::serialize_widgets(&state.widgets);
- inspector.update_state(surface, json);
+ if let Ok(raw_fd) = create_memfd_with_data("clear_ui_state", json.as_bytes()) {
+ use std::os::unix::io::{FromRawFd, AsFd};
+ let file = unsafe { std::fs::File::from_raw_fd(raw_fd) };
+ inspector.update_state(surface, file.as_fd(), json.len() as u32);
+ }
}
}
}
diff --git a/src/shader.wgsl b/src/shader.wgsl
index 8493b48..b2b2708 100644
--- a/src/shader.wgsl
+++ b/src/shader.wgsl
@@ -1,6 +1,8 @@
struct VertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) color: vec4f,
+ @location(1) ndc_position: vec2f,
+ @location(2) @interpolate(flat) is_background: f32,
}
@vertex
@@ -11,10 +13,30 @@ fn vs_main(
var out: VertexOutput;
out.clip_position = vec4f(position, 0.0, 1.0);
out.color = color;
+ out.ndc_position = position;
+
+ // Check if the vertex is exactly at the NDC boundaries (meaning it is the background quad)
+ if (abs(position.x) > 0.999 && abs(position.y) > 0.999) {
+ out.is_background = 1.0;
+ } else {
+ out.is_background = 0.0;
+ }
+
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
- return in.color;
+ var final_color = in.color;
+ if (in.is_background > 0.5) {
+ // Feather the background quad edges
+ let dist_x = 1.0 - abs(in.ndc_position.x);
+ let dist_y = 1.0 - abs(in.ndc_position.y);
+ let min_dist = min(dist_x, dist_y);
+
+ let feather = 0.15; // Size of the feathering zone (15% of the half-width/height)
+ let fade = smoothstep(0.0, 1.0, min_dist / feather);
+ final_color.a = final_color.a * fade;
+ }
+ return final_color;
}
diff --git a/src/widget.rs b/src/widget.rs
index 1ff1912..c108fff 100644
--- a/src/widget.rs
+++ b/src/widget.rs
@@ -207,6 +207,59 @@ pub mod focus {
}
}
+pub mod popovers {
+ use super::Widget;
+ use std::cell::RefCell;
+
+ thread_local! {
+ static ACTIVE_POPOVERS: RefCell<Vec<*const (dyn Widget + 'static)>> = RefCell::new(Vec::new());
+ }
+
+ pub fn clear() {
+ ACTIVE_POPOVERS.with(|list| {
+ list.borrow_mut().clear();
+ });
+ }
+
+ pub fn register(w: &(dyn Widget + 'static)) {
+ ACTIVE_POPOVERS.with(|list| {
+ let ptr = w as *const (dyn Widget + 'static);
+ let mut list = list.borrow_mut();
+ if !list.contains(&ptr) {
+ list.push(ptr);
+ }
+ });
+ }
+
+ pub fn get_active() -> Vec<*const (dyn Widget + 'static)> {
+ ACTIVE_POPOVERS.with(|list| {
+ list.borrow().clone()
+ })
+ }
+
+ pub fn is_coordinate_covered(query_address: usize, px: f32, py: f32) -> bool {
+ ACTIVE_POPOVERS.with(|list| {
+ let list = list.borrow();
+ for popover_ptr in list.iter() {
+ let current_data = *popover_ptr as *const () as usize;
+ if query_address == current_data {
+ continue;
+ }
+ unsafe {
+ if let Some(popover) = popover_ptr.as_ref() {
+ if let Some((x, y, width, height)) = popover.popover_rect() {
+ if px >= x && px <= x + width && py >= y && py <= y + height {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ false
+ })
+ }
+}
+
pub mod hover_animation {
use std::cell::RefCell;
use crate::colors;
@@ -658,6 +711,48 @@ impl TextLabel {
weight_sum * font_size
}
+ pub fn curved_layout(
+ text: &str,
+ cx: f32,
+ cy: f32,
+ r: f32,
+ start_angle: f32,
+ end_angle: f32,
+ font_size: f32,
+ color: [u8; 3],
+ ) -> Vec<TextLabel> {
+ let mut labels = Vec::new();
+ let char_widths: Vec<f32> = text.chars().map(|c| {
+ Self::estimate_width(&c.to_string(), font_size)
+ }).collect();
+ let total_width: f32 = char_widths.iter().sum();
+
+ let mid_angle = (start_angle + end_angle) / 2.0;
+ let angular_width = total_width / r;
+ let text_start_angle = mid_angle - angular_width / 2.0;
+
+ let mut current_angle = text_start_angle;
+ for (i, c) in text.chars().enumerate() {
+ let cw = char_widths[i];
+ let dtheta = cw / r;
+ let char_center_angle = current_angle + dtheta / 2.0;
+
+ let x = cx + r * char_center_angle.cos() - cw / 2.0;
+ let y = cy + r * char_center_angle.sin() - font_size / 2.0;
+
+ labels.push(TextLabel {
+ text: c.to_string(),
+ x,
+ y,
+ font_size,
+ color,
+ });
+
+ current_angle += dtheta;
+ }
+ labels
+ }
+
pub fn is_covered_by(&self, px: f32, py: f32, pw: f32, ph: f32) -> bool {
let text_w = Self::estimate_width(&self.text, self.font_size);
let x_overlap = self.x <= px + pw && (self.x + text_w) >= px;
@@ -710,6 +805,14 @@ impl WidgetBase {
}
}
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct GraphNode {
+ pub name: String,
+ pub position: (f32, f32), // (column, row)
+ pub parameters: Vec<(String, String, String)>, // (name, value, type)
+ pub geom_visible: bool,
+}
+
pub trait Widget {
fn base(&self) -> Option<&WidgetBase> { None }
fn base_mut(&mut self) -> Option<&mut WidgetBase> { None }
@@ -722,6 +825,10 @@ pub trait Widget {
}
}
+ fn label(&self) -> Option<String> { None }
+
+ fn set_curved_circle(&mut self, _circle: Option<(f32, f32, f32)>) {}
+
fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
if let Some(b) = self.base_mut() {
b.x = x;
@@ -739,6 +846,9 @@ pub trait Widget {
}
fn hit_test(&self, px: f32, py: f32) -> bool {
+ if crate::widget::popovers::is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+ return false;
+ }
let (x, y, w, h) = self.rect();
let (hx, hw) = if let Some(b) = self.base() {
if b.row_w > 0.0 {
@@ -757,6 +867,11 @@ pub trait Widget {
fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
hover_animation::set_cursor_pos(px, py);
+ if crate::widget::popovers::is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+ let was = self.hovered();
+ self.set_hovered(false);
+ return was;
+ }
self.on_cursor_moved(px, py)
}
@@ -827,6 +942,7 @@ pub trait Widget {
fn draggable(&self) -> bool { false }
fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { Vec::new() }
+ fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> { Vec::new() }
fn all_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
let mut quads = self.extra_quads();
if let Some(hq) = self.highlight_quad() {
@@ -867,6 +983,7 @@ pub trait Widget {
0.0
}
fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> { None }
+ fn render_popover(&self, _pc: &mut dyn crate::layout::RenderTarget) {}
fn set_text(&mut self, text: &str) {
if let Some(b) = self.base_mut() {
b.label = Some(text.to_string());
@@ -919,12 +1036,25 @@ pub trait Widget {
fn set_spreadsheet_data(&mut self, _headers: Vec<String>, _rows: Vec<Vec<String>>) {}
fn tick(&mut self, _dt: f32) -> bool { false }
+ fn set_nodes(&mut self, _nodes: &[GraphNode]) {}
+ fn get_nodes(&self) -> Vec<GraphNode> { vec![] }
+ fn selected_node(&self) -> Option<usize> { None }
+ fn set_selected_node(&mut self, _idx: Option<usize>) {}
+ fn double_clicked_node(&self) -> Option<usize> { None }
+ fn clear_double_clicked_node(&mut self) {}
+ fn set_grid_snap_enabled(&mut self, _enabled: bool) {}
+ fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)> { None }
+
fn parent(&self) -> Option<*mut (dyn Widget + 'static)> { None }
fn set_parent(&mut self, _parent: Option<*mut (dyn Widget + 'static)>) {}
fn children(&self) -> Vec<*mut (dyn Widget + 'static)> { vec![] }
fn add_child(&mut self, _child: *mut (dyn Widget + 'static)) {}
fn clear_children(&mut self) {}
fn z_index(&self) -> i32 { 0 }
+ fn set_center_items(&mut self, _center: bool) {}
+ fn menu_items(&self) -> Vec<String> { vec![] }
+ fn menu_item_checked(&self) -> Vec<Option<bool>> { vec![] }
+ fn is_vertical(&self) -> bool { false }
}
#[derive(Clone)]
@@ -1287,11 +1417,19 @@ impl Widget for ParametersBg {
fn set_hovered(&mut self, v: bool) { self.hovered = v; }
fn hovered(&self) -> bool { self.hovered }
fn hit_test(&self, px: f32, py: f32) -> bool {
+ if crate::widget::popovers::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 set_display_params(&mut self, params: &[(String, String, String)]) {
self.display_params = params.to_vec();
+ self.focused_param = None;
+ }
+
+ fn unfocus(&mut self) {
+ self.focused_param = None;
}
fn node_params(&self) -> Vec<(String, String, String)> {
@@ -1565,6 +1703,9 @@ pub struct MenuBar {
pub visible: bool,
pub focused: bool,
pub z_level: i32,
+ pub center_items: bool,
+ pub curved_circle: Option<(f32, f32, f32)>,
+ pub title_pos: Option<(f32, f32)>,
}
impl MenuBar {
@@ -1586,9 +1727,17 @@ impl MenuBar {
visible: true,
focused: false,
z_level: 100,
+ center_items: false,
+ curved_circle: None,
+ title_pos: None,
}
}
+ pub fn with_center_items(mut self, center: bool) -> Self {
+ self.center_items = center;
+ self
+ }
+
pub fn with_title(mut self, title: &str) -> Self {
self.title = title.to_string();
self
@@ -1664,6 +1813,15 @@ impl Widget for MenuBar {
}
}
+ fn set_curved_circle(&mut self, circle: Option<(f32, f32, f32)>) {
+ self.curved_circle = circle;
+ if circle.is_none() {
+ for menu in &mut self.menus {
+ menu.curved_arc = None;
+ }
+ }
+ }
+
fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
self.x = x;
self.y = y;
@@ -1684,15 +1842,70 @@ impl Widget for MenuBar {
cy += ih;
}
} else {
- let mut cx = 8.0;
- if !self.title.is_empty() {
- cx += self.title.len() as f32 * 7.5 + 24.0;
- }
- for menu in &mut self.menus {
- let iw = menu.active_title().len() as f32 * 7.5 + 16.0;
- menu.set_rect(x + cx, y, iw, h);
- menu.set_parent(Some(parent_ptr));
- cx += iw;
+ if let Some((ccx, ccy, ccr)) = self.curved_circle {
+ let r_mid = ccr - h / 2.0;
+ let mut total_width = 8.0;
+ if !self.title.is_empty() {
+ total_width += self.title.len() as f32 * 7.5 + 24.0;
+ }
+ for menu in &self.menus {
+ total_width += menu.active_title().len() as f32 * 7.5 + 16.0;
+ }
+
+ let total_angular_width = total_width / r_mid;
+ let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
+ let mut current_angle = start_angle;
+
+ if !self.title.is_empty() {
+ let title_w = self.title.len() as f32 * 7.5 + 24.0;
+ let dtheta_title = title_w / r_mid;
+ let theta_title = current_angle + dtheta_title / 2.0;
+
+ let tx = ccx + r_mid * theta_title.cos() - title_w / 2.0 + 8.0;
+ let ty = ccy + r_mid * theta_title.sin() - h / 2.0;
+ self.title_pos = Some((tx, ty));
+ current_angle += dtheta_title;
+ } else {
+ self.title_pos = None;
+ }
+
+ for menu in &mut self.menus {
+ let iw = menu.active_title().len() as f32 * 7.5 + 16.0;
+ let dtheta_menu = iw / r_mid;
+ let theta_menu = current_angle + dtheta_menu / 2.0;
+
+ let mx = ccx + r_mid * theta_menu.cos() - iw / 2.0;
+ let my = ccy + r_mid * theta_menu.sin() - h / 2.0;
+
+ menu.set_rect(mx, my, iw, h);
+ menu.set_parent(Some(parent_ptr));
+ menu.curved_arc = Some((ccx, ccy, ccr, h, current_angle, current_angle + dtheta_menu));
+ current_angle += dtheta_menu;
+ }
+ } else {
+ self.title_pos = None;
+ let mut cx = 8.0;
+ if self.center_items {
+ let mut total_width = 8.0;
+ if !self.title.is_empty() {
+ total_width += self.title.len() as f32 * 7.5 + 24.0;
+ }
+ for menu in &self.menus {
+ total_width += menu.active_title().len() as f32 * 7.5 + 16.0;
+ }
+ if self.w > total_width {
+ cx = (self.w - total_width) / 2.0;
+ }
+ }
+ if !self.title.is_empty() {
+ cx += self.title.len() as f32 * 7.5 + 24.0;
+ }
+ for menu in &mut self.menus {
+ let iw = menu.active_title().len() as f32 * 7.5 + 16.0;
+ menu.set_rect(x + cx, y, iw, h);
+ menu.set_parent(Some(parent_ptr));
+ cx += iw;
+ }
}
}
}
@@ -1719,6 +1932,43 @@ impl Widget for MenuBar {
if !self.visible {
return false;
}
+ if crate::widget::popovers::is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+ return false;
+ }
+ if let Some((ccx, ccy, ccr)) = self.curved_circle {
+ let dx = px - ccx;
+ let dy = py - ccy;
+ let dist = (dx * dx + dy * dy).sqrt();
+ if dist >= ccr - self.h && dist <= ccr {
+ let angle = dy.atan2(dx);
+ let mut norm_angle = angle;
+ if norm_angle < 0.0 {
+ norm_angle += 2.0 * std::f32::consts::PI;
+ }
+
+ let r_mid = ccr - self.h / 2.0;
+ let mut total_width = 8.0;
+ if !self.title.is_empty() {
+ total_width += self.title.len() as f32 * 7.5 + 24.0;
+ }
+ for menu in &self.menus {
+ total_width += menu.active_title().len() as f32 * 7.5 + 16.0;
+ }
+ let total_angular_width = total_width / r_mid;
+ let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
+ let end_angle = 1.5 * std::f32::consts::PI + total_angular_width / 2.0;
+
+ if norm_angle >= start_angle && norm_angle <= end_angle {
+ return true;
+ }
+ }
+ for menu in &self.menus {
+ if menu.hit_test(px, py) {
+ return true;
+ }
+ }
+ return false;
+ }
let (rx, ry, rw, rh) = self.rect();
if px >= rx && px <= rx + rw && py >= ry && py <= ry + rh {
return true;
@@ -1738,12 +1988,21 @@ impl Widget for MenuBar {
let (rx, ry, rw, rh) = self.rect();
self.set_rect(rx, ry, rw, rh);
+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/home/lsgalante/Dropbox/Clear/debug.txt") {
+ use std::io::Write;
+ let _ = writeln!(f, "MenuBar::on_cursor_moved px={}, py={} curved={:?} rect={:?}", px, py, self.curved_circle, (rx, ry, rw, rh));
+ }
+
let mut changed = false;
self.hovered_menu = None;
for (idx, menu) in self.menus.iter_mut().enumerate() {
if menu.cursor_moved(px, py) {
changed = true;
}
+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/home/lsgalante/Dropbox/Clear/debug.txt") {
+ use std::io::Write;
+ let _ = writeln!(f, " Menu[{}] active_title={} curved={:?} hovered={} hit={}", idx, menu.active_title(), menu.curved_arc, menu.hovered(), menu.hit_test(px, py));
+ }
if menu.hovered() {
self.hovered_menu = Some(idx);
}
@@ -1760,7 +2019,8 @@ impl Widget for MenuBar {
let mut changed = false;
for menu in &mut self.menus {
- if menu.mouse_input(button, state, px, py) {
+ let res = menu.mouse_input(button, state, px, py);
+ if res {
changed = true;
}
}
@@ -1847,19 +2107,70 @@ impl Widget for MenuBar {
quads
}
+ fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
+ if !self.visible {
+ return Vec::new();
+ }
+ let mut arcs = Vec::new();
+ for menu in &self.menus {
+ arcs.extend(menu.extra_arcs());
+ }
+ arcs
+ }
+
fn text_labels(&self) -> Vec<TextLabel> {
if !self.visible {
return Vec::new();
}
let mut labels = Vec::new();
- if !self.title.is_empty() {
- labels.push(TextLabel {
- text: self.title.clone(),
- x: self.x + 8.0,
- y: self.y + 7.0,
- font_size: 12.0,
- color: [0xaa, 0xaa, 0xbb],
- });
+ if let Some((ccx, ccy, ccr)) = self.curved_circle {
+ let r_mid = ccr - self.h / 2.0;
+ let mut total_width = 8.0;
+ if !self.title.is_empty() {
+ total_width += self.title.len() as f32 * 7.5 + 24.0;
+ }
+ for menu in &self.menus {
+ total_width += menu.active_title().len() as f32 * 7.5 + 16.0;
+ }
+ let total_angular_width = total_width / r_mid;
+ let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
+ let mut current_angle = start_angle;
+
+ if !self.title.is_empty() {
+ let title_w = self.title.len() as f32 * 7.5 + 24.0;
+ let dtheta_title = title_w / r_mid;
+ labels.extend(TextLabel::curved_layout(
+ &self.title,
+ ccx, ccy, r_mid,
+ current_angle, current_angle + dtheta_title,
+ 12.0,
+ [0xaa, 0xaa, 0xbb],
+ ));
+ current_angle += dtheta_title;
+ }
+ } else {
+ let mut start_x = 8.0;
+ if self.center_items {
+ let mut total_width = 8.0;
+ if !self.title.is_empty() {
+ total_width += self.title.len() as f32 * 7.5 + 24.0;
+ }
+ for menu in &self.menus {
+ total_width += menu.active_title().len() as f32 * 7.5 + 16.0;
+ }
+ if self.w > total_width {
+ start_x = (self.w - total_width) / 2.0;
+ }
+ }
+ if !self.title.is_empty() {
+ labels.push(TextLabel {
+ text: self.title.clone(),
+ x: self.x + start_x,
+ y: self.y + 7.0,
+ font_size: 12.0,
+ color: [0xaa, 0xaa, 0xbb],
+ });
+ }
}
for menu in &self.menus {
labels.extend(menu.text_labels());
@@ -1888,6 +2199,10 @@ impl Widget for MenuBar {
fn z_index(&self) -> i32 {
self.z_level
}
+
+ fn set_center_items(&mut self, center: bool) {
+ self.center_items = center;
+ }
}
impl Drop for MenuBar {
@@ -1912,6 +2227,7 @@ pub struct Menu {
was_open: Option<usize>,
pub parent: Option<*mut (dyn Widget + 'static)>,
pub children: Vec<*mut (dyn Widget + 'static)>,
+ pub curved_arc: Option<(f32, f32, f32, f32, f32, f32)>,
}
impl Menu {
@@ -1931,6 +2247,7 @@ impl Menu {
was_open: None,
parent: None,
children: Vec::new(),
+ curved_arc: None,
}
}
@@ -1968,6 +2285,10 @@ impl Widget for Menu {
(self.x, self.y, self.w, self.h)
}
+ fn label(&self) -> Option<String> {
+ Some(self.active_title().to_string())
+ }
+
fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
self.x = x;
self.y = y;
@@ -1988,6 +2309,31 @@ impl Widget for Menu {
}
fn hit_test(&self, px: f32, py: f32) -> bool {
+ if crate::widget::popovers::is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+ return false;
+ }
+ if let Some((cx, cy, r, thickness, start_angle, end_angle)) = self.curved_arc {
+ let dx = px - cx;
+ let dy = py - cy;
+ let dist = (dx * dx + dy * dy).sqrt();
+ if dist >= r - thickness && dist <= r {
+ let angle = dy.atan2(dx);
+ let mut norm_angle = angle;
+ if norm_angle < 0.0 {
+ norm_angle += 2.0 * std::f32::consts::PI;
+ }
+ if norm_angle >= start_angle && norm_angle <= end_angle {
+ return true;
+ }
+ }
+ if self.open {
+ let (dx, dy, dw, dh) = self.dropdown_rect();
+ if dh > 0.0 && px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+ return true;
+ }
+ }
+ return false;
+ }
let (rx, ry, rw, rh) = self.rect();
if px >= rx && px <= rx + rw && py >= ry && py <= ry + rh {
return true;
@@ -2029,22 +2375,10 @@ impl Widget for Menu {
return false;
}
- let (rx, ry, rw, rh) = self.rect();
- if px >= rx && px < rx + rw && py >= ry && py < ry + rh {
- if self.was_open == Some(0) || self.open {
- self.open = false;
- self.was_open = None;
- } else {
- self.open = true;
- self.was_open = None;
- focus::set_focused(self);
- }
- return true;
- }
-
+ // Check dropdown click if open
if self.open {
let (dx, dy, dw, dh) = self.dropdown_rect();
- if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+ if dh > 0.0 && px >= dx && px < dx + dw && py >= dy && py < dy + dh {
let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
if di < self.items.len() {
self.clicked_item = Some(di);
@@ -2054,7 +2388,16 @@ impl Widget for Menu {
}
}
- false
+ // Since we passed hit_test and didn't click dropdown, it's a click on the header title
+ if self.was_open == Some(0) || self.open {
+ self.open = false;
+ self.was_open = None;
+ } else {
+ self.open = true;
+ self.was_open = None;
+ focus::set_focused(self);
+ }
+ true
}
fn focus(&mut self) {
@@ -2099,7 +2442,7 @@ impl Widget for Menu {
fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
let mut quads = Vec::new();
- if self.hovering && !self.open {
+ if self.hovering && !self.open && self.curved_arc.is_none() {
quads.push((self.x, self.y, self.w, self.h, colors::PANEL_MENU_HOVER));
}
if self.open {
@@ -2114,15 +2457,36 @@ impl Widget for Menu {
quads
}
+ fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
+ let mut arcs = Vec::new();
+ if let Some((cx, cy, r, thickness, start_angle, end_angle)) = self.curved_arc {
+ if self.hovering && !self.open {
+ arcs.push((cx, cy, r, thickness, start_angle, end_angle, colors::PANEL_MENU_HOVER));
+ }
+ }
+ arcs
+ }
+
fn text_labels(&self) -> Vec<TextLabel> {
let mut labels = Vec::new();
- labels.push(TextLabel {
- text: self.active_title().to_string(),
- x: self.x + 8.0,
- y: self.y + 7.0,
- font_size: 12.0,
- color: [0xcc, 0xcc, 0xd4],
- });
+ if let Some((cx, cy, r, thickness, start_angle, end_angle)) = self.curved_arc {
+ let r_mid = r - thickness / 2.0;
+ labels.extend(TextLabel::curved_layout(
+ &self.active_title(),
+ cx, cy, r_mid,
+ start_angle, end_angle,
+ 12.0,
+ [0xcc, 0xcc, 0xd4],
+ ));
+ } else {
+ labels.push(TextLabel {
+ text: self.active_title().to_string(),
+ x: self.x + 8.0,
+ y: self.y + 7.0,
+ font_size: 12.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ }
if self.open {
let (dx, dy, _, _) = self.dropdown_rect();
for (i, item) in self.items.iter().enumerate() {
@@ -2165,6 +2529,18 @@ impl Widget for Menu {
fn z_index(&self) -> i32 {
100
}
+
+ fn menu_items(&self) -> Vec<String> {
+ self.items.clone()
+ }
+
+ fn menu_item_checked(&self) -> Vec<Option<bool>> {
+ self.item_checked.clone()
+ }
+
+ fn is_vertical(&self) -> bool {
+ self.vertical
+ }
}
unsafe impl Send for Menu {}
@@ -2505,9 +2881,9 @@ impl Widget for Node {
fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
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 color(&self) -> [f32; 4] {
- if self.dragging { colors::NODE_DRAG }
- else if self.selected { colors::NODE_SELECTED }
- else { colors::NODE_IDLE }
+ if self.dragging { colors::node_drag_color() }
+ else if self.selected { colors::node_selected_color() }
+ else { colors::node_color() }
}
fn set_hovered(&mut self, v: bool) { self.hovered = v; }
fn hovered(&self) -> bool { self.hovered }
@@ -2676,6 +3052,14 @@ impl Checkbox {
self.base.label = Some(label.to_string());
self
}
+
+ pub fn set_checked(&mut self, checked: bool) {
+ self.checked = checked;
+ }
+
+ pub fn checked(&self) -> bool {
+ self.checked
+ }
}
impl Widget for Checkbox {
@@ -3306,6 +3690,7 @@ pub struct Spinbox {
min: i32, max: i32, step: i32,
editing: bool,
edit_buffer: String,
+ pub cursor_idx: usize,
hover_dec: bool,
hover_inc: bool,
unit: Option<String>,
@@ -3324,6 +3709,7 @@ impl Spinbox {
step,
editing: false,
edit_buffer: String::new(),
+ cursor_idx: 0,
hover_dec: false,
hover_inc: false,
unit: None,
@@ -3399,7 +3785,18 @@ impl Widget for Spinbox {
true
} else if px < split {
self.editing = true;
- self.edit_buffer = self.value.to_string();
+ if self.decimals > 0 {
+ let divisor = 10.0f32.powi(self.decimals as i32);
+ self.edit_buffer = format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize);
+ } else {
+ self.edit_buffer = self.value.to_string();
+ }
+ let char_width = 8.4;
+ let click_idx = (((px - (self.base.x + 4.0)) / char_width).round() as isize)
+ .max(0)
+ .min(self.edit_buffer.chars().count() as isize) as usize;
+ self.cursor_idx = click_idx;
+ focus::set_focused(self);
true
} else {
false
@@ -3419,6 +3816,7 @@ impl Widget for Spinbox {
} else {
self.edit_buffer = self.value.to_string();
}
+ self.cursor_idx = self.edit_buffer.chars().count();
focus::set_focused(self);
}
@@ -3444,8 +3842,39 @@ impl Widget for Spinbox {
if event.state != ElementState::Pressed { return false; }
match &event.logical_key {
Key::Named(NamedKey::Backspace) => {
- self.edit_buffer.pop();
- true
+ if self.cursor_idx > 0 {
+ let mut chars: Vec<char> = self.edit_buffer.chars().collect();
+ chars.remove(self.cursor_idx - 1);
+ self.edit_buffer = chars.into_iter().collect();
+ self.cursor_idx -= 1;
+ return true;
+ }
+ false
+ }
+ Key::Named(NamedKey::Delete) => {
+ if self.cursor_idx < self.edit_buffer.chars().count() {
+ let mut chars: Vec<char> = self.edit_buffer.chars().collect();
+ chars.remove(self.cursor_idx);
+ self.edit_buffer = chars.into_iter().collect();
+ return true;
+ }
+ false
+ }
+ Key::Named(NamedKey::ArrowLeft) => {
+ if self.cursor_idx > 0 {
+ self.cursor_idx -= 1;
+ true
+ } else {
+ false
+ }
+ }
+ Key::Named(NamedKey::ArrowRight) => {
+ if self.cursor_idx < self.edit_buffer.chars().count() {
+ self.cursor_idx += 1;
+ true
+ } else {
+ false
+ }
}
Key::Named(NamedKey::Enter) => {
if self.decimals > 0 {
@@ -3470,9 +3899,22 @@ impl Widget for Spinbox {
if let Some(text) = &event.text {
for ch in text.chars() {
match ch {
- '-' if self.edit_buffer.is_empty() => self.edit_buffer.push('-'),
- '.' if self.decimals > 0 && !self.edit_buffer.contains('.') => self.edit_buffer.push('.'),
- '0'..='9' => self.edit_buffer.push(ch),
+ '-' if self.cursor_idx == 0 && !self.edit_buffer.starts_with('-') => {
+ self.edit_buffer.insert(0, '-');
+ self.cursor_idx += 1;
+ }
+ '.' if self.decimals > 0 && !self.edit_buffer.contains('.') => {
+ let mut chars: Vec<char> = self.edit_buffer.chars().collect();
+ chars.insert(self.cursor_idx, '.');
+ self.edit_buffer = chars.into_iter().collect();
+ self.cursor_idx += 1;
+ }
+ '0'..='9' => {
+ let mut chars: Vec<char> = self.edit_buffer.chars().collect();
+ chars.insert(self.cursor_idx, ch);
+ self.edit_buffer = chars.into_iter().collect();
+ self.cursor_idx += 1;
+ }
_ => {}
}
}
@@ -3512,7 +3954,7 @@ impl Widget for Spinbox {
// Caret cursor
let char_width = 8.4;
- let cursor_x = self.base.x + 4.0 + (self.edit_buffer.len() as f32 * char_width);
+ let cursor_x = self.base.x + 4.0 + (self.cursor_idx as f32 * char_width);
let max_cursor_x = split - 4.0;
let final_cursor_x = cursor_x.min(max_cursor_x);
let cursor_y = self.base.y + (self.base.h - 14.0) / 2.0;
@@ -3984,6 +4426,9 @@ impl Widget for Spreadsheet {
if !self.visible {
return false;
}
+ if crate::widget::popovers::is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+ return false;
+ }
let (rx, ry, rw, rh) = self.rect();
px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
}
@@ -4599,6 +5044,62 @@ mod tests {
assert!(node.take_geom_toggle());
}
+ #[test]
+ fn test_graph_interaction() {
+ let mut graph = Graph::new();
+ graph.set_rect(0.0, 0.0, 800.0, 600.0);
+ graph.set_grid_sizes(100.0, 50.0);
+ graph.set_skipped_sizes(10.0, 20.0);
+ graph.set_grid_origin(0.0, 0.0);
+
+ let nodes = vec![
+ GraphNode {
+ name: "Node A".to_string(),
+ position: (0.0, 0.0),
+ parameters: vec![],
+ geom_visible: true,
+ },
+ GraphNode {
+ name: "Node B".to_string(),
+ position: (2.0, 1.0),
+ parameters: vec![],
+ geom_visible: true,
+ },
+ ];
+ graph.set_nodes(&nodes);
+
+ // 1. Initial State
+ assert_eq!(graph.get_nodes().len(), 2);
+ assert_eq!(graph.selected_node(), None);
+
+ // 2. Select Node A
+ // Node A screen rect: (0, 0, 100, 50)
+ let clicked = graph.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 25.0);
+ assert!(clicked);
+ assert_eq!(graph.selected_node(), Some(0));
+ assert!(graph.draggable());
+
+ // 3. Drag Node A
+ graph.drag_begin(50.0, 25.0);
+ assert!(graph.is_dragging());
+
+ // Enable snapping
+ graph.set_grid_snap_enabled(true);
+ graph.drag_update(170.0, 85.0); // drag offset from Node A center (50, 25): nx = 170 - 50 = 120, ny = 85 - 25 = 60
+ assert_eq!(graph.drag_node_pos, Some((120.0, 60.0)));
+
+ graph.drag_end();
+ assert_eq!(graph.get_nodes()[0].position, (1.0, 1.0)); // Snapped grid position: (120/120, 60/60)
+
+ // 4. Toggle geometry visibility of Node B
+ // Node B screen rect: (2 * 120 = 240, 1 * 60 = 60, 100, 50)
+ // Toggle button: tx = 240 + 100 - 30 = 310, ty = 60 + (50 - 18)/2 = 76, tw = 18, th = 18
+ let clicked_toggle = graph.mouse_input(MouseButton::Left, ElementState::Pressed, 319.0, 85.0);
+ assert!(clicked_toggle);
+ assert_eq!(graph.take_node_geom_toggle(), Some((1, false)));
+ }
+
+
#[test]
fn test_menubar_vertical_horizontal_labels() {
// Create MenuBar with custom horizontal and vertical labels
@@ -5300,6 +5801,9 @@ impl Widget for Dropdown {
}
fn hit_test(&self, px: f32, py: f32) -> bool {
+ if crate::widget::popovers::is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+ return false;
+ }
let (x, y, w, h) = self.rect();
let hx = if self.base.row_w > 0.0 { self.base.row_x } else { x };
let hw = if self.base.row_w > 0.0 { self.base.row_w } else { w };
@@ -5455,20 +5959,6 @@ impl Widget for Dropdown {
quads.push((self.base.x, self.base.y, self.base.w, self.base.h, border_color));
quads.push((self.base.x + 1.0, self.base.y + 1.0, self.base.w - 2.0, self.base.h - 2.0, bg_color));
- if self.open {
- let dy = self.base.y + self.base.h;
- let dh = self.options.len() as f32 * 24.0;
- // Border
- quads.push((self.base.x, dy, self.base.w, dh, [0.22, 0.22, 0.28, 1.0]));
- // BG
- quads.push((self.base.x + 1.0, dy + 1.0, self.base.w - 2.0, dh - 2.0, [0.06, 0.06, 0.09, 1.0]));
-
- if let Some(h_idx) = self.hovered_item {
- let iy = dy + h_idx as f32 * 24.0;
- quads.push((self.base.x + 2.0, iy + 2.0, self.base.w - 4.0, 20.0, [0.20, 0.40, 0.65, 0.6]));
- }
- }
-
quads
}
@@ -5502,27 +5992,6 @@ impl Widget for Dropdown {
color: [0x83, 0x83, 0x8a],
});
- if self.open {
- let dy = self.base.y + self.base.h;
- for (idx, opt) in self.options.iter().enumerate() {
- let iy = dy + idx as f32 * 24.0 + (24.0 - 12.0) / 2.0;
- let text_color = if self.hovered_item == Some(idx) {
- [0xff, 0xff, 0xff]
- } else if self.selected == idx {
- [0x3a, 0x9a, 0xff]
- } else {
- [0xcc, 0xcc, 0xd4]
- };
- labels.push(TextLabel {
- text: opt.clone(),
- x: self.base.x + 8.0,
- y: iy,
- font_size: 12.0,
- color: text_color,
- });
- }
- }
-
labels
}
@@ -5540,6 +6009,9 @@ impl Widget for Dropdown {
None
}
}
+ fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) {
+ Dropdown::render_popover(self, pc);
+ }
}
unsafe impl Send for Dropdown {}
@@ -6666,6 +7138,67 @@ impl Widget for Separator {
}
}
+fn serialize_single_widget(w: &dyn Widget, json: &mut String) {
+ let (x, y, width, height) = w.rect();
+ let label = w.label().or_else(|| w.base().and_then(|b| b.label.clone())).unwrap_or_default();
+ let focused = w.base().map_or(false, |b| b.focused);
+ let hovered = w.hovered();
+ let value = w.value();
+ let type_name = w.type_name();
+
+ // Escape JSON label
+ let escaped_label = label.replace('\\', "\\\\").replace('"', "\\\"");
+
+ json.push_str(&format!(
+ "{{\"type\":\"{}\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":{},\"hovered\":{},\"value\":{}",
+ type_name, escaped_label, x, y, width, height, focused, hovered, value
+ ));
+
+ // Handle children
+ let children = w.children();
+ let menu_items = w.menu_items();
+
+ if type_name == "Menu" && w.is_menu_open() && !menu_items.is_empty() {
+ json.push_str(",\"children\":[");
+ let mut max_len = 0;
+ for item in &menu_items {
+ max_len = max_len.max(item.len());
+ }
+ let dw = (max_len as f32 * 7.5 + 40.0).max(120.0);
+ let vertical = w.is_vertical();
+ let dx = if vertical { x + width } else { x };
+ let dy = if vertical { y } else { y + height };
+
+ let checked_states = w.menu_item_checked();
+ for (i, item) in menu_items.iter().enumerate() {
+ if i > 0 {
+ json.push(',');
+ }
+ let item_y = dy + i as f32 * DROPDOWN_ITEM_H;
+ let checked = checked_states.get(i).copied().flatten().unwrap_or(false);
+ let item_escaped = item.replace('\\', "\\\\").replace('"', "\\\"");
+ json.push_str(&format!(
+ "{{\"type\":\"MenuItem\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":false,\"hovered\":false,\"value\":{}}}",
+ item_escaped, dx, item_y, dw, DROPDOWN_ITEM_H, if checked { 1 } else { 0 }
+ ));
+ }
+ json.push_str("]}");
+ } else if !children.is_empty() {
+ json.push_str(",\"children\":[");
+ for (i, child_ptr) in children.iter().enumerate() {
+ if i > 0 {
+ json.push(',');
+ }
+ unsafe {
+ serialize_single_widget(&**child_ptr, json);
+ }
+ }
+ json.push_str("]}");
+ } else {
+ json.push('}');
+ }
+}
+
pub fn serialize_widgets(widgets: &[Box<dyn Widget>]) -> String {
let mut json = String::new();
json.push('[');
@@ -6673,25 +7206,438 @@ pub fn serialize_widgets(widgets: &[Box<dyn Widget>]) -> String {
if i > 0 {
json.push(',');
}
- let (x, y, width, height) = w.rect();
- let label = w.base().and_then(|b| b.label.clone()).unwrap_or_default();
- let focused = w.base().map_or(false, |b| b.focused);
- let hovered = w.hovered();
- let value = w.value();
- let type_name = w.type_name();
-
- // Escape JSON label
- let escaped_label = label.replace('\\', "\\\\").replace('"', "\\\"");
-
- json.push_str(&format!(
- "{{\"type\":\"{}\",\"label\":\"{}\",\"rect\":[{},{},{},{}],\"focused\":{},\"hovered\":{},\"value\":{}}}",
- type_name, escaped_label, x, y, width, height, focused, hovered, value
- ));
+ serialize_single_widget(&**w, &mut json);
}
json.push(']');
json
}
+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,
+ grid_origin_x: f32,
+ grid_origin_y: f32,
+ skipped_row_h: f32,
+ skipped_col_w: f32,
+ nodes: Vec<GraphNode>,
+ selected_idx: Option<usize>,
+ double_clicked_idx: Option<usize>,
+ double_click_timer: Option<(std::time::Instant, usize)>,
+ grid_snap_enabled: bool,
+ node_geom_toggled: Option<(usize, bool)>,
+
+ // For dragging a node
+ dragging_idx: Option<usize>,
+ drag_ox: f32,
+ drag_oy: f32,
+ drag_node_pos: Option<(f32, f32)>,
+
+ // Hover tracking
+ toggle_hovered_idx: Option<usize>,
+}
+
+impl Graph {
+ pub fn new() -> Self {
+ Self {
+ x: 0.0, y: 0.0, w: 0.0, h: 0.0,
+ hovered: false,
+ show_network_grid: false,
+ grid_size_x: 150.0,
+ grid_size_y: 75.0,
+ grid_origin_x: 0.0,
+ grid_origin_y: 0.0,
+ skipped_row_h: 37.5,
+ skipped_col_w: 37.5,
+ nodes: Vec::new(),
+ selected_idx: None,
+ double_clicked_idx: None,
+ double_click_timer: None,
+ grid_snap_enabled: false,
+ node_geom_toggled: None,
+ dragging_idx: None,
+ drag_ox: 0.0,
+ drag_oy: 0.0,
+ drag_node_pos: None,
+ toggle_hovered_idx: None,
+ }
+ }
+
+ pub fn node_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
+ let node = self.nodes.get(idx)?;
+ let (nx, ny) = if self.dragging_idx == Some(idx) {
+ self.drag_node_pos.unwrap_or((
+ node.position.0 * (self.grid_size_x + self.skipped_col_w) + self.grid_origin_x,
+ node.position.1 * (self.grid_size_y + self.skipped_row_h) + self.grid_origin_y,
+ ))
+ } else {
+ (
+ node.position.0 * (self.grid_size_x + self.skipped_col_w) + self.grid_origin_x,
+ node.position.1 * (self.grid_size_y + self.skipped_row_h) + self.grid_origin_y,
+ )
+ };
+ Some((nx, ny, self.grid_size_x, self.grid_size_y))
+ }
+
+ pub fn toggle_rect(&self, idx: usize) -> Option<(f32, f32, f32, f32)> {
+ let (nx, ny, nw, nh) = self.node_rect(idx)?;
+ Some((nx + nw - 30.0, ny + (nh - 18.0) / 2.0, 18.0, 18.0))
+ }
+
+ fn find_empty_cell(&self, start_x: f32, start_y: f32, skip_idx: Option<usize>) -> (f32, f32) {
+ let x = start_x;
+ let mut y = start_y;
+ loop {
+ let occupied = self.nodes.iter().enumerate().any(|(idx, node)| {
+ if Some(idx) == skip_idx {
+ false
+ } else {
+ (node.position.0 - x).abs() < 0.01 && (node.position.1 - y).abs() < 0.01
+ }
+ });
+ if occupied {
+ y += 1.0;
+ } else {
+ break;
+ }
+ }
+ (x, y)
+ }
+}
+
+impl Widget for Graph {
+ fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+ 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 color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+ fn hit_test(&self, px: f32, py: f32) -> bool {
+ if crate::widget::popovers::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 set_show_network_grid(&mut self, show: bool) { self.show_network_grid = show; }
+ fn set_grid_sizes(&mut self, gx: f32, gy: f32) { self.grid_size_x = gx; self.grid_size_y = gy; }
+ fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
+ fn set_skipped_sizes(&mut self, row_h: f32, col_w: f32) { self.skipped_row_h = row_h; self.skipped_col_w = col_w; }
+
+ fn set_nodes(&mut self, nodes: &[GraphNode]) {
+ self.nodes = nodes.to_vec();
+ if let Some(sel) = self.selected_idx {
+ if sel >= self.nodes.len() {
+ self.selected_idx = None;
+ }
+ }
+ }
+ fn get_nodes(&self) -> Vec<GraphNode> { self.nodes.clone() }
+ fn selected_node(&self) -> Option<usize> { self.selected_idx }
+ fn set_selected_node(&mut self, idx: Option<usize>) { self.selected_idx = idx; }
+ fn double_clicked_node(&self) -> Option<usize> { self.double_clicked_idx }
+ fn clear_double_clicked_node(&mut self) { self.double_clicked_idx = None; }
+ fn set_grid_snap_enabled(&mut self, enabled: bool) { self.grid_snap_enabled = enabled; }
+ fn take_node_geom_toggle(&mut self) -> Option<(usize, bool)> { self.node_geom_toggled.take() }
+
+ fn text_labels(&self) -> 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) {
+ labels.push(TextLabel {
+ text: node.name.clone(),
+ x: nx + 8.0,
+ y: ny + (nh - 12.0) / 2.0,
+ font_size: 14.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ }
+ }
+ labels
+ }
+
+ fn focus(&mut self) {
+ focus::set_focused(self);
+ }
+
+ fn is_dragging(&self) -> bool { self.dragging_idx.is_some() }
+ fn draggable(&self) -> bool { self.dragging_idx.is_some() }
+
+ 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 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 };
+
+ 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 };
+
+ self.drag_node_pos = Some((nx, ny));
+ return true;
+ }
+ false
+ }
+
+ 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;
+ }
+ }
+
+ fn on_cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was_toggle_hovered = self.toggle_hovered_idx;
+ self.toggle_hovered_idx = None;
+ for i in 0..self.nodes.len() {
+ 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);
+ break;
+ }
+ }
+ }
+ was_toggle_hovered != self.toggle_hovered_idx
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left { return false; }
+ match state {
+ ElementState::Pressed => {
+ 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.dragging_idx = Some(i);
+ self.drag_ox = px - nx;
+ self.drag_oy = py - ny;
+ self.drag_node_pos = Some((nx, ny));
+ self.focus();
+ return true;
+ }
+ }
+ }
+ self.selected_idx = None;
+ false
+ }
+ ElementState::Released => {
+ if self.dragging_idx.is_some() {
+ self.drag_end();
+ return true;
+ }
+ false
+ }
+ }
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+
+ if self.show_network_grid && self.grid_size_x > 0.0 && self.grid_size_y > 0.0 {
+ let grid_color = [0.0, 0.0, 0.0, 0.0];
+ let max_alpha = colors::CONTENT_BG[3];
+ let steps = 20;
+
+ 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;
+ let ry_end = ((self.y + self.h - self.grid_origin_y) / step_y).ceil() as i32 + 1;
+ let ry_start = ry_start.max(-100_000);
+ let ry_end = ry_end.min(100_000);
+
+ let cx_start = ((self.x - self.grid_origin_x) / step_x).floor() as i32 - 1;
+ let cx_end = ((self.x + self.w - self.grid_origin_x) / step_x).ceil() as i32 + 1;
+ let cx_start = cx_start.max(-100_000);
+ let cx_end = cx_end.min(100_000);
+
+ for ry in ry_start..=ry_end {
+ let y1 = self.grid_origin_y + (ry as f32) * step_y;
+ let draw_start_y = y1.max(self.y);
+ let draw_end_y = (y1 + self.grid_size_y).min(self.y + self.h);
+ if draw_start_y < draw_end_y {
+ for cx in cx_start..=cx_end {
+ let x1 = self.grid_origin_x + (cx as f32) * step_x;
+ let draw_start_x = x1.max(self.x);
+ let draw_end_x = (x1 + self.grid_size_x).min(self.x + self.w);
+ if draw_start_x < draw_end_x {
+ quads.push((draw_start_x, draw_start_y, draw_end_x - draw_start_x, draw_end_y - draw_start_y, colors::CONTENT_BG));
+ }
+ }
+ }
+ }
+
+ if self.skipped_row_h > 0.0 {
+ for k in ry_start..=ry_end {
+ let y1 = self.grid_origin_y + (k as f32) * step_y;
+ let y2 = y1 + self.grid_size_y;
+ if y1 < self.y + self.h {
+ let draw_start_y = y2.max(self.y);
+ let draw_end_y = (y2 + self.skipped_row_h).min(self.y + self.h);
+ if draw_start_y < draw_end_y {
+ for cx in cx_start..=cx_end {
+ let x1 = self.grid_origin_x + (cx as f32) * step_x;
+ let x_mid = x1 + self.grid_size_x / 2.0;
+ let w_total = self.grid_size_x;
+ let sub_w = w_total / steps as f32;
+
+ for i in 0..steps {
+ let sx_start = x1 + i as f32 * sub_w;
+ let sx_end = sx_start + sub_w;
+ let draw_start_x = sx_start.max(self.x);
+ let draw_end_x = sx_end.min(self.x + self.w);
+ if draw_start_x < draw_end_x {
+ let sx_mid = (sx_start + sx_end) / 2.0;
+ let dist = (sx_mid - x_mid).abs();
+ let d = (dist / (w_total / 2.0)).min(1.0);
+ let alpha = max_alpha * (1.0 - d);
+ if alpha > 0.001 {
+ quads.push((draw_start_x, draw_start_y, draw_end_x - draw_start_x, draw_end_y - draw_start_y, [colors::CONTENT_BG[0], colors::CONTENT_BG[1], colors::CONTENT_BG[2], alpha]));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if self.skipped_col_w > 0.0 {
+ for k in cx_start..=cx_end {
+ let x1 = self.grid_origin_x + (k as f32) * step_x;
+ let x2 = x1 + self.grid_size_x;
+ if x1 < self.x + self.w {
+ let draw_start_x = x2.max(self.x);
+ let draw_end_x = (x2 + self.skipped_col_w).min(self.x + self.w);
+ if draw_start_x < draw_end_x {
+ for ry in ry_start..=ry_end {
+ let y1 = self.grid_origin_y + (ry as f32) * step_y;
+ let y_mid = y1 + self.grid_size_y / 2.0;
+ let h_total = self.grid_size_y;
+ let sub_h = h_total / steps as f32;
+
+ for i in 0..steps {
+ let sy_start = y1 + i as f32 * sub_h;
+ let sy_end = sy_start + sub_h;
+ let draw_start_y = sy_start.max(self.y);
+ let draw_end_y = sy_end.min(self.y + self.h);
+ if draw_start_y < draw_end_y {
+ let sy_mid = (sy_start + sy_end) / 2.0;
+ let dist = (sy_mid - y_mid).abs();
+ let d = (dist / (h_total / 2.0)).min(1.0);
+ let alpha = max_alpha * (1.0 - d);
+ if alpha > 0.001 {
+ quads.push((draw_start_x, draw_start_y, draw_end_x - draw_start_x, draw_end_y - draw_start_y, [colors::CONTENT_BG[0], colors::CONTENT_BG[1], colors::CONTENT_BG[2], alpha]));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for k in ry_start..=ry_end {
+ let y1 = self.grid_origin_y + (k as f32) * step_y;
+ let y2 = y1 + self.grid_size_y;
+ if y1 < self.y + self.h {
+ if y1 >= self.y {
+ quads.push((self.x, y1, self.w, 1.0, grid_color));
+ }
+ if y2 >= self.y && y2 < self.y + self.h {
+ quads.push((self.x, y2, self.w, 1.0, grid_color));
+ }
+ }
+ }
+
+ for k in cx_start..=cx_end {
+ let x1 = self.grid_origin_x + (k as f32) * step_x;
+ let x2 = x1 + self.grid_size_x;
+ if x1 < self.x + self.w {
+ if x1 >= self.x {
+ quads.push((x1, self.y, 1.0, self.h, grid_color));
+ }
+ if x2 >= self.x && x2 < self.x + self.w {
+ quads.push((x2, self.y, 1.0, self.h, grid_color));
+ }
+ }
+ }
+ }
+ }
+
+ for i in 0..self.nodes.len() {
+ if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
+ let 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()
+ };
+ quads.push((nx, ny, nw, nh, bg_color));
+
+ if let Some((tx, ty, tw, th)) = self.toggle_rect(i) {
+ let btn_color = if self.toggle_hovered_idx == Some(i) {
+ colors::TOGGLE_HOVER
+ } else {
+ colors::TOGGLE_OFF
+ };
+ quads.push((tx, ty, tw, th, btn_color));
+
+ if self.nodes[i].geom_visible {
+ let inset = 3.0;
+ quads.push((tx + inset, ty + inset, tw - inset * 2.0, th - inset * 2.0, colors::TOGGLE_ON));
+ }
+ }
+ }
+ }
+
+ quads
+ }
+}
+