GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Add Breadcrumb widget, Section layout helper, and unit tests
- Add new Breadcrumb widget for path navigation and rendering
- Add Section layout helper to layout.rs for bordered, labeled content sections
- Refactor Column and Row layout helpers to remove direct RenderTarget lifetimes
- Update UI colors in color.rs (node colors, canvas, viewport, panel menus, and splitters)
- Add unit tests for Node geometry toggle visibility and MenuBar orientation labels
- Ignore .antigravitycli/ folder
.gitignore | 2 +
Cargo.lock | 20 -
src/color.rs | 16 +-
src/layout.rs | 108 +++--
src/main.rs | 2 +-
src/widget.rs | 1225 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
6 files changed, 1278 insertions(+), 95 deletions(-)
diff --git a/.gitignore b/.gitignore
index ea8c4bf..62737aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
/target
+.antigravitycli/
+
diff --git a/Cargo.lock b/Cargo.lock
index 55463fd..cfd45fe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -238,7 +238,6 @@ dependencies = [
"bytemuck",
"glyphon",
"pollster",
- "taffy",
"tokio",
"wgpu",
"winit",
@@ -657,12 +656,6 @@ dependencies = [
"bitflags 2.11.1",
]
-[[package]]
-name = "grid"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
-
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1621,7 +1614,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
- "serde_derive",
]
[[package]]
@@ -1831,18 +1823,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "taffy"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859"
-dependencies = [
- "arrayvec",
- "grid",
- "serde",
- "slotmap",
-]
-
[[package]]
name = "termcolor"
version = "1.4.1"
diff --git a/src/color.rs b/src/color.rs
index 3dd05c3..5650a2a 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -1,9 +1,12 @@
pub const HEADER_BG: [f32; 4] = [0.08, 0.08, 0.12, 1.0];
pub const HEADER_ACCENT: [f32; 4] = [0.60, 0.40, 0.20, 1.0];
pub const SIDEBAR_BG: [f32; 4] = [0.10, 0.10, 0.13, 1.0];
-pub const CONTENT_BG: [f32; 4] = [0.13, 0.13, 0.16, 1.0];
+pub const CONTENT_BG: [f32; 4] = [0.13, 0.13, 0.16, 0.2];
pub const PANEL_IDLE: [f32; 4] = [0.14, 0.70, 0.38, 1.0];
pub const PANEL_DRAG: [f32; 4] = [0.24, 0.85, 0.50, 1.0];
+pub const NODE_IDLE: [f32; 4] = [0.10, 0.45, 0.70, 1.0];
+pub const NODE_SELECTED: [f32; 4] = [0.20, 0.65, 0.90, 1.0];
+pub const NODE_DRAG: [f32; 4] = [0.30, 0.80, 1.00, 1.0];
pub const BUTTON_IDLE: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
pub const BUTTON_HOVER: [f32; 4] = [0.30, 0.52, 0.78, 1.0];
pub const BUTTON_PRESS: [f32; 4] = [0.12, 0.28, 0.50, 1.0];
@@ -29,6 +32,17 @@ pub const SPINBOX_BUTTON: [f32; 4] = [0.25, 0.25, 0.32, 1.0];
pub const SPINBOX_BUTTON_HOVER: [f32; 4] = [0.35, 0.35, 0.42, 1.0];
pub const SPINBOX_DISPLAY: [f32; 4] = [0.12, 0.12, 0.16, 1.0];
+pub const CANVAS_BG: [f32; 4] = [0.05, 0.05, 0.10, 1.0];
+pub const VIEWPORT_BG: [f32; 4] = [0.08, 0.08, 0.12, 0.25];
+pub const PARAM_BG: [f32; 4] = [0.10, 0.10, 0.14, 0.25];
+
+pub const PANEL_MENU_BG: [f32; 4] = [0.08, 0.08, 0.12, 1.0];
+pub const PANEL_MENU_HOVER: [f32; 4] = [0.18, 0.18, 0.25, 1.0];
+
+pub const SPLITTER_IDLE: [f32; 4] = [0.20, 0.20, 0.27, 1.0];
+pub const SPLITTER_HOVER: [f32; 4] = [0.40, 0.40, 0.50, 1.0];
+pub const SPLITTER_DRAG: [f32; 4] = [0.50, 0.50, 0.60, 1.0];
+
pub const TEXT_FG: [f32; 4] = [0.80, 0.80, 0.85, 1.0];
pub const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
pub const TEXT_HEADER: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
diff --git a/src/layout.rs b/src/layout.rs
index 8a167c6..f63ad43 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -5,11 +5,15 @@ pub trait RenderTarget {
fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]);
}
-fn render_widget<T: Widget>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32) {
+pub fn render_widget<T: Widget>(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.extra_quads() {
pc.rect(qc, qx, qy, qw, qh);
}
+ // Render hover highlight on top of widget surfaces
+ if let Some(hc) = w.hover_highlight() {
+ pc.rect(hc, x, y, ww, wh);
+ }
for label in w.text_labels() {
pc.text(
&label.text,
@@ -26,8 +30,7 @@ fn render_widget<T: Widget>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32
}
}
-pub struct Column<'a> {
- pc: &'a mut dyn RenderTarget,
+pub struct Column {
ox: f32,
oy: f32,
cx: f32,
@@ -35,57 +38,63 @@ pub struct Column<'a> {
pub cw: f32,
}
-impl<'a> Column<'a> {
- pub fn new(pc: &'a mut dyn RenderTarget, ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
- Self { pc, ox, oy, cx, y: cy, cw }
+impl Column {
+ pub fn new(ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
+ Self { ox, oy, cx, y: cy, cw }
}
- fn ax(&self, x_off: f32) -> f32 {
+ pub fn ax(&self, x_off: f32) -> f32 {
self.ox + self.cx + x_off
}
- fn ay(&self) -> f32 {
+ pub fn ay(&self) -> f32 {
self.oy + self.y
}
+ pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
+ pc.rect(color, self.ax(x_off), self.ay(), w, h);
+ self.y += h;
+ }
+
+ pub fn advance(&mut self, dy: f32) {
+ self.y += dy;
+ }
+
pub fn spacing(&mut self, dy: f32) {
self.y += dy;
}
- pub fn separator(&mut self) {
+ pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
let x = self.ax(8.0);
let y = self.ay();
- self.pc
- .rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 16.0, 1.0);
+ pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 16.0, 1.0);
self.y += 8.0;
}
- pub fn header(&mut self, text: &str, x_off: f32) {
+ pub fn header(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32) {
let x = self.ax(x_off);
let y = self.ay();
- self.pc
- .text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
+ pc.text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
self.y += 22.0;
}
- pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
+ pub fn text(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
let x = self.ax(x_off);
let y = self.ay() + y_off;
- self.pc
- .text(text, x, y, font_size, color);
+ pc.text(text, x, y, font_size, color);
}
- pub fn widget<T: Widget>(&mut self, w: &mut T, x_off: f32, ww: f32, wh: f32) {
+ pub fn widget<T: Widget>(&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();
- render_widget(self.pc, w, x, y, ww, wh);
- self.y += wh;
+ render_widget(pc, w, x, y, ww, wh);
+ self.y += wh + w.top_room();
}
- pub fn row<F: FnOnce(&mut Row)>(&mut self, h: f32, f: F) {
+ pub fn row<F: FnOnce(&mut Row)>(&mut self, pc: &mut dyn RenderTarget, h: f32, f: F) {
let row_y = self.ay();
let mut row = Row {
- pc: &mut *self.pc,
+ pc: &mut *pc,
base_x: self.ox + self.cx,
y: row_y,
};
@@ -110,3 +119,58 @@ impl<'a> Row<'a> {
render_widget(self.pc, w, self.base_x + x_off, self.y, ww, wh);
}
}
+
+pub struct Section {
+ left: f32,
+ top: f32,
+ pub content_y: f32,
+ pub cw: f32,
+}
+
+impl Section {
+ pub fn new(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str) -> Self {
+ pc.text(label, left + 12.0, top, 14.0, [0.83, 0.83, 0.83, 1.0]);
+ pc.rect([0.18, 0.18, 0.27, 1.0], left + 8.0, top + 22.0, cw - 16.0, 1.0);
+ Self { left, top, content_y: top + 40.0, cw }
+ }
+
+ pub fn ax(&self, x_off: f32) -> f32 { self.left + x_off }
+
+ pub fn ay(&self) -> f32 { self.content_y }
+
+ pub fn spacing(&mut self, dy: f32) { self.content_y += dy; }
+
+ pub fn text(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
+ 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) {
+ render_widget(pc, w, self.ax(x_off), self.ay(), ww, wh);
+ self.content_y += wh + w.top_room();
+ }
+
+ pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
+ let x = self.ax(8.0);
+ let y = self.ay();
+ pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 16.0, 1.0);
+ self.content_y += 8.0;
+ }
+
+ pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
+ pc.rect(color, self.ax(x_off), self.ay(), w, h);
+ self.content_y += h;
+ }
+
+ pub fn finish(&mut self, pc: &mut dyn RenderTarget) -> f32 {
+ let border: [f32; 4] = [0.25, 0.25, 0.35, 1.0];
+ let x = self.left + 8.0;
+ let y = self.top + 22.0;
+ let w = self.cw - 16.0;
+ let h = self.content_y - y;
+ pc.rect(border, x, y, w, 1.0);
+ pc.rect(border, x, y + h + 4.0, w, 1.0);
+ pc.rect(border, x, y, 1.0, h + 4.0);
+ pc.rect(border, x + w - 1.0, y, 1.0, h + 4.0);
+ self.content_y + 12.0
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 5b69fc1..48040ca 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,7 +5,7 @@ use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes};
-use clear_ui::widget::{Button, Checkbox, ContentBg, Header, Panel, ProgressBar, Sidebar, Slider, Spinbox, StatusBar, Toggle, Widget};
+use clear_ui::widget::{Button, Checkbox, ContentBg, Header, Panel, ProgressBar, Sidebar, Slider, Spinbox, StatusBar, TextLabel, Toggle, Widget};
use glyphon::{
Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
diff --git a/src/widget.rs b/src/widget.rs
index 45af781..79a11f8 100644
--- a/src/widget.rs
+++ b/src/widget.rs
@@ -23,6 +23,12 @@ pub trait Widget {
fn cursor_moved(&mut self, _px: f32, _py: f32) -> bool { false }
fn mouse_input(&mut self, _button: MouseButton, _state: ElementState, _px: f32, _py: f32) -> bool { false }
+ fn set_hovered(&mut self, _hovered: bool) {}
+ fn hovered(&self) -> bool { false }
+ fn hover_highlight(&self) -> Option<[f32; 4]> {
+ if self.hovered() { Some([1.0, 1.0, 1.0, 0.06]) } else { None }
+ }
+
fn color(&self) -> [f32; 4];
fn is_dragging(&self) -> bool { false }
@@ -35,38 +41,549 @@ pub trait Widget {
fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { Vec::new() }
fn text_labels(&self) -> Vec<TextLabel> { Vec::new() }
fn value(&self) -> i32 { 0 }
+ fn top_room(&self) -> f32 { 0.0 }
+
+ fn set_drag_bounds(&mut self, _bx: f32, _by: f32, _bw: f32, _bh: f32) {}
fn focus(&mut self) {}
fn unfocus(&mut self) {}
fn keyboard_input(&mut self, _event: &KeyEvent) -> bool { false }
+
+ fn menu_click(&mut self) -> Option<(usize, usize)> { None }
+ fn set_item_checked(&mut self, _menu_idx: usize, _item_idx: usize, _checked: bool) {}
+ fn set_grid_snap(&mut self, _gx: f32, _gy: f32) {}
+ fn set_node_name(&mut self, _name: &str) {}
+ fn set_visible(&mut self, _visible: bool) {}
+ fn visible(&self) -> bool { true }
+ fn set_path(&mut self, _segments: &[String]) {}
+ fn path_click(&mut self) -> Option<usize> { None }
+
+ fn node_params(&self) -> Vec<(String, String)> { vec![] }
+ fn set_display_params(&mut self, _params: &[(String, String)]) {}
+
+ fn set_config_toggle(&mut self, _id: usize, _val: bool) {}
+ fn take_config_toggle(&mut self) -> Option<(usize, bool)> { None }
+ fn set_show_network_grid(&mut self, _show: bool) {}
+ fn set_config_spin(&mut self, _id: usize, _val: f32) {}
+ fn take_config_spin(&mut self) -> Option<(usize, f32)> { None }
+ fn set_grid_sizes(&mut self, _gx: f32, _gy: f32) {}
+ fn set_grid_origin(&mut self, _ox: f32, _oy: f32) {}
+ fn set_palette_state(&mut self, _visible: bool, _query: &str, _items: &[String], _selected: usize) {}
+
+ fn set_geom_visible(&mut self, _visible: bool) {}
+ fn geom_visible(&self) -> bool { true }
+ fn take_geom_toggle(&mut self) -> bool { false }
}
pub struct Header {
x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
}
impl Header {
- pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
+ pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false } }
}
impl Widget for Header {
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] { colors::HEADER_BG }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
}
pub struct ContentBg {
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,
}
impl ContentBg {
- pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
+ 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: 75.0, grid_size_y: 75.0, grid_origin_x: 0.0, grid_origin_y: 0.0 }
+ }
}
impl Widget for ContentBg {
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] { colors::CONTENT_BG }
+ 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 { false }
+
+ 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 extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ if !self.show_network_grid || self.grid_size_x <= 0.0 || self.grid_size_y <= 0.0 {
+ return vec![];
+ }
+ let mut quads = Vec::new();
+ let grid_color = [0.25, 0.25, 0.32, 1.0];
+
+ let start_y = self.y;
+ let diff_y = start_y - self.grid_origin_y;
+ let k_y = (diff_y / self.grid_size_y).floor();
+ let mut y = self.grid_origin_y + k_y * self.grid_size_y;
+ while y < self.y + self.h {
+ if y >= self.y {
+ quads.push((self.x, y, self.w, 1.0, grid_color));
+ }
+ y += self.grid_size_y;
+ }
+
+ let start_x = self.x;
+ let diff_x = start_x - self.grid_origin_x;
+ let k_x = (diff_x / self.grid_size_x).floor();
+ let mut x = self.grid_origin_x + k_x * self.grid_size_x;
+ while x < self.x + self.w {
+ if x >= self.x {
+ quads.push((x, self.y, 1.0, self.h, grid_color));
+ }
+ x += self.grid_size_x;
+ }
+ quads
+ }
+}
+
+pub struct ViewportBg {
+ x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
+}
+
+impl ViewportBg {
+ pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false } }
+}
+
+impl Widget for ViewportBg {
+ 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] { colors::VIEWPORT_BG }
+ 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 { false }
+}
+
+pub struct ParametersBg {
+ x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
+ display_params: Vec<(String, String)>,
+}
+
+impl ParametersBg {
+ pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false, display_params: Vec::new() }
+ }
+}
+
+impl Widget for ParametersBg {
+ 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] { colors::PARAM_BG }
+ 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 { false }
+
+ fn set_display_params(&mut self, params: &[(String, String)]) {
+ self.display_params = params.to_vec();
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ self.display_params.iter().enumerate().map(|(i, (name, value))| {
+ TextLabel {
+ text: format!("{}: {}", name, value),
+ x: self.x + 8.0,
+ y: self.y + 30.0 + (i as f32) * 20.0,
+ font_size: 12.0,
+ color: [0xaa, 0xaa, 0xbb],
+ }
+ }).collect()
+ }
+}
+
+pub struct Canvas {
+ x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
+}
+
+impl Canvas {
+ pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false } }
+}
+
+impl Widget for Canvas {
+ 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 { false }
+}
+
+const DROPDOWN_ITEM_H: f32 = 22.0;
+const CHECKBOX_WIDTH: f32 = 16.0;
+
+pub struct MenuBar {
+ x: f32, y: f32, w: f32, h: f32,
+ hovering: bool,
+ pub title: String,
+ pub menu_items: Vec<String>,
+ pub vertical_items: Vec<String>,
+ pub menu_dropdowns: Vec<Vec<String>>,
+ menu_dropdown_checked: Vec<Vec<Option<bool>>>,
+ hovered_menu: Option<usize>,
+ open_menu: Option<usize>,
+ hovered_dropdown: Option<usize>,
+ clicked_dropdown: Option<(usize, usize)>,
+ was_open: Option<usize>,
+ pub vertical: bool,
+}
+
+impl MenuBar {
+ pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
+ Self {
+ x, y, w, h, hovering: false,
+ title: String::new(),
+ menu_items: Vec::new(),
+ vertical_items: Vec::new(),
+ menu_dropdowns: Vec::new(),
+ menu_dropdown_checked: Vec::new(),
+ hovered_menu: None,
+ open_menu: None,
+ hovered_dropdown: None,
+ clicked_dropdown: None,
+ was_open: None,
+ vertical: false,
+ }
+ }
+
+ pub fn with_title(mut self, title: &str) -> Self {
+ self.title = title.to_string();
+ self
+ }
+
+ pub fn with_item(mut self, label: &str, items: &[&str]) -> Self {
+ self.menu_items.push(label.to_string());
+ self.vertical_items.push(label.to_string());
+ self.menu_dropdowns.push(items.iter().map(|s| s.to_string()).collect());
+ self.menu_dropdown_checked.push(vec![None; items.len()]);
+ self
+ }
+
+ pub fn with_item_vh(mut self, horizontal_label: &str, vertical_label: &str, items: &[&str]) -> Self {
+ self.menu_items.push(horizontal_label.to_string());
+ self.vertical_items.push(vertical_label.to_string());
+ self.menu_dropdowns.push(items.iter().map(|s| s.to_string()).collect());
+ self.menu_dropdown_checked.push(vec![None; items.len()]);
+ self
+ }
+
+ pub fn with_vertical(mut self, vertical: bool) -> Self {
+ self.vertical = vertical;
+ self
+ }
+
+ fn item_y_vertical(&self, idx: usize) -> f32 {
+ let mut y = 8.0;
+ if !self.title.is_empty() {
+ y += 24.0;
+ }
+ y + idx as f32 * 24.0
+ }
+
+ fn item_h_vertical(&self) -> f32 {
+ 24.0
+ }
+
+ fn dropdown_rect(&self, idx: usize) -> (f32, f32, f32, f32) {
+ if self.vertical {
+ let iy = self.item_y_vertical(idx);
+ let dx = self.x + self.w;
+ let dy = self.y + iy;
+ let mut dw = 80.0f32;
+ if let Some(items) = self.menu_dropdowns.get(idx) {
+ for (i, item) in items.iter().enumerate() {
+ let mut tw = item.len() as f32 * 7.5 + 16.0;
+ if self.menu_dropdown_checked.get(idx)
+ .and_then(|m| m.get(i))
+ .is_some()
+ {
+ tw += CHECKBOX_WIDTH;
+ }
+ dw = dw.max(tw);
+ }
+ }
+ let dh = self.menu_dropdowns.get(idx).map_or(0.0, |items| items.len() as f32 * DROPDOWN_ITEM_H);
+ (dx, dy, dw, dh)
+ } else {
+ let ix = menu_item_x(&self.title, &self.menu_items, idx);
+ let iw = menu_item_w(&self.menu_items, idx);
+ let dx = self.x + ix;
+ let dy = self.y + self.h;
+ let mut dw = iw;
+ if let Some(items) = self.menu_dropdowns.get(idx) {
+ for (i, item) in items.iter().enumerate() {
+ let mut tw = item.len() as f32 * 7.5 + 16.0;
+ if self.menu_dropdown_checked.get(idx)
+ .and_then(|m| m.get(i))
+ .is_some()
+ {
+ tw += CHECKBOX_WIDTH;
+ }
+ dw = dw.max(tw);
+ }
+ }
+ let dh = self.menu_dropdowns.get(idx).map_or(0.0, |items| items.len() as f32 * DROPDOWN_ITEM_H);
+ (dx, dy, dw, dh)
+ }
+ }
+}
+impl Widget for MenuBar {
+ fn rect(&self) -> (f32, f32, f32, f32) {
+ if self.vertical {
+ let total_h = if self.menu_items.is_empty() {
+ self.h
+ } else {
+ let last_idx = self.menu_items.len() - 1;
+ self.item_y_vertical(last_idx) + self.item_h_vertical()
+ };
+ (self.x, self.y, self.w, total_h)
+ } else {
+ (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] { colors::PANEL_MENU_BG }
+ fn set_hovered(&mut self, v: bool) { self.hovering = v; }
+ fn hovered(&self) -> bool { self.hovering }
+
+ fn hit_test(&self, px: f32, py: f32) -> bool {
+ let (rx, ry, rw, rh) = self.rect();
+ if px >= rx && px <= rx + rw && py >= ry && py <= ry + rh {
+ return true;
+ }
+ if let Some(idx) = self.open_menu {
+ let (dx, dy, dw, dh) = self.dropdown_rect(idx);
+ if dh > 0.0 && px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+ return true;
+ }
+ }
+ false
+ }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ self.was_open = None;
+ let was = self.hovering;
+ self.hovering = self.hit_test(px, py);
+ let old_menu = self.hovered_menu;
+ let old_dd = self.hovered_dropdown;
+ self.hovered_menu = None;
+ self.hovered_dropdown = None;
+
+ if self.hovering {
+ if self.vertical {
+ if px >= self.x && px < self.x + self.w {
+ let ly = py - self.y;
+ for i in 0..self.menu_items.len() {
+ let iy = self.item_y_vertical(i);
+ let ih = self.item_h_vertical();
+ if ly >= iy && ly < iy + ih {
+ self.hovered_menu = Some(i);
+ break;
+ }
+ }
+ }
+ } else {
+ if py >= self.y && py < self.y + self.h {
+ let lx = px - self.x;
+ for i in 0..self.menu_items.len() {
+ let ix = menu_item_x(&self.title, &self.menu_items, i);
+ if lx >= ix && lx < ix + menu_item_w(&self.menu_items, i) {
+ self.hovered_menu = Some(i);
+ break;
+ }
+ }
+ }
+ }
+ if let Some(idx) = self.open_menu {
+ let (dx, dy, dw, dh) = self.dropdown_rect(idx);
+ if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+ let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
+ if let Some(items) = self.menu_dropdowns.get(idx) {
+ if di < items.len() {
+ self.hovered_dropdown = Some(di);
+ }
+ }
+ }
+ }
+ }
+
+ was != self.hovering || old_menu != self.hovered_menu || old_dd != self.hovered_dropdown
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left || state != ElementState::Pressed { return false; }
+ if !self.hit_test(px, py) { return false; }
+
+ if self.vertical {
+ if px >= self.x && px < self.x + self.w {
+ let ly = py - self.y;
+ for i in 0..self.menu_items.len() {
+ let iy = self.item_y_vertical(i);
+ let ih = self.item_h_vertical();
+ if ly >= iy && ly < iy + ih {
+ if self.was_open == Some(i) {
+ self.was_open = None;
+ } else {
+ self.open_menu = Some(i);
+ self.was_open = None;
+ }
+ return true;
+ }
+ }
+ self.was_open = None;
+ self.open_menu = None;
+ return true;
+ }
+ } else {
+ if py >= self.y && py < self.y + self.h {
+ let lx = px - self.x;
+ for i in 0..self.menu_items.len() {
+ let ix = menu_item_x(&self.title, &self.menu_items, i);
+ if lx >= ix && lx < ix + menu_item_w(&self.menu_items, i) {
+ if self.was_open == Some(i) {
+ self.was_open = None;
+ } else {
+ self.open_menu = Some(i);
+ self.was_open = None;
+ }
+ return true;
+ }
+ }
+ self.was_open = None;
+ self.open_menu = None;
+ return true;
+ }
+ }
+
+ if let Some(idx) = self.open_menu {
+ let (dx, dy, dw, dh) = self.dropdown_rect(idx);
+ if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
+ let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
+ if let Some(items) = self.menu_dropdowns.get(idx) {
+ if di < items.len() {
+ self.clicked_dropdown = Some((idx, di));
+ self.open_menu = None;
+ return true;
+ }
+ }
+ }
+ }
+
+ false
+ }
+
+ fn unfocus(&mut self) {
+ self.was_open = self.open_menu.take();
+ }
+
+ fn menu_click(&mut self) -> Option<(usize, usize)> {
+ self.clicked_dropdown.take()
+ }
+
+ fn set_item_checked(&mut self, menu_idx: usize, item_idx: usize, checked: bool) {
+ if let Some(menu) = self.menu_dropdown_checked.get_mut(menu_idx) {
+ if item_idx < menu.len() {
+ menu[item_idx] = Some(checked);
+ }
+ }
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ if let Some(idx) = self.hovered_menu {
+ if self.vertical {
+ let iy = self.item_y_vertical(idx);
+ let ih = self.item_h_vertical();
+ quads.push((self.x, self.y + iy, self.w, ih, colors::PANEL_MENU_HOVER));
+ } else {
+ let ix = menu_item_x(&self.title, &self.menu_items, idx);
+ quads.push((self.x + ix, self.y, menu_item_w(&self.menu_items, idx), self.h, colors::PANEL_MENU_HOVER));
+ }
+ }
+ if let Some(idx) = self.open_menu {
+ let (dx, dy, dw, dh) = self.dropdown_rect(idx);
+ if dh > 0.0 {
+ quads.push((dx, dy, dw, dh, colors::PANEL_MENU_BG));
+ if let Some(di) = self.hovered_dropdown {
+ quads.push((dx, dy + di as f32 * DROPDOWN_ITEM_H, dw, DROPDOWN_ITEM_H, colors::PANEL_MENU_HOVER));
+ }
+ }
+ }
+ quads
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ 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 self.vertical {
+ for (i, item) in self.vertical_items.iter().enumerate() {
+ let iy = self.item_y_vertical(i);
+ labels.push(TextLabel {
+ text: item.clone(),
+ x: self.x + 8.0,
+ y: self.y + iy + 5.0,
+ font_size: 12.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ }
+ } else {
+ for (i, item) in self.menu_items.iter().enumerate() {
+ labels.push(TextLabel {
+ text: item.clone(),
+ x: self.x + menu_item_x(&self.title, &self.menu_items, i),
+ y: self.y + 7.0,
+ font_size: 12.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ }
+ }
+ if let Some(idx) = self.open_menu {
+ if let Some(items) = self.menu_dropdowns.get(idx) {
+ let (dx, dy, _, _) = self.dropdown_rect(idx);
+ for (i, item) in items.iter().enumerate() {
+ let checked = self.menu_dropdown_checked.get(idx)
+ .and_then(|m| m.get(i))
+ .and_then(|&v| v);
+ let prefix = match checked {
+ Some(true) => "\u{2713} ",
+ Some(false) => " ",
+ None => "",
+ };
+ labels.push(TextLabel {
+ text: format!("{}{}", prefix, item),
+ x: dx + 8.0,
+ y: dy + i as f32 * DROPDOWN_ITEM_H + 5.0,
+ font_size: 12.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ }
+ }
+ }
+ labels
+ }
}
pub enum ButtonKind {
@@ -95,6 +612,8 @@ impl Button {
impl Widget for Button {
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 set_hovered(&mut self, v: bool) { self.hovering = v; }
+ fn hovered(&self) -> bool { self.hovering }
fn color(&self) -> [f32; 4] {
match self.kind {
ButtonKind::Primary => {
@@ -149,28 +668,37 @@ pub enum PageButton {
pub struct Sidebar {
x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
}
impl Sidebar {
- pub fn new(w: f32) -> Self { Self { x: 0.0, y: 0.0, w, h: 0.0 } }
+ pub fn new(w: f32) -> Self { Self { x: 0.0, y: 0.0, w, h: 0.0, hovered: false } }
}
impl Widget for Sidebar {
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] { colors::SIDEBAR_BG }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
}
pub struct Panel {
x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
dragging: bool,
drag_ox: f32, drag_oy: f32,
drag_start_x: f32, drag_start_y: f32,
+ bounds: Option<(f32, f32, f32, f32)>,
}
impl Panel {
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
- Self { x, y, w, h, dragging: false, drag_ox: 0.0, drag_oy: 0.0, drag_start_x: 0.0, drag_start_y: 0.0 }
+ Self { x, y, w, h, hovered: false, dragging: false, drag_ox: 0.0, drag_oy: 0.0, drag_start_x: 0.0, drag_start_y: 0.0, bounds: None }
+ }
+
+ pub fn set_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+ self.bounds = Some((bx, by, bw, bh));
}
}
@@ -178,6 +706,12 @@ impl Widget for Panel {
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::PANEL_DRAG } else { colors::PANEL_IDLE } }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+ self.bounds = Some((bx, by, bw, bh));
+ }
fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
if button != MouseButton::Left { return false; }
@@ -201,6 +735,11 @@ impl Widget for Panel {
fn drag_update(&mut self, px: f32, py: f32) -> bool {
let nx = px - self.drag_ox;
let ny = py - self.drag_oy;
+ let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
+ (nx.clamp(bx, bx + bw - self.w), ny.clamp(by, by + bh - self.h))
+ } else {
+ (nx, ny)
+ };
if (nx - self.x).abs() > 0.01 || (ny - self.y).abs() > 0.01 {
self.x = nx;
self.y = ny;
@@ -220,6 +759,196 @@ impl Widget for Panel {
fn drag_end(&mut self) { self.dragging = false; }
}
+pub struct Node {
+ x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
+ selected: bool,
+ dragging: bool,
+ drag_ox: f32, drag_oy: f32,
+ bounds: Option<(f32, f32, f32, f32)>,
+ grid_snap_x: f32,
+ grid_snap_y: f32,
+ grid_origin_x: f32,
+ grid_origin_y: f32,
+ name: String,
+ pub parameters: Vec<(String, String)>,
+ geom_visible: bool,
+ geom_toggled: bool,
+ toggle_hovered: bool,
+}
+
+impl Node {
+ pub fn new(x: f32, y: f32, w: f32, h: f32, name: &str) -> Self {
+ Self {
+ x, y, w, h,
+ hovered: false, selected: false, dragging: false, drag_ox: 0.0, drag_oy: 0.0,
+ bounds: None, grid_snap_x: 0.0, grid_snap_y: 0.0,
+ grid_origin_x: 0.0, grid_origin_y: 0.0,
+ name: name.to_string(), parameters: Vec::new(),
+ geom_visible: true, geom_toggled: false, toggle_hovered: false,
+ }
+ }
+
+ pub fn with_params(mut self, params: &[(&str, &str)]) -> Self {
+ self.parameters = params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
+ self
+ }
+
+ pub fn with_grid_snap(mut self, gx: f32, gy: f32) -> Self {
+ self.grid_snap_x = gx;
+ self.grid_snap_y = gy;
+ self
+ }
+
+ fn toggle_rect(&self) -> (f32, f32, f32, f32) {
+ (self.x + self.w - 30.0, self.y + (self.h - 18.0) / 2.0, 18.0, 18.0)
+ }
+}
+
+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 }
+ }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn focus(&mut self) { self.selected = true; }
+ fn unfocus(&mut self) { self.selected = false; }
+
+ fn node_params(&self) -> Vec<(String, String)> { self.parameters.clone() }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ vec![TextLabel {
+ text: self.name.clone(),
+ x: self.x + 8.0,
+ y: self.y + (self.h - 12.0) / 2.0,
+ font_size: 14.0,
+ color: [0xcc, 0xcc, 0xd4],
+ }]
+ }
+
+ fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+ self.bounds = Some((bx, by, bw, bh));
+ }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was_hovered = self.hovered;
+ self.hovered = self.hit_test(px, py);
+
+ let was_toggle_hovered = self.toggle_hovered;
+ let (tx, ty, tw, th) = self.toggle_rect();
+ self.toggle_hovered = px >= tx && px < tx + tw && py >= ty && py < ty + th;
+
+ was_hovered != self.hovered || was_toggle_hovered != self.toggle_hovered
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left { return false; }
+ match state {
+ ElementState::Pressed => {
+ if self.hit_test(px, py) {
+ let (tx, ty, tw, th) = self.toggle_rect();
+ if px >= tx && px < tx + tw && py >= ty && py < ty + th {
+ self.geom_visible = !self.geom_visible;
+ self.geom_toggled = true;
+ return true;
+ }
+ self.drag_begin(px, py);
+ return true;
+ }
+ }
+ ElementState::Released => {
+ if self.dragging { self.drag_end(); return true; }
+ }
+ }
+ false
+ }
+
+ fn is_dragging(&self) -> bool { self.dragging }
+ fn draggable(&self) -> bool { !self.toggle_hovered }
+
+ fn set_grid_snap(&mut self, gx: f32, gy: f32) { self.grid_snap_x = gx; self.grid_snap_y = gy; }
+ fn set_grid_origin(&mut self, ox: f32, oy: f32) { self.grid_origin_x = ox; self.grid_origin_y = oy; }
+ fn set_node_name(&mut self, name: &str) { self.name = name.to_string(); }
+
+ fn drag_update(&mut self, px: f32, py: f32) -> bool {
+ let nx = px - self.drag_ox;
+ let ny = py - self.drag_oy;
+ let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
+ (nx.clamp(bx, bx + bw - self.w), ny.clamp(by, by + bh - self.h))
+ } else {
+ (nx, ny)
+ };
+ let nx = if self.grid_snap_x > 0.0 {
+ let relative = nx - self.grid_origin_x;
+ let snapped = (relative / self.grid_snap_x).round() * self.grid_snap_x;
+ snapped + self.grid_origin_x
+ } else { nx };
+ let ny = if self.grid_snap_y > 0.0 {
+ let relative = ny - self.grid_origin_y;
+ let snapped = (relative / self.grid_snap_y).round() * self.grid_snap_y;
+ snapped + self.grid_origin_y
+ } else { ny };
+ if (nx - self.x).abs() > 0.01 || (ny - self.y).abs() > 0.01 {
+ self.x = nx;
+ self.y = ny;
+ return true;
+ }
+ false
+ }
+
+ fn drag_begin(&mut self, px: f32, py: f32) {
+ self.dragging = true;
+ self.drag_ox = px - self.x;
+ self.drag_oy = py - self.y;
+ }
+
+ fn drag_end(&mut self) { self.dragging = false; }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let (tx, ty, tw, th) = self.toggle_rect();
+ let bg_color = if self.toggle_hovered {
+ colors::TOGGLE_HOVER
+ } else {
+ colors::TOGGLE_OFF
+ };
+
+ let mut quads = vec![
+ (tx, ty, tw, th, bg_color)
+ ];
+
+ if self.geom_visible {
+ let inset = 3.0;
+ quads.push((tx + inset, ty + inset, tw - inset * 2.0, th - inset * 2.0, colors::TOGGLE_ON));
+ }
+
+ quads
+ }
+
+ fn set_geom_visible(&mut self, visible: bool) { self.geom_visible = visible; }
+ fn geom_visible(&self) -> bool { self.geom_visible }
+ fn take_geom_toggle(&mut self) -> bool { std::mem::take(&mut self.geom_toggled) }
+}
+
+fn menu_item_x(title: &str, menu_items: &[String], idx: usize) -> f32 {
+ let mut x = 8.0;
+ if !title.is_empty() {
+ x += title.len() as f32 * 7.5 + 24.0;
+ }
+ for i in 0..idx {
+ x += menu_items[i].len() as f32 * 7.5 + 16.0;
+ }
+ x
+}
+
+fn menu_item_w(menu_items: &[String], idx: usize) -> f32 {
+ menu_items[idx].len() as f32 * 7.5 + 16.0
+}
+
pub struct Checkbox {
x: f32, y: f32, w: f32, h: f32,
hovering: bool,
@@ -237,6 +966,8 @@ impl Widget for Checkbox {
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] { colors::CHECKBOX_BG }
+ fn set_hovered(&mut self, v: bool) { self.hovering = v; }
+ fn hovered(&self) -> bool { self.hovering }
fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
let was = self.hovering;
@@ -264,16 +995,31 @@ impl Widget for Checkbox {
}
}
+#[derive(Debug, Clone)]
pub struct Toggle {
x: f32, y: f32, w: f32, h: f32,
hovering: bool,
toggled: bool,
just_toggled: bool,
+ label: Option<String>,
}
impl Toggle {
pub fn new() -> Self {
- Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovering: false, toggled: false, just_toggled: false }
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovering: false, toggled: false, just_toggled: false, label: None }
+ }
+
+ pub fn with_label(mut self, label: &str) -> Self {
+ self.label = Some(label.to_string());
+ self
+ }
+
+ pub fn set_label(&mut self, label: &str) {
+ self.label = Some(label.to_string());
+ }
+
+ pub fn set_toggled(&mut self, v: bool) {
+ self.toggled = v;
}
}
@@ -281,6 +1027,10 @@ impl Widget for Toggle {
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.toggled { colors::TOGGLE_ON } else { colors::TOGGLE_OFF } }
+ fn set_hovered(&mut self, v: bool) { self.hovering = v; }
+ fn hovered(&self) -> bool { self.hovering }
+
+ fn top_room(&self) -> f32 { 0.0 }
fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
let was = self.hovering;
@@ -302,10 +1052,39 @@ impl Widget for Toggle {
}
false
}
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ let bg = if self.toggled { colors::TOGGLE_ON } else { colors::TOGGLE_OFF };
+ quads.push((self.x, self.y, self.w, self.h, bg));
+ if self.hovering && self.label.is_some() {
+ quads.push((self.x - 87.0, self.y, 87.0 + self.w, self.h, [1.0, 1.0, 1.0, 0.06]));
+ }
+ quads
+ }
+
+ fn take_click(&mut self) -> bool {
+ if self.just_toggled { self.just_toggled = false; true } else { false }
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ if let Some(ref label) = self.label {
+ vec![TextLabel {
+ text: label.clone(),
+ x: self.x - 85.0,
+ y: self.y + (self.h - 12.0) / 2.0,
+ font_size: 12.0,
+ color: [0x83, 0x83, 0x8a],
+ }]
+ } else {
+ Vec::new()
+ }
+ }
}
pub struct Slider {
x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
dragging: bool,
value: f32,
drag_offset: f32,
@@ -313,7 +1092,7 @@ pub struct Slider {
impl Slider {
pub fn new() -> Self {
- Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, dragging: false, value: 0.5, drag_offset: 0.0 }
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false, dragging: false, value: 0.5, drag_offset: 0.0 }
}
}
@@ -321,6 +1100,8 @@ impl Widget for Slider {
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] { colors::SLIDER_TRACK }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
fn draggable(&self) -> bool { true }
fn is_dragging(&self) -> bool { self.dragging }
@@ -350,12 +1131,13 @@ impl Widget for Slider {
pub struct ProgressBar {
x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
value: f32,
}
impl ProgressBar {
pub fn new(value: f32) -> Self {
- Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value }
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false, value }
}
}
@@ -363,38 +1145,128 @@ impl Widget for ProgressBar {
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] { colors::PROGRESS_BG }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
}
pub struct StatusBar {
x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
}
impl StatusBar {
- pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
+ pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false } }
}
impl Widget for StatusBar {
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] { colors::STATUS_BG }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+}
+
+pub struct Splitter {
+ x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
+ dragging: bool,
+ drag_ox: f32,
+}
+
+impl Splitter {
+ pub fn new(w: f32) -> Self {
+ Self { x: 0.0, y: 0.0, w, h: 0.0, hovered: false, dragging: false, drag_ox: 0.0 }
+ }
+}
+
+impl Widget for Splitter {
+ 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::SPLITTER_DRAG }
+ else if self.hovered { colors::SPLITTER_HOVER }
+ else { colors::SPLITTER_IDLE }
+ }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was = self.hovered;
+ self.hovered = self.hit_test(px, py);
+ was != self.hovered
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if button != MouseButton::Left { return false; }
+ match state {
+ ElementState::Pressed => {
+ if self.hit_test(px, py) {
+ self.drag_begin(px, py);
+ return true;
+ }
+ }
+ ElementState::Released => {
+ if self.dragging { self.drag_end(); return true; }
+ }
+ }
+ false
+ }
+
+ fn is_dragging(&self) -> bool { self.dragging }
+ fn draggable(&self) -> bool { true }
+
+ fn drag_update(&mut self, px: f32, _py: f32) -> bool {
+ let new_x = px - self.drag_ox;
+ if (new_x - self.x).abs() > 0.5 {
+ self.x = new_x;
+ return true;
+ }
+ false
+ }
+
+ fn drag_begin(&mut self, px: f32, _py: f32) {
+ self.dragging = true;
+ self.drag_ox = px - self.x;
+ }
+
+ fn drag_end(&mut self) { self.dragging = false; }
}
#[derive(Debug, Clone)]
pub struct Spinbox {
x: f32, y: f32, w: f32, h: f32,
pub value: i32,
- min: i32,
- max: i32,
- step: i32,
+ min: i32, max: i32, step: i32,
editing: bool,
edit_buffer: String,
+ hovered: bool,
hover_dec: bool,
hover_inc: bool,
+ label: Option<String>,
+ unit: Option<String>,
}
impl Spinbox {
pub fn new(value: i32, min: i32, max: i32, step: i32) -> Self {
- Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value, min, max, step, editing: false, edit_buffer: String::new(), hover_dec: false, hover_inc: false }
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, value, min, max, step, editing: false, edit_buffer: String::new(), hovered: false, hover_dec: false, hover_inc: false, label: None, unit: None }
+ }
+
+ pub fn with_label(mut self, label: &str) -> Self {
+ self.label = Some(label.to_string());
+ self
+ }
+
+ pub fn set_label(&mut self, label: &str) {
+ self.label = Some(label.to_string());
+ }
+
+ pub fn with_unit(mut self, unit: &str) -> Self {
+ self.unit = Some(unit.to_string());
+ self
+ }
+
+ pub fn set_unit(&mut self, unit: &str) {
+ self.unit = Some(unit.to_string());
}
}
@@ -403,13 +1275,19 @@ impl Widget for Spinbox {
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] { colors::SPINBOX_BG }
fn value(&self) -> i32 { self.value }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn top_room(&self) -> f32 { if self.label.is_some() { 16.0 } else { 0.0 } }
fn cursor_moved(&mut self, px: f32, _py: f32) -> bool {
- if !self.hit_test(px, _py) {
+ let was = self.hovered;
+ self.hovered = self.hit_test(px, _py);
+ if !self.hovered {
let changed = self.hover_dec || self.hover_inc;
self.hover_dec = false;
self.hover_inc = false;
- return changed;
+ return changed || was != self.hovered;
}
let split = self.x + self.w * 0.55;
let hd = px >= split && px < split + self.w * 0.225;
@@ -417,7 +1295,7 @@ impl Widget for Spinbox {
let changed = hd != self.hover_dec || hi != self.hover_inc;
self.hover_dec = hd;
self.hover_inc = hi;
- changed
+ changed || was != self.hovered
}
fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
@@ -427,10 +1305,10 @@ impl Widget for Spinbox {
ElementState::Pressed => {
let split = self.x + self.w * 0.55;
if px >= split && px < split + self.w * 0.225 {
- self.value = (self.value + self.step).min(self.max);
+ self.value = (self.value - self.step).max(self.min);
true
} else if px >= split + self.w * 0.225 {
- self.value = (self.value - self.step).max(self.min);
+ self.value = (self.value + self.step).min(self.max);
true
} else if px < split {
self.editing = true;
@@ -496,59 +1374,90 @@ impl Widget for Spinbox {
}
}
+ fn hover_highlight(&self) -> Option<[f32; 4]> {
+ None // rendered in extra_quads to cover label area
+ }
+
fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ if self.hovered {
+ let (hy, hh) = if self.label.is_some() {
+ (self.y - 20.0, self.h + 20.0)
+ } else {
+ (self.y, self.h)
+ };
+ quads.push((self.x, hy, self.w, hh, [1.0, 1.0, 1.0, 0.06]));
+ }
let split = self.x + self.w * 0.55;
let btn_w = self.w * 0.225;
let inc_col = if self.hover_inc { colors::SPINBOX_BUTTON_HOVER } else { colors::SPINBOX_BUTTON };
let dec_col = if self.hover_dec { colors::SPINBOX_BUTTON_HOVER } else { colors::SPINBOX_BUTTON };
- vec![
- (self.x, self.y, self.w * 0.55, self.h, colors::SPINBOX_DISPLAY),
- (split, self.y, btn_w, self.h, inc_col),
- (split + btn_w, self.y, btn_w, self.h, dec_col),
- ]
+ quads.push((self.x, self.y, self.w * 0.55, self.h, colors::SPINBOX_DISPLAY));
+ quads.push((split, self.y, btn_w, self.h, dec_col));
+ quads.push((split + btn_w, self.y, btn_w, self.h, inc_col));
+ quads
}
fn text_labels(&self) -> Vec<TextLabel> {
- let value_text = if self.editing { self.edit_buffer.clone() } else { self.value.to_string() };
- vec![
- TextLabel {
- text: value_text,
+ let mut labels = Vec::new();
+ if let Some(ref label) = self.label {
+ labels.push(TextLabel {
+ text: label.clone(),
x: self.x + 4.0,
- y: self.y + 2.0,
- font_size: 14.0,
- color: [0xcc, 0xcc, 0xd4],
- },
- TextLabel {
- text: "+".to_string(),
- x: self.x + self.w * 0.6625 - 4.0,
- y: self.y + 2.0,
- font_size: 12.0,
- color: [0xcc, 0xcc, 0xd4],
- },
- TextLabel {
- text: "-".to_string(),
- x: self.x + self.w * 0.8875 - 4.0,
- y: self.y + 2.0,
+ y: self.y - 16.0,
font_size: 12.0,
- color: [0xcc, 0xcc, 0xd4],
- },
- ]
+ color: [0x83, 0x83, 0x8a],
+ });
+ }
+ let value_text = if self.editing { self.edit_buffer.clone() } else { self.value.to_string() };
+ labels.push(TextLabel {
+ text: value_text,
+ x: self.x + 4.0,
+ y: self.y + 2.0,
+ font_size: 14.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ if let Some(ref unit) = self.unit {
+ labels.push(TextLabel {
+ text: unit.clone(),
+ x: self.x + 4.0 + 36.0,
+ y: self.y + 3.0,
+ font_size: 11.0,
+ color: [0x73, 0x73, 0x7a],
+ });
+ }
+ labels.push(TextLabel {
+ text: "-".to_string(),
+ x: self.x + self.w * 0.6625 - 4.0,
+ y: self.y + 2.0,
+ font_size: 12.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ labels.push(TextLabel {
+ text: "+".to_string(),
+ x: self.x + self.w * 0.8875 - 4.0,
+ y: self.y + 2.0,
+ font_size: 12.0,
+ color: [0xcc, 0xcc, 0xd4],
+ });
+ labels
}
}
#[derive(Debug, Clone)]
-pub struct ColorPicker {
+pub struct ColorSelector {
x: f32, y: f32, w: f32, h: f32,
pub color: [u8; 3],
just_clicked: bool,
+ hovered: bool,
editing: bool,
edit_buffer: String,
label: Option<String>,
}
-impl ColorPicker {
+impl ColorSelector {
pub fn new(color: [u8; 3]) -> Self {
- Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, color, just_clicked: false, editing: false, edit_buffer: String::new(), label: None }
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, color, just_clicked: false, hovered: false, editing: false, edit_buffer: String::new(), label: None }
}
pub fn with_label(mut self, label: &str) -> Self {
@@ -557,14 +1466,24 @@ impl ColorPicker {
}
}
-impl Widget for ColorPicker {
+impl Widget for ColorSelector {
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 set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn top_room(&self) -> f32 { if self.label.is_some() { 16.0 } else { 0.0 } }
fn color(&self) -> [f32; 4] {
[self.color[0] as f32 / 255.0, self.color[1] as f32 / 255.0, self.color[2] as f32 / 255.0, 1.0]
}
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was = self.hovered;
+ self.hovered = self.hit_test(px, py);
+ was != self.hovered
+ }
+
fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
if button != MouseButton::Left { return false; }
if state != ElementState::Pressed { return false; }
@@ -633,13 +1552,25 @@ impl Widget for ColorPicker {
}
}
+ fn hover_highlight(&self) -> Option<[f32; 4]> {
+ None // rendered in extra_quads to cover label area
+ }
+
fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ if self.hovered {
+ let (hy, hh) = if self.label.is_some() {
+ (self.y - 20.0, self.h + 20.0)
+ } else {
+ (self.y, self.h)
+ };
+ quads.push((self.x, hy, self.w, hh, [1.0, 1.0, 1.0, 0.06]));
+ }
let pick_x = self.x + self.w * 0.65;
let pick_w = self.w * 0.35;
let (r, g, b, _) = (self.color[0] as f32 / 255.0, self.color[1] as f32 / 255.0, self.color[2] as f32 / 255.0, 1.0);
- vec![
- (pick_x, self.y, pick_w, self.h, [r, g, b, 1.0]),
- ]
+ quads.push((pick_x, self.y, pick_w, self.h, [r, g, b, 1.0]));
+ quads
}
fn text_labels(&self) -> Vec<TextLabel> {
@@ -665,6 +1596,108 @@ impl Widget for ColorPicker {
}
}
+const BREADCRUMB_PADDING: f32 = 8.0;
+const SEGMENT_GAP: f32 = 4.0;
+
+pub struct Breadcrumb {
+ x: f32, y: f32, w: f32, h: f32,
+ hovered: bool,
+ path: Vec<String>,
+ hovered_seg: Option<usize>,
+ clicked_seg: Option<usize>,
+}
+
+impl Breadcrumb {
+ pub fn new() -> Self {
+ Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false,
+ path: Vec::new(), hovered_seg: None, clicked_seg: None }
+ }
+
+ fn seg_at(&self, px: f32) -> Option<usize> {
+ let mut cx = self.x + BREADCRUMB_PADDING;
+ for (i, seg) in self.path.iter().enumerate() {
+ let w = seg.len() as f32 * 7.5;
+ if px >= cx && px < cx + w {
+ return Some(i);
+ }
+ cx += w + SEGMENT_GAP;
+ }
+ None
+ }
+}
+
+impl Widget for Breadcrumb {
+ 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.10, 0.10, 0.14, 1.0] }
+ fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+ fn hovered(&self) -> bool { self.hovered }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was = self.hovered;
+ self.hovered = self.hit_test(px, py);
+ let old = self.hovered_seg;
+ self.hovered_seg = if self.hovered { self.seg_at(px) } else { None };
+ was != self.hovered || old != self.hovered_seg
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, _py: f32) -> bool {
+ if button != MouseButton::Left || state != ElementState::Pressed { return false; }
+ if let Some(i) = self.seg_at(px) {
+ if i < self.path.len() - 1 {
+ self.clicked_seg = Some(i);
+ return true;
+ }
+ }
+ false
+ }
+
+ fn set_path(&mut self, segments: &[String]) {
+ let mut s = Vec::with_capacity(segments.len().max(1));
+ if segments.is_empty() || (segments.len() == 1 && segments[0].is_empty()) {
+ s.push("/".to_string());
+ } else {
+ s.push("/".to_string());
+ for name in segments {
+ s.push(format!(" \u{203A} {}", name));
+ }
+ }
+ self.path = s;
+ }
+
+ fn path_click(&mut self) -> Option<usize> { self.clicked_seg.take() }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = Vec::new();
+ if let Some(i) = self.hovered_seg {
+ let mut cx = self.x + BREADCRUMB_PADDING;
+ for j in 0..i {
+ let w = self.path[j].len() as f32 * 7.5;
+ cx += w + SEGMENT_GAP;
+ }
+ let w = self.path[i].len() as f32 * 7.5;
+ quads.push((cx, self.y, w, self.h, [1.0, 1.0, 1.0, 0.06]));
+ }
+ quads
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ let mut labels = Vec::new();
+ let mut cx = self.x + BREADCRUMB_PADDING;
+ for (i, seg) in self.path.iter().enumerate() {
+ labels.push(TextLabel {
+ text: seg.clone(),
+ x: cx,
+ y: self.y + 6.0,
+ font_size: 12.0,
+ color: if i == self.path.len() - 1 { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] },
+ });
+ cx += seg.len() as f32 * 7.5 + SEGMENT_GAP;
+ }
+ labels
+ }
+}
+
fn parse_hex(s: &str) -> Option<[u8; 3]> {
let s = s.trim_start_matches('#');
if s.len() != 6 { return None; }
@@ -673,3 +1706,93 @@ fn parse_hex(s: &str) -> Option<[u8; 3]> {
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some([r, g, b])
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use winit::event::{ElementState, MouseButton};
+
+ #[test]
+ fn test_node_toggle_geometry_visibility() {
+ let mut node = Node::new(100.0, 100.0, 200.0, 50.0, "Test Node");
+
+ // 1. Initial state
+ assert!(node.geom_visible());
+ assert!(!node.take_geom_toggle());
+ assert!(node.draggable());
+
+ // Get the toggle rect
+ let (tx, ty, tw, th) = node.toggle_rect();
+
+ // 2. Hover toggle area
+ // Move cursor inside toggle area
+ let changed = node.cursor_moved(tx + tw / 2.0, ty + th / 2.0);
+ assert!(changed);
+ assert!(node.toggle_hovered);
+ assert!(!node.draggable(), "Node should not be draggable when hovering over the toggle widget");
+
+ // Move cursor outside toggle area but inside node
+ let changed2 = node.cursor_moved(tx - 10.0, ty + th / 2.0);
+ assert!(changed2);
+ assert!(!node.toggle_hovered);
+ assert!(node.draggable());
+
+ // 3. Click toggle area
+ // Move cursor back inside toggle area
+ node.cursor_moved(tx + tw / 2.0, ty + th / 2.0);
+ // Press Left button
+ let input_changed = node.mouse_input(MouseButton::Left, ElementState::Pressed, tx + tw / 2.0, ty + th / 2.0);
+ assert!(input_changed);
+ assert!(!node.geom_visible(), "Geometry visibility should be toggled off");
+ assert!(node.take_geom_toggle(), "take_geom_toggle should return true after toggle click");
+ assert!(!node.take_geom_toggle(), "take_geom_toggle should clear state after being called once");
+
+ // Click again to toggle back on
+ let input_changed2 = node.mouse_input(MouseButton::Left, ElementState::Pressed, tx + tw / 2.0, ty + th / 2.0);
+ assert!(input_changed2);
+ assert!(node.geom_visible(), "Geometry visibility should be toggled back on");
+ assert!(node.take_geom_toggle());
+ }
+
+ #[test]
+ fn test_menubar_vertical_horizontal_labels() {
+ // Create MenuBar with custom horizontal and vertical labels
+ let mut menubar = MenuBar::new(0.0, 0.0, 120.0, 30.0)
+ .with_title("App")
+ .with_item_vh("FileH", "FileV", &["Open", "Save"])
+ .with_item("Edit", &["Undo"]);
+
+ // 1. Horizontal mode (default)
+ assert!(!menubar.vertical);
+ let labels_h = menubar.text_labels();
+ // Title should be "App", item 0 should be "FileH", item 1 should be "Edit"
+ assert_eq!(labels_h[0].text, "App");
+ assert_eq!(labels_h[1].text, "FileH");
+ assert_eq!(labels_h[2].text, "Edit");
+
+ // Hover test in horizontal layout
+ let ix = menu_item_x("App", &["FileH".to_string(), "Edit".to_string()], 0);
+ let iw = menu_item_w(&["FileH".to_string(), "Edit".to_string()], 0);
+
+ // Move cursor inside "FileH" bounds
+ menubar.cursor_moved(ix + iw / 2.0, 15.0);
+ assert_eq!(menubar.hovered_menu, Some(0));
+
+ // 2. Vertical mode
+ let mut menubar_v = menubar.with_vertical(true);
+ assert!(menubar_v.vertical);
+ let labels_v = menubar_v.text_labels();
+ // Title should be "App", item 0 should be "FileV", item 1 should be "Edit"
+ assert_eq!(labels_v[0].text, "App");
+ assert_eq!(labels_v[1].text, "FileV");
+ assert_eq!(labels_v[2].text, "Edit");
+
+ // Hover test in vertical layout
+ let iy = menubar_v.item_y_vertical(0);
+ let ih = menubar_v.item_h_vertical();
+
+ // Move cursor inside "FileV" bounds
+ menubar_v.cursor_moved(20.0, iy + ih / 2.0);
+ assert_eq!(menubar_v.hovered_menu, Some(0));
+ }
+}