mirror of
https://github.com/google/pebble.git
synced 2025-05-25 12:44:53 +00:00
847 lines
36 KiB
C
847 lines
36 KiB
C
/*
|
|
* Copyright 2024 Google LLC
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
#include "action_menu_layer.h"
|
|
#include "action_menu_window_private.h"
|
|
|
|
#include "applib/applib_malloc.auto.h"
|
|
#include "applib/fonts/fonts.h"
|
|
#include "applib/graphics/graphics.h"
|
|
#include "applib/graphics/text.h"
|
|
#include "applib/ui/animation.h"
|
|
#include "applib/ui/menu_layer.h"
|
|
#include "applib/ui/property_animation.h"
|
|
#include "applib/ui/window_private.h"
|
|
#include "kernel/pbl_malloc.h"
|
|
#include "kernel/ui/kernel_ui.h"
|
|
#include "resource/resource_ids.auto.h"
|
|
#include "shell/system_theme.h"
|
|
#include "system/passert.h"
|
|
#include "util/math.h"
|
|
|
|
#include <string.h>
|
|
|
|
#define INDICATOR "»"
|
|
|
|
static const int VERTICAL_PADDING = PBL_IF_COLOR_ELSE(2, 4);
|
|
static const int EXTRA_PADDING_1_BIT = 2;
|
|
static const int SHORT_COL_COUNT = 3;
|
|
static const int MAX_NUM_VISIBLE_LINES = 2;
|
|
static const int SHORT_ITEM_MAX_ROWS_SPALDING = 3;
|
|
|
|
static GFont prv_get_item_font(void) {
|
|
return system_theme_get_font(TextStyleFont_MenuCellTitle);
|
|
}
|
|
|
|
//! Only used on round displays to achieve a fish-eye effect
|
|
static GFont prv_get_unfocused_item_font(void) {
|
|
return system_theme_get_font(TextStyleFont_Header);
|
|
}
|
|
|
|
static uint16_t prv_get_num_rows(MenuLayer *menu_layer, uint16_t section_index,
|
|
void *callback_context) {
|
|
ActionMenuLayer *aml = callback_context;
|
|
return (uint16_t)(aml->num_items +
|
|
(aml->num_short_items + SHORT_COL_COUNT - 1) / SHORT_COL_COUNT);
|
|
}
|
|
|
|
static void prv_cell_column_draw(GContext *ctx, struct Layer const *cell_layer,
|
|
ActionMenuLayer *aml, ActionMenuItem *items,
|
|
int num_items, int sel_idx) {
|
|
const GFont font = aml->layout_cache.font;
|
|
const int16_t font_height = fonts_get_font_height(font);
|
|
const GRect *layer_bounds = &cell_layer->bounds;
|
|
GRect r = *layer_bounds;
|
|
#if PBL_ROUND
|
|
// more narrow on round
|
|
r = grect_inset_internal(r, 25, 0);
|
|
// center the columns horizontally if there's only one row
|
|
const bool is_single_short_row = aml->num_short_items <= SHORT_COL_COUNT;
|
|
r.size.w /= is_single_short_row ? num_items : SHORT_COL_COUNT;
|
|
#else
|
|
r.size.w /= SHORT_COL_COUNT;
|
|
#endif
|
|
r.origin.y += (r.size.h - font_height) / 2 - 4;
|
|
|
|
for (int i = 0; i < num_items; i++) {
|
|
if (!items[i].label) {
|
|
break;
|
|
}
|
|
|
|
if (sel_idx == i) {
|
|
graphics_context_set_text_color(ctx, PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack));
|
|
#if SCREEN_COLOR_DEPTH_BITS == 1
|
|
// We only want to have a background on non-color platforms, while leaving this in with
|
|
// a PBL_IF_COLOR_ELSE makes this a no-op, we'll save some cycles and code space just
|
|
// skipping it.
|
|
graphics_context_set_fill_color(ctx, GColorWhite);
|
|
|
|
const int16_t y_offset = 1;
|
|
const int16_t padding = r.size.w / 6;
|
|
const uint16_t corner_radius = 4;
|
|
GRect bg_rect = r;
|
|
bg_rect.origin.y = layer_bounds->origin.y;
|
|
bg_rect.size.h = layer_bounds->size.h;
|
|
bg_rect = grect_inset_internal(bg_rect, padding, y_offset);
|
|
graphics_fill_round_rect(ctx, &bg_rect, corner_radius, GCornersAll);
|
|
#endif
|
|
} else {
|
|
graphics_context_set_text_color(ctx, PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite));
|
|
}
|
|
|
|
graphics_draw_text(ctx, items[i].label, font, r, GTextOverflowModeTrailingEllipsis,
|
|
GTextAlignmentCenter, NULL);
|
|
r.origin.x += r.size.w;
|
|
}
|
|
}
|
|
|
|
static const ActionMenuItem *prv_get_item_for_index(ActionMenuLayer *aml, int idx) {
|
|
if (!aml->num_items && !aml->num_short_items) {
|
|
return NULL;
|
|
}
|
|
|
|
PBL_ASSERTN(idx >= 0);
|
|
|
|
if (idx < aml->num_items) {
|
|
return &aml->items[idx];
|
|
} else {
|
|
const int short_items_idx = idx - aml->num_items;
|
|
PBL_ASSERTN(short_items_idx < aml->num_short_items);
|
|
return &aml->short_items[short_items_idx];
|
|
}
|
|
}
|
|
|
|
static int16_t prv_get_item_line_height(ActionMenuLayer *aml, int idx) {
|
|
const GFont font = aml->layout_cache.font;
|
|
const ActionMenuItem *item = prv_get_item_for_index(aml, idx);
|
|
GRect box = menu_layer_get_layer(&aml->menu_layer)->bounds;
|
|
// In calculating the item line height for round displays, we need to horizontally inset by the
|
|
// standard focused cell inset since that's the horizontal inset of the cells where we show
|
|
// the vertical scrolling animation of long text cells (where the height is crucial to be correct)
|
|
const int inset = PBL_IF_ROUND_ELSE(MENU_CELL_ROUND_FOCUSED_HORIZONTAL_INSET,
|
|
menu_cell_basic_horizontal_inset());
|
|
// Tintin has a rounded rectangle highlight
|
|
box = grect_inset_internal(box, PBL_IF_COLOR_ELSE(inset, 2 * inset), 0);
|
|
|
|
// We offset the text 5 pixels from the left of the cell. If the indicator is
|
|
// present, the indicator also will be offset, so we add 5 pixels more spacing
|
|
// between the text and the indicator. This extra padding isn't needed for round.
|
|
const int nudge = PBL_IF_ROUND_ELSE(0, menu_cell_basic_horizontal_inset());
|
|
GContext *ctx = graphics_context_get_current_context();
|
|
// On rectangular displays, if the indicator is present, the indicator also will be offset,
|
|
// so we add another nudge between the text and the indicator.
|
|
#if PBL_RECT
|
|
if (!item->is_leaf) {
|
|
const GSize indicator_size = graphics_text_layout_get_max_used_size(ctx, INDICATOR,
|
|
font, box,
|
|
GTextOverflowModeWordWrap,
|
|
GTextAlignmentRight, NULL);
|
|
box.size.w -= (indicator_size.w + nudge);
|
|
}
|
|
#endif
|
|
return graphics_text_layout_get_text_height(ctx, item->label, font, box.size.w,
|
|
GTextOverflowModeWordWrap, PBL_IF_ROUND_ELSE(GTextAlignmentCenter, GTextAlignmentLeft));
|
|
}
|
|
|
|
// Item Scroll Animation
|
|
///////////////////////////////////
|
|
|
|
static int16_t prv_get_cell_offset(void *subject) {
|
|
ActionMenuLayer *aml = subject;
|
|
return aml->item_animation.current_offset_y;
|
|
}
|
|
|
|
T_STATIC void prv_set_cell_offset(void *subject, int16_t value) {
|
|
ActionMenuLayer *aml = subject;
|
|
aml->item_animation.current_offset_y = value;
|
|
layer_mark_dirty(&aml->layer);
|
|
}
|
|
|
|
static void prv_cell_animation_stopped_handler(Animation *animation, bool finished, void *context) {
|
|
ActionMenuLayer *aml = context;
|
|
if (finished) {
|
|
prv_set_cell_offset(aml, aml->item_animation.bottom_offset_y);
|
|
}
|
|
}
|
|
|
|
static const PropertyAnimationImplementation s_item_animation_implementation = {
|
|
.base = {
|
|
.update = (AnimationUpdateImplementation)property_animation_update_int16
|
|
},
|
|
.accessors = {
|
|
.setter = { .int16 = prv_set_cell_offset },
|
|
.getter = { .int16 = prv_get_cell_offset }
|
|
}
|
|
};
|
|
|
|
static void prv_unschedule_item_animation(ActionMenuLayer *aml) {
|
|
animation_unschedule(aml->item_animation.animation);
|
|
aml->item_animation.animation = NULL;
|
|
}
|
|
|
|
static void prv_animate_cell(ActionMenuLayer *aml, GRect *label_text_frame, bool *draw_top_shading,
|
|
bool *draw_bottom_shading) {
|
|
// Check to see if this item spans more than max number of visible lines,
|
|
// in which case we want to make it scroll.
|
|
const int16_t item_height = aml->layout_cache.item_heights[aml->selected_index];
|
|
const int16_t line_height = fonts_get_font_height(aml->layout_cache.font);
|
|
|
|
#if SCREEN_COLOR_DEPTH_BITS == 1
|
|
// We need to force it to scroll a little extra for 1 bit
|
|
label_text_frame->origin.y -= EXTRA_PADDING_1_BIT;
|
|
#endif
|
|
// On rect displays, calculate the visible item height based on a desired number of visible lines
|
|
// On round displays, use the height of the provided box since it might be inset for the indicator
|
|
const int16_t max_visible_item_height = PBL_IF_RECT_ELSE(MAX_NUM_VISIBLE_LINES * line_height,
|
|
label_text_frame->size.h);
|
|
if (item_height > max_visible_item_height) {
|
|
// Compute the limit at which we should bounce back to the top of the layer. Since
|
|
// there are at most MAX_NUM_VISIBLE_LINES shown at a given time, we want to stop
|
|
// when there are that number of lines in view and no more lines remaining below.
|
|
const int16_t max_scroll_distance = item_height - max_visible_item_height;
|
|
ActionMenuItemAnimation *item_animation = &aml->item_animation;
|
|
if (item_animation->animation == NULL) {
|
|
const int16_t DELAY_PER_LINE = 600; /* milliseconds to delay per line */
|
|
|
|
// Top offset represents when the text has scrolled to its minimum y value so the last line of
|
|
// text is visible. Bottom offset represents when the text has scrolled all the way to its
|
|
// maximum y so the first line of text is visible.
|
|
item_animation->top_offset_y = -max_scroll_distance;
|
|
item_animation->bottom_offset_y = 0;
|
|
item_animation->current_offset_y = 0;
|
|
|
|
// Create the animation that will scroll us up in the cell
|
|
PropertyAnimation *animation = property_animation_create(&s_item_animation_implementation,
|
|
(void *)aml, NULL, &item_animation->top_offset_y);
|
|
|
|
animation_set_duration((Animation *)animation, DELAY_PER_LINE * (item_height / line_height));
|
|
animation_set_curve((Animation *)animation, AnimationCurveLinear);
|
|
animation_set_handlers((Animation *)animation, (AnimationHandlers){0}, aml);
|
|
|
|
// Create the animation that stalls when we have auto-scrolled up completely
|
|
PropertyAnimation *s_animation = property_animation_create(&s_item_animation_implementation,
|
|
(void *)aml, &item_animation->top_offset_y, &item_animation->top_offset_y);
|
|
|
|
animation_set_duration((Animation *)s_animation, DELAY_PER_LINE /* ms to wait */);
|
|
animation_set_handlers((Animation *)s_animation, (AnimationHandlers){0}, aml);
|
|
|
|
// Create the reverse animation that takes us from the scrolled up position back down
|
|
PropertyAnimation *r_animation = property_animation_create(&s_item_animation_implementation,
|
|
(void *)aml, &item_animation->top_offset_y, &item_animation->bottom_offset_y);
|
|
|
|
animation_set_duration((Animation *)r_animation,
|
|
(DELAY_PER_LINE / 4) * (item_height / line_height));
|
|
animation_set_curve((Animation *)r_animation, AnimationCurveEaseInOut);
|
|
animation_set_handlers((Animation *)r_animation, (AnimationHandlers){0}, aml);
|
|
|
|
item_animation->animation = animation_sequence_create((Animation *)animation,
|
|
(Animation *)s_animation, (Animation *)r_animation);
|
|
|
|
animation_set_handlers(item_animation->animation,
|
|
(AnimationHandlers){ .stopped = prv_cell_animation_stopped_handler },
|
|
aml);
|
|
animation_set_play_count(item_animation->animation, PLAY_COUNT_INFINITE);
|
|
animation_set_delay(item_animation->animation, DELAY_PER_LINE /* ms */);
|
|
animation_schedule(item_animation->animation);
|
|
}
|
|
*draw_top_shading = (item_animation->current_offset_y != item_animation->bottom_offset_y);
|
|
*draw_bottom_shading = (item_animation->current_offset_y != item_animation->top_offset_y);
|
|
|
|
// update the rect height and offset based on the current animation state
|
|
label_text_frame->origin.y += item_animation->current_offset_y;
|
|
label_text_frame->size.h = item_height;
|
|
}
|
|
}
|
|
|
|
// Menu Layer Drawing Routines
|
|
///////////////////////////////
|
|
|
|
static bool prv_should_center(ActionMenuLayer *aml) {
|
|
// We only center an ActionMenuLayer's items if the user has specified to
|
|
// center the items or there is only one item in the ActionMenuLayer.
|
|
if (aml->num_items == 1 || aml->layout_cache.align == ActionMenuAlignCenter) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static void prv_cell_item_content_draw_rect(GContext *ctx, const Layer *cell_layer,
|
|
const ActionMenuLayer *aml, const ActionMenuItem *item,
|
|
bool selected, GRect *content_box) {
|
|
char *indicator = NULL;
|
|
const int16_t horizontal_padding = menu_cell_basic_horizontal_inset();
|
|
const GFont font = aml->layout_cache.font;
|
|
if (!item->is_leaf) {
|
|
// If an item is not a leaf, then there would be an indicator when it is focused. Either
|
|
// we draw the indicator or we force the box to be smaller to force the text to render as
|
|
// if the indicator was present in case it would line wrap.
|
|
if (selected) {
|
|
indicator = INDICATOR;
|
|
} else {
|
|
const GSize indicator_size = graphics_text_layout_get_max_used_size(
|
|
ctx, INDICATOR, font, *content_box, GTextOverflowModeWordWrap, GTextAlignmentRight, NULL);
|
|
content_box->size.w -= (indicator_size.w + (2 * horizontal_padding));
|
|
}
|
|
} else {
|
|
content_box->size.w -= horizontal_padding;
|
|
}
|
|
|
|
#if SCREEN_COLOR_DEPTH_BITS == 1
|
|
// Fill in the background layer. This effectively does nothing on watches where we have the
|
|
// ability to draw with color, but on others, it will render a background behind the selected
|
|
// cell.
|
|
const int x_offset = horizontal_padding;
|
|
const int y_padding = EXTRA_PADDING_1_BIT;
|
|
const uint16_t corner_radius = 4;
|
|
GRect bg_box = grect_inset_internal(cell_layer->bounds, x_offset, 0);
|
|
bg_box.size.h -= y_padding;
|
|
graphics_fill_round_rect(ctx, &bg_box, corner_radius, GCornersAll);
|
|
// We have to adjust the box to compensate for the padding we added. Note that we can't call
|
|
// inset as it will discard our offset when it standardizes.
|
|
content_box->origin.x += x_offset;
|
|
content_box->size.w -= (2 * x_offset);
|
|
content_box->size.h -= (2 * y_padding);
|
|
#endif
|
|
|
|
// Cast the cell layer so we can briefly modify its bounds. We do this because we're
|
|
// desperate for stack space and we understand the call hierarchy. We'll restore the state below.
|
|
Layer *mutable_cell_layer = (Layer *)cell_layer;
|
|
const GRect saved_bounds = mutable_cell_layer->bounds;
|
|
mutable_cell_layer->bounds = *content_box;
|
|
|
|
// Draw the menu cell specifying that we're allowing word wrapping
|
|
const GTextOverflowMode overflow_mode = GTextOverflowModeWordWrap;
|
|
menu_cell_basic_draw_custom(ctx, mutable_cell_layer, font, item->label, font, indicator, font,
|
|
NULL, NULL, false, overflow_mode);
|
|
|
|
// Restore the cell layer's bounds
|
|
mutable_cell_layer->bounds = saved_bounds;
|
|
}
|
|
|
|
static void prv_cell_item_content_draw_round(GContext *ctx, const Layer *cell_layer,
|
|
const ActionMenuLayer *aml, const ActionMenuItem *item,
|
|
bool selected, GRect *content_box) {
|
|
const int16_t horizontal_inset = selected ? MENU_CELL_ROUND_FOCUSED_HORIZONTAL_INSET :
|
|
MENU_CELL_ROUND_UNFOCUSED_HORIZONTAL_INSET;
|
|
*content_box = grect_inset(*content_box, GEdgeInsets(0, horizontal_inset));
|
|
|
|
// Use a smaller font for the unfocused cells to achieve a fish-eye effect
|
|
const GFont font = selected ? aml->layout_cache.font : prv_get_unfocused_item_font();
|
|
const GTextOverflowMode overflow_mode = selected ? GTextOverflowModeWordWrap :
|
|
GTextOverflowModeTrailingEllipsis;
|
|
const GTextAlignment text_alignment = GTextAlignmentCenter;
|
|
const GSize text_size = graphics_text_layout_get_max_used_size(ctx, item->label, font,
|
|
*content_box, overflow_mode,
|
|
text_alignment, NULL);
|
|
GRect text_box = (GRect) { .size = text_size };
|
|
const GAlign item_label_text_alignment = GAlignCenter;
|
|
grect_align(&text_box, content_box, item_label_text_alignment, true /* clip */);
|
|
text_box.origin.y -= fonts_get_font_cap_offset(font);
|
|
|
|
graphics_draw_text(ctx, item->label, font, text_box, overflow_mode, text_alignment, NULL);
|
|
}
|
|
|
|
static int16_t prv_get_indicator_height(const ActionMenuLayer *aml) {
|
|
// This magic factor is an approximation of the indicator height in relation to the font line
|
|
// height; it Just Works(tm)
|
|
return fonts_get_font_height(aml->layout_cache.font) * 40 / 100;
|
|
}
|
|
|
|
static void prv_draw_indicator_round(GContext *ctx, const ActionMenuLayer *aml,
|
|
const GRect *label_text_container) {
|
|
const int indicator_height = fonts_get_font_height(aml->layout_cache.font);
|
|
const int text_height = aml->layout_cache.item_heights[aml->selected_index];
|
|
const int content_height = MIN(label_text_container->size.h, text_height + indicator_height);
|
|
|
|
GRect content_frame = (GRect) {
|
|
.size = GSize(label_text_container->size.w, content_height)
|
|
};
|
|
GRect indicator_frame = (GRect) {
|
|
.size = GSize(label_text_container->size.w, indicator_height)
|
|
};
|
|
|
|
grect_align(&content_frame, label_text_container, GAlignCenter, true);
|
|
grect_align(&indicator_frame, &content_frame, GAlignBottom, true);
|
|
|
|
graphics_draw_text(ctx, INDICATOR, aml->layout_cache.font, indicator_frame,
|
|
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
|
|
}
|
|
|
|
static void prv_cell_item_draw(GContext *ctx, const Layer *cell_layer,
|
|
ActionMenuLayer *aml, const ActionMenuItem *item,
|
|
bool selected) {
|
|
GRect label_text_container = cell_layer->bounds;
|
|
// bottom_inset won't be used on black and white, using UNUSED here quiets the linter
|
|
UNUSED int16_t bottom_inset = 0;
|
|
#if PBL_ROUND
|
|
// On round displays, inset the box from the bottom to account for drawing the indicator at the
|
|
// bottom center, and then draw the indicator
|
|
const bool selected_with_indicator = (selected && !item->is_leaf);
|
|
if (selected_with_indicator) {
|
|
prv_draw_indicator_round(ctx, aml, &label_text_container);
|
|
|
|
const int16_t indicator_text_margin = 7;
|
|
bottom_inset = prv_get_indicator_height(aml) + indicator_text_margin;
|
|
label_text_container.size.h -= bottom_inset;
|
|
}
|
|
#endif
|
|
|
|
GRect label_text_frame = label_text_container;
|
|
bool draw_top_shading = false;
|
|
bool draw_bottom_shading = false;
|
|
// If we are the selected index, check to see if we have started scrolling.
|
|
// If we have, use our internal box to draw the layer, otherwise use the
|
|
// layer box.
|
|
if (selected) {
|
|
prv_animate_cell(aml, &label_text_frame, &draw_top_shading, &draw_bottom_shading);
|
|
#if !defined(RECOVERY_FW) && SCREEN_COLOR_DEPTH_BITS == 8
|
|
// Replace the clip box with a clip box that will render the item in the right place with the
|
|
// right size, without menu layer's selection clipping. Menu layer will responsible for cleaning
|
|
// up the changes made to this clip box.
|
|
ctx->draw_state.clip_box.origin = ctx->draw_state.drawing_box.origin;
|
|
ctx->draw_state.clip_box.size = cell_layer->bounds.size;
|
|
// We have to update the clip box of the drawing state to account for text padding to
|
|
// force it to clip around the shadow.
|
|
if (draw_top_shading) {
|
|
ctx->draw_state.clip_box.origin.y += VERTICAL_PADDING;
|
|
ctx->draw_state.clip_box.size.h -= VERTICAL_PADDING;
|
|
}
|
|
if (draw_bottom_shading) {
|
|
ctx->draw_state.clip_box.size.h -= VERTICAL_PADDING + bottom_inset;
|
|
}
|
|
// Prevent drawing outside of the context bitmap
|
|
grect_clip(&ctx->draw_state.clip_box, &ctx->dest_bitmap.bounds);
|
|
#endif
|
|
graphics_context_set_text_color(ctx, PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack));
|
|
graphics_context_set_fill_color(ctx, PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite));
|
|
}
|
|
|
|
PBL_IF_RECT_ELSE(prv_cell_item_content_draw_rect,
|
|
prv_cell_item_content_draw_round)(ctx, cell_layer, aml, item, selected,
|
|
&label_text_frame);
|
|
|
|
#if !defined(RECOVERY_FW) && SCREEN_COLOR_DEPTH_BITS == 8
|
|
const int16_t fade_height = 10;
|
|
graphics_context_set_compositing_mode(ctx, GCompOpSet);
|
|
if (draw_top_shading) {
|
|
GRect top_bounds = label_text_container;
|
|
top_bounds.origin.y += VERTICAL_PADDING;
|
|
top_bounds.size.h = fade_height;
|
|
graphics_draw_bitmap_in_rect(ctx, &aml->item_animation.fade_top, &top_bounds);
|
|
}
|
|
|
|
if (draw_bottom_shading) {
|
|
GRect bottom_bounds = label_text_container;
|
|
bottom_bounds.size.h = fade_height;
|
|
bottom_bounds.origin.y = grect_get_max_y(&label_text_container) -
|
|
(fade_height + VERTICAL_PADDING);
|
|
graphics_draw_bitmap_in_rect(ctx, &aml->item_animation.fade_bottom, &bottom_bounds);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void prv_draw_row(GContext* ctx, const Layer *cell_layer, MenuIndex *cell_index,
|
|
void *callback_context) {
|
|
ActionMenuLayer *aml = callback_context;
|
|
|
|
if (cell_index->row < aml->num_items) {
|
|
const ActionMenuItem *item = prv_get_item_for_index(aml, cell_index->row);
|
|
const bool selected = menu_layer_is_index_selected(&aml->menu_layer, cell_index);
|
|
prv_cell_item_draw(ctx, cell_layer, aml, item, selected);
|
|
} else {
|
|
const int base_idx = (cell_index->row - aml->num_items) * SHORT_COL_COUNT;
|
|
const int sel_idx = aml->selected_index - (base_idx + aml->num_items);
|
|
const int num_items = CLIP(aml->num_short_items - base_idx, 0, SHORT_COL_COUNT);
|
|
prv_cell_column_draw(ctx, cell_layer, aml, (ActionMenuItem *)&aml->short_items[base_idx],
|
|
num_items, sel_idx);
|
|
}
|
|
}
|
|
|
|
static int prv_get_menu_layer_row(ActionMenuLayer *aml, int item_index) {
|
|
if (item_index < aml->num_items) {
|
|
return item_index;
|
|
} else {
|
|
return aml->num_items + (item_index - aml->num_items) / SHORT_COL_COUNT;
|
|
}
|
|
}
|
|
|
|
T_STATIC void prv_set_selected_index(ActionMenuLayer *aml, int new_selected_index, bool animated) {
|
|
new_selected_index = CLIP(new_selected_index, 0, aml->num_items + aml->num_short_items - 1);
|
|
|
|
if (new_selected_index != aml->selected_index) {
|
|
// Unschedule any running item animation but don't NULL the pointer, to prevent another
|
|
// animation from being accidentally re-scheduled.
|
|
animation_unschedule(aml->item_animation.animation);
|
|
}
|
|
|
|
if (new_selected_index >= aml->num_items) {
|
|
// For short columns, aml->selected_index needs to be updated here, because the column index
|
|
// will be lost in the menu layer selection changed callback. Otherwise, it will be updated
|
|
// in prv_selection_changed_cb() to ensure the correct index is used by the draw functions.
|
|
aml->selected_index = new_selected_index;
|
|
}
|
|
|
|
const int menu_layer_index = prv_get_menu_layer_row(aml, new_selected_index);
|
|
menu_layer_set_selected_index(&aml->menu_layer, MenuIndex(0, menu_layer_index),
|
|
MenuRowAlignCenter, animated);
|
|
}
|
|
|
|
static void prv_scroll_handler(ClickRecognizerRef recognizer, void *context) {
|
|
ActionMenuLayer *aml = context;
|
|
const bool up = (click_recognizer_get_button_id(recognizer) == BUTTON_ID_UP);
|
|
const int new_idx = aml->selected_index + (up ? -1 : 1);
|
|
prv_set_selected_index(aml, new_idx, true /* animated */);
|
|
}
|
|
|
|
static void prv_select_handler(ClickRecognizerRef recognizer, void *context) {
|
|
ActionMenuLayer *aml = context;
|
|
const ActionMenuItem *item = prv_get_item_for_index(aml, aml->selected_index);
|
|
if (item && aml->cb) {
|
|
aml->cb(item, aml->context);
|
|
}
|
|
}
|
|
|
|
static bool prv_aml_is_short(ActionMenuLayer *aml) {
|
|
return (aml->num_short_items != 0 || aml->num_items == 0);
|
|
}
|
|
|
|
static int16_t prv_get_cell_padding(ActionMenuLayer *aml) {
|
|
const int16_t default_sep_height = 10;
|
|
#if PBL_ROUND
|
|
// when showing columns, set cells further apart
|
|
return prv_aml_is_short(aml) ? default_sep_height : 1;
|
|
#elif SCREEN_COLOR_DEPTH_BITS == 1
|
|
return default_sep_height;
|
|
#else
|
|
const int16_t line_height = fonts_get_font_height(aml->layout_cache.font);
|
|
const int16_t sep_height = MAX(menu_cell_small_cell_height() - line_height,
|
|
default_sep_height) + 1;
|
|
return sep_height;
|
|
#endif
|
|
}
|
|
|
|
static int16_t prv_get_cell_height_cb(struct MenuLayer *menu_layer,
|
|
MenuIndex *cell_index,
|
|
void *context) {
|
|
ActionMenuLayer *aml = (ActionMenuLayer *)context;
|
|
const int16_t line_height = fonts_get_font_height(aml->layout_cache.font);
|
|
// If we have short items, just return the line height.
|
|
if (prv_aml_is_short(aml)) {
|
|
return line_height;
|
|
}
|
|
|
|
#if PBL_ROUND
|
|
return menu_layer_is_index_selected(menu_layer, cell_index) ?
|
|
MENU_CELL_ROUND_FOCUSED_SHORT_CELL_HEIGHT :
|
|
MENU_CELL_ROUND_UNFOCUSED_TALL_CELL_HEIGHT;
|
|
#else
|
|
const int16_t max_visible_height = line_height * MAX_NUM_VISIBLE_LINES;
|
|
const int16_t actual_height = aml->layout_cache.item_heights[cell_index->row];
|
|
return (VERTICAL_PADDING * 2) + MIN(max_visible_height, actual_height);
|
|
#endif
|
|
}
|
|
|
|
static int16_t prv_get_separator_height_cb(struct MenuLayer *menu_layer, MenuIndex *cell_index,
|
|
void *callback_context) {
|
|
// We use the separator to pad the cells (insert spacing), so we compute the height
|
|
// needed for each separator here.
|
|
ActionMenuLayer *aml = callback_context;
|
|
return prv_get_cell_padding(aml);
|
|
}
|
|
|
|
typedef struct ActionMenuSeparatorConfig {
|
|
GSize separator;
|
|
} ActionMenuSeparatorConfig;
|
|
|
|
static const ActionMenuSeparatorConfig s_separator_configs[NumPreferredContentSizes] = {
|
|
[PreferredContentSizeSmall] = {
|
|
.separator = {100, 1},
|
|
},
|
|
[PreferredContentSizeMedium] = {
|
|
.separator = {100, 1},
|
|
},
|
|
[PreferredContentSizeLarge] = {
|
|
.separator = {162, 2},
|
|
},
|
|
[PreferredContentSizeExtraLarge] = {
|
|
.separator = {162, 2},
|
|
},
|
|
};
|
|
|
|
static void prv_draw_separator_cb(GContext *ctx, const Layer *cell_layer,
|
|
MenuIndex *cell_index, void *callback_context) {
|
|
ActionMenuLayer *aml = callback_context;
|
|
if (aml->separator_index && cell_index->row == aml->separator_index) {
|
|
const PreferredContentSize runtime_platform_default_size =
|
|
system_theme_get_default_content_size_for_runtime_platform();
|
|
const ActionMenuSeparatorConfig *config = &s_separator_configs[runtime_platform_default_size];
|
|
|
|
// If this index is the seperator index, we want to draw the separator line
|
|
// in the vertical center of the separator
|
|
const int16_t nudge_down = PBL_IF_RECT_ELSE(3, 0);
|
|
const int16_t nudge_right = menu_cell_basic_horizontal_inset() + 1;
|
|
|
|
const int16_t separator_width = config->separator.w;
|
|
const GRect *cell_layer_bounds = &cell_layer->bounds;
|
|
const int16_t offset_x = PBL_IF_RECT_ELSE(nudge_right,
|
|
(cell_layer->bounds.size.w - separator_width) / 2);
|
|
const int16_t offset_y = (cell_layer_bounds->size.h / 2) + nudge_down;
|
|
GPoint separator_start_point = gpoint_add(cell_layer_bounds->origin,
|
|
GPoint(offset_x, offset_y));
|
|
graphics_context_set_stroke_color(ctx, PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite));
|
|
|
|
separator_start_point.y += config->separator.h;
|
|
for (int i = 0; i < config->separator.h; i++) {
|
|
// First point from bottom will be +0, second +1, third +0, etc.
|
|
separator_start_point.y--;
|
|
separator_start_point.x += i & 1;
|
|
graphics_draw_horizontal_line_dotted(ctx, separator_start_point, separator_width);
|
|
separator_start_point.x -= i & 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
static int16_t prv_get_header_height_cb(struct MenuLayer *menu_layer, uint16_t second_index,
|
|
void *callback_context) {
|
|
ActionMenuLayer *aml = callback_context;
|
|
if (!prv_should_center(aml) || prv_aml_is_short(aml) || aml->num_items == 0) {
|
|
return 0;
|
|
}
|
|
|
|
const int16_t line_height = fonts_get_font_height(aml->layout_cache.font);
|
|
const int16_t padding = prv_get_cell_padding(aml);
|
|
const int16_t max_visible_height = line_height * MAX_NUM_VISIBLE_LINES;
|
|
|
|
const GRect *bounds = &aml->layer.bounds;
|
|
int16_t total_h = 0;
|
|
|
|
for (int16_t idx = 0; idx < aml->num_items; idx++) {
|
|
int16_t item_height = aml->layout_cache.item_heights[idx];
|
|
total_h += MIN(max_visible_height, item_height);
|
|
}
|
|
|
|
const int16_t header_padding = 6 * aml->num_items;
|
|
const int16_t header_height = ((bounds->size.h - total_h) / 2) - padding;
|
|
return MAX(header_height - header_padding, 0);
|
|
}
|
|
|
|
static void prv_draw_header_cb(GContext *ctx, const Layer *cell_layer, uint16_t section_index,
|
|
void *callback_context) {
|
|
// The header here is just being used for padding, so we don't actually need to draw anything.
|
|
return;
|
|
}
|
|
|
|
static void prv_selection_changed_cb(struct MenuLayer *menu_layer, MenuIndex new_index,
|
|
MenuIndex old_index, void *callback_context) {
|
|
ActionMenuLayer *aml = callback_context;
|
|
if (new_index.row < aml->num_items) {
|
|
// Enable a new item animation to be scheduled
|
|
prv_unschedule_item_animation(aml);
|
|
aml->selected_index = new_index.row;
|
|
}
|
|
}
|
|
|
|
static void prv_changed_proc(Layer *layer) {
|
|
ActionMenuLayer *aml = (ActionMenuLayer *)layer;
|
|
const GRect *aml_bounds = &layer->bounds;
|
|
GRect menu_layer_frame = *aml_bounds;
|
|
#if PBL_ROUND
|
|
if (prv_aml_is_short(aml)) {
|
|
// clip the menu layer to show exactly SHORT_ITEM_MAX_ROWS_SPALDING lines at a time
|
|
const int16_t font_height = fonts_get_font_height(aml->layout_cache.font);
|
|
const int16_t cell_padding = prv_get_cell_padding(aml);
|
|
const int num_visible_rows = MIN(prv_get_num_rows(&aml->menu_layer, 0, aml),
|
|
SHORT_ITEM_MAX_ROWS_SPALDING);
|
|
menu_layer_frame.size.h = (font_height * num_visible_rows) +
|
|
(cell_padding * (num_visible_rows - 1));
|
|
grect_align(&menu_layer_frame, aml_bounds, GAlignCenter, true /* clip */);
|
|
}
|
|
#endif
|
|
layer_set_frame(menu_layer_get_layer(&aml->menu_layer), &menu_layer_frame);
|
|
}
|
|
|
|
static void prv_update_proc(Layer *layer, GContext *ctx) {
|
|
#if PBL_ROUND
|
|
ActionMenuLayer *aml = (ActionMenuLayer *)layer;
|
|
const int num_rows = prv_get_num_rows(&aml->menu_layer, 0, aml);
|
|
if (prv_aml_is_short(aml) && (num_rows > SHORT_ITEM_MAX_ROWS_SPALDING)) {
|
|
// draw some "content indicator" arrows
|
|
const GRect *aml_bounds = &layer->bounds;
|
|
const GRect *menu_layer_frame = &menu_layer_get_layer(&aml->menu_layer)->frame;
|
|
const int16_t arrow_layer_height = (aml_bounds->size.h - menu_layer_frame->size.h) / 2;
|
|
|
|
const int row = prv_get_menu_layer_row(aml, aml->selected_index);
|
|
const GColor bg_color = GColorBlack;
|
|
const GColor fg_color = PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite);
|
|
|
|
GRect arrow_rect = (GRect) { .size = GSize(aml_bounds->size.w, arrow_layer_height) };
|
|
if (row >= SHORT_ITEM_MAX_ROWS_SPALDING - 1) {
|
|
grect_align(&arrow_rect, aml_bounds, GAlignTop, true /* clip */);
|
|
content_indicator_draw_arrow(ctx, &arrow_rect, ContentIndicatorDirectionUp, fg_color,
|
|
bg_color, GAlignTop);
|
|
}
|
|
if (num_rows - row >= SHORT_ITEM_MAX_ROWS_SPALDING) {
|
|
grect_align(&arrow_rect, aml_bounds, GAlignBottom, true /* clip */);
|
|
content_indicator_draw_arrow(ctx, &arrow_rect, ContentIndicatorDirectionDown, fg_color,
|
|
bg_color, GAlignBottom);
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void prv_update_aml_cache(ActionMenuLayer *aml, int selected_index) {
|
|
prv_unschedule_item_animation(aml);
|
|
|
|
if (aml->layout_cache.item_heights != NULL) {
|
|
applib_free(aml->layout_cache.item_heights);
|
|
aml->layout_cache.item_heights = NULL;
|
|
}
|
|
|
|
if (aml->num_items > 0) {
|
|
// Update the cache of heights. We do this here to avoid recomputing the same
|
|
// values repeatedly when we call the menu layer height callback.
|
|
aml->layout_cache.item_heights = applib_zalloc(aml->num_items * sizeof(int16_t));
|
|
for (int idx = 0; idx < aml->num_items; idx++) {
|
|
aml->layout_cache.item_heights[idx] = prv_get_item_line_height(aml, idx);
|
|
}
|
|
}
|
|
|
|
#if PBL_ROUND
|
|
const bool center_focused = !prv_aml_is_short(aml);
|
|
menu_layer_set_center_focused(&aml->menu_layer, center_focused);
|
|
#endif
|
|
|
|
layer_mark_dirty(&aml->layer);
|
|
menu_layer_reload_data(&aml->menu_layer);
|
|
prv_set_selected_index(aml, selected_index, false /* animated */);
|
|
}
|
|
|
|
// Public API
|
|
/////////////////////
|
|
|
|
void action_menu_layer_click_config_provider(ActionMenuLayer *aml) {
|
|
window_single_repeating_click_subscribe(BUTTON_ID_UP, 100, prv_scroll_handler);
|
|
window_set_click_context(BUTTON_ID_UP, aml);
|
|
window_single_repeating_click_subscribe(BUTTON_ID_DOWN, 100, prv_scroll_handler);
|
|
window_set_click_context(BUTTON_ID_DOWN, aml);
|
|
window_single_click_subscribe(BUTTON_ID_SELECT, prv_select_handler);
|
|
window_set_click_context(BUTTON_ID_SELECT, aml);
|
|
}
|
|
|
|
void action_menu_layer_set_callback(ActionMenuLayer *aml,
|
|
ActionMenuLayerCallback cb,
|
|
void *context) {
|
|
aml->cb = cb;
|
|
aml->context = context;
|
|
}
|
|
|
|
void action_menu_layer_init(ActionMenuLayer *aml, const GRect *frame) {
|
|
layer_init(&aml->layer, frame);
|
|
|
|
// Since menu_layer_set_callbacks() will call the menu functions, we need to initialize
|
|
// the ActionMenuLayer attributes before setting the callbacks onto the menu.
|
|
aml->item_animation = (ActionMenuItemAnimation){};
|
|
aml->layout_cache = (ActionMenuLayoutCache){
|
|
.font = prv_get_item_font()
|
|
};
|
|
aml->layer.property_changed_proc = prv_changed_proc;
|
|
aml->layer.update_proc = prv_update_proc;
|
|
|
|
menu_layer_init(&aml->menu_layer, &aml->layer.bounds);
|
|
menu_layer_set_normal_colors(&aml->menu_layer, GColorBlack,
|
|
PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite));
|
|
#if PBL_ROUND
|
|
menu_layer_pad_bottom_enable(&aml->menu_layer, false);
|
|
#endif
|
|
menu_layer_set_callbacks(&aml->menu_layer, aml, &(MenuLayerCallbacks){
|
|
.get_num_rows = prv_get_num_rows,
|
|
.draw_row = prv_draw_row,
|
|
.get_cell_height = prv_get_cell_height_cb,
|
|
.get_separator_height = prv_get_separator_height_cb,
|
|
.draw_separator = prv_draw_separator_cb,
|
|
.get_header_height = prv_get_header_height_cb,
|
|
.draw_header = prv_draw_header_cb,
|
|
.selection_changed = prv_selection_changed_cb
|
|
});
|
|
|
|
#if !defined(RECOVERY_FW)
|
|
gbitmap_init_with_resource_system(&aml->item_animation.fade_top, SYSTEM_APP,
|
|
RESOURCE_ID_ACTION_MENU_FADE_TOP);
|
|
gbitmap_init_with_resource_system(&aml->item_animation.fade_bottom, SYSTEM_APP,
|
|
RESOURCE_ID_ACTION_MENU_FADE_BOTTOM);
|
|
#endif
|
|
|
|
layer_add_child(&aml->layer, menu_layer_get_layer(&aml->menu_layer));
|
|
layer_set_hidden((Layer *)&aml->menu_layer.inverter, true);
|
|
aml->menu_layer.selection_animation_disabled = true;
|
|
}
|
|
|
|
void action_menu_layer_deinit(ActionMenuLayer *aml) {
|
|
if (aml->layout_cache.item_heights) {
|
|
applib_free(aml->layout_cache.item_heights);
|
|
}
|
|
|
|
prv_unschedule_item_animation(aml);
|
|
|
|
#ifndef RECOVERY_FW
|
|
gbitmap_deinit(&aml->item_animation.fade_top);
|
|
gbitmap_deinit(&aml->item_animation.fade_bottom);
|
|
#endif
|
|
|
|
menu_layer_deinit(&aml->menu_layer);
|
|
}
|
|
|
|
ActionMenuLayer *action_menu_layer_create(GRect frame) {
|
|
ActionMenuLayer *aml = applib_zalloc(sizeof(ActionMenuLayer));
|
|
if (!aml) {
|
|
return NULL;
|
|
}
|
|
|
|
action_menu_layer_init(aml, &frame);
|
|
return aml;
|
|
}
|
|
|
|
void action_menu_layer_destroy(ActionMenuLayer *aml) {
|
|
if (!aml) {
|
|
return;
|
|
}
|
|
|
|
action_menu_layer_deinit(aml);
|
|
applib_free(aml);
|
|
}
|
|
|
|
void action_menu_layer_set_align(ActionMenuLayer *aml, ActionMenuAlign align) {
|
|
if (!aml) {
|
|
return;
|
|
}
|
|
aml->layout_cache.align = align;
|
|
}
|
|
|
|
void action_menu_layer_set_items(ActionMenuLayer *aml, const ActionMenuItem* items, int num_items,
|
|
unsigned default_selected_item, unsigned separator_index) {
|
|
aml->items = items;
|
|
aml->num_items = num_items;
|
|
aml->separator_index = separator_index;
|
|
prv_update_aml_cache(aml, default_selected_item);
|
|
}
|
|
|
|
void action_menu_layer_set_short_items(ActionMenuLayer *aml, const ActionMenuItem* items,
|
|
int num_items, unsigned default_selected_item) {
|
|
aml->short_items = items;
|
|
aml->separator_index = 0;
|
|
aml->num_short_items = num_items;
|
|
prv_update_aml_cache(aml, default_selected_item);
|
|
}
|