2026-09-04 14:26:42 +02:00
|
|
|
package main
|
|
|
|
|
|
2026-09-04 16:08:21 +02:00
|
|
|
import engine "shared:zephr"
|
2026-09-04 14:26:42 +02:00
|
|
|
|
|
|
|
|
// M3 containers, list items and navigation.
|
|
|
|
|
|
|
|
|
|
// -- Card --------------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// 12dp radius. The three variants differ only in container role and outline --
|
|
|
|
|
// M3 expresses elevation as colour, so no shadow is missing here.
|
|
|
|
|
|
|
|
|
|
M3_Card_Kind :: enum {
|
|
|
|
|
Elevated,
|
|
|
|
|
Filled,
|
|
|
|
|
Outlined,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_card_begin :: proc(kind: M3_Card_Kind, id: engine.Id) {
|
|
|
|
|
bg: engine.Color
|
|
|
|
|
outline := m3_alpha(m3.surface, 0)
|
|
|
|
|
thickness: f32 = 0
|
|
|
|
|
|
|
|
|
|
switch kind {
|
|
|
|
|
case .Elevated:
|
|
|
|
|
bg = m3.surface_c_low
|
|
|
|
|
case .Filled:
|
|
|
|
|
bg = m3.surface_c_highest
|
|
|
|
|
case .Outlined:
|
|
|
|
|
bg = m3.surface
|
|
|
|
|
outline = m3.outline_variant
|
|
|
|
|
thickness = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_children_sum(1))
|
|
|
|
|
engine.ui_set_next_background_color(bg)
|
|
|
|
|
engine.ui_set_next_border_color(outline)
|
|
|
|
|
engine.ui_set_next_border_thickness(thickness)
|
|
|
|
|
engine.ui_set_next_border_radius(M3_SHAPE_MD)
|
|
|
|
|
engine.ui_set_next_flags({.DrawBackground, .DrawBorder})
|
|
|
|
|
open_column(id)
|
|
|
|
|
engine.ui_padding(16, {.Left, .Right, .Top, .Bottom})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_card_end :: proc() {
|
|
|
|
|
close_box()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Divider -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
m3_divider :: proc(id: engine.Id) {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(1, 1))
|
|
|
|
|
engine.ui_set_next_background_color(m3.outline_variant)
|
|
|
|
|
engine.ui_set_next_flags({.DrawBackground})
|
|
|
|
|
engine.ui_create_box_with_id({}, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- List item ---------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// One-line list item: 56dp tall, 16dp horizontal padding, bodyLarge headline,
|
|
|
|
|
// with an optional leading icon and trailing text.
|
|
|
|
|
|
|
|
|
|
M3_LIST_ITEM_H :: 56
|
|
|
|
|
|
|
|
|
|
m3_list_item :: proc(headline, leading_icon, trailing: string, id: engine.Id) -> bool {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_LIST_ITEM_H, 1))
|
|
|
|
|
engine.ui_set_next_align_y(.Center)
|
|
|
|
|
box := engine.ui_create_box_with_id({.MouseClickable, .KeyboardClickable}, id)
|
|
|
|
|
sig := engine.ui_signal_from_box(box)
|
|
|
|
|
|
|
|
|
|
// The state layer is the only container a list item has at rest.
|
|
|
|
|
if engine.ui_hovering(sig) {
|
|
|
|
|
box.background_color = m3_state_layer(m3.surface, m3.on_surface, M3_STATE_HOVER)
|
|
|
|
|
box.flags |= {.DrawBackground}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
engine.push_parent(box)
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_align_y(.Center)
|
|
|
|
|
open_row()
|
|
|
|
|
|
|
|
|
|
engine.ui_spacer(engine.ui_px(16, 1))
|
|
|
|
|
|
|
|
|
|
if len(leading_icon) > 0 {
|
|
|
|
|
engine.ui_set_next_font_size(24)
|
|
|
|
|
engine.ui_set_next_text_color(m3.on_surface_variant)
|
|
|
|
|
engine.ui_text(leading_icon)
|
|
|
|
|
engine.ui_spacer(engine.ui_px(16, 1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_font_size(M3_BODY_LARGE)
|
|
|
|
|
engine.ui_set_next_text_color(m3.on_surface)
|
|
|
|
|
engine.ui_text(headline)
|
|
|
|
|
|
|
|
|
|
engine.ui_spacer(engine.ui_percent(1, 0))
|
|
|
|
|
|
|
|
|
|
if len(trailing) > 0 {
|
|
|
|
|
engine.ui_set_next_font_size(M3_LABEL_SMALL)
|
|
|
|
|
engine.ui_set_next_text_color(m3.on_surface_variant)
|
|
|
|
|
engine.ui_text(trailing)
|
|
|
|
|
}
|
|
|
|
|
engine.ui_spacer(engine.ui_px(16, 1))
|
|
|
|
|
|
|
|
|
|
close_box() // row
|
|
|
|
|
close_box() // box
|
|
|
|
|
return engine.ui_clicked(sig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Snackbar ----------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// inverseSurface container at 4dp radius, 48dp min height, with the action
|
|
|
|
|
// label in inversePrimary.
|
|
|
|
|
|
|
|
|
|
m3_snackbar :: proc(message, action: string, id: engine.Id) -> bool {
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(48, 1))
|
|
|
|
|
engine.ui_set_next_background_color(m3.inverse_surface)
|
|
|
|
|
engine.ui_set_next_border_radius(M3_SHAPE_XS)
|
|
|
|
|
engine.ui_set_next_align_y(.Center)
|
|
|
|
|
engine.ui_set_next_flags({.DrawBackground})
|
|
|
|
|
engine.ui_row(id) // pops at the end of this proc
|
|
|
|
|
|
|
|
|
|
engine.ui_spacer(engine.ui_px(16, 1))
|
|
|
|
|
engine.ui_set_next_font_size(M3_BODY_MEDIUM)
|
|
|
|
|
engine.ui_set_next_text_color(m3.inverse_on_surface)
|
|
|
|
|
engine.ui_text(message)
|
|
|
|
|
|
|
|
|
|
engine.ui_spacer(engine.ui_percent(1, 0))
|
|
|
|
|
|
|
|
|
|
clicked := false
|
|
|
|
|
if len(action) > 0 {
|
|
|
|
|
s: engine.ButtonStyle
|
|
|
|
|
s.background_idle = m3_alpha(m3.surface, 0)
|
|
|
|
|
s.background_hover = m3_state_layer(m3.inverse_surface, m3.inverse_primary, M3_STATE_HOVER)
|
|
|
|
|
s.background_active = m3_state_layer(m3.inverse_surface, m3.inverse_primary, M3_STATE_PRESSED)
|
|
|
|
|
s.background_disabled = m3_alpha(m3.surface, 0)
|
|
|
|
|
s.text_idle = m3.inverse_primary
|
|
|
|
|
s.text_hover = m3.inverse_primary
|
|
|
|
|
s.text_active = m3.inverse_primary
|
|
|
|
|
s.text_disabled = m3.inverse_primary
|
|
|
|
|
s.border_radius = M3_SHAPE_XS
|
|
|
|
|
s.padding_x = 8
|
|
|
|
|
s.padding_y = 0
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(36, 1))
|
|
|
|
|
engine.ui_set_next_width(engine.ui_children_sum(1))
|
|
|
|
|
engine.ui_push_font_size(M3_LABEL_LARGE)
|
|
|
|
|
defer engine.ui_pop_font_size()
|
|
|
|
|
engine.ui_push_font_weight(M3_WEIGHT_MEDIUM)
|
|
|
|
|
defer engine.ui_pop_font_weight()
|
|
|
|
|
clicked, _ = engine.ui_button_styled(action, s, true, id + 1)
|
|
|
|
|
}
|
|
|
|
|
engine.ui_spacer(engine.ui_px(8, 1))
|
|
|
|
|
return clicked
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Navigation bar ----------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// 80dp tall. The selected item gets a 64x32 pill in secondaryContainer behind
|
|
|
|
|
// its icon -- that indicator, not a colour change, is what marks selection.
|
|
|
|
|
|
|
|
|
|
M3_NAV_BAR_H :: 80
|
|
|
|
|
M3_NAV_INDICATOR_W :: 64
|
|
|
|
|
M3_NAV_INDICATOR_H :: 32
|
|
|
|
|
|
|
|
|
|
M3_Nav_Item :: struct {
|
|
|
|
|
icon: string,
|
|
|
|
|
label: string,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m3_nav_bar :: proc(items: []M3_Nav_Item, selected: ^int, bottom_inset: f32, id: engine.Id) -> bool {
|
|
|
|
|
changed := false
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_NAV_BAR_H + bottom_inset, 1))
|
|
|
|
|
engine.ui_set_next_background_color(m3.surface_c)
|
|
|
|
|
engine.ui_set_next_flags({.DrawBackground})
|
|
|
|
|
engine.ui_column(id) // pops at the end of this proc
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_NAV_BAR_H, 1))
|
|
|
|
|
engine.ui_row() // pops at the end of this proc
|
|
|
|
|
|
|
|
|
|
for item, i in items {
|
|
|
|
|
is_sel := i == selected^
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_width(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_height(engine.ui_fill())
|
|
|
|
|
engine.ui_set_next_align_x(.Center)
|
|
|
|
|
// Start, not Center: the spec positions the item from the top (12dp pad,
|
|
|
|
|
// 32dp indicator, 4dp gap, label, 16dp bottom = 80dp). Centring the group
|
|
|
|
|
// instead splits the leftover evenly and lifts the icon off its 12dp mark.
|
|
|
|
|
engine.ui_set_next_align_y(.Start)
|
|
|
|
|
cell := engine.ui_create_box_with_id({.MouseClickable, .KeyboardClickable}, id + engine.Id(10 + i))
|
|
|
|
|
sig := engine.ui_signal_from_box(cell)
|
|
|
|
|
if engine.ui_clicked(sig) {
|
|
|
|
|
selected^ = i
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
engine.push_parent(cell)
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_width(engine.ui_children_sum(1))
|
|
|
|
|
engine.ui_set_next_height(engine.ui_children_sum(1))
|
|
|
|
|
engine.ui_set_next_align_x(.Center)
|
|
|
|
|
open_column()
|
|
|
|
|
|
|
|
|
|
engine.ui_spacer(engine.ui_px(12, 1))
|
|
|
|
|
|
|
|
|
|
// Indicator pill; unselected items get a transparent one of the same
|
|
|
|
|
// size so the icon never shifts between states.
|
|
|
|
|
engine.ui_set_next_width(engine.ui_px(M3_NAV_INDICATOR_W, 1))
|
|
|
|
|
engine.ui_set_next_height(engine.ui_px(M3_NAV_INDICATOR_H, 1))
|
|
|
|
|
engine.ui_set_next_align_x(.Center)
|
|
|
|
|
engine.ui_set_next_align_y(.Center)
|
|
|
|
|
engine.ui_set_next_background_color(is_sel ? m3.secondary_container : m3_alpha(m3.surface, 0))
|
|
|
|
|
engine.ui_set_next_border_radius(M3_NAV_INDICATOR_H / 2)
|
|
|
|
|
engine.ui_set_next_flags({.DrawBackground})
|
|
|
|
|
open_row()
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_font_size(24)
|
|
|
|
|
engine.ui_set_next_text_color(is_sel ? m3.on_secondary_container : m3.on_surface_variant)
|
|
|
|
|
engine.ui_text(item.icon)
|
|
|
|
|
close_box() // indicator
|
|
|
|
|
|
|
|
|
|
engine.ui_spacer(engine.ui_px(4, 1))
|
|
|
|
|
|
|
|
|
|
engine.ui_set_next_font_size(M3_LABEL_MEDIUM)
|
|
|
|
|
engine.ui_set_next_font_weight(M3_WEIGHT_MEDIUM)
|
|
|
|
|
engine.ui_set_next_text_color(is_sel ? m3.on_surface : m3.on_surface_variant)
|
|
|
|
|
engine.ui_text(item.label)
|
|
|
|
|
|
|
|
|
|
close_box() // column
|
|
|
|
|
close_box() // cell
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return changed
|
|
|
|
|
}
|