Skip to main content

button_press/
button-press.rs

1use mittens_engine::{engine, utils};
2
3#[path = "example_util/mod.rs"]
4mod example_util;
5
6use std::collections::HashMap;
7use std::sync::{Mutex, OnceLock};
8
9#[derive(Debug, Clone, Copy)]
10struct ButtonBorder {
11    left: f32,
12    top: f32,
13    right: f32,
14    bottom: f32,
15}
16
17impl ButtonBorder {
18    fn new(left: f32, top: f32, right: f32, bottom: f32) -> Self {
19        Self {
20            left,
21            top,
22            right,
23            bottom,
24        }
25    }
26
27    fn uniform(v: f32) -> Self {
28        Self {
29            left: v,
30            top: v,
31            right: v,
32            bottom: v,
33        }
34    }
35}
36
37#[derive(Debug, Clone, Copy)]
38struct ButtonState {
39    pressed: bool,
40
41    cap_root: engine::ecs::ComponentId,
42    cap_raised_z: f32,
43    cap_pressed_z: f32,
44
45    frame_color: engine::ecs::ComponentId,
46    face_color: engine::ecs::ComponentId,
47
48    text_id: engine::ecs::ComponentId,
49    text_color: engine::ecs::ComponentId,
50
51    frame_color_up: [f32; 4],
52    face_color_up: [f32; 4],
53    text_color_up: [f32; 4],
54
55    frame_color_down: [f32; 4],
56    face_color_down: [f32; 4],
57    text_color_down: [f32; 4],
58}
59
60static BUTTONS: OnceLock<Mutex<HashMap<engine::ecs::ComponentId, ButtonState>>> = OnceLock::new();
61
62fn darken([r, g, b, a]: [f32; 4], factor: f32) -> [f32; 4] {
63    [r * factor, g * factor, b * factor, a]
64}
65
66fn set_button_pressed(
67    world: &mut engine::ecs::World,
68    emit: &mut dyn engine::ecs::SignalEmitter,
69    scope: engine::ecs::ComponentId,
70    pressed: bool,
71) {
72    let buttons = BUTTONS.get_or_init(|| Mutex::new(HashMap::new()));
73    let mut guard = buttons.lock().expect("button state lock");
74
75    // Signals are scoped to the hit renderable; handlers are invoked for all ancestors.
76    // Resolve the button root by walking up the topology chain until we find a registered button.
77    let mut cursor = Some(scope);
78    let mut button_root = None;
79    while let Some(node) = cursor {
80        if guard.contains_key(&node) {
81            button_root = Some(node);
82            break;
83        }
84        cursor = world.parent_of(node);
85    }
86    let Some(button_root) = button_root else {
87        return;
88    };
89
90    let Some(state) = guard.get_mut(&button_root) else {
91        return;
92    };
93
94    if state.pressed == pressed {
95        return;
96    }
97    state.pressed = pressed;
98
99    let (frame_rgba, face_rgba, text_rgba, cap_z) = if pressed {
100        (
101            state.frame_color_down,
102            state.face_color_down,
103            state.text_color_down,
104            state.cap_pressed_z,
105        )
106    } else {
107        (
108            state.frame_color_up,
109            state.face_color_up,
110            state.text_color_up,
111            state.cap_raised_z,
112        )
113    };
114
115    emit.push_intent_now(
116        button_root,
117        engine::ecs::IntentValue::SetColor {
118            component_ids: vec![state.frame_color],
119            rgba: frame_rgba,
120        },
121    );
122    emit.push_intent_now(
123        button_root,
124        engine::ecs::IntentValue::SetColor {
125            component_ids: vec![state.face_color],
126            rgba: face_rgba,
127        },
128    );
129
130    // Swap the text color component node rather than mutating in-place.
131    // This demonstrates late-attached style application for already-built text.
132    emit.push_intent_now(
133        button_root,
134        engine::ecs::IntentValue::RemoveSubtree {
135            component_ids: vec![state.text_color],
136        },
137    );
138    let new_text_color =
139        world.add_component(engine::ecs::component::ColorComponent { rgba: text_rgba });
140    emit.push_intent_now(
141        button_root,
142        engine::ecs::IntentValue::Attach {
143            parents: vec![state.text_id],
144            child: new_text_color,
145        },
146    );
147    state.text_color = new_text_color;
148
149    // Move the cap flush into the frame (clamped to two fixed depths).
150    // Use SetPosition so we don't accidentally stomp cap TRS.
151    emit.push_intent_now(
152        button_root,
153        engine::ecs::IntentValue::SetPosition {
154            component_ids: vec![state.cap_root],
155            position: [0.0, 0.0, cap_z],
156        },
157    );
158}
159
160fn button_press_handler(
161    world: &mut engine::ecs::World,
162    emit: &mut dyn engine::ecs::SignalEmitter,
163    env: &engine::ecs::Signal,
164) {
165    match env.event.as_ref() {
166        Some(engine::ecs::EventSignal::DragStart { .. }) => {
167            set_button_pressed(world, emit, env.scope, true);
168        }
169        Some(engine::ecs::EventSignal::DragEnd { .. }) => {
170            set_button_pressed(world, emit, env.scope, false);
171        }
172        _ => {}
173    }
174}
175
176fn spawn_raycastable_cube(
177    universe: &mut engine::Universe,
178    parent: engine::ecs::ComponentId,
179    pos: [f32; 3],
180    scale: [f32; 3],
181) -> engine::ecs::ComponentId {
182    use engine::ecs::component::{RaycastableComponent, RenderableComponent, TransformComponent};
183
184    let t = universe.world.add_component(
185        TransformComponent::new()
186            .with_position(pos[0], pos[1], pos[2])
187            .with_scale(scale[0], scale[1], scale[2]),
188    );
189    let r = universe.world.add_component(RenderableComponent::cube());
190    let rc = universe
191        .world
192        .add_component(RaycastableComponent::enabled());
193
194    let _ = universe.attach(parent, t);
195    let _ = universe.attach(t, r);
196    let _ = universe.attach(r, rc);
197
198    r
199}
200
201fn spawn_raycastable_colored_cube(
202    universe: &mut engine::Universe,
203    parent: engine::ecs::ComponentId,
204    pos: [f32; 3],
205    scale: [f32; 3],
206    rgba: [f32; 4],
207) -> engine::ecs::ComponentId {
208    use engine::ecs::component::ColorComponent;
209
210    let r = spawn_raycastable_cube(universe, parent, pos, scale);
211    let c = universe
212        .world
213        .add_component(ColorComponent::rgba(rgba[0], rgba[1], rgba[2], rgba[3]));
214    let _ = universe.attach(r, c);
215    r
216}
217
218fn wrap_at_for_button_middle_width(middle_w: f32, text_scale: f32) -> usize {
219    // TextSystem layout: each glyph advances by 1.0 in local X.
220    // With `text_root` scaled by `text_scale`, each glyph is ~`text_scale` world units wide.
221    // Reserve some horizontal padding so letters don't collide with the face border.
222    let padding_world = (middle_w * 0.10).clamp(0.02, 0.25);
223    let usable_world = (middle_w - padding_world).max(0.05);
224    let glyph_w_world = text_scale.abs().max(1e-4);
225
226    let chars_per_line = (usable_world / glyph_w_world).floor() as isize;
227    (chars_per_line.max(1) as usize).min(200)
228}
229
230fn measure_word_wrapped_block(text: &str, wrap_at: usize) -> (usize, usize) {
231    // Approximate the block size (max columns, rows) for word-wrap-at-space behavior.
232    // This is used only for centering the text root visually.
233    if text.is_empty() {
234        return (0, 0);
235    }
236
237    let wrap_at = wrap_at.max(1);
238
239    let mut rows = 1usize;
240    let mut col = 0usize;
241    let mut max_col = 0usize;
242
243    for (wi, word) in text.split_whitespace().enumerate() {
244        let wlen = word.chars().count();
245        if wlen == 0 {
246            continue;
247        }
248
249        // If this isn't the first word on the line, account for a space.
250        let needs_space = col > 0;
251        let add = wlen + if needs_space { 1 } else { 0 };
252
253        if col > 0 && col + add > wrap_at {
254            // Wrap at the previous whitespace opportunity.
255            max_col = max_col.max(col);
256            rows += 1;
257            col = 0;
258        }
259
260        // Place the word (and preceding space if any).
261        col += add;
262
263        // If a single word exceeds wrap_at, we don't break it (matching TextSystem word_wrap).
264        max_col = max_col.max(col);
265
266        // Preserve explicit newlines in the input.
267        if wi == 0 {
268            // no-op
269        }
270    }
271
272    max_col = max_col.max(col);
273    (max_col, rows)
274}
275
276fn spawn_button(
277    universe: &mut engine::Universe,
278    pos: [f32; 3],
279    middle_wh: [f32; 2],
280    border: ButtonBorder,
281    text: &str,
282    text_scale: f32,
283    color: [f32; 4],
284    background_color: [f32; 4],
285) -> engine::ecs::ComponentId {
286    use engine::ecs::component::{
287        ColorComponent, TextComponent, TextureFilteringComponent, TransformComponent,
288        TransparentCutoutComponent,
289    };
290
291    let button_root = universe
292        .world
293        .add_component(TransformComponent::new().with_position(pos[0], pos[1], pos[2]));
294
295    // Frame: static border around the button.
296    let frame_root = universe.world.add_component(TransformComponent::new());
297    let frame_color = universe
298        .world
299        .add_component(ColorComponent::rgba(color[0], color[1], color[2], color[3]));
300    let _ = universe.attach(button_root, frame_root);
301    let _ = universe.attach(frame_root, frame_color);
302
303    // Cap: pressable face + text.
304    let cap_raised_z = 0.030;
305    let cap_pressed_z = 0.000;
306    let cap_root = universe
307        .world
308        .add_component(TransformComponent::new().with_position(0.0, 0.0, cap_raised_z));
309    let _ = universe.attach(button_root, cap_root);
310
311    let face_color = universe.world.add_component(ColorComponent::rgba(
312        background_color[0],
313        background_color[1],
314        background_color[2],
315        background_color[3],
316    ));
317    let _ = universe.attach(cap_root, face_color);
318
319    // Geometry.
320    let depth = 0.025;
321    let middle_w = middle_wh[0];
322    let middle_h = middle_wh[1];
323
324    // Border pieces.
325    let full_w = middle_w + border.left + border.right;
326    let full_h = middle_h + border.top + border.bottom;
327
328    // Left / right.
329    let _left = spawn_raycastable_cube(
330        universe,
331        frame_color,
332        [-(middle_w * 0.5 + border.left * 0.5), 0.0, 0.0],
333        [border.left, full_h, depth],
334    );
335    let _right = spawn_raycastable_cube(
336        universe,
337        frame_color,
338        [(middle_w * 0.5 + border.right * 0.5), 0.0, 0.0],
339        [border.right, full_h, depth],
340    );
341
342    // Top / bottom.
343    let _top = spawn_raycastable_cube(
344        universe,
345        frame_color,
346        [0.0, (middle_h * 0.5 + border.top * 0.5), 0.0],
347        [full_w, border.top, depth],
348    );
349    let _bottom = spawn_raycastable_cube(
350        universe,
351        frame_color,
352        [0.0, -(middle_h * 0.5 + border.bottom * 0.5), 0.0],
353        [full_w, border.bottom, depth],
354    );
355
356    // Face (pressable center).
357    let _face = spawn_raycastable_cube(
358        universe,
359        face_color,
360        [0.0, 0.0, 0.0],
361        [middle_w, middle_h, depth],
362    );
363
364    // Text. (Heuristic centering; TextComponent is top-left anchored today.)
365    let text_root = universe.world.add_component(
366        TransformComponent::new()
367            .with_position(0.0, 0.0, depth * 0.5 + 0.006)
368            .with_scale(text_scale, text_scale, 1.0),
369    );
370    let _ = universe.attach(cap_root, text_root);
371
372    let wrap_at = wrap_at_for_button_middle_width(middle_w, text_scale);
373    let (cols, rows) = measure_word_wrapped_block(text, wrap_at);
374    let cols_f = cols.max(1) as f32;
375    let rows_f = rows.max(1) as f32;
376
377    // Center the text block around (0,0) in glyph-local units.
378    // X is left-to-right (0..cols-1), Y is down (0, -1, -2...).
379    let x_center = -0.5 * (cols_f - 1.0);
380    let y_center = 0.5 * (rows_f - 1.0);
381    let text_offset = universe
382        .world
383        .add_component(TransformComponent::new().with_position(x_center, y_center, 0.0));
384    let _ = universe.attach(text_root, text_offset);
385
386    let text_id = universe
387        .world
388        .add_component(TextComponent::with_word_wrap(text, wrap_at));
389    let _ = universe.attach(text_offset, text_id);
390
391    let cutout = universe
392        .world
393        .add_component(TransparentCutoutComponent::new());
394    let _ = universe.attach(text_id, cutout);
395
396    let filtering = universe
397        .world
398        .add_component(TextureFilteringComponent::nearest_magnification());
399    let _ = universe.attach(text_id, filtering);
400
401    universe.add(button_root);
402
403    // Attach text color *after* text has been built/initialized.
404    // We intentionally initialize the ColorComponent before attachment so that its `init()` will
405    // NOT re-run on attach; this exercises the TextSystem ParentChanged refresh behavior.
406    let text_color = universe
407        .world
408        .add_component(ColorComponent::rgba(0.0, 0.0, 0.0, 1.0));
409    universe.add(text_color);
410    let _ = universe.attach(text_id, text_color);
411
412    // Stash state for the signal handler.
413    let state = ButtonState {
414        pressed: false,
415        cap_root,
416        cap_raised_z,
417        cap_pressed_z,
418        frame_color,
419        face_color,
420        text_id,
421        text_color,
422        frame_color_up: color,
423        face_color_up: background_color,
424        text_color_up: [0.0, 0.0, 0.0, 1.0],
425        frame_color_down: darken(color, 0.75),
426        face_color_down: darken(background_color, 0.75),
427        text_color_down: [1.0, 1.0, 1.0, 1.0],
428    };
429
430    BUTTONS
431        .get_or_init(|| Mutex::new(HashMap::new()))
432        .lock()
433        .expect("button state lock")
434        .insert(button_root, state);
435
436    button_root
437}
438
439fn main() {
440    mittens_engine::example_support::ensure_model_assets();
441    utils::logger::init();
442
443    let world = engine::ecs::World::default();
444    let mut universe = engine::Universe::new(world);
445
446    // Minimal lit scene + pointer raycaster.
447    use engine::ecs::component::{
448        AmbientLightComponent, BackgroundColorComponent, Camera3DComponent,
449        DirectionalLightComponent, InputComponent, InputTransformModeComponent, PointerComponent,
450        TransformComponent,
451    };
452
453    let bg = universe
454        .world
455        .add_component(BackgroundColorComponent::new());
456    let bg_c = universe
457        .world
458        .add_component(engine::ecs::component::ColorComponent::rgba(
459            0.92, 0.92, 0.96, 1.0,
460        ));
461    let _ = universe.world.add_child(bg, bg_c);
462    universe.add(bg);
463
464    let ambient = universe
465        .world
466        .add_component(AmbientLightComponent::rgb(0.35, 0.35, 0.35));
467    universe.add(ambient);
468
469    let sun_t = universe
470        .world
471        .add_component(TransformComponent::new().with_position(1.0, 1.0, 1.0));
472    let sun = universe
473        .world
474        .add_component(DirectionalLightComponent::new());
475    let _ = universe.attach(sun_t, sun);
476    universe.add(sun_t);
477
478    let input = universe
479        .world
480        .add_component(InputComponent::new().with_speed(2.5));
481    let input_mode = universe.world.add_component(
482        InputTransformModeComponent::forward_z()
483            .with_fps_rotation()
484            .with_roll_axis_y(),
485    );
486    let _ = universe.attach(input, input_mode);
487
488    let rig_t = universe
489        .world
490        .add_component(TransformComponent::new().with_position(0.0, 0.0, 2.5));
491    let _ = universe.attach(input, rig_t);
492
493    let cam = universe
494        .world
495        .add_component(Camera3DComponent::new().with_far(600.0).with_fov(70.0));
496    let _ = universe.attach(rig_t, cam);
497
498    let pointer = universe.world.add_component(PointerComponent::new());
499    let _ = universe.attach(cam, pointer);
500
501    example_util::spawn_desktop_camera_controls_hint(&mut universe, rig_t);
502    universe.add(input);
503
504    // The button.
505    let button_root = spawn_button(
506        &mut universe,
507        [0.0, 0.0, 0.0],
508        [1.20, 0.45],
509        ButtonBorder::new(0.025, 0.025, 0.025, 0.025),
510        "click me",
511        0.18,
512        [0.15, 0.15, 0.20, 1.0],
513        [0.85, 0.85, 0.92, 1.0],
514    );
515
516    // A small 3-cube stack to the left of the button for gizmo testing:
517    //   [cyan]
518    // [yellow][light brown]
519    //
520    // These live under an EditorComponent subtree so clicking attaches the editor gizmo.
521    {
522        use engine::ecs::component::{EditorComponent, TransformComponent};
523
524        let editor_root = universe.world.add_component(EditorComponent::new());
525
526        // Position the stack in world space (left of the button).
527        let stack_root = universe
528            .world
529            .add_component(TransformComponent::new().with_position(-1.55, 0.0, 0.0));
530        let _ = universe.attach(editor_root, stack_root);
531
532        let cube = 0.22_f32;
533        let gap = 0.04_f32;
534        let step = cube + gap;
535        let y_bottom = -0.5 * step;
536        let y_top = 0.5 * step;
537        let x_left = -0.5 * step;
538        let x_right = 0.5 * step;
539
540        let yellow = [1.0, 0.92, 0.22, 1.0];
541        let light_brown = [0.80, 0.66, 0.46, 1.0];
542        let cyan = [0.20, 0.95, 1.0, 1.0];
543
544        let _bottom_left = spawn_raycastable_colored_cube(
545            &mut universe,
546            stack_root,
547            [x_left, y_bottom, 0.0],
548            [cube, cube, 0.06],
549            yellow,
550        );
551        let _bottom_right = spawn_raycastable_colored_cube(
552            &mut universe,
553            stack_root,
554            [x_right, y_bottom, 0.0],
555            [cube, cube, 0.06],
556            light_brown,
557        );
558        let _top = spawn_raycastable_colored_cube(
559            &mut universe,
560            stack_root,
561            [0.0, y_top, 0.0],
562            [cube, cube, 0.06],
563            cyan,
564        );
565
566        universe.add(editor_root);
567    }
568
569    universe.add_signal_handler(
570        engine::ecs::SignalKind::DragStart,
571        button_root,
572        button_press_handler,
573    );
574    universe.add_signal_handler(
575        engine::ecs::SignalKind::DragEnd,
576        button_root,
577        button_press_handler,
578    );
579
580    universe.systems.process_commands(
581        &mut universe.world,
582        &mut universe.visuals,
583        &mut universe.render_assets,
584        &mut universe.command_queue,
585    );
586
587    universe.enable_repl();
588    engine::Windowing::run_app(universe).expect("Windowing failed");
589}