/* * 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 "menu_layer.h" #include "applib/graphics/graphics.h" #include "applib/ui/kino/kino_reel.h" #include "applib/ui/kino/kino_reel_gbitmap_private.h" #include "process_management/process_manager.h" #include "shell/system_theme.h" #include "system/passert.h" #include "util/math.h" ///////////////////////////////// // System Provided Cell Types // // NOTES: Below are the implementations of system provided cell drawing functions. // ////////////////////// // Basic menu cell typedef struct MenuCellDimensions { int16_t basic_cell_height; int16_t small_cell_height; int16_t horizontal_inset; int16_t title_subtitle_left_margin; } MenuCellDimensions; static const MenuCellDimensions s_menu_cell_dimensions[NumPreferredContentSizes] = { //! @note these are the same as Medium until Small is designed [PreferredContentSizeSmall] = { .basic_cell_height = 44, .small_cell_height = 34, .horizontal_inset = 5, .title_subtitle_left_margin = 30, }, [PreferredContentSizeMedium] = { .basic_cell_height = 44, .small_cell_height = 34, .horizontal_inset = 5, .title_subtitle_left_margin = 30, }, [PreferredContentSizeLarge] = { .basic_cell_height = 61, .small_cell_height = 42, .horizontal_inset = 10, .title_subtitle_left_margin = 34, }, //! @note these are the same as Large until ExtraLarge is designed [PreferredContentSizeExtraLarge] = { .basic_cell_height = 61, .small_cell_height = 42, .horizontal_inset = 10, .title_subtitle_left_margin = 34, }, }; static const MenuCellDimensions *prv_get_dimensions_for_runtime_platform_default_size(void) { const PreferredContentSize runtime_platform_default_size = system_theme_get_default_content_size_for_runtime_platform(); return &s_menu_cell_dimensions[runtime_platform_default_size]; } int16_t menu_cell_basic_cell_height(void) { return prv_get_dimensions_for_runtime_platform_default_size()->basic_cell_height; } int16_t menu_cell_small_cell_height(void) { return prv_get_dimensions_for_runtime_platform_default_size()->small_cell_height; } int16_t menu_cell_basic_horizontal_inset(void) { return prv_get_dimensions_for_runtime_platform_default_size()->horizontal_inset; } static int16_t prv_title_subtitle_left_margin(void) { return prv_get_dimensions_for_runtime_platform_default_size()->title_subtitle_left_margin; } static ALWAYS_INLINE GFont prv_get_cell_title_font(const MenuCellLayerConfig *config) { return config->title_font ?: system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle); } static ALWAYS_INLINE GFont prv_get_cell_subtitle_font(const MenuCellLayerConfig *config) { return config->subtitle_font ?: system_theme_get_font_for_default_size(TextStyleFont_MenuCellSubtitle); } static ALWAYS_INLINE GFont prv_get_cell_value_font(const MenuCellLayerConfig *config) { return config->value_font ?: prv_get_cell_title_font(config); } static ALWAYS_INLINE void prv_draw_icon(GContext *ctx, GBitmap *icon, const GRect *icon_frame, bool is_legacy2) { if (!is_legacy2) { bool tint_icon = (icon && (gbitmap_get_format(icon) == GBitmapFormat1Bit)); if (tint_icon) { graphics_context_set_compositing_mode(ctx, GCompOpTint); } else if (ctx->draw_state.compositing_mode == GCompOpAssign) { graphics_context_set_compositing_mode(ctx, GCompOpSet); } } graphics_draw_bitmap_in_rect(ctx, icon, icon_frame); } static void prv_menu_cell_basic_draw_custom_rect( GContext *ctx, const Layer *cell_layer, const MenuCellLayerConfig *config) { const GRect *bounds = &cell_layer->bounds; const bool is_legacy2 = process_manager_compiled_with_legacy2_sdk(); const GFont title_font = prv_get_cell_title_font(config); const int16_t title_height = fonts_get_font_height(title_font); const GFont subtitle_font = prv_get_cell_subtitle_font(config); const int16_t subtitle_height = config->subtitle ? fonts_get_font_height(subtitle_font) : 0; const int16_t full_height = title_height + subtitle_height + 10; const int horizontal_margin = menu_cell_basic_horizontal_inset(); const int vertical_margin = (bounds->size.h - full_height) / 2; int left_margin = 0; GRect box; const GAlign icon_align = (GAlign)config->icon_align; if (config->icon) { box = (GRect) { .size = config->icon->bounds.size, .origin = bounds->origin, }; if (is_legacy2) { static const GSize s_icon_size = { 33, 44 }; if (icon_align == GAlignRight) { box.origin.x += bounds->size.w - (horizontal_margin + config->icon->bounds.size.w); } else { // icon on left box.origin.x += ((config->icon->bounds.size.w & 1) + // Nudge odd-width icons one right ((s_icon_size.w - config->icon->bounds.size.w) / 2)); } box.origin.y += (s_icon_size.h - config->icon->bounds.size.h) / 2; } else { const GRect container_rect = grect_inset(*bounds, GEdgeInsets(vertical_margin, horizontal_margin)); grect_align(&box, &container_rect, icon_align, true /* clip */); if ((icon_align == GAlignTopLeft) || (icon_align == GAlignTop) || (icon_align == GAlignTopRight)) { // Offset by the cap offset to match round's icon-title delta box.origin.y += fonts_get_font_cap_offset(title_font); } } if (config->icon_box_model) { box.origin = gpoint_add(box.origin, config->icon_box_model->offset); } left_margin = box.origin.x; prv_draw_icon(ctx, config->icon, &box, is_legacy2); } box = *bounds; if (icon_align == GAlignRight) { left_margin = horizontal_margin; box.size.w -= config->icon->bounds.size.w; } else { left_margin = !config->icon ? horizontal_margin : config->icon_form_fit ? (left_margin + config->icon->bounds.size.w + (config->icon_box_model ? config->icon_box_model->margin.w : 0)) : prv_title_subtitle_left_margin() + horizontal_margin; } box.origin.x += left_margin; box.size.w -= left_margin; GRect value_box = box; if (config->overflow_mode != GTextOverflowModeWordWrap) { box.origin.y += vertical_margin; box.size.h = title_height + 4; value_box.origin.y = box.origin.y; } else { // Value box is centered when drawing with GTextOverflowModeWordWrap. value_box.origin.y = MIN(box.size.h - full_height, title_height) / 2; } if (is_legacy2) { // Update the text color to Black for legacy apps - this is to maintain existing behavior for // 2.x compiled apps - no need to restore original since original 2.x did not do any restore ctx->draw_state.text_color = GColorBlack; } if (config->value && (icon_align != GAlignRight)) { value_box.size.w -= horizontal_margin; const GFont value_font = prv_get_cell_value_font(config); const GSize text_size = graphics_text_layout_get_max_used_size( ctx, config->value, value_font, value_box, config->overflow_mode, GTextAlignmentRight, NULL); box.size.w -= (text_size.w + horizontal_margin * 2); graphics_draw_text(ctx, config->value, value_font, value_box, config->overflow_mode, GTextAlignmentRight, NULL); } if (config->title) { graphics_draw_text(ctx, config->title, title_font, box, config->overflow_mode, GTextAlignmentLeft, NULL); } if (config->subtitle) { box.origin.y += title_height; box.size.h = subtitle_height + 4; graphics_draw_text(ctx, config->subtitle, subtitle_font, box, config->overflow_mode, GTextAlignmentLeft, NULL); } } // This function duplicates `grect_inset()` but helps us save some stack space by using pointer // arguments and always inlining the function static ALWAYS_INLINE void prv_grect_inset(GRect *rect, GEdgeInsets *insets) { grect_standardize(rect); const int16_t new_width = rect->size.w - insets->left - insets->right; const int16_t new_height = rect->size.h - insets->top - insets->bottom; if (new_width < 0 || new_height < 0) { *rect = GRectZero; } else { *rect = GRect(rect->origin.x + insets->left, rect->origin.y + insets->top, new_width, new_height); } } static ALWAYS_INLINE bool prv_should_render_subtitle_round(const MenuCellLayerConfig *config, bool is_selected) { // If the cell isn't selected and there's no value text, then no subtitle text should be shown return ((is_selected || config->value) && (config->subtitle != NULL)); } static ALWAYS_INLINE GRect prv_menu_cell_basic_draw_custom_one_column_round( GContext *ctx, const GRect *cell_layer_bounds, const MenuCellLayerConfig *config, GTextAlignment text_alignment, GAlign container_alignment, bool is_selected) { if (!cell_layer_bounds) { return GRectZero; } const GFont title_font = prv_get_cell_title_font(config); const uint8_t title_font_height = fonts_get_font_height(title_font); GSize cell_layer_bounds_size = cell_layer_bounds->size; // Bail out if we can't even fit a single line of the title if (title_font_height > cell_layer_bounds_size.h) { return GRectZero; } // Initialize our subtitle text height and icon frame size to 0 since we don't know yet if we will // render them int16_t subtitle_text_frame_height = 0; GSize icon_frame_size = GSizeZero; const GFont subtitle_font = prv_get_cell_subtitle_font(config); const bool render_subtitle = prv_should_render_subtitle_round(config, is_selected); if (render_subtitle) { subtitle_text_frame_height = fonts_get_font_height(subtitle_font); } const int subtitle_text_cap_offset = (config->subtitle) ? fonts_get_font_cap_offset(subtitle_font) : 0; const GAlign icon_align = (GAlign)config->icon_align; const bool render_icon = ((config->icon != NULL) && (icon_align != GAlignRight)); const GSize icon_bitmap_size = config->icon ? config->icon->bounds.size : GSizeZero; if (render_icon) { icon_frame_size = icon_bitmap_size; if (config->icon_box_model) { icon_frame_size = gsize_add(icon_frame_size, config->icon_box_model->margin); } } const bool can_use_two_lines_for_title = !(render_subtitle || render_icon); const bool can_use_many_lines_for_title = (config->overflow_mode == GTextOverflowModeWordWrap); const int16_t initial_title_text_lines = can_use_two_lines_for_title ? 2 : 1; int16_t title_text_frame_height = can_use_many_lines_for_title ? graphics_text_layout_get_text_height(ctx, config->title, title_font, cell_layer_bounds_size.w, config->overflow_mode, text_alignment) : title_font_height * initial_title_text_lines; const int title_text_cap_offset = (config->title) ? fonts_get_font_cap_offset(title_font) : 0; int16_t container_height = title_text_frame_height + subtitle_text_frame_height; if (icon_align == GAlignTop) { // The icon is rendered above the others, add to container height container_height += icon_frame_size.h; } else if (icon_frame_size.h > cell_layer_bounds_size.h) { // The icon is rendered beside but does not fit, cut it out icon_frame_size = GSizeZero; } if (container_height > cell_layer_bounds_size.h) { // If we couldn't fit one line of title, subtitle, and icon, try cutting out the icon if (icon_align == GAlignTop) { container_height -= icon_frame_size.h; } if (container_height > cell_layer_bounds_size.h) { // If we couldn't fit one title line and the subtitle, try cutting out the subtitle instead container_height = title_text_frame_height + icon_frame_size.h; if (container_height > cell_layer_bounds_size.h) { // If we couldn't fit just the title and icon, try just two lines for the title container_height = title_font_height * 2; if (container_height > cell_layer_bounds_size.h) { // If we couldn't fit two title lines, just use one title line title_text_frame_height = title_font_height; } else { title_text_frame_height = container_height; } subtitle_text_frame_height = 0; if (icon_align == GAlignTop) { icon_frame_size = GSizeZero; } } else { subtitle_text_frame_height = 0; } } else { icon_frame_size = GSizeZero; } } // We'll reuse this rect to conserve stack space; here it is used as the title text frame GRect rect = (GRect) { .origin = cell_layer_bounds->origin, .size = GSize(cell_layer_bounds_size.w, title_text_frame_height), }; // Update the title text frame's height using the max used size of the title text because we // might only have one line of text to render even though we have space for two lines title_text_frame_height = graphics_text_layout_get_max_used_size( ctx, config->title, title_font, rect, config->overflow_mode, text_alignment, NULL).h; // Calculate the final container height and create a rectangle for it container_height = title_text_frame_height + subtitle_text_frame_height; const bool icon_on_left = ((icon_align == GAlignLeft) || (icon_align == GAlignTopLeft)); if (icon_align == GAlignTop) { // The icon is on its own line at the top, extend accordingly container_height += icon_frame_size.h; } else if (render_icon && (icon_align == GAlignLeft)) { // Let the icon extend the container height if it's taller than the title/subtitle combo container_height = MAX(icon_frame_size.h, container_height); } GRect container_rect = (GRect) { .size = GSize(cell_layer_bounds_size.w, container_height) }; // Align the container rect in the cell grect_align(&container_rect, cell_layer_bounds, container_alignment, true /* clip */); // Here we reuse rect as the icon frame rect.size = icon_frame_size; // Align the icon frame (which might have zero height) at the top center of the container grect_align(&rect, &container_rect, icon_align, true /* clip */); // Save the title origin y before re-purposing container_rect int title_text_frame_origin_y = container_rect.origin.y; // Draw the icon (if one was provided and we have room for it) if (render_icon && !gsize_equal(&rect.size, &GSizeZero)) { // Only draw the icon if it fits within the cell if (gsize_equal(&rect.size, &icon_frame_size)) { // round has never worked with legacy2 apps static const bool is_legacy2 = false; // Reuse container_rect as icon frame container_rect = (GRect) { .origin = rect.origin, .size = icon_bitmap_size, }; if (config->icon_box_model) { container_rect.origin = gpoint_add(container_rect.origin, config->icon_box_model->offset); } prv_draw_icon(ctx, config->icon, &container_rect, is_legacy2); } } int cell_layer_bounds_origin_x = cell_layer_bounds->origin.x; // Move the title and subtitle closer together to match designs const int16_t icon_on_left_title_subtitle_vertical_spacing_offset = -3; if (icon_align == GAlignTop) { // Set the title text's frame origin at the bottom of the icon's frame title_text_frame_origin_y = grect_get_max_y(&rect); } else if (icon_on_left) { // Move content to the right for the icon on the left cell_layer_bounds_origin_x = grect_get_max_x(&rect); cell_layer_bounds_size.w -= cell_layer_bounds_origin_x - cell_layer_bounds->origin.x; if (icon_align == GAlignLeft) { // Vertically center the title and subtitle within the container title_text_frame_origin_y = cell_layer_bounds->origin.y + ((cell_layer_bounds->size.h - title_text_frame_height - subtitle_text_frame_height - 1) / 2); if (subtitle_text_frame_height) { title_text_frame_origin_y -= icon_on_left_title_subtitle_vertical_spacing_offset; } } } // Draw the subtitle (if one was provided and we have room), taking into account the cap offset if (render_subtitle && (subtitle_text_frame_height != 0)) { int16_t subtitle_text_frame_origin_y = (int16_t)(title_text_frame_origin_y + title_text_frame_height - subtitle_text_cap_offset); if (icon_align == GAlignLeft) { subtitle_text_frame_origin_y += icon_on_left_title_subtitle_vertical_spacing_offset; } // Reuse rect as the subtitle text frame rect = (GRect) { .origin = GPoint(cell_layer_bounds_origin_x, subtitle_text_frame_origin_y), .size = GSize(cell_layer_bounds_size.w, subtitle_text_frame_height) }; graphics_draw_text(ctx, config->subtitle, subtitle_font, rect, config->overflow_mode, text_alignment, NULL); } // Draw the title, which we're guaranteed to have room for because otherwise we would have bailed // out at the beginning of this function // Reuse rect as the title text frame rect = (GRect) { .origin = GPoint(cell_layer_bounds_origin_x, title_text_frame_origin_y), .size = GSize(cell_layer_bounds_size.w, title_text_frame_height) }; // Accumulate the cap offsets we need to position the title properly int cap_offsets_to_apply = title_text_cap_offset; if ((icon_align == GAlignLeft) && subtitle_text_frame_height) { cap_offsets_to_apply += subtitle_text_cap_offset; } rect.origin.y -= cap_offsets_to_apply; graphics_draw_text(ctx, config->title, title_font, rect, config->overflow_mode, text_alignment, NULL); // Add back the cap offset so functions that use the returned title text frame can position // themselves using the actual frame rect.origin.y += cap_offsets_to_apply; return rect; } static ALWAYS_INLINE void prv_menu_cell_basic_draw_custom_two_columns_round( GContext *ctx, const GRect *cell_layer_bounds, const MenuCellLayerConfig *config, bool is_selected) { if (!cell_layer_bounds) { return; } const GSize icon_size = config->icon ? config->icon->bounds.size : GSizeZero; // Calculate the size used by the value or icon on the right // NOTE: If a value and icon is provided we only draw the value so we can re-use this function for // drawing "icon on right" and "value" GSize right_element_size; const GFont value_font = prv_get_cell_value_font(config); if (config->value) { right_element_size = graphics_text_layout_get_max_used_size( ctx, config->value, value_font, *cell_layer_bounds, config->overflow_mode, GTextAlignmentRight, NULL); } else { right_element_size = icon_size; } // We reuse this rect to save stack space; here it is the rect of the left column content GRect rect = *cell_layer_bounds; prv_grect_inset(&rect, &GEdgeInsets(0, right_element_size.w, 0, 0)); // We overwrite rect to store the rect of the title text frame drawn in the one-column function rect = prv_menu_cell_basic_draw_custom_one_column_round(ctx, &rect, config, GTextAlignmentLeft, GAlignLeft, is_selected); // Don't draw the right element if we couldn't draw the title in the left column if (grect_equal(&rect, &GRectZero)) { return; } // Now we store the right element frame in rect rect = (GRect) { .origin = GPoint(grect_get_max_x(&rect), rect.origin.y), .size = right_element_size }; if (config->value) { rect.origin.y -= fonts_get_font_cap_offset(value_font); graphics_draw_text(ctx, config->value, value_font, rect, config->overflow_mode, GTextAlignmentRight, NULL); } else { // Only draw the icon if it fits within the cell after aligning it center right grect_clip(&rect, cell_layer_bounds); grect_align(&rect, cell_layer_bounds, GAlignRight, true); if (gsize_equal(&rect.size, &icon_size)) { // round has never worked with legacy2 apps static const bool is_legacy2 = false; if (config->icon_box_model) { rect.origin = gpoint_add(rect.origin, config->icon_box_model->offset); } prv_draw_icon(ctx, config->icon, &rect, is_legacy2); } } } static ALWAYS_INLINE void prv_menu_cell_basic_draw_custom_round( GContext* ctx, const Layer *cell_layer, const MenuCellLayerConfig *config) { // TODO PBL-23041: When round MenuLayer animations are enabled, we need a "is_selected" function const bool cell_is_selected = menu_cell_layer_is_highlighted(cell_layer); const bool draw_two_columns = (config->value || (config->icon && ((GAlign)config->icon_align == GAlignRight))); // Determine appropriate insets to match designs GRect cell_layer_bounds = cell_layer->bounds; const int16_t horizontal_inset = (cell_is_selected && !draw_two_columns) ? MENU_CELL_ROUND_FOCUSED_HORIZONTAL_INSET : MENU_CELL_ROUND_UNFOCUSED_HORIZONTAL_INSET; prv_grect_inset(&cell_layer_bounds, &GEdgeInsets(0, horizontal_inset + config->horizontal_inset)); if (draw_two_columns) { prv_menu_cell_basic_draw_custom_two_columns_round(ctx, &cell_layer_bounds, config, cell_is_selected); } else { prv_menu_cell_basic_draw_custom_one_column_round(ctx, &cell_layer_bounds, config, GTextAlignmentCenter, GAlignCenter, cell_is_selected); } } static ALWAYS_INLINE void prv_draw_cell(GContext *ctx, const Layer *cell_layer, const MenuCellLayerConfig *config) { PBL_IF_RECT_ELSE(prv_menu_cell_basic_draw_custom_rect, prv_menu_cell_basic_draw_custom_round)(ctx, cell_layer, config); } //! TODO: PBL-21467 Replace with MenuCellLayer void menu_cell_layer_draw(GContext *ctx, const Layer *cell_layer, const MenuCellLayerConfig *config) { prv_draw_cell(ctx, cell_layer, config); } static ALWAYS_INLINE void prv_draw_basic( GContext* ctx, const Layer *cell_layer, GFont const title_font, const char *title, GFont const value_font, const char *value, GFont const subtitle_font, const char *subtitle, GBitmap *icon, bool icon_on_right, GTextOverflowMode overflow_mode) { MenuCellLayerConfig config = { .title_font = title_font, .subtitle_font = subtitle_font, .value_font = value_font, .title = title, .subtitle = subtitle, .value = value, .icon = icon, .icon_align = icon_on_right ? MenuCellLayerIconAlign_Right : PBL_IF_RECT_ELSE(MenuCellLayerIconAlign_Left, MenuCellLayerIconAlign_Top), .overflow_mode = overflow_mode, }; prv_draw_cell(ctx, cell_layer, &config); } void menu_cell_basic_draw_custom(GContext* ctx, const Layer *cell_layer, GFont const title_font, const char *title, GFont const value_font, const char *value, GFont const subtitle_font, const char *subtitle, GBitmap *icon, bool icon_on_right, GTextOverflowMode overflow_mode) { prv_draw_basic(ctx, cell_layer, title_font, title, value_font, value, subtitle_font, subtitle, icon, icon_on_right, overflow_mode); } void menu_cell_basic_draw_icon_right(GContext* ctx, const Layer *cell_layer, const char *title, const char *subtitle, GBitmap *icon) { prv_draw_basic(ctx, cell_layer, NULL, title, NULL, NULL, NULL, subtitle, icon, true, GTextOverflowModeFill); } void menu_cell_basic_draw(GContext* ctx, const Layer *cell_layer, const char *title, const char *subtitle, GBitmap *icon) { prv_draw_basic(ctx, cell_layer, NULL, title, NULL, NULL, NULL, subtitle, icon, false, GTextOverflowModeFill); } ////////////////////// // Title menu cell void menu_cell_title_draw(GContext* ctx, const Layer *cell_layer, const char *title) { // Title: if (title) { const bool is_legacy2 = process_manager_compiled_with_legacy2_sdk(); if (is_legacy2) { // Update the text color to Black for legacy apps - this is to maintain existing behavior for // 2.x compiled apps - no need to restore original since original 2.x did not do any restore graphics_context_set_text_color(ctx, GColorBlack); } GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_28); GRect box = cell_layer->bounds; box.origin.x = 3; box.origin.y -= 4; box.size.w -= 3; graphics_draw_text(ctx, title, font, box, GTextOverflowModeFill, GTextAlignmentLeft, NULL); } } ////////////////////// // Basic header cell void menu_cell_basic_header_draw(GContext* ctx, const Layer *cell_layer, const char *title) { // Title: if (title) { const bool is_legacy2 = process_manager_compiled_with_legacy2_sdk(); if (is_legacy2) { // Update the text color to Black for legacy apps - this is to maintain existing behavior for // 2.x compiled apps - no need to restore original since original 2.x did not do any restore graphics_context_set_text_color(ctx, GColorBlack); } GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD); GRect box = cell_layer->bounds; // Pixel nudging... box.origin.x += 2; box.origin.y -= 1; graphics_draw_text(ctx, title, font, box, GTextOverflowModeFill, GTextAlignmentLeft, NULL); } }