Files
android-ui-dogfooding/m3_controls.odin
T
2026-09-04 16:08:34 +02:00

297 lines
9.0 KiB
Odin

package main
import engine "shared: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)
engine.ui_box_equip_custom_draw(box, switch_draw, d, transmute(u32)d.t)
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)
engine.ui_box_equip_custom_draw(box, checkbox_draw, d, transmute(u32)d.t)
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)
engine.ui_box_equip_custom_draw(box, radio_draw, d, transmute(u32)d.t)
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)
engine.ui_box_equip_custom_draw(box, slider_draw, d, transmute(u32)d.t)
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
engine.ui_box_equip_custom_draw(box, progress_draw, d, transmute(u32)d.t)
}
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,
}
}