1
2use glam::{Vec2, Vec4};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum Axis { Horizontal, Vertical }
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum FlexJustify { Start, End, Center, SpaceBetween, SpaceAround, SpaceEvenly }
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum FlexAlign { Start, End, Center, Stretch, Baseline }
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum FlexWrap { NoWrap, Wrap, WrapReverse }
20
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum PositionType { Relative, Absolute, Fixed, Sticky }
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub enum SizeUnit { Pixel, Percent, Auto, MinContent, MaxContent, FitContent }
26
27#[derive(Debug, Clone, Copy)]
28pub struct SizeValue {
29 pub value: f32,
30 pub unit: SizeUnit,
31}
32
33impl SizeValue {
34 pub fn px(v: f32) -> Self { Self { value: v, unit: SizeUnit::Pixel } }
35 pub fn pct(v: f32) -> Self { Self { value: v, unit: SizeUnit::Percent } }
36 pub fn auto() -> Self { Self { value: 0.0, unit: SizeUnit::Auto } }
37 pub fn resolve(&self, parent: f32) -> f32 {
38 match self.unit {
39 SizeUnit::Pixel => self.value,
40 SizeUnit::Percent => parent * self.value / 100.0,
41 _ => 0.0,
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, Default)]
47pub struct Edges {
48 pub top: f32,
49 pub right: f32,
50 pub bottom: f32,
51 pub left: f32,
52}
53
54impl Edges {
55 pub fn all(v: f32) -> Self { Self { top: v, right: v, bottom: v, left: v } }
56 pub fn xy(x: f32, y: f32) -> Self { Self { top: y, right: x, bottom: y, left: x } }
57 pub fn horizontal(&self) -> f32 { self.left + self.right }
58 pub fn vertical(&self) -> f32 { self.top + self.bottom }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum Display { Flex, Grid, Block, Inline, InlineBlock, None }
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub enum Overflow { Visible, Hidden, Scroll, Auto }
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub enum TextOverflow { Clip, Ellipsis, Scroll }
71#[derive(Debug, Clone, Copy, PartialEq, Default)]
72pub enum BorderStyle { #[default] None, Solid, Dashed, Dotted, Double, Groove, Ridge, Inset, Outset }
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub enum BackgroundType { None, Solid, LinearGradient, RadialGradient, Image, Video }
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub enum CursorStyle { Default, Pointer, Text, Move, Crosshair, Wait, Help, EResize, NotAllowed }
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub enum Visibility { Visible, Hidden }
79
80#[derive(Debug, Clone)]
81pub struct Background {
82 pub background_type: BackgroundType,
83 pub color: Vec4,
84 pub gradient_from: Vec4,
85 pub gradient_to: Vec4,
86 pub gradient_angle: f32,
87 pub image_id: Option<u64>,
88 pub size_x: f32,
89 pub size_y: f32,
90 pub repeat: bool,
91}
92
93impl Default for Background {
94 fn default() -> Self {
95 Self {
96 background_type: BackgroundType::None,
97 color: Vec4::ZERO,
98 gradient_from: Vec4::ZERO,
99 gradient_to: Vec4::ONE,
100 gradient_angle: 0.0,
101 image_id: None,
102 size_x: 0.0,
103 size_y: 0.0,
104 repeat: false,
105 }
106 }
107}
108
109impl Background {
110 pub fn solid(color: Vec4) -> Self { Self { background_type: BackgroundType::Solid, color, ..Default::default() } }
111}
112
113#[derive(Debug, Clone, Default)]
114pub struct Border {
115 pub style: BorderStyle,
116 pub color: Vec4,
117 pub width: Edges,
118 pub radius: [f32; 4],
119}
120
121#[derive(Debug, Clone)]
122pub struct WidgetStyle {
123 pub display: Display,
124 pub position_type: PositionType,
125 pub overflow: Overflow,
126 pub overflow_x: Overflow,
127 pub overflow_y: Overflow,
128 pub visibility: Visibility,
129 pub opacity: f32,
130 pub z_index: i32,
131 pub width: SizeValue,
132 pub height: SizeValue,
133 pub min_width: SizeValue,
134 pub max_width: SizeValue,
135 pub min_height: SizeValue,
136 pub max_height: SizeValue,
137 pub margin: Edges,
138 pub padding: Edges,
139 pub top: SizeValue,
140 pub right: SizeValue,
141 pub bottom: SizeValue,
142 pub left: SizeValue,
143 pub flex_direction: Axis,
144 pub flex_wrap: FlexWrap,
145 pub justify_content: FlexJustify,
146 pub align_items: FlexAlign,
147 pub align_content: FlexAlign,
148 pub flex_grow: f32,
149 pub flex_shrink: f32,
150 pub flex_basis: SizeValue,
151 pub gap: Vec2,
152 pub background: Background,
153 pub border: Border,
154 pub cursor: CursorStyle,
155 pub font_size: f32,
156 pub font_family: String,
157 pub font_weight: u16,
158 pub font_italic: bool,
159 pub color: Vec4,
160 pub text_align: TextAlignH,
161 pub line_height: f32,
162 pub letter_spacing: f32,
163 pub word_spacing: f32,
164 pub text_decoration: TextDeco,
165 pub pointer_events: bool,
166 pub user_select: bool,
167 pub filter_blur: f32,
168 pub filter_brightness: f32,
169 pub transform_translate: Vec2,
170 pub transform_rotate: f32,
171 pub transform_scale: Vec2,
172 pub transition_duration: f32,
173 pub transition_easing: String,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq)]
177pub enum TextAlignH { Left, Center, Right, Justify }
178#[derive(Debug, Clone, Copy, PartialEq)]
179pub enum TextDeco { None, Underline, Overline, LineThrough }
180
181impl Default for WidgetStyle {
182 fn default() -> Self {
183 Self {
184 display: Display::Flex,
185 position_type: PositionType::Relative,
186 overflow: Overflow::Visible,
187 overflow_x: Overflow::Visible,
188 overflow_y: Overflow::Visible,
189 visibility: Visibility::Visible,
190 opacity: 1.0,
191 z_index: 0,
192 width: SizeValue::auto(),
193 height: SizeValue::auto(),
194 min_width: SizeValue::px(0.0),
195 max_width: SizeValue::px(f32::INFINITY),
196 min_height: SizeValue::px(0.0),
197 max_height: SizeValue::px(f32::INFINITY),
198 margin: Edges::all(0.0),
199 padding: Edges::all(0.0),
200 top: SizeValue::auto(),
201 right: SizeValue::auto(),
202 bottom: SizeValue::auto(),
203 left: SizeValue::auto(),
204 flex_direction: Axis::Horizontal,
205 flex_wrap: FlexWrap::NoWrap,
206 justify_content: FlexJustify::Start,
207 align_items: FlexAlign::Stretch,
208 align_content: FlexAlign::Start,
209 flex_grow: 0.0,
210 flex_shrink: 1.0,
211 flex_basis: SizeValue::auto(),
212 gap: Vec2::ZERO,
213 background: Background::default(),
214 border: Border::default(),
215 cursor: CursorStyle::Default,
216 font_size: 14.0,
217 font_family: "Inter".into(),
218 font_weight: 400,
219 font_italic: false,
220 color: Vec4::ONE,
221 text_align: TextAlignH::Left,
222 line_height: 1.4,
223 letter_spacing: 0.0,
224 word_spacing: 0.0,
225 text_decoration: TextDeco::None,
226 pointer_events: true,
227 user_select: true,
228 filter_blur: 0.0,
229 filter_brightness: 1.0,
230 transform_translate: Vec2::ZERO,
231 transform_rotate: 0.0,
232 transform_scale: Vec2::ONE,
233 transition_duration: 0.0,
234 transition_easing: "ease".into(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq)]
244pub enum WidgetKind {
245 Container, Button, Label, Image, Input, Checkbox, Radio, Slider, Toggle,
246 Dropdown, Combobox, ProgressBar, Spinner, Tooltip, Modal, Drawer, Tabs,
247 ScrollView, ListView, GridView, TreeView, Table, Card, Divider,
248 Icon, Badge, Avatar, Chip, Tag, Rating, ColorPicker, DatePicker, TimePicker,
249 Breadcrumb, Pagination, Stepper, Accordion, Popover, Notification, Toast,
250 Sidebar, Navbar, Footer, Hero, Grid, Flex, Stack, Spacer, Placeholder,
251}
252
253impl WidgetKind {
254 pub fn label(self) -> &'static str {
255 match self {
256 WidgetKind::Container => "Container",
257 WidgetKind::Button => "Button",
258 WidgetKind::Label => "Label",
259 WidgetKind::Image => "Image",
260 WidgetKind::Input => "Input",
261 WidgetKind::Checkbox => "Checkbox",
262 WidgetKind::Radio => "Radio",
263 WidgetKind::Slider => "Slider",
264 WidgetKind::Toggle => "Toggle",
265 WidgetKind::Dropdown => "Dropdown",
266 WidgetKind::Combobox => "Combobox",
267 WidgetKind::ProgressBar => "Progress Bar",
268 WidgetKind::Spinner => "Spinner",
269 WidgetKind::Modal => "Modal",
270 WidgetKind::Tabs => "Tabs",
271 WidgetKind::ScrollView => "Scroll View",
272 WidgetKind::ListView => "List View",
273 WidgetKind::GridView => "Grid View",
274 WidgetKind::TreeView => "Tree View",
275 WidgetKind::Table => "Table",
276 WidgetKind::ColorPicker => "Color Picker",
277 WidgetKind::Accordion => "Accordion",
278 WidgetKind::Sidebar => "Sidebar",
279 WidgetKind::Navbar => "Navbar",
280 _ => "Widget",
281 }
282 }
283
284 pub fn category(self) -> &'static str {
285 match self {
286 WidgetKind::Container | WidgetKind::Grid | WidgetKind::Flex | WidgetKind::Stack => "Layout",
287 WidgetKind::Button | WidgetKind::Checkbox | WidgetKind::Radio | WidgetKind::Toggle | WidgetKind::Slider | WidgetKind::Dropdown | WidgetKind::Input => "Form",
288 WidgetKind::Label | WidgetKind::Icon | WidgetKind::Badge | WidgetKind::Avatar => "Display",
289 WidgetKind::ListView | WidgetKind::GridView | WidgetKind::TreeView | WidgetKind::Table | WidgetKind::ScrollView => "Data",
290 WidgetKind::Modal | WidgetKind::Tooltip | WidgetKind::Popover | WidgetKind::Notification | WidgetKind::Toast => "Overlay",
291 WidgetKind::Navbar | WidgetKind::Sidebar | WidgetKind::Footer | WidgetKind::Breadcrumb => "Navigation",
292 _ => "Other",
293 }
294 }
295}
296
297#[derive(Debug, Clone)]
298pub struct Widget {
299 pub id: u64,
300 pub name: String,
301 pub kind: WidgetKind,
302 pub style: WidgetStyle,
303 pub children: Vec<u64>,
304 pub parent: Option<u64>,
305 pub computed_rect: [Vec2; 2],
306 pub visible: bool,
307 pub enabled: bool,
308 pub focused: bool,
309 pub hovered: bool,
310 pub pressed: bool,
311 pub text: String,
312 pub placeholder: String,
313 pub value_float: f32,
314 pub value_min: f32,
315 pub value_max: f32,
316 pub value_checked: bool,
317 pub value_string: String,
318 pub image_id: Option<u64>,
319 pub icon_name: String,
320 pub variant: String,
321 pub custom_data: HashMap<String, String>,
322 pub event_handlers: Vec<String>,
323 pub animation_id: Option<u32>,
324}
325
326impl Widget {
327 pub fn new(id: u64, name: impl Into<String>, kind: WidgetKind) -> Self {
328 Self {
329 id, name: name.into(), kind,
330 style: WidgetStyle::default(),
331 children: Vec::new(), parent: None,
332 computed_rect: [Vec2::ZERO; 2],
333 visible: true, enabled: true, focused: false, hovered: false, pressed: false,
334 text: String::new(), placeholder: String::new(),
335 value_float: 0.0, value_min: 0.0, value_max: 1.0,
336 value_checked: false, value_string: String::new(),
337 image_id: None, icon_name: String::new(),
338 variant: "default".into(),
339 custom_data: HashMap::new(),
340 event_handlers: Vec::new(),
341 animation_id: None,
342 }
343 }
344
345 pub fn button(id: u64, name: impl Into<String>, label: impl Into<String>) -> Self {
346 let mut w = Widget::new(id, name, WidgetKind::Button);
347 w.text = label.into();
348 w.style.padding = Edges::xy(16.0, 8.0);
349 w.style.background = Background::solid(Vec4::new(0.2, 0.5, 1.0, 1.0));
350 w.style.border.radius = [4.0; 4];
351 w
352 }
353
354 pub fn label(id: u64, name: impl Into<String>, text: impl Into<String>) -> Self {
355 let mut w = Widget::new(id, name, WidgetKind::Label);
356 w.text = text.into();
357 w
358 }
359
360 pub fn width_px(&self) -> f32 { self.computed_rect[1].x - self.computed_rect[0].x }
361 pub fn height_px(&self) -> f32 { self.computed_rect[1].y - self.computed_rect[0].y }
362
363 pub fn contains(&self, p: Vec2) -> bool {
364 p.x >= self.computed_rect[0].x && p.x < self.computed_rect[1].x &&
365 p.y >= self.computed_rect[0].y && p.y < self.computed_rect[1].y
366 }
367
368 pub fn center(&self) -> Vec2 {
369 (self.computed_rect[0] + self.computed_rect[1]) * 0.5
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq)]
378pub enum RenderMode { Screen, WorldSpace, CameraSpace }
379#[derive(Debug, Clone, Copy, PartialEq)]
380pub enum ScaleMode { ConstantPixelSize, ScaleWithScreenSize, ConstantPhysicalSize }
381
382#[derive(Debug, Clone)]
383pub struct UiCanvas {
384 pub widgets: Vec<Widget>,
385 pub root_ids: Vec<u64>,
386 pub width: f32,
387 pub height: f32,
388 pub render_mode: RenderMode,
389 pub scale_mode: ScaleMode,
390 pub reference_width: f32,
391 pub reference_height: f32,
392 pub match_width_or_height: f32,
393 pub pixels_per_unit: f32,
394 pub sort_order: i32,
395 pub next_id: u64,
396 pub theme: UiTheme,
397}
398
399impl UiCanvas {
400 pub fn new(width: f32, height: f32) -> Self {
401 let mut canvas = Self {
402 widgets: Vec::new(),
403 root_ids: Vec::new(),
404 width, height,
405 render_mode: RenderMode::Screen,
406 scale_mode: ScaleMode::ScaleWithScreenSize,
407 reference_width: 1920.0,
408 reference_height: 1080.0,
409 match_width_or_height: 0.5,
410 pixels_per_unit: 1.0,
411 sort_order: 0,
412 next_id: 1,
413 theme: UiTheme::default_dark(),
414 };
415 canvas.build_demo();
416 canvas
417 }
418
419 fn build_demo(&mut self) {
420 let canvas_w = self.width;
421 let canvas_h = self.height;
422 let root_id = self.add_widget(Widget::new(0, "Root", WidgetKind::Container));
424 if let Some(w) = self.find_widget_mut(root_id) {
425 w.style.width = SizeValue::px(canvas_w);
426 w.style.height = SizeValue::px(canvas_h);
427 w.style.flex_direction = Axis::Vertical;
428 w.style.background = Background::solid(Vec4::new(0.1, 0.1, 0.12, 1.0));
429 }
430 let navbar = self.add_child_widget(root_id, Widget::new(0, "Navbar", WidgetKind::Navbar));
432 if let Some(w) = self.find_widget_mut(navbar) {
433 w.style.width = SizeValue::pct(100.0);
434 w.style.height = SizeValue::px(48.0);
435 w.style.background = Background::solid(Vec4::new(0.15, 0.15, 0.18, 1.0));
436 w.style.padding = Edges::xy(16.0, 0.0);
437 w.style.align_items = FlexAlign::Center;
438 }
439 let main = self.add_child_widget(root_id, Widget::new(0, "Main", WidgetKind::Flex));
441 if let Some(w) = self.find_widget_mut(main) {
442 w.style.flex_grow = 1.0;
443 w.style.flex_direction = Axis::Horizontal;
444 }
445 let sidebar = self.add_child_widget(main, Widget::new(0, "Sidebar", WidgetKind::Sidebar));
447 if let Some(w) = self.find_widget_mut(sidebar) {
448 w.style.width = SizeValue::px(240.0);
449 w.style.height = SizeValue::pct(100.0);
450 w.style.background = Background::solid(Vec4::new(0.13, 0.13, 0.15, 1.0));
451 }
452 let content = self.add_child_widget(main, Widget::new(0, "Content", WidgetKind::Container));
454 if let Some(w) = self.find_widget_mut(content) {
455 w.style.flex_grow = 1.0;
456 w.style.padding = Edges::all(24.0);
457 w.style.flex_direction = Axis::Vertical;
458 w.style.gap = Vec2::new(16.0, 16.0);
459 }
460 let btn1 = Widget::button(0, "PrimaryButton", "Save Project");
462 self.add_child_widget(content, btn1);
463 let btn2 = Widget::button(0, "SecondaryButton", "Export");
464 self.add_child_widget(content, btn2);
465 let lbl = Widget::label(0, "TitleLabel", "Properties");
466 self.add_child_widget(content, lbl);
467 }
468
469 pub fn add_widget(&mut self, mut widget: Widget) -> u64 {
470 let id = self.next_id;
471 self.next_id += 1;
472 widget.id = id;
473 if widget.parent.is_none() {
474 self.root_ids.push(id);
475 }
476 self.widgets.push(widget);
477 id
478 }
479
480 pub fn add_child_widget(&mut self, parent_id: u64, mut widget: Widget) -> u64 {
481 let id = self.next_id;
482 self.next_id += 1;
483 widget.id = id;
484 widget.parent = Some(parent_id);
485 self.widgets.push(widget);
486 if let Some(parent) = self.find_widget_mut(parent_id) {
487 parent.children.push(id);
488 }
489 id
490 }
491
492 pub fn find_widget(&self, id: u64) -> Option<&Widget> {
493 self.widgets.iter().find(|w| w.id == id)
494 }
495
496 pub fn find_widget_mut(&mut self, id: u64) -> Option<&mut Widget> {
497 self.widgets.iter_mut().find(|w| w.id == id)
498 }
499
500 pub fn hit_test(&self, p: Vec2) -> Option<u64> {
501 for w in self.widgets.iter().rev() {
503 if w.visible && w.enabled && w.style.pointer_events && w.contains(p) {
504 return Some(w.id);
505 }
506 }
507 None
508 }
509
510 pub fn widget_count(&self) -> usize { self.widgets.len() }
511}
512
513#[derive(Debug, Clone)]
518pub struct UiTheme {
519 pub name: String,
520 pub primary: Vec4,
521 pub secondary: Vec4,
522 pub success: Vec4,
523 pub warning: Vec4,
524 pub error: Vec4,
525 pub background: Vec4,
526 pub surface: Vec4,
527 pub on_background: Vec4,
528 pub on_surface: Vec4,
529 pub on_primary: Vec4,
530 pub border_color: Vec4,
531 pub border_radius: f32,
532 pub font_family: String,
533 pub font_size_sm: f32,
534 pub font_size_md: f32,
535 pub font_size_lg: f32,
536 pub spacing_unit: f32,
537 pub shadow_color: Vec4,
538 pub focus_ring_color: Vec4,
539 pub disabled_opacity: f32,
540}
541
542impl UiTheme {
543 pub fn default_dark() -> Self {
544 Self {
545 name: "Dark".into(),
546 primary: Vec4::new(0.24, 0.52, 1.0, 1.0),
547 secondary: Vec4::new(0.6, 0.4, 1.0, 1.0),
548 success: Vec4::new(0.2, 0.8, 0.4, 1.0),
549 warning: Vec4::new(1.0, 0.7, 0.2, 1.0),
550 error: Vec4::new(0.9, 0.3, 0.3, 1.0),
551 background: Vec4::new(0.1, 0.1, 0.12, 1.0),
552 surface: Vec4::new(0.15, 0.15, 0.18, 1.0),
553 on_background: Vec4::new(0.95, 0.95, 0.95, 1.0),
554 on_surface: Vec4::new(0.9, 0.9, 0.9, 1.0),
555 on_primary: Vec4::ONE,
556 border_color: Vec4::new(0.3, 0.3, 0.35, 1.0),
557 border_radius: 6.0,
558 font_family: "Inter".into(),
559 font_size_sm: 12.0,
560 font_size_md: 14.0,
561 font_size_lg: 18.0,
562 spacing_unit: 8.0,
563 shadow_color: Vec4::new(0.0, 0.0, 0.0, 0.4),
564 focus_ring_color: Vec4::new(0.24, 0.52, 1.0, 0.6),
565 disabled_opacity: 0.38,
566 }
567 }
568
569 pub fn default_light() -> Self {
570 let mut t = Self::default_dark();
571 t.name = "Light".into();
572 t.background = Vec4::new(0.97, 0.97, 0.97, 1.0);
573 t.surface = Vec4::ONE;
574 t.on_background = Vec4::new(0.1, 0.1, 0.1, 1.0);
575 t.on_surface = Vec4::new(0.15, 0.15, 0.15, 1.0);
576 t.border_color = Vec4::new(0.8, 0.8, 0.8, 1.0);
577 t
578 }
579
580 pub fn spacing(&self, n: f32) -> f32 { self.spacing_unit * n }
581}
582
583#[derive(Debug, Clone, Copy, PartialEq)]
588pub enum UiEditorTool { Select, Pan, Rect, Text, Image, InsertWidget }
589#[derive(Debug, Clone, Copy, PartialEq)]
590pub enum UiEditorPanel { Canvas, Properties, Hierarchy, Components, Theme, Preview }
591
592#[derive(Debug, Clone)]
593pub struct UiCanvasEditor {
594 pub canvas: UiCanvas,
595 pub selected_widgets: Vec<u64>,
596 pub tool: UiEditorTool,
597 pub active_panel: UiEditorPanel,
598 pub zoom: f32,
599 pub pan: Vec2,
600 pub show_grid: bool,
601 pub grid_size: f32,
602 pub snap_to_grid: bool,
603 pub show_ruler: bool,
604 pub show_guides: bool,
605 pub guides: Vec<(bool, f32)>, pub theme_names: Vec<String>,
607 pub active_theme: usize,
608 pub themes: Vec<UiTheme>,
609 pub history: Vec<Vec<Widget>>,
610 pub history_pos: usize,
611 pub search_query: String,
612 pub preview_responsive: bool,
613 pub preview_width: f32,
614 pub preview_height: f32,
615 pub show_layout_debug: bool,
616 pub animate_transitions: bool,
617}
618
619impl UiCanvasEditor {
620 pub fn new() -> Self {
621 let canvas = UiCanvas::new(1920.0, 1080.0);
622 let themes = vec![UiTheme::default_dark(), UiTheme::default_light()];
623 let theme_names: Vec<String> = themes.iter().map(|t| t.name.clone()).collect();
624 Self {
625 canvas,
626 selected_widgets: Vec::new(),
627 tool: UiEditorTool::Select,
628 active_panel: UiEditorPanel::Canvas,
629 zoom: 1.0,
630 pan: Vec2::ZERO,
631 show_grid: true,
632 grid_size: 8.0,
633 snap_to_grid: false,
634 show_ruler: true,
635 show_guides: true,
636 guides: Vec::new(),
637 theme_names,
638 active_theme: 0,
639 themes,
640 history: Vec::new(),
641 history_pos: 0,
642 search_query: String::new(),
643 preview_responsive: false,
644 preview_width: 1920.0,
645 preview_height: 1080.0,
646 show_layout_debug: false,
647 animate_transitions: true,
648 }
649 }
650
651 pub fn select(&mut self, id: u64) { self.selected_widgets = vec![id]; }
652 pub fn add_to_selection(&mut self, id: u64) {
653 if !self.selected_widgets.contains(&id) { self.selected_widgets.push(id); }
654 }
655 pub fn deselect_all(&mut self) { self.selected_widgets.clear(); }
656
657 pub fn selected_widget(&self) -> Option<&Widget> {
658 self.selected_widgets.first().and_then(|&id| self.canvas.find_widget(id))
659 }
660
661 pub fn snapshot(&mut self) {
662 self.history.truncate(self.history_pos);
663 self.history.push(self.canvas.widgets.clone());
664 self.history_pos = self.history.len();
665 }
666
667 pub fn undo(&mut self) {
668 if self.history_pos > 1 {
669 self.history_pos -= 1;
670 self.canvas.widgets = self.history[self.history_pos - 1].clone();
671 }
672 }
673
674 pub fn redo(&mut self) {
675 if self.history_pos < self.history.len() {
676 self.canvas.widgets = self.history[self.history_pos].clone();
677 self.history_pos += 1;
678 }
679 }
680
681 pub fn current_theme(&self) -> &UiTheme {
682 &self.themes[self.active_theme.min(self.themes.len() - 1)]
683 }
684
685 pub fn screen_to_canvas(&self, screen: Vec2) -> Vec2 {
686 (screen - self.pan) / self.zoom
687 }
688
689 pub fn canvas_to_screen(&self, canvas: Vec2) -> Vec2 {
690 canvas * self.zoom + self.pan
691 }
692
693 pub fn hit_test_canvas(&self, screen: Vec2) -> Option<u64> {
694 let canvas_pos = self.screen_to_canvas(screen);
695 self.canvas.hit_test(canvas_pos)
696 }
697
698 pub fn search_widgets(&self, query: &str) -> Vec<&Widget> {
699 let q = query.to_lowercase();
700 self.canvas.widgets.iter().filter(|w| {
701 w.name.to_lowercase().contains(&q) ||
702 w.kind.label().to_lowercase().contains(&q) ||
703 w.text.to_lowercase().contains(&q)
704 }).collect()
705 }
706
707 pub fn duplicate_selected(&mut self) {
708 if self.selected_widgets.is_empty() { return; }
709 self.snapshot();
710 let id = self.selected_widgets[0];
711 if let Some(widget) = self.canvas.find_widget(id).cloned() {
712 let parent_id = widget.parent;
713 let mut new_w = widget.clone();
714 let new_id = self.canvas.next_id;
715 new_w.id = new_id;
716 new_w.name = format!("{}_copy", new_w.name);
717 new_w.children.clear();
718 if let Some(pid) = parent_id {
719 self.canvas.add_child_widget(pid, new_w);
720 } else {
721 self.canvas.add_widget(new_w);
722 }
723 self.selected_widgets = vec![new_id];
724 }
725 }
726}
727
728#[cfg(test)]
732mod tests {
733 use super::*;
734
735 #[test]
736 fn test_size_value() {
737 let pct = SizeValue::pct(50.0);
738 assert!((pct.resolve(200.0) - 100.0).abs() < 1e-5);
739 let px = SizeValue::px(42.0);
740 assert!((px.resolve(100.0) - 42.0).abs() < 1e-5);
741 }
742
743 #[test]
744 fn test_canvas_creation() {
745 let canvas = UiCanvas::new(1920.0, 1080.0);
746 assert!(canvas.widget_count() > 0);
747 }
748
749 #[test]
750 fn test_widget_hierarchy() {
751 let mut canvas = UiCanvas::new(800.0, 600.0);
752 let parent = canvas.add_widget(Widget::new(0, "Parent", WidgetKind::Container));
753 let child = canvas.add_child_widget(parent, Widget::new(0, "Child", WidgetKind::Button));
754 assert_eq!(canvas.find_widget(child).unwrap().parent, Some(parent));
755 assert!(canvas.find_widget(parent).unwrap().children.contains(&child));
756 }
757
758 #[test]
759 fn test_theme() {
760 let theme = UiTheme::default_dark();
761 assert!((theme.spacing(2.0) - 16.0).abs() < 1e-5);
762 }
763
764 #[test]
765 fn test_editor() {
766 let mut ed = UiCanvasEditor::new();
767 assert!(ed.canvas.widget_count() > 0);
768 ed.snapshot();
769 ed.undo();
770 }
771}