Files
android-ui-dogfooding/appbar.odin
T
2026-09-04 14:26:42 +02:00

118 lines
4.3 KiB
Odin

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
}