diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index f15b583..0c31aeb 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -21,7 +21,7 @@ + android:theme="@android:style/Theme.NoTitleBar"> diff --git a/appbar.odin b/appbar.odin new file mode 100644 index 0000000..5c91385 --- /dev/null +++ b/appbar.odin @@ -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 +} diff --git a/assets/Roboto-VariableFont_wdth_wght.ttf b/assets/Roboto-VariableFont_wdth_wght.ttf new file mode 100644 index 0000000..bba55f6 Binary files /dev/null and b/assets/Roboto-VariableFont_wdth_wght.ttf differ diff --git a/assets/material_icons.ttf b/assets/material_icons.ttf new file mode 100644 index 0000000..f1d83e0 Binary files /dev/null and b/assets/material_icons.ttf differ diff --git a/m3_buttons.odin b/m3_buttons.odin new file mode 100644 index 0000000..c9f2a2d --- /dev/null +++ b/m3_buttons.odin @@ -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 +} diff --git a/m3_containers.odin b/m3_containers.odin new file mode 100644 index 0000000..cc9c7d8 --- /dev/null +++ b/m3_containers.odin @@ -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 +} diff --git a/m3_controls.odin b/m3_controls.odin new file mode 100644 index 0000000..589d8ec --- /dev/null +++ b/m3_controls.odin @@ -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, + } +} diff --git a/m3_demo.odin b/m3_demo.odin new file mode 100644 index 0000000..7547f44 --- /dev/null +++ b/m3_demo.odin @@ -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) + } +} diff --git a/m3_layout.odin b/m3_layout.odin new file mode 100644 index 0000000..5400617 --- /dev/null +++ b/m3_layout.odin @@ -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() +} diff --git a/m3_tokens.odin b/m3_tokens.odin new file mode 100644 index 0000000..03011a3 --- /dev/null +++ b/m3_tokens.odin @@ -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 diff --git a/main.odin b/main.odin index 366ea85..5416254 100644 --- a/main.odin +++ b/main.odin @@ -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) { } } -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)) - } +@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() { + 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} +}