2026-09-04 14:26:42 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import engine "../zephr"
|
|
|
|
|
|
|
|
|
|
// M3 selection controls and indicators.
|
|
|
|
|
//
|
|
|
|
|
// These are drawn rather than assembled from buttons: a switch thumb that grows
|
|
|
|
|
// on selection, a checkbox tick, a slider track split at the handle. All of them
|
|
|
|
|
// are containers with a custom draw, plus a clickable box for the signal.
|
|
|
|
|
|
|
|
|
|
// -- Switch ------------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// Track 52x32 fully rounded. Thumb 16dp unselected, 24dp selected, and the
|
|
|
|
|
// growth plus travel is what sells the control -- both are animated.
|
|
|
|
|
|
|
|
|
|
M3_SWITCH_W :: 52
|
|
|
|
|
M3_SWITCH_H :: 32
|
|
|
|
|
M3_SWITCH_THUMB_OFF :: 16
|
|
|
|
|
M3_SWITCH_THUMB_ON :: 24
|
|
|
|
|
M3_SWITCH_BORDER :: 2
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
Switch_Draw :: struct {
|
|
|
|
|
t: f32, // 0 = off, 1 = on
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
switch_draw :: proc(box: ^engine.Box, user_data: rawptr) {
|
|
|
|
|
d := cast(^Switch_Draw)user_data
|
|
|
|
|
if d == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
t := d.t
|
|
|
|
|
|
|
|
|
|
r := box.rect
|
|
|
|
|
h := r.y1 - r.y0
|
|
|
|
|
|
|
|
|
|
track_bg := m3_lerp_color(m3.surface_variant, m3.primary, t)
|
|
|
|
|
engine.ui_draw_rect(r, track_bg, 0, 0, h / 2)
|
|
|
|
|
|
|
|
|
|
// Unselected the track is outlined; the outline fades out as it fills.
|
|
|
|
|
if t < 1 {
|
|
|
|
|
engine.ui_draw_rect(r, m3_alpha(m3.outline, 1 - t), M3_SWITCH_BORDER, 0, h / 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumb_d := M3_SWITCH_THUMB_OFF + (M3_SWITCH_THUMB_ON - M3_SWITCH_THUMB_OFF) * t
|
|
|
|
|
thumb_c := m3_lerp_color(m3.outline, m3.on_primary, t)
|
|
|
|
|
|
|
|
|
|
// Travel is measured between thumb centres so the inset stays even at both
|
|
|
|
|
// ends despite the thumb changing size.
|
|
|
|
|
pad := (h - M3_SWITCH_THUMB_ON) / 2
|
|
|
|
|
cx0 := r.x0 + pad + M3_SWITCH_THUMB_ON / 2
|
|
|
|
|
cx1 := r.x1 - pad - M3_SWITCH_THUMB_ON / 2
|
|
|
|
|
cx := cx0 + (cx1 - cx0) * t
|
|
|
|
|
cy := (r.y0 + r.y1) / 2
|
|
|
|
|
|
|
|
|
|
thumb := engine.ui_rect(cx - thumb_d / 2, cy - thumb_d / 2, cx + thumb_d / 2, cy + thumb_d / 2)
|
|
|
|
|
engine.ui_draw_rect(thumb, thumb_c, 0, 0, thumb_d / 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_switch :: proc(on: ^bool, id: engine.Id) -> bool {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_px(M3_SWITCH_W, 1))
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_SWITCH_H, 1))
|
|
|
|
|
box := engine.ui_create_box_with_id({.MouseClickable, .KeyboardClickable}, id)
|
|
|
|
|
sig := engine.ui_signal_from_box(box)
|
|
|
|
|
|
|
|
|
|
clicked := engine.ui_clicked(sig)
|
|
|
|
|
if clicked {
|
|
|
|
|
on^ = !on^
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d := new(Switch_Draw, context.temp_allocator)
|
|
|
|
|
d.t = engine.ui_anim_f32(id, on^ ? 1 : 0)
|
2026-09-04 16:07:32 +02:00
|
|
|
engine.ui_box_equip_custom_draw(box, switch_draw, d, transmute(u32)d.t)
|
2026-09-04 14:26:42 +02:00
|
|
|
return clicked
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Checkbox ----------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// 18dp box with a 2dp outline and 2dp radius, centred in a 48dp target.
|
|
|
|
|
|
|
|
|
|
M3_CHECKBOX_SIZE :: 18
|
|
|
|
|
M3_CHECKBOX_TARGET :: 48
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
Check_Draw :: struct {
|
|
|
|
|
t: f32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
checkbox_draw :: proc(box: ^engine.Box, user_data: rawptr) {
|
|
|
|
|
d := cast(^Check_Draw)user_data
|
|
|
|
|
if d == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
t := d.t
|
|
|
|
|
|
|
|
|
|
r := box.rect
|
|
|
|
|
cx := (r.x0 + r.x1) / 2
|
|
|
|
|
cy := (r.y0 + r.y1) / 2
|
|
|
|
|
half := f32(M3_CHECKBOX_SIZE) / 2
|
|
|
|
|
inner := engine.ui_rect(cx - half, cy - half, cx + half, cy + half)
|
|
|
|
|
|
|
|
|
|
if t > 0 {
|
|
|
|
|
engine.ui_draw_rect(inner, m3_alpha(m3.primary, t), 0, 0, M3_SHAPE_XS / 2)
|
|
|
|
|
}
|
|
|
|
|
if t < 1 {
|
|
|
|
|
engine.ui_draw_rect(inner, m3_alpha(m3.on_surface_variant, 1 - t), 2, 0, M3_SHAPE_XS / 2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The tick is a glyph rather than geometry: the icon font already has one that
|
|
|
|
|
// matches the spec, and drawing two rotated strokes would only approximate it.
|
|
|
|
|
m3_checkbox :: proc(checked: ^bool, id: engine.Id) -> bool {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_px(M3_CHECKBOX_TARGET, 1))
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_CHECKBOX_TARGET, 1))
|
|
|
|
|
engine.ui_set_next_align_x(.Center)
|
|
|
|
|
engine.ui_set_next_align_y(.Center)
|
|
|
|
|
box := engine.ui_create_box_with_id({.MouseClickable, .KeyboardClickable}, id)
|
|
|
|
|
sig := engine.ui_signal_from_box(box)
|
|
|
|
|
|
|
|
|
|
clicked := engine.ui_clicked(sig)
|
|
|
|
|
if clicked {
|
|
|
|
|
checked^ = !checked^
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d := new(Check_Draw, context.temp_allocator)
|
|
|
|
|
d.t = engine.ui_anim_f32(id, checked^ ? 1 : 0)
|
2026-09-04 16:07:32 +02:00
|
|
|
engine.ui_box_equip_custom_draw(box, checkbox_draw, d, transmute(u32)d.t)
|
2026-09-04 14:26:42 +02:00
|
|
|
|
|
|
|
|
if checked^ {
|
|
|
|
|
engine.push_parent(box)
|
|
|
|
|
engine.ui_set_next_font_size(M3_CHECKBOX_SIZE)
|
|
|
|
|
engine.ui_set_next_text_color(m3.on_primary)
|
|
|
|
|
engine.ui_text(ICON_CHECK)
|
|
|
|
|
engine.pop_parent()
|
|
|
|
|
}
|
|
|
|
|
return clicked
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Radio button ------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// 20dp circle, 2dp outline, 10dp inner dot when selected.
|
|
|
|
|
|
|
|
|
|
M3_RADIO_SIZE :: 20
|
|
|
|
|
M3_RADIO_TARGET :: 48
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
Radio_Draw :: struct {
|
|
|
|
|
t: f32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
radio_draw :: proc(box: ^engine.Box, user_data: rawptr) {
|
|
|
|
|
d := cast(^Radio_Draw)user_data
|
|
|
|
|
if d == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
r := box.rect
|
|
|
|
|
cx := (r.x0 + r.x1) / 2
|
|
|
|
|
cy := (r.y0 + r.y1) / 2
|
|
|
|
|
half := f32(M3_RADIO_SIZE) / 2
|
|
|
|
|
|
|
|
|
|
ring := engine.ui_rect(cx - half, cy - half, cx + half, cy + half)
|
|
|
|
|
ring_c := m3_lerp_color(m3.on_surface_variant, m3.primary, d.t)
|
|
|
|
|
engine.ui_draw_rect(ring, ring_c, 2, 0, half)
|
|
|
|
|
|
|
|
|
|
// The dot scales up from nothing rather than appearing, which is what the
|
|
|
|
|
// spec's selection transition does.
|
|
|
|
|
dot_half := (f32(M3_RADIO_SIZE) / 2 - 4) * d.t
|
|
|
|
|
if dot_half > 0 {
|
|
|
|
|
dot := engine.ui_rect(cx - dot_half, cy - dot_half, cx + dot_half, cy + dot_half)
|
|
|
|
|
engine.ui_draw_rect(dot, m3.primary, 0, 0, dot_half)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_radio :: proc(selected: bool, id: engine.Id) -> bool {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_px(M3_RADIO_TARGET, 1))
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_RADIO_TARGET, 1))
|
|
|
|
|
box := engine.ui_create_box_with_id({.MouseClickable, .KeyboardClickable}, id)
|
|
|
|
|
sig := engine.ui_signal_from_box(box)
|
|
|
|
|
|
|
|
|
|
d := new(Radio_Draw, context.temp_allocator)
|
|
|
|
|
d.t = engine.ui_anim_f32(id, selected ? 1 : 0)
|
2026-09-04 16:07:32 +02:00
|
|
|
engine.ui_box_equip_custom_draw(box, radio_draw, d, transmute(u32)d.t)
|
2026-09-04 14:26:42 +02:00
|
|
|
return engine.ui_clicked(sig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Slider ------------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// 4dp track, 20dp handle. The inactive track is drawn only to the right of the
|
|
|
|
|
// handle so the two never overlap through the handle's translucent edge.
|
|
|
|
|
|
|
|
|
|
M3_SLIDER_TRACK_H :: 4
|
|
|
|
|
M3_SLIDER_HANDLE :: 20
|
|
|
|
|
M3_SLIDER_HEIGHT :: 48
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
Slider_Draw :: struct {
|
|
|
|
|
t: f32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
slider_draw :: proc(box: ^engine.Box, user_data: rawptr) {
|
|
|
|
|
d := cast(^Slider_Draw)user_data
|
|
|
|
|
if d == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
r := box.rect
|
|
|
|
|
cy := (r.y0 + r.y1) / 2
|
|
|
|
|
half_h := f32(M3_SLIDER_TRACK_H) / 2
|
|
|
|
|
hh := f32(M3_SLIDER_HANDLE) / 2
|
|
|
|
|
|
|
|
|
|
// The handle centre stays inside the box at both extremes.
|
|
|
|
|
x0 := r.x0 + hh
|
|
|
|
|
x1 := r.x1 - hh
|
|
|
|
|
cx := x0 + (x1 - x0) * d.t
|
|
|
|
|
|
|
|
|
|
engine.ui_draw_rect(engine.ui_rect(r.x0, cy - half_h, cx - hh - 2, cy + half_h),
|
|
|
|
|
m3.primary, 0, 0, half_h)
|
|
|
|
|
engine.ui_draw_rect(engine.ui_rect(cx + hh + 2, cy - half_h, r.x1, cy + half_h),
|
|
|
|
|
m3.surface_c_highest, 0, 0, half_h)
|
|
|
|
|
engine.ui_draw_rect(engine.ui_rect(cx - hh, cy - hh, cx + hh, cy + hh),
|
|
|
|
|
m3.primary, 0, 0, hh)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Returns the value in 0..1, dragging while held.
|
|
|
|
|
m3_slider :: proc(value: ^f32, id: engine.Id) -> bool {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_SLIDER_HEIGHT, 1))
|
|
|
|
|
box := engine.ui_create_box_with_id({.MouseClickable, .KeyboardClickable}, id)
|
|
|
|
|
sig := engine.ui_signal_from_box(box)
|
|
|
|
|
|
|
|
|
|
changed := false
|
|
|
|
|
if engine.ui_dragging(sig) || engine.ui_pressed(sig) {
|
|
|
|
|
hh := f32(M3_SLIDER_HANDLE) / 2
|
|
|
|
|
x0 := box.rect.x0 + hh
|
|
|
|
|
x1 := box.rect.x1 - hh
|
|
|
|
|
if x1 > x0 {
|
|
|
|
|
// Pointer, not mouse: this tracks a finger just as well.
|
|
|
|
|
t := (engine.ui_pointer_pos().x - x0) / (x1 - x0)
|
|
|
|
|
value^ = clamp(t, 0, 1)
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d := new(Slider_Draw, context.temp_allocator)
|
|
|
|
|
d.t = clamp(value^, 0, 1)
|
2026-09-04 16:07:32 +02:00
|
|
|
engine.ui_box_equip_custom_draw(box, slider_draw, d, transmute(u32)d.t)
|
2026-09-04 14:26:42 +02:00
|
|
|
return changed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Linear progress ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
M3_PROGRESS_H :: 4
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
Progress_Draw :: struct {
|
|
|
|
|
t: f32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@(private = "file")
|
|
|
|
|
progress_draw :: proc(box: ^engine.Box, user_data: rawptr) {
|
|
|
|
|
d := cast(^Progress_Draw)user_data
|
|
|
|
|
if d == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
r := box.rect
|
|
|
|
|
half := f32(M3_PROGRESS_H) / 2
|
|
|
|
|
engine.ui_draw_rect(r, m3.surface_c_highest, 0, 0, half)
|
|
|
|
|
|
|
|
|
|
w := (r.x1 - r.x0) * clamp(d.t, 0, 1)
|
|
|
|
|
if w > 0 {
|
|
|
|
|
engine.ui_draw_rect(engine.ui_rect(r.x0, r.y0, r.x0 + w, r.y1),
|
|
|
|
|
m3.primary, 0, 0, half)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_progress_linear :: proc(value: f32, id: engine.Id) {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_PROGRESS_H, 1))
|
|
|
|
|
box := engine.ui_create_box_with_id({}, id)
|
|
|
|
|
|
|
|
|
|
d := new(Progress_Draw, context.temp_allocator)
|
|
|
|
|
d.t = value
|
2026-09-04 16:07:32 +02:00
|
|
|
engine.ui_box_equip_custom_draw(box, progress_draw, d, transmute(u32)d.t)
|
2026-09-04 14:26:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_lerp_color :: proc(a, b: engine.Color, t: f32) -> engine.Color {
|
|
|
|
|
return {
|
|
|
|
|
a.r + (b.r - a.r) * t,
|
|
|
|
|
a.g + (b.g - a.g) * t,
|
|
|
|
|
a.b + (b.b - a.b) * t,
|
|
|
|
|
a.a + (b.a - a.a) * t,
|
|
|
|
|
}
|
|
|
|
|
}
|