From d0953caa7b24fb84c6be24b4a1b8d5ba3d004f46 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Sun, 8 Mar 2026 01:13:19 +0800 Subject: [PATCH] Add small FAB, range slider, three-line list item This introduces new MD3 widgets to improve component coverage: - 40dp small FAB variant per MD3 spec (12dp corner radius, 24dp icon). Also fix touch target handling in iui_fab_internal to expand hit area to 48dp minimum for all FAB variants, ensuring MD3 accessibility compliance. - Two-thumb range slider for selecting value ranges. Features distance-based thumb selection when target overlap, per-thumb crossing resolution after step quantization, and division-by-zero guards for both the new range slider and the existing single-thumb slider. - Convenience wrapper for 88dp three-line list items with overline, headline, and supporting text, mirroring the existing iui_list_item_two_line pattern. --- include/iui-spec.h | 9 ++ include/iui.h | 43 ++++++++ src/basic.c | 251 ++++++++++++++++++++++++++++++++++++++++++++- src/fab.c | 14 ++- src/list.c | 16 +++ src/md3-spec.dsl | 14 +++ 6 files changed, 345 insertions(+), 2 deletions(-) diff --git a/include/iui-spec.h b/include/iui-spec.h index 86745ec..d180808 100644 --- a/include/iui-spec.h +++ b/include/iui-spec.h @@ -91,6 +91,15 @@ /* FAB (Floating Action Button) - * https://m3.material.io/components/floating-action-button/specs */ +#ifndef IUI_FAB_SMALL_SIZE +#define IUI_FAB_SMALL_SIZE 40.f +#endif +#ifndef IUI_FAB_SMALL_CORNER_RADIUS +#define IUI_FAB_SMALL_CORNER_RADIUS 12.f +#endif +#ifndef IUI_FAB_SMALL_ICON_SIZE +#define IUI_FAB_SMALL_ICON_SIZE 24.f +#endif #ifndef IUI_FAB_SIZE #define IUI_FAB_SIZE 56.f #endif diff --git a/include/iui.h b/include/iui.h index 6a95710..7c36a06 100644 --- a/include/iui.h +++ b/include/iui.h @@ -141,6 +141,14 @@ typedef struct { bool disabled; /* grayed out, no interaction */ } iui_slider_options; +/* MD3 Range Slider state for two-thumb interaction + * Reference: https://m3.material.io/components/sliders/specs + */ +typedef struct { + float value_low; /* current low-end value (updated by widget) */ + float value_high; /* current high-end value (updated by widget) */ +} iui_range_slider_state; + /* MD3 Card styles */ typedef enum iui_card_style { IUI_CARD_ELEVATED, /* shadow effect */ @@ -938,6 +946,22 @@ float iui_slider_ex(iui_context *ctx, float step, const iui_slider_options *options); +/* Range slider with two thumbs for selecting a value range + * @state: range slider state (value_low/value_high updated by widget) + * @min: minimum allowed value + * @max: maximum allowed value + * @step: quantization step (0 = continuous) + * @options: optional slider appearance/behavior (NULL for defaults) + * + * Returns true if either value changed this frame + */ +bool iui_range_slider(iui_context *ctx, + iui_range_slider_state *state, + float min, + float max, + float step, + const iui_slider_options *options); + /* Displays a clickable button * @ctx: current UI context * @label: button label text @@ -1498,6 +1522,11 @@ typedef enum iui_fab_size { IUI_FAB_LARGE /* 96dp */ } iui_fab_size_t; +/* Small FAB (40dp) + * Returns true if clicked this frame + */ +bool iui_fab_small(iui_context *ctx, float x, float y, const char *icon); + /* Standard FAB (56dp) * Returns true if clicked this frame */ @@ -2136,6 +2165,20 @@ bool iui_list_item_two_line(iui_context *ctx, const char *supporting, const char *icon); +/* Three-line list item (88dp) with overline, headline, and supporting text + * @overline: category/overline text above headline + * @headline: primary text + * @supporting: secondary text below headline + * @icon: optional leading icon name (NULL = no icon) + * + * Returns true if item was clicked + */ +bool iui_list_item_three_line(iui_context *ctx, + const char *overline, + const char *headline, + const char *supporting, + const char *icon); + /* List divider (inset by 16dp from left) */ void iui_list_divider(iui_context *ctx); diff --git a/src/basic.c b/src/basic.c index b3413b5..722274f 100644 --- a/src/basic.c +++ b/src/basic.c @@ -349,7 +349,11 @@ float iui_slider_ex(iui_context *ctx, clamp_float(track_rect.x, track_rect.x + track_rect.width, thumb_x); /* Calculate value from thumb position */ - norm_value = (thumb_x - track_rect.x) / track_rect.width; + if (track_rect.width > 0.f) { + norm_value = (thumb_x - track_rect.x) / track_rect.width; + } else { + norm_value = 0.f; + } value = norm_value * (max - min) + min; if (step > 0.f) value = roundf(value / step) * step; @@ -443,6 +447,251 @@ float iui_slider_ex(iui_context *ctx, return value; } +/* Range Slider - two-thumb variant for selecting a value range */ + +bool iui_range_slider(iui_context *ctx, + iui_range_slider_state *state, + float min, + float max, + float step, + const iui_slider_options *options) +{ + if (!ctx->current_window || !state || max <= min) + return false; + + /* Clamp inputs and enforce low <= high */ + float low = clamp_float(min, max, state->value_low); + float high = clamp_float(min, max, state->value_high); + if (low > high) { + float tmp = low; + low = high; + high = tmp; + } + float orig_low = low, orig_high = high; + + bool disabled = options && options->disabled; + + /* Get colors */ + uint32_t active_color = (options && options->active_track_color) + ? options->active_track_color + : ctx->colors.primary; + uint32_t inactive_color = (options && options->inactive_track_color) + ? options->inactive_track_color + : ctx->colors.surface_container_highest; + uint32_t handle_color = (options && options->handle_color) + ? options->handle_color + : ctx->colors.primary; + + if (disabled) { + active_color = + iui_state_layer(ctx->colors.on_surface, IUI_STATE_FOCUS_ALPHA); + inactive_color = + iui_state_layer(ctx->colors.on_surface, IUI_STATE_FOCUS_ALPHA); + handle_color = + iui_state_layer(ctx->colors.on_surface, IUI_STATE_DISABLE_ALPHA); + } + + /* Draw labels */ + if (options && options->start_text) { + uint32_t lc = disabled ? iui_state_layer(ctx->colors.on_surface, + IUI_STATE_DISABLE_ALPHA) + : ctx->colors.on_surface; + draw_align_text(ctx, &ctx->layout, options->start_text, lc, + IUI_ALIGN_LEFT); + } + if (options && options->end_text) { + uint32_t lc = disabled ? iui_state_layer(ctx->colors.on_surface, + IUI_STATE_DISABLE_ALPHA) + : ctx->colors.on_surface; + draw_align_text(ctx, &ctx->layout, options->end_text, lc, + IUI_ALIGN_RIGHT); + } + if (options && (options->start_text || options->end_text)) + iui_newline(ctx); + + float center_y = ctx->layout.y + 0.5f * ctx->layout.height; + float track_height = IUI_SLIDER_TRACK_HEIGHT; + float track_margin = ctx->layout.width * 0.05f; + iui_rect_t track_rect = { + .x = ctx->layout.x + track_margin, + .y = center_y - track_height * 0.5f, + .width = ctx->layout.width - track_margin * 2.f, + .height = track_height, + }; + + float range = max - min; + float norm_low = (low - min) / range; + float norm_high = (high - min) / range; + float thumb_low_x = norm_low * track_rect.width + track_rect.x; + float thumb_high_x = norm_high * track_rect.width + track_rect.x; + + /* Generate unique IDs for the two thumbs */ + uint32_t base_id = iui_hash("range_slider", 12) ^ + iui_hash_pos(ctx->layout.x, ctx->layout.y); + uint32_t id_low = iui_slider_masked_id(base_id); + uint32_t id_high = iui_slider_masked_id(base_id ^ 0x12345678u); + + iui_register_slider(ctx, id_low); + iui_register_slider(ctx, id_high); + + /* Determine which thumb is being dragged */ + bool dragging_low = + ((ctx->slider.active_id & IUI_SLIDER_ID_MASK) == id_low) && + !(ctx->slider.active_id & IUI_SLIDER_ANIM_FLAG); + bool dragging_high = + ((ctx->slider.active_id & IUI_SLIDER_ID_MASK) == id_high) && + !(ctx->slider.active_id & IUI_SLIDER_ANIM_FLAG); + + float thumb_size = IUI_SLIDER_THUMB_IDLE; + float half = thumb_size * 0.5f; + float pressed_half = IUI_SLIDER_THUMB_PRESSED * 0.5f; + + /* Touch rects for both thumbs */ + iui_rect_t touch_low = {thumb_low_x - half, center_y - half, thumb_size, + thumb_size}; + iui_rect_t touch_high = {thumb_high_x - half, center_y - half, thumb_size, + thumb_size}; + iui_expand_touch_target(&touch_low, IUI_SLIDER_TOUCH_TARGET); + iui_expand_touch_target(&touch_high, IUI_SLIDER_TOUCH_TARGET); + + iui_state_t state_low = iui_get_component_state(ctx, touch_low, disabled); + iui_state_t state_high = iui_get_component_state(ctx, touch_high, disabled); + + if (!disabled) { + /* Start drag on press - pick closer thumb when both are pressed */ + bool low_pressed = + (state_low == IUI_STATE_PRESSED) && !dragging_low && !dragging_high; + bool high_pressed = (state_high == IUI_STATE_PRESSED) && + !dragging_low && !dragging_high; + + if (low_pressed && high_pressed) { + /* Both pressed (overlapping targets): pick closer thumb */ + float dist_low = fabsf(ctx->mouse_pos.x - thumb_low_x); + float dist_high = fabsf(ctx->mouse_pos.x - thumb_high_x); + if (dist_high < dist_low) + low_pressed = false; + else + high_pressed = false; + } + + if (low_pressed) { + ctx->slider.active_id = id_low; + ctx->slider.drag_offset = ctx->mouse_pos.x - thumb_low_x; + dragging_low = true; + } else if (high_pressed) { + ctx->slider.active_id = id_high; + ctx->slider.drag_offset = ctx->mouse_pos.x - thumb_high_x; + dragging_high = true; + } + + /* Update drag */ + if (dragging_low && (ctx->mouse_held & IUI_MOUSE_LEFT)) { + thumb_low_x = ctx->mouse_pos.x - ctx->slider.drag_offset; + thumb_low_x = clamp_float(track_rect.x, thumb_high_x, thumb_low_x); + } else if (dragging_low) { + ctx->slider.active_id = 0; + dragging_low = false; + } + + if (dragging_high && (ctx->mouse_held & IUI_MOUSE_LEFT)) { + thumb_high_x = ctx->mouse_pos.x - ctx->slider.drag_offset; + thumb_high_x = clamp_float( + thumb_low_x, track_rect.x + track_rect.width, thumb_high_x); + } else if (dragging_high) { + ctx->slider.active_id = 0; + dragging_high = false; + } + } + + /* Calculate values from thumb positions */ + if (track_rect.width > 0.f) { + norm_low = (thumb_low_x - track_rect.x) / track_rect.width; + norm_high = (thumb_high_x - track_rect.x) / track_rect.width; + } else { + norm_low = 0.f; + norm_high = 0.f; + } + low = norm_low * range + min; + high = norm_high * range + min; + + if (step > 0.f) { + low = roundf(low / step) * step; + high = roundf(high / step) * step; + } + low = clamp_float(min, max, low); + high = clamp_float(min, max, high); + + /* Resolve crossing after quantization: clamp the non-dragged thumb */ + if (low > high) { + if (dragging_high) + high = low; + else + low = high; + } + + /* Recalculate thumb positions after quantization */ + norm_low = (low - min) / range; + norm_high = (high - min) / range; + thumb_low_x = norm_low * track_rect.width + track_rect.x; + thumb_high_x = norm_high * track_rect.width + track_rect.x; + + /* Draw inactive track (full width) */ + ctx->renderer.draw_box(track_rect, track_height * 0.5f, inactive_color, + ctx->renderer.user); + + /* Draw active track (between thumbs) */ + float active_x = thumb_low_x; + float active_w = thumb_high_x - thumb_low_x; + if (active_w > 0.f) { + ctx->renderer.draw_box( + (iui_rect_t) {active_x, track_rect.y, active_w, track_height}, + track_height * 0.5f, active_color, ctx->renderer.user); + } + + /* State layers on hover/drag */ + for (int i = 0; i < 2; i++) { + float tx = (i == 0) ? thumb_low_x : thumb_high_x; + bool hovered = (i == 0) ? (state_low == IUI_STATE_HOVERED) + : (state_high == IUI_STATE_HOVERED); + bool dragging = (i == 0) ? dragging_low : dragging_high; + + if ((hovered || dragging) && !disabled) { + float ss = thumb_size * 1.5f; + uint8_t alpha = + dragging ? IUI_STATE_DRAG_ALPHA : IUI_STATE_HOVER_ALPHA; + uint32_t sc = iui_state_layer(handle_color, alpha); + ctx->renderer.draw_box( + (iui_rect_t) {tx - ss * 0.5f, center_y - ss * 0.5f, ss, ss}, + ss * 0.5f, sc, ctx->renderer.user); + } + } + + /* Draw both thumbs */ + float draw_half_low = dragging_low ? pressed_half : half; + float draw_half_high = dragging_high ? pressed_half : half; + float draw_size_low = draw_half_low * 2.f; + float draw_size_high = draw_half_high * 2.f; + + ctx->renderer.draw_box( + (iui_rect_t) {thumb_low_x - draw_half_low, center_y - draw_half_low, + draw_size_low, draw_size_low}, + draw_half_low, handle_color, ctx->renderer.user); + ctx->renderer.draw_box( + (iui_rect_t) {thumb_high_x - draw_half_high, center_y - draw_half_high, + draw_size_high, draw_size_high}, + draw_half_high, handle_color, ctx->renderer.user); + + /* MD3 validation */ + IUI_MD3_TRACK_SLIDER(touch_low, touch_low.height * 0.5f); + + iui_newline(ctx); + + state->value_low = low; + state->value_high = high; + + return (low != orig_low) || (high != orig_high); +} + /* Buttons */ bool iui_button(iui_context *ctx, diff --git a/src/fab.c b/src/fab.c index bbe4784..87d062a 100644 --- a/src/fab.c +++ b/src/fab.c @@ -40,8 +40,12 @@ static bool iui_fab_internal(iui_context *ctx, iui_rect_t fab_rect = {x, y, fab_w, fab_h}; + /* Expand touch target to meet 48dp minimum for accessibility */ + iui_rect_t touch_rect = fab_rect; + iui_expand_touch_target(&touch_rect, IUI_ICON_BUTTON_TOUCH_TARGET); + /* Get component state for interaction */ - iui_state_t state = iui_get_component_state(ctx, fab_rect, false); + iui_state_t state = iui_get_component_state(ctx, touch_rect, false); /* MD3 FAB colors */ uint32_t container_color = ctx->colors.primary_container; @@ -102,6 +106,14 @@ static bool iui_fab_internal(iui_context *ctx, return false; } +/* Small FAB (40dp) */ +bool iui_fab_small(iui_context *ctx, float x, float y, const char *icon) +{ + return iui_fab_internal(ctx, x, y, IUI_FAB_SMALL_SIZE, + IUI_FAB_SMALL_CORNER_RADIUS, + IUI_FAB_SMALL_ICON_SIZE, icon, NULL); +} + /* Standard FAB (56dp) */ bool iui_fab(iui_context *ctx, float x, float y, const char *icon) { diff --git a/src/list.c b/src/list.c index 89cd05b..cc59c88 100644 --- a/src/list.c +++ b/src/list.c @@ -400,6 +400,22 @@ bool iui_list_item_two_line(iui_context *ctx, return iui_list_item_ex(ctx, IUI_LIST_TWO_LINE, &item); } +bool iui_list_item_three_line(iui_context *ctx, + const char *overline, + const char *headline, + const char *supporting, + const char *icon) +{ + iui_list_item item = { + .overline = overline, + .headline = headline, + .supporting = supporting, + .leading_type = icon ? IUI_LIST_LEADING_ICON : IUI_LIST_LEADING_NONE, + .leading_icon = icon, + }; + return iui_list_item_ex(ctx, IUI_LIST_THREE_LINE, &item); +} + void iui_list_divider(iui_context *ctx) { if (!ctx || !ctx->current_window) diff --git a/src/md3-spec.dsl b/src/md3-spec.dsl index 49cc352..8c52ddb 100644 --- a/src/md3-spec.dsl +++ b/src/md3-spec.dsl @@ -56,6 +56,13 @@ COMPONENT segmented { touch_target 48 } +COMPONENT fab_small { + size EXACT 40 ±1 + icon_size 24 + touch_target 48 + corner_radius @shape.medium +} + COMPONENT fab { size EXACT 56 ±1 icon_size 24 @@ -108,6 +115,13 @@ COMPONENT slider { touch_target 48 } +COMPONENT range_slider { + track_height 4 + thumb_idle 20 + thumb_pressed 28 + touch_target 48 +} + COMPONENT switch { track_width 52 track_height 32