Material3 components

This commit is contained in:
2026-09-04 14:26:42 +02:00
parent 891fadca85
commit 0419237433
11 changed files with 1362 additions and 37 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
<activity android:name="android.app.NativeActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
android:theme="@android:style/Theme.NoTitleBar">
<meta-data android:name="android.app.lib_name"
android:value="main" />
+117
View File
@@ -0,0 +1,117 @@
package main
import engine "../zephr"
// Material 3 "top app bar - small", to spec.
//
// Every constant here is a Material dp/sp value, which only reads correctly
// because layout points are dp -- the backend adopts DisplayMetrics.density as
// backing_scale. Expressed in pixels these would be right on exactly one device.
APP_BAR_HEIGHT :: 64 // dp, M3 small top app bar container
APP_BAR_EDGE_GAP :: 4 // dp, edge -> 48dp touch target (puts the glyph at 16dp)
APP_BAR_TOUCH_TARGET :: 48 // dp, minimum accessible target
APP_BAR_ICON_SIZE :: 24 // sp, glyph size
APP_BAR_TITLE_SIZE :: 22 // sp, titleLarge
APP_BAR_TITLE_INSET :: 56 // dp from the leading edge when a nav icon is present
APP_BAR_TITLE_PLAIN :: 16 // dp from the leading edge when there is not
App_Bar :: struct {
title: string,
leading: string, // icon glyph, or "" for none
actions: []string, // trailing icon glyphs, in order
// M3 marks the scrolled state with a container colour change, not a shadow.
scrolled: bool,
// Status bar height in dp. The bar draws under it and pads its content down,
// which is what edge-to-edge looks like natively.
top_inset: f32,
}
App_Bar_Result :: struct {
leading_clicked: bool,
action_clicked: int, // index into actions, or -1
}
// Icon slots are real buttons so they get signals and keyboard focus, but M3
// draws no container at rest -- only the state layer on hover/press.
@(private = "file")
icon_button_style :: proc() -> engine.ButtonStyle {
s: engine.ButtonStyle
s.background_idle = {0, 0, 0, 0}
s.background_hover = {1, 1, 1, 0.08} // M3 state layer opacities
s.background_active = {1, 1, 1, 0.12}
s.background_disabled = {0, 0, 0, 0}
s.border_idle = {0, 0, 0, 0}
s.border_hover = {0, 0, 0, 0}
s.border_active = {0, 0, 0, 0}
s.border_disabled = {0, 0, 0, 0}
s.text_idle = m3.on_surface
s.text_hover = m3.on_surface
s.text_active = m3.on_surface
s.text_disabled = {1, 1, 1, 0.38}
s.border_radius = APP_BAR_TOUCH_TARGET / 2 // circular state layer
s.border_thickness = 0
s.padding_x = 0
s.padding_y = 0
return s
}
@(private = "file")
icon_button :: proc(glyph: string, id: engine.Id) -> bool {
engine.ui_set_next_width(engine.ui_px(APP_BAR_TOUCH_TARGET, 1))
engine.ui_set_next_height(engine.ui_px(APP_BAR_TOUCH_TARGET, 1))
// Must be push, not set_next: a button builds its container and an inner
// content row before it ever creates the label box, so a set_next size is
// consumed by the container and the glyph falls back to the ambient default.
engine.ui_push_font_size(APP_BAR_ICON_SIZE)
defer engine.ui_pop_font_size()
click, _ := engine.ui_button_styled(glyph, icon_button_style(), true, id)
return click
}
ui_app_bar :: proc(bar: App_Bar) -> App_Bar_Result {
res := App_Bar_Result{action_clicked = -1}
engine.ui_set_next_width(engine.ui_percent(1, 1))
engine.ui_set_next_height(engine.ui_px(APP_BAR_HEIGHT + bar.top_inset, 1))
engine.ui_set_next_background_color(bar.scrolled ? m3.surface_c : m3.surface)
engine.ui_set_next_flags({.DrawBackground})
engine.ui_column() // pops at the end of this proc
// The container spans the status bar; the content row sits below it.
engine.ui_padding(bar.top_inset, {.Top})
engine.ui_set_next_width(engine.ui_percent(1, 1))
engine.ui_set_next_height(engine.ui_px(APP_BAR_HEIGHT, 1))
engine.ui_set_next_align_y(.Center)
engine.ui_row() // pops at the end of this proc
if len(bar.leading) > 0 {
engine.ui_spacer(engine.ui_px(APP_BAR_EDGE_GAP, 1))
res.leading_clicked = icon_button(bar.leading, 1)
// 4 + 48 already consumed; pad out to the 56dp headline inset.
engine.ui_spacer(engine.ui_px(APP_BAR_TITLE_INSET - APP_BAR_EDGE_GAP - APP_BAR_TOUCH_TARGET, 1))
} else {
engine.ui_spacer(engine.ui_px(APP_BAR_TITLE_PLAIN, 1))
}
engine.ui_set_next_font_size(APP_BAR_TITLE_SIZE)
engine.ui_set_next_text_color(m3.on_surface)
engine.ui_text(bar.title)
// Shove the actions to the trailing edge.
engine.ui_spacer(engine.ui_percent(1, 0))
for glyph, i in bar.actions {
if icon_button(glyph, engine.Id(100 + i)) {
res.action_clicked = i
}
}
engine.ui_spacer(engine.ui_px(APP_BAR_EDGE_GAP, 1))
return res
}
Binary file not shown.
Binary file not shown.
+247
View File
@@ -0,0 +1,247 @@
package main
import engine "../zephr"
// M3 common buttons, FABs and chips.
//
// Spec: common buttons are 40dp tall, fully rounded (20dp), labelLarge 14sp/500,
// with 24dp horizontal padding -- 16dp on the leading side when an icon is
// present, since the icon already supplies optical weight.
M3_BUTTON_HEIGHT :: 40
M3_BUTTON_PAD_X :: 24
M3_BUTTON_PAD_X_ICON :: 16
M3_BUTTON_ICON_SIZE :: 18 // dp, smaller than an app bar icon
M3_BUTTON_GAP :: 8 // dp, icon -> label
M3_Button_Kind :: enum {
Filled, // highest emphasis
Tonal, // filled tonal
Outlined, // medium emphasis, 1dp outline
Elevated, // surfaceContainerLow, reads as lifted
Text, // lowest emphasis, no container
}
@(private = "file")
button_style :: proc(kind: M3_Button_Kind) -> engine.ButtonStyle {
s: engine.ButtonStyle
s.border_radius = M3_BUTTON_HEIGHT / 2 // fully rounded
s.border_thickness = 0
s.padding_x = M3_BUTTON_PAD_X
s.padding_y = 0
container, on_container: engine.Color
switch kind {
case .Filled:
container, on_container = m3.primary, m3.on_primary
case .Tonal:
container, on_container = m3.secondary_container, m3.on_secondary_container
case .Elevated:
container, on_container = m3.surface_c_low, m3.primary
case .Outlined:
container, on_container = m3_alpha(m3.surface, 0), m3.primary
s.border_thickness = 1
case .Text:
container, on_container = m3_alpha(m3.surface, 0), m3.primary
}
s.background_idle = container
// Every state is the container with an on-container layer over it -- the
// spec defines states this way rather than naming separate colours.
s.background_hover = m3_state_layer(container, on_container, M3_STATE_HOVER)
s.background_active = m3_state_layer(container, on_container, M3_STATE_PRESSED)
s.background_disabled = kind == .Text || kind == .Outlined \
? m3_alpha(m3.surface, 0) \
: m3_alpha(m3.on_surface, M3_DISABLED_BG)
outline := kind == .Outlined ? m3.outline : m3_alpha(m3.surface, 0)
s.border_idle = outline
s.border_hover = outline
s.border_active = outline
s.border_disabled = kind == .Outlined ? m3_alpha(m3.on_surface, M3_DISABLED_BG) : outline
s.text_idle = on_container
s.text_hover = on_container
s.text_active = on_container
s.text_disabled = m3_alpha(m3.on_surface, M3_DISABLED_FG)
return s
}
m3_button :: proc(label: string, kind: M3_Button_Kind, id: engine.Id, enabled := true) -> bool {
engine.ui_set_next_height(engine.ui_px(M3_BUTTON_HEIGHT, 1))
engine.ui_set_next_width(engine.ui_children_sum(1))
// push, not set_next: a button builds its container and content row before
// the label box, so a set_next value never reaches the text.
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()
click, _ := engine.ui_button_styled(label, button_style(kind), enabled, id)
return click
}
m3_button_icon :: proc(label, icon: string, kind: M3_Button_Kind, id: engine.Id, enabled := true) -> bool {
st := button_style(kind)
st.padding_x = M3_BUTTON_PAD_X_ICON
st.icon_size = M3_BUTTON_ICON_SIZE // 18dp, per spec -- not the 14sp label size
engine.ui_set_next_height(engine.ui_px(M3_BUTTON_HEIGHT, 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()
click, _ := engine.ui_button_styled_with_icons(label, st, icon, "", enabled, id)
return click
}
// -- FAB ---------------------------------------------------------------------
M3_Fab_Size :: enum {
Small, // 40dp, 12dp radius
Regular, // 56dp, 16dp radius
Large, // 96dp, 28dp radius
}
m3_fab :: proc(icon: string, size: M3_Fab_Size, id: engine.Id) -> bool {
dim, radius, icon_size: f32
switch size {
case .Small: dim, radius, icon_size = 40, M3_SHAPE_MD, 24
case .Regular: dim, radius, icon_size = 56, M3_SHAPE_LG, 24
case .Large: dim, radius, icon_size = 96, M3_SHAPE_XL, 36
}
s: engine.ButtonStyle
// The primary-container FAB is the default of the three colour mappings.
s.background_idle = m3.primary_container
s.background_hover = m3_state_layer(m3.primary_container, m3.on_primary_container, M3_STATE_HOVER)
s.background_active = m3_state_layer(m3.primary_container, m3.on_primary_container, M3_STATE_PRESSED)
s.background_disabled = m3_alpha(m3.on_surface, M3_DISABLED_BG)
s.text_idle = m3.on_primary_container
s.text_hover = m3.on_primary_container
s.text_active = m3.on_primary_container
s.text_disabled = m3_alpha(m3.on_surface, M3_DISABLED_FG)
s.border_radius = radius
s.border_thickness = 0
s.padding_x = 0
s.padding_y = 0
engine.ui_set_next_width(engine.ui_px(dim, 1))
engine.ui_set_next_height(engine.ui_px(dim, 1))
engine.ui_push_font_size(i32(icon_size))
defer engine.ui_pop_font_size()
click, _ := engine.ui_button_styled(icon, s, true, id)
return click
}
// Extended FAB: 56dp tall, 16dp radius, icon + label, 16dp padding.
m3_fab_extended :: proc(label, icon: string, id: engine.Id) -> bool {
s: engine.ButtonStyle
s.background_idle = m3.primary_container
s.background_hover = m3_state_layer(m3.primary_container, m3.on_primary_container, M3_STATE_HOVER)
s.background_active = m3_state_layer(m3.primary_container, m3.on_primary_container, M3_STATE_PRESSED)
s.background_disabled = m3_alpha(m3.on_surface, M3_DISABLED_BG)
s.text_idle = m3.on_primary_container
s.text_hover = m3.on_primary_container
s.text_active = m3.on_primary_container
s.text_disabled = m3_alpha(m3.on_surface, M3_DISABLED_FG)
s.border_radius = M3_SHAPE_LG
s.border_thickness = 0
s.padding_x = 16
s.padding_y = 0
s.icon_size = 24 // spec FAB icon; the 14sp label must not drag it down
engine.ui_set_next_height(engine.ui_px(56, 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()
click, _ := engine.ui_button_styled_with_icons(label, s, icon, "", true, id)
return click
}
// -- Chips -------------------------------------------------------------------
M3_CHIP_HEIGHT :: 32
M3_CHIP_RADIUS :: M3_SHAPE_SM
M3_CHIP_PAD_X :: 16
M3_CHIP_ICON_SIZE :: 18 // dp, per spec
// Assist chip: outlined container, labelLarge, optional leading icon.
m3_chip_assist :: proc(label, icon: string, id: engine.Id) -> bool {
s: engine.ButtonStyle
s.background_idle = m3_alpha(m3.surface, 0)
s.background_hover = m3_state_layer(m3.surface, m3.on_surface, M3_STATE_HOVER)
s.background_active = m3_state_layer(m3.surface, m3.on_surface, M3_STATE_PRESSED)
s.background_disabled = m3_alpha(m3.surface, 0)
s.border_idle = m3.outline
s.border_hover = m3.outline
s.border_active = m3.outline
s.border_disabled = m3_alpha(m3.on_surface, M3_DISABLED_BG)
s.text_idle = m3.on_surface
s.text_hover = m3.on_surface
s.text_active = m3.on_surface
s.text_disabled = m3_alpha(m3.on_surface, M3_DISABLED_FG)
s.border_radius = M3_CHIP_RADIUS
s.border_thickness = 1
s.padding_x = M3_CHIP_PAD_X
s.padding_y = 0
s.icon_size = M3_CHIP_ICON_SIZE
engine.ui_set_next_height(engine.ui_px(M3_CHIP_HEIGHT, 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()
click, _ := engine.ui_button_styled_with_icons(label, s, icon, "", true, id)
return click
}
// Filter chip: selection swaps the container to secondaryContainer, drops the
// outline, and shows a leading check. Toggles `selected` in place.
m3_chip_filter :: proc(label: string, selected: ^bool, id: engine.Id) -> bool {
s: engine.ButtonStyle
container := selected^ ? m3.secondary_container : m3_alpha(m3.surface, 0)
on_container := selected^ ? m3.on_secondary_container : m3.on_surface_variant
s.background_idle = container
s.background_hover = m3_state_layer(container, on_container, M3_STATE_HOVER)
s.background_active = m3_state_layer(container, on_container, M3_STATE_PRESSED)
s.background_disabled = m3_alpha(m3.surface, 0)
outline := selected^ ? m3_alpha(m3.surface, 0) : m3.outline
s.border_idle = outline
s.border_hover = outline
s.border_active = outline
s.border_disabled = outline
s.text_idle = on_container
s.text_hover = on_container
s.text_active = on_container
s.text_disabled = m3_alpha(m3.on_surface, M3_DISABLED_FG)
s.border_radius = M3_CHIP_RADIUS
s.border_thickness = selected^ ? 0 : 1
s.padding_x = M3_CHIP_PAD_X
s.padding_y = 0
s.icon_size = M3_CHIP_ICON_SIZE
engine.ui_set_next_height(engine.ui_px(M3_CHIP_HEIGHT, 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()
leading := selected^ ? ICON_CHECK : ""
click, _ := engine.ui_button_styled_with_icons(label, s, leading, "", true, id)
if click {
selected^ = !selected^
}
return click
}
+242
View File
@@ -0,0 +1,242 @@
package main
import engine "../zephr"
// 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
}
+296
View File
@@ -0,0 +1,296 @@
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)
engine.ui_box_equip_custom_draw(box, switch_draw, d)
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)
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)
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)
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)
}
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,
}
}
+212
View File
@@ -0,0 +1,212 @@
package main
import "core:fmt"
import engine "../zephr"
// A gallery of the M3 components, laid out the way the spec's own catalogue is:
// a scrolling body under the top app bar, a navigation bar pinned to the bottom.
demo_nav_index := 0
demo_switch_a := true
demo_switch_b := false
demo_check_a := true
demo_check_b := false
demo_radio := 0
demo_slider: f32 = 0.4
demo_chip_fav := true
demo_chip_new := false
demo_progress: f32 = 0.65
@(private = "file")
section :: proc(title: string, id: engine.Id) {
engine.ui_spacer(engine.ui_px(24, 1))
engine.ui_set_next_font_size(M3_TITLE_SMALL)
engine.ui_set_next_font_weight(M3_WEIGHT_MEDIUM)
engine.ui_set_next_text_color(m3.primary)
engine.ui_text_with_id(title, id)
engine.ui_spacer(engine.ui_px(12, 1))
}
@(private = "file")
row_begin :: proc(id: engine.Id, caller := #caller_location) {
engine.ui_set_next_width(engine.ui_fill())
engine.ui_set_next_height(engine.ui_children_sum(1))
engine.ui_set_next_align_y(.Center)
open_row(id, caller)
}
@(private = "file")
gap :: proc() {
engine.ui_spacer(engine.ui_px(8, 1))
}
m3_demo_screen :: proc() {
engine.ui_set_next_width(engine.ui_percent(1, 1))
engine.ui_set_next_height(engine.ui_percent(1, 1))
engine.ui_set_next_background_color(m3.surface)
engine.ui_set_next_flags({.DrawBackground})
engine.ui_column() // pops at the end of this proc
bar := ui_app_bar({
title = "Material 3",
leading = ICON_MENU,
actions = {ICON_SEARCH, ICON_MORE_VERT},
top_inset = insets.top,
})
if bar.leading_clicked {
show_toast("menu")
}
if bar.action_clicked >= 0 {
show_toast(bar.action_clicked == 0 ? "search" : "more")
}
// Body takes the space between the bar and the nav bar, and scrolls.
engine.ui_set_next_width(engine.ui_fill())
engine.ui_set_next_height(engine.ui_fill())
open_scroll_column(1)
engine.ui_padding(16, {.Left, .Right})
// -- Buttons
section("Common buttons", 100)
row_begin(101); {
_ = m3_button("Filled", .Filled, 110)
gap()
_ = m3_button("Tonal", .Tonal, 111)
gap()
_ = m3_button("Outlined", .Outlined, 112)
close_box()
}
engine.ui_spacer(engine.ui_px(8, 1))
row_begin(102); {
_ = m3_button("Elevated", .Elevated, 113)
gap()
_ = m3_button("Text", .Text, 114)
gap()
_ = m3_button("Disabled", .Filled, 115, false)
close_box()
}
engine.ui_spacer(engine.ui_px(8, 1))
row_begin(103); {
if m3_button_icon("Add item", ICON_ADD, .Filled, 116) {
show_toast("added")
}
gap()
_ = m3_button_icon("Share", ICON_SHARE, .Outlined, 117)
close_box()
}
// -- FABs
section("FAB", 200)
row_begin(201); {
_ = m3_fab(ICON_ADD, .Small, 210)
gap()
_ = m3_fab(ICON_EDIT, .Regular, 211)
gap()
_ = m3_fab_extended("Compose", ICON_EDIT, 212)
close_box()
}
// -- Chips
section("Chips", 300)
row_begin(301); {
_ = m3_chip_assist("Assist", ICON_STAR, 310)
gap()
_ = m3_chip_filter("Favourites", &demo_chip_fav, 311)
gap()
_ = m3_chip_filter("New", &demo_chip_new, 312)
close_box()
}
// -- Selection controls
section("Selection", 400)
row_begin(401); {
_ = m3_switch(&demo_switch_a, 410)
gap()
_ = m3_switch(&demo_switch_b, 411)
gap()
_ = m3_checkbox(&demo_check_a, 412)
_ = m3_checkbox(&demo_check_b, 413)
if m3_radio(demo_radio == 0, 414) {
demo_radio = 0
}
if m3_radio(demo_radio == 1, 415) {
demo_radio = 1
}
close_box()
}
// -- Slider + progress
section("Slider", 500)
_ = m3_slider(&demo_slider, 510)
engine.ui_set_next_font_size(M3_BODY_SMALL)
engine.ui_set_next_text_color(m3.on_surface_variant)
engine.ui_text_with_id(fmt.tprintf("value %.2f", demo_slider), 511)
section("Progress", 600)
m3_progress_linear(demo_progress, 610)
// -- Cards
section("Cards", 700)
m3_card_begin(.Elevated, 710); {
engine.ui_set_next_font_size(M3_TITLE_MEDIUM)
engine.ui_set_next_font_weight(M3_WEIGHT_MEDIUM)
engine.ui_set_next_text_color(m3.on_surface)
engine.ui_text_with_id("Elevated card", 711)
engine.ui_spacer(engine.ui_px(4, 1))
engine.ui_set_next_font_size(M3_BODY_MEDIUM)
engine.ui_set_next_text_color(m3.on_surface_variant)
engine.ui_text_with_id("surfaceContainerLow", 712)
m3_card_end()
}
engine.ui_spacer(engine.ui_px(8, 1))
m3_card_begin(.Filled, 720); {
engine.ui_set_next_font_size(M3_TITLE_MEDIUM)
engine.ui_set_next_font_weight(M3_WEIGHT_MEDIUM)
engine.ui_set_next_text_color(m3.on_surface)
engine.ui_text_with_id("Filled card", 721)
m3_card_end()
}
engine.ui_spacer(engine.ui_px(8, 1))
m3_card_begin(.Outlined, 730); {
engine.ui_set_next_font_size(M3_TITLE_MEDIUM)
engine.ui_set_next_font_weight(M3_WEIGHT_MEDIUM)
engine.ui_set_next_text_color(m3.on_surface)
engine.ui_text_with_id("Outlined card", 731)
m3_card_end()
}
// -- Lists
section("List", 800)
if m3_list_item("Notifications", ICON_NOTIFICATIONS, "12", 810) {
show_toast("notifications")
}
m3_divider(811)
if m3_list_item("Bookmarks", ICON_BOOKMARK, "", 812) {
show_toast("bookmarks")
}
m3_divider(813)
if m3_list_item("Settings", ICON_SETTINGS, "", 814) {
show_toast("settings")
}
// -- Snackbar
section("Snackbar", 900)
if m3_snackbar("Item moved to trash", "Undo", 910) {
show_toast("undo")
}
engine.ui_spacer(engine.ui_px(24, 1))
// Close the scroll body so the nav bar is a sibling of it, not content
// inside it -- that is what keeps it pinned to the bottom.
close_box()
items := []M3_Nav_Item{
{ICON_HOME, "Home"},
{ICON_FAVORITE, "Saved"},
{ICON_PERSON, "Profile"},
}
if m3_nav_bar(items, &demo_nav_index, insets.bottom, 1000) {
show_toast(items[demo_nav_index].label)
}
}
+42
View File
@@ -0,0 +1,42 @@
package main
import engine "../zephr"
// Explicitly scoped containers.
//
// engine.ui_row/ui_column/ui_scroll_column carry @(deferred_none=pop_parent),
// so they pop when the *calling scope* ends. That is fine when a container and
// all its children sit in one proc, but it makes them unusable for anything
// opened in one proc and closed in another -- a begin/end pair, or a container
// that must close before a sibling is drawn. In those cases the row pops the
// instant the helper returns and the children land in the wrong parent.
//
// These push explicitly, with no deferred pop, so the caller controls the scope.
// caller is threaded through because box identity is hashed from it: wrapping
// ui_create_box_with_id without it would give every call site the same id.
open_row :: proc(id: engine.Id = 0, caller := #caller_location) -> ^engine.Box {
engine.set_next_layout_axis(.X)
b := engine.ui_create_box_with_id({}, id, caller)
engine.push_parent(b)
return b
}
open_column :: proc(id: engine.Id = 0, caller := #caller_location) -> ^engine.Box {
engine.set_next_layout_axis(.Y)
b := engine.ui_create_box_with_id({}, id, caller)
engine.push_parent(b)
return b
}
open_scroll_column :: proc(id: engine.Id = 0, caller := #caller_location) -> ^engine.Box {
engine.set_next_layout_axis(.Y)
engine.ui_set_next_flags({.ClipToBounds, .ScrollableY})
b := engine.ui_create_box_with_id({}, id, caller)
engine.push_parent(b)
return b
}
close_box :: proc() {
engine.pop_parent()
}
+159
View File
@@ -0,0 +1,159 @@
package main
import engine "../zephr"
// Material 3 design tokens.
//
// Baseline dark scheme, verbatim from the M3 spec. Sizes are dp and type sizes
// are sp, which are the same thing here because layout points are dp.
//
// Two spec notes that shape everything below:
// - M3 conveys elevation with surface *colour*, not shadow. The engine draws
// no shadows, and it does not need to: the container roles below are the
// real mechanism.
// - Corner radii are uniform. The engine's border_radius is a single float,
// so shapes needing per-corner radii (segmented button ends) are noted.
// -- Colour roles ------------------------------------------------------------
hex :: proc(v: u32, a: f32 = 1) -> engine.Color {
return {
f32((v >> 16) & 0xFF) / 255,
f32((v >> 8) & 0xFF) / 255,
f32(v & 0xFF) / 255,
a,
}
}
M3 :: struct {
primary, on_primary, primary_container, on_primary_container: engine.Color,
secondary, on_secondary, secondary_container, on_secondary_container: engine.Color,
tertiary, on_tertiary, tertiary_container, on_tertiary_container: engine.Color,
error, on_error, error_container, on_error_container: engine.Color,
surface, on_surface, surface_variant, on_surface_variant: engine.Color,
surface_c_lowest, surface_c_low, surface_c, surface_c_high, surface_c_highest: engine.Color,
outline, outline_variant: engine.Color,
inverse_surface, inverse_on_surface, inverse_primary: engine.Color,
scrim: engine.Color,
}
m3: M3
m3_init_dark :: proc() {
m3.primary = hex(0xD0BCFF)
m3.on_primary = hex(0x381E72)
m3.primary_container = hex(0x4F378B)
m3.on_primary_container = hex(0xEADDFF)
m3.secondary = hex(0xCCC2DC)
m3.on_secondary = hex(0x332D41)
m3.secondary_container = hex(0x4A4458)
m3.on_secondary_container = hex(0xE8DEF8)
m3.tertiary = hex(0xEFB8C8)
m3.on_tertiary = hex(0x492532)
m3.tertiary_container = hex(0x633B48)
m3.on_tertiary_container = hex(0xFFD8E4)
m3.error = hex(0xF2B8B5)
m3.on_error = hex(0x601410)
m3.error_container = hex(0x8C1D18)
m3.on_error_container = hex(0xF9DEDC)
m3.surface = hex(0x141218)
m3.on_surface = hex(0xE6E0E9)
m3.surface_variant = hex(0x49454F)
m3.on_surface_variant = hex(0xCAC4D0)
// The elevation ladder. Higher role == "further from the surface".
m3.surface_c_lowest = hex(0x0F0D13)
m3.surface_c_low = hex(0x1D1B20)
m3.surface_c = hex(0x211F26)
m3.surface_c_high = hex(0x2B2930)
m3.surface_c_highest = hex(0x36343B)
m3.outline = hex(0x938F99)
m3.outline_variant = hex(0x49454F)
m3.inverse_surface = hex(0xE6E0E9)
m3.inverse_on_surface = hex(0x322F35)
m3.inverse_primary = hex(0x6750A4)
m3.scrim = hex(0x000000)
}
// State layer opacities, applied over a container colour.
M3_STATE_HOVER :: f32(0.08)
M3_STATE_FOCUS :: f32(0.10)
M3_STATE_PRESSED :: f32(0.10)
M3_DISABLED_FG :: f32(0.38)
M3_DISABLED_BG :: f32(0.12)
// Composites a state layer over a base colour, which is how M3 defines every
// hover/press appearance rather than by naming separate colours.
m3_state_layer :: proc(base: engine.Color, layer: engine.Color, opacity: f32) -> engine.Color {
return {
base.r + (layer.r - base.r) * opacity,
base.g + (layer.g - base.g) * opacity,
base.b + (layer.b - base.b) * opacity,
base.a,
}
}
m3_alpha :: proc(c: engine.Color, a: f32) -> engine.Color {
return {c.r, c.g, c.b, a}
}
// -- Type scale (sp) ---------------------------------------------------------
M3_DISPLAY_SMALL :: 36
M3_HEADLINE_MEDIUM :: 28
M3_HEADLINE_SMALL :: 24
M3_TITLE_LARGE :: 22
M3_TITLE_MEDIUM :: 16
M3_TITLE_SMALL :: 14
M3_BODY_LARGE :: 16
M3_BODY_MEDIUM :: 14
M3_BODY_SMALL :: 12
M3_LABEL_LARGE :: 14
M3_LABEL_MEDIUM :: 12
M3_LABEL_SMALL :: 11
// Roboto weights the M3 type scale uses. Title/label roles are medium (500).
M3_WEIGHT_REGULAR :: f32(400)
M3_WEIGHT_MEDIUM :: f32(500)
// -- Shape scale (dp) --------------------------------------------------------
M3_SHAPE_XS :: 4
M3_SHAPE_SM :: 8
M3_SHAPE_MD :: 12
M3_SHAPE_LG :: 16
M3_SHAPE_XL :: 28
M3_SHAPE_FULL :: 9999 // clamped by the widget's half-height
// -- Icon codepoints ---------------------------------------------------------
//
// Written as byte escapes rather than literal glyphs: these live in the Private
// Use Area, so they are invisible in an editor and are silently lost by tools
// that do not round-trip them. The codepoint in each comment is the source of
// truth, and must stay in step with the pyftsubset --unicodes list.
ICON_ARROW_BACK :: "\xEE\x97\x84" // U+E5C4 arrow_back
ICON_MENU :: "\xEE\x97\x92" // U+E5D2 menu
ICON_MORE_VERT :: "\xEE\x97\x94" // U+E5D4 more_vert
ICON_SEARCH :: "\xEE\xA2\xB6" // U+E8B6 search
ICON_CHECK :: "\xEE\x97\x8A" // U+E5CA check
ICON_CLOSE :: "\xEE\x97\x8D" // U+E5CD close
ICON_ADD :: "\xEE\x85\x85" // U+E145 add
ICON_HOME :: "\xEE\xA2\x8A" // U+E88A home
ICON_FAVORITE :: "\xEE\xA1\xBD" // U+E87D favorite
ICON_SETTINGS :: "\xEE\xA2\xB8" // U+E8B8 settings
ICON_EDIT :: "\xEE\x8F\x89" // U+E3C9 edit
ICON_DELETE :: "\xEE\xA1\xB2" // U+E872 delete
ICON_PERSON :: "\xEE\x9F\xBD" // U+E7FD person
ICON_STAR :: "\xEE\xA0\xB8" // U+E838 star
ICON_SHARE :: "\xEE\xA0\x8D" // U+E80D share
ICON_DOWNLOAD :: "\xEE\x8B\x84" // U+E2C4 download
ICON_BOOKMARK :: "\xEE\xA1\xA6" // U+E866 bookmark
ICON_NOTIFICATIONS :: "\xEE\x9F\xB4" // U+E7F4 notifications
ICON_EXPAND_MORE :: "\xEE\x97\x8F" // U+E5CF expand_more
+47 -37
View File
@@ -1,13 +1,10 @@
package main
import "shared:android"
import "core:fmt"
import "base:runtime"
import engine "../zephr"
import "jni"
counter := 0
device_model: string
device_sdk: i32
toast_ctx: Context
@@ -20,26 +17,26 @@ Edge_Insets :: struct {
insets: Edge_Insets
insets_valid: bool
@export
android_main :: proc "c" (state: ^android.android_app) {
context = runtime.default_context()
context.user_ptr = state
engine.init("", "", {1000, 600}, false, "")
defer engine.deinit()
// Static field reads only -- safe from the engine's thread. Anything
// touching the UI toolkit (Toast, dialogs) needs the main looper instead.
if jni.init(state) {
device_model, _ = build_model()
device_sdk, _ = build_sdk_int()
toast_ctx = Context(jni.activity(state))
main_activity = Activity(jni.activity(state))
main :: proc() {
real_main()
}
_, font_ok := engine.ui_text_register_font(#load("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"))
real_main :: proc() {
engine.init("", "", {480, 1000}, false, "")
defer engine.deinit()
_, font_ok := engine.ui_text_register_font(#load("./assets/Roboto-VariableFont_wdth_wght.ttf"))
ensure(font_ok)
// Material Symbols, subset to the glyphs the app bar uses: 1.3KB of a 356KB
// font. Its codepoints are Private Use Area, so nothing else claims them and
// the shaper's per-codepoint fallback routes them here on its own. ☰ and ⋮
// were rendering as tofu because Roboto has no glyph for either.
_, icon_font_ok := engine.ui_text_register_font(#load("./assets/material_icons.ttf"))
ensure(icon_font_ok)
m3_init_dark()
for !engine.should_quit() {
engine.frame_start()
defer engine.frame_end()
@@ -63,7 +60,7 @@ android_main :: proc "c" (state: ^android.android_app) {
window_size := engine.get_window_size()
engine.ui_begin_build(window_size)
engine.ui_text_set_default_pixel_size(108)
engine.ui_text_set_default_pixel_size(16) // dp
engine.ui_push_text_color(0xFFFFFF)
draw_ui()
engine.ui_pop_text_color()
@@ -73,21 +70,25 @@ android_main :: proc "c" (state: ^android.android_app) {
}
}
@export
android_main :: proc "c" (state: ^android.android_app) {
context = runtime.default_context()
context.user_ptr = state
// Static field reads only -- safe from the engine's thread. Anything
// touching the UI toolkit (Toast, dialogs) needs the main looper instead.
if jni.init(state) {
device_model, _ = build_model()
device_sdk, _ = build_sdk_int()
toast_ctx = Context(jni.activity(state))
main_activity = Activity(jni.activity(state))
}
real_main()
}
draw_ui :: proc() {
engine.ui_set_next_width(engine.ui_children_sum(1))
engine.ui_column(); {
engine.ui_padding(insets.left, {.Left})
engine.ui_padding(insets.right, {.Right})
engine.ui_padding(insets.top, {.Top})
engine.ui_padding(insets.bottom, {.Bottom})
engine.ui_text(fmt.tprintf("%d", counter))
engine.ui_text(fmt.tprintf("%s / API %d", device_model, device_sdk))
engine.ui_text(fmt.tprintf("ins %v %v %v %v ok=%v", insets.left, insets.top, insets.right, insets.bottom, insets_valid))
if click, _ := engine.ui_button("Hello"); click {
counter += 1
show_toast(fmt.tprintf("Clicked %d times", counter))
}
}
m3_demo_screen()
}
// Toast needs a Java looper on this thread. Preparing one is a one-time cost
@@ -127,12 +128,12 @@ refresh_insets :: proc() -> bool {
if cut, got_cut := insets_type_display_cutout(); got_cut {
mask |= cut
}
if ins, got := window_insets_get_ignoring_visibility(wi, mask); got && ins != nil {
if ins, got := window_insets_get(wi, mask); got && ins != nil {
l := insets_left(ins) or_return
t := insets_top(ins) or_return
r := insets_right(ins) or_return
b := insets_bottom(ins) or_return
insets = {f32(l), f32(t), f32(r), f32(b)}
insets = to_points({f32(l), f32(t), f32(r), f32(b)})
return true
}
}
@@ -141,6 +142,15 @@ refresh_insets :: proc() -> bool {
t := window_insets_sys_top(wi) or_return
r := window_insets_sys_right(wi) or_return
b := window_insets_sys_bottom(wi) or_return
insets = {f32(l), f32(t), f32(r), f32(b)}
insets = to_points({f32(l), f32(t), f32(r), f32(b)})
return true
}
// WindowInsets are reported in physical pixels, but layout is in dp.
to_points :: proc(v: Edge_Insets) -> Edge_Insets {
s := engine.get_backing_scale()
if s <= 0 {
s = 1
}
return {v.left / s, v.top / s, v.right / s, v.bottom / s}
}