Material3 components
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
main :: proc() {
|
||||
real_main()
|
||||
}
|
||||
|
||||
engine.init("", "", {1000, 600}, false, "")
|
||||
real_main :: proc() {
|
||||
engine.init("", "", {480, 1000}, 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))
|
||||
}
|
||||
|
||||
_, font_ok := engine.ui_text_register_font(#load("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"))
|
||||
_, 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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user