/* * 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 "clar.h" #include "fixtures/load_test_resources.h" #include "applib/fonts/fonts_private.h" #include "applib/graphics/framebuffer.h" #include "applib/graphics/graphics.h" #include "applib/graphics/gtypes.h" #include "applib/graphics/text.h" #include "applib/graphics/text_resources.h" #include "applib/ui/layer.h" #include "applib/ui/window_private.h" #include "resource/resource_ids.auto.h" #include "util/size.h" // Helper Functions //////////////////////////////////// #include "test_graphics.h" #include "8bit/test_framebuffer.h" #include "util.h" /////////////////////////////////////////////////////////// // Stubs #include "stubs_analytics.h" #include "stubs_app_state.h" #include "stubs_applib_resource.h" #include "stubs_bootbits.h" #include "stubs_heap.h" #include "stubs_logging.h" #include "stubs_memory_layout.h" #include "stubs_mutex.h" #include "stubs_passert.h" #include "stubs_pbl_malloc.h" #include "stubs_pebble_tasks.h" #include "stubs_print.h" #include "stubs_prompt.h" #include "stubs_serial.h" #include "stubs_sleep.h" #include "stubs_syscall_internal.h" #include "stubs_syscalls.h" #include "stubs_system_reset.h" #include "stubs_task_watchdog.h" #include "stubs_ui_window.h" #include "stubs_unobstructed_area.h" /////////////////////////////////////////////////////////// // Tests static FrameBuffer *fb = NULL; static GContext ctx; #define NUM_STEPS (5) #define DELTA 20 static FontInfo s_font_info; static GBitmap *s_dest_bitmap; static char *s_text = "A B C D E F G " "H I J K L M N O P Q R S T U V W X Y Z a b c d e f g h i j k l m n o " "p q r s t u v w x y z A B C D E F G H I J K L M N O P Q R" "S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z"; void prv_prepare_fb_steps_xy(GSize size, int16_t steps_x, int16_t steps_y) { gbitmap_destroy(s_dest_bitmap); s_dest_bitmap = gbitmap_create_blank(GSize(size.w * steps_x, size.h * steps_y), GBITMAP_NATIVE_FORMAT); ctx.dest_bitmap = *s_dest_bitmap; ctx.draw_state.clip_box = (GRect){.size = size}; ctx.draw_state.drawing_box = ctx.draw_state.clip_box; graphics_context_set_text_color(&ctx, GColorBlack); graphics_context_set_fill_color(&ctx, GColorLightGray); memset(s_dest_bitmap->addr, 0xff, s_dest_bitmap->row_size_bytes * s_dest_bitmap->bounds.size.h); } void prv_prepare_fb_steps(GSize size) { prv_prepare_fb_steps_xy(size, NUM_STEPS, NUM_STEPS); } void test_graphics_draw_text_flow__initialize(void) { fb = malloc(sizeof(FrameBuffer)); framebuffer_init(fb, &(GSize) {DISP_COLS, DISP_ROWS}); ctx = (GContext){}; // Setup resources fake_spi_flash_init(0, 0x1000000); pfs_init(false); pfs_format(true /* write erase headers */); load_resource_fixture_in_flash(RESOURCES_FIXTURE_PATH, SYSTEM_RESOURCES_FIXTURE_NAME, false /* is_next */); resource_init(); memset(&s_font_info, 0, sizeof(s_font_info)); cl_assert(text_resources_init_font(0, RESOURCE_ID_GOTHIC_18_BOLD, 0, &s_font_info)); test_graphics_context_init(&ctx, fb); setup_test_aa_sw(&ctx, fb, ctx.dest_bitmap.bounds, ctx.dest_bitmap.bounds, false, 1); prv_prepare_fb_steps(GSize(DISP_COLS, DISP_ROWS)); } void test_graphics_draw_text_flow__cleanup(void) { free(fb); gbitmap_destroy(s_dest_bitmap); s_dest_bitmap = NULL; } #define RECT_TEXT_0_0 GRect(0, 0, DISP_COLS, DISP_ROWS) GRangeHorizontal perimeter_for_display_round(const GPerimeter *perimeter, const GSize *ctx_size, GRangeVertical vertical_range, uint16_t inset); static uint8_t *prv_bitmap_offset_for_steps(GBitmap *bmp, int sx, int sy, int steps_x, int steps_y) { sx += (steps_x - 1) / 2; sy += (steps_y - 1) / 2; int16_t step_w = bmp->bounds.size.w / steps_x; int16_t step_h = bmp->bounds.size.h / steps_y; return ((uint8_t *)bmp->addr) + (sy * step_h * bmp->row_size_bytes) + (sx * step_w); } typedef enum { RenderMoveTextBox, RenderMoveDrawBox, } RenderMoveMode; void render_steps(TextLayoutExtended *layout, RenderMoveMode mode, int delta, int16_t height, char **texts) { const GTextLayoutCacheRef layout_cache = (GTextLayoutCacheRef const)layout; int steps_x = ctx.dest_bitmap.bounds.size.w / ctx.draw_state.clip_box.size.w; int steps_y = ctx.dest_bitmap.bounds.size.h / ctx.draw_state.clip_box.size.h; int text_idx = 0; for (int sx = -((steps_x - 1) / 2) ; sx <= steps_x / 2; sx++) { for (int sy = -((steps_y - 1) / 2); sy <= steps_y / 2; sy++) { // as draw_text internally uses absolute coordinates to derive its state we cannot // simply adjust the draw_box to accomplish a side-by-side comparison ctx.dest_bitmap.addr = prv_bitmap_offset_for_steps(s_dest_bitmap, sx, sy, steps_x, steps_y); GRect box = {.size = GSize(DISP_COLS, height)}; const GPoint origin = GPoint(delta * sx, delta * sy); if (mode == RenderMoveTextBox) { box.origin = origin; ctx.draw_state.drawing_box.origin = GPointZero; } else { ctx.draw_state.drawing_box.origin = origin; } graphics_fill_rect(&ctx, &box); graphics_draw_rect(&ctx, &(GRect){.origin = GPoint(-ctx.draw_state.drawing_box.origin.x, -ctx.draw_state.drawing_box.origin.y), .size = ctx.draw_state.clip_box.size}); char *text = texts ? texts[text_idx++] : s_text; graphics_draw_text(&ctx, text, &s_font_info, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, layout_cache); } } } void test_graphics_draw_text_flow__flow_no_paging(void) { TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback=perimeter_for_display_round}, .perimeter.inset = 8, }, }; render_steps(&layout, RenderMoveTextBox, DELTA, DISP_ROWS, NULL); cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); } void test_graphics_draw_text_flow__flow_no_paging_draw_box(void) { TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback=perimeter_for_display_round}, .perimeter.inset = 8, }, }; render_steps(&layout, RenderMoveDrawBox, DELTA, DISP_ROWS, NULL); // should result in the very same output as if you did a placement via text box cl_check(gbitmap_pbi_eq(s_dest_bitmap, "test_graphics_draw_text_flow__flow_no_paging.pbi")); } void test_graphics_draw_text_flow__with_origin_zero(void) { TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback=perimeter_for_display_round}, .perimeter.inset = 8, .paging.page_on_screen.size_h = DISP_ROWS, // setting a page height != enables positioning .paging.origin_on_screen = {0, 0}, }, }; render_steps(&layout, RenderMoveTextBox, DELTA, DISP_ROWS, NULL); cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); } void test_graphics_draw_text_flow__with_origin_non_zero(void) { TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback=perimeter_for_display_round}, .perimeter.inset = 8, .paging.page_on_screen.size_h = DISP_ROWS, // setting a page height != enables positioning .paging.origin_on_screen = {DELTA, 2 * DELTA}, }, }; render_steps(&layout, RenderMoveTextBox, DELTA, DISP_ROWS, NULL); cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); } void test_graphics_draw_text_flow__with_paging(void) { prv_prepare_fb_steps(GSize(DISP_COLS, 2 * DISP_ROWS)); TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback=perimeter_for_display_round}, .perimeter.inset = 8, .paging.page_on_screen = { .origin_y = 25, .size_h = 100 }, // setting a page height != enables positioning }, }; render_steps(&layout, RenderMoveTextBox, DELTA, 1000, NULL); cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); } void test_graphics_draw_text_flow__avoid_repeat_text_to_avoid_orphans(void) { TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback=perimeter_for_display_round}, .perimeter.inset = 8, .paging.page_on_screen = { .origin_y = 25, .size_h = 100 }, // setting a page height != enables positioning }, }; char first_page_one_line[] = "A B C D E F G H I"; char second_page_one_line[] = "A B C D E F G H I J K L M N"; char second_page_two_lines[] = "A B C D E F G H I J K L M N O P Q R S T U V"; char second_page_full[] = "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u v w x y z " "A"; char third_page_one_line[] = "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u v w x y z " "A B C D E F G"; char third_page_two_lines[] = "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u v w x y z " "A B C D E F G I J K L M N O P"; char third_page_full[] = "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u v w x y z " "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u"; char fourth_page_one_line[] = "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u v w x y z " "A B C D E F G H I J K L M N O P Q R S T U V W X Y Z " "a b c d e f g h j k l m n o p q r s t u v w x y z"; char *texts[] = { first_page_one_line, second_page_one_line, second_page_two_lines, second_page_full, third_page_one_line, third_page_two_lines, third_page_full, fourth_page_one_line, }; const int16_t num_steps = ARRAY_LENGTH(texts); const GSize size = GSize(144, 300); prv_prepare_fb_steps_xy(size, num_steps, 1); ctx.draw_state.avoid_text_orphans = true; render_steps(&layout, RenderMoveDrawBox, 0, size.h, texts); // draw markers to visualize page breaks ctx.draw_state.clip_box = s_dest_bitmap->bounds; ctx.draw_state.drawing_box = s_dest_bitmap->bounds; ctx.dest_bitmap.addr = s_dest_bitmap->addr; graphics_context_set_stroke_color(&ctx, GColorDarkGray); int16_t y = layout.flow_data.paging.page_on_screen.origin_y; while (y < size.h) { graphics_draw_line(&ctx, GPoint(0, y), GPoint(s_dest_bitmap->bounds.size.w, y)); y += layout.flow_data.paging.page_on_screen.size_h; } cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); } GRangeHorizontal perimeter_for_circle(GRangeVertical vertical_range, GPoint center, int32_t radius); GRangeHorizontal perimeter_for_display_rect(const GPerimeter *perimeter, const GSize *ctx_size, GRangeVertical vertical_range, uint16_t inset); // easiest way to make these dimensions identical to spalding although the tests take // defaults from basalt's screen resolution. The original `perimeter_for_display_round` // uses the platform-specific `DISP_FRAME` static GRangeHorizontal prv_perimeter_for_display_round(const GPerimeter *perimeter, const GSize *ctx_size, GRangeVertical vertical_range, uint16_t inset) { const GRect disp_180_frame = GRect(0, 0, 180, 180); const GPoint center = grect_center_point(&disp_180_frame); const int32_t radius = grect_shortest_side(disp_180_frame) / 2 - inset; return perimeter_for_circle(vertical_range, center, radius); } void test_graphics_draw_text_flow__draw_text_doom(void) { // text and configuration we see in text_flow demo app cl_assert(text_resources_init_font(0, RESOURCE_ID_GOTHIC_24_BOLD, 0, &s_font_info)); TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback = prv_perimeter_for_display_round}, .perimeter.inset = 8, .paging.page_on_screen = { .origin_y = 48, .size_h = 85 }, .paging.origin_on_screen.y = 412, }, }; char text[] = "Dib: You're just jealous...\nZim: This has nothing to do with jelly!\n" "Zim: You dare agree with me? Prepare to meet your horrible doom!"; prv_prepare_fb_steps_xy(GSize(180, 300), 1, 1); ctx.draw_state.avoid_text_orphans = true; graphics_draw_text(&ctx, text, &s_font_info, GRect(0, 0, 180, 1000), GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, (GTextAttributes *const) &layout); cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); prv_prepare_fb_steps_xy(GSize(180, 300), 1, 1); ctx.draw_state.avoid_text_orphans = true; ctx.draw_state.clip_box.origin.y = 48; ctx.draw_state.clip_box.size.h = 85; ctx.draw_state.drawing_box.origin.y = -183; graphics_draw_text(&ctx, text, &s_font_info, GRect(0, 0, 180, 1000), GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, (GTextAttributes *const) &layout); cl_check(gbitmap_pbi_eq(s_dest_bitmap, namecat(namecat(__func__, "__clipped"), ".pbi"))); prv_prepare_fb_steps_xy(GSize(180, 300), 1, 1); ctx.draw_state.avoid_text_orphans = false; graphics_draw_text(&ctx, text, &s_font_info, GRect(0, 0, 180, 1000), GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, (GTextAttributes *const) &layout); cl_check(gbitmap_pbi_eq(s_dest_bitmap, namecat(namecat(__func__, "__with_orphan"), ".pbi"))); } void test_graphics_draw_text_flow__max_used_size_draw_text_doom(void) { cl_assert(text_resources_init_font(0, RESOURCE_ID_GOTHIC_24_BOLD, 0, &s_font_info)); TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback = prv_perimeter_for_display_round}, .perimeter.inset = 8, .paging.page_on_screen = { .origin_y = 48, .size_h = 85 }, .paging.origin_on_screen.y = 412, }, }; void *layout_ref = (void *)&layout; char text[] = "Dib: You're just jealous...\nZim: This has nothing to do with jelly!\n" "Zim: You dare agree with me? Prepare to meet your horrible doom!"; const GFont font = &s_font_info; const GRect box = GRect(0, 0, 180, 1000); const GTextOverflowMode overflow_mode = GTextOverflowModeTrailingEllipsis; const GTextAlignment text_alignment = GTextAlignmentCenter; const GSize fb_size = GSize(180, 300); const int16_t steps_x = 1; const int16_t steps_y = 1; prv_prepare_fb_steps_xy(fb_size, steps_x, steps_y); ctx.draw_state.avoid_text_orphans = true; const GSize size_with_orphan_avoidance = graphics_text_layout_get_max_used_size(&ctx, text, font, box, overflow_mode, text_alignment, layout_ref); // TODO: PBL-34191 move .avoid_text_orphans from GContext to TextLayout so layout is invalidated // Invalidate the layout so it will be recalculated for the next step layout.hash = 0; prv_prepare_fb_steps_xy(fb_size, steps_x, steps_y); ctx.draw_state.avoid_text_orphans = false; const GSize size_without_orphan_avoidance = graphics_text_layout_get_max_used_size(&ctx, text, font, box, overflow_mode, text_alignment, layout_ref); // We should get different heights because the orphan avoidance algorithm adds an extra line cl_assert_equal_i(size_with_orphan_avoidance.h, 279); cl_assert_equal_i(size_without_orphan_avoidance.h, 255); } void test_graphics_draw_text_flow__no_infinite_loop(void) { TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback = perimeter_for_display_rect}, }, }; char text[] = "Prevent orhpans for tall-enough pages."; const int16_t line_height = 22; // some more pixels to show that orphan prevention really only applies if there's enough space // for enough *full* lines const int16_t some = 5; prv_prepare_fb_steps_xy(GSize(180, 300), 3, 1); ctx.draw_state.avoid_text_orphans = true; for (int i = 0; i < 3; i++) { const int number_of_lines_per_page = i + 1; layout.flow_data.paging.page_on_screen.size_h = number_of_lines_per_page * line_height + some; layout.flow_data.paging.origin_on_screen.y = layout.flow_data.paging.page_on_screen.size_h - line_height; graphics_draw_text(&ctx, text, &s_font_info, GRect(0, 0, 180, 1000), GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, (GTextAttributes *const) &layout); const int16_t second_page_start_y = (layout.flow_data.paging.page_on_screen.size_h - layout.flow_data.paging.origin_on_screen.y); const int16_t second_page_end_y = (second_page_start_y + layout.flow_data.paging.page_on_screen.size_h); graphics_draw_line(&ctx, GPoint(0, second_page_start_y), GPoint(180, second_page_start_y)); graphics_draw_line(&ctx, GPoint(0, second_page_end_y), GPoint(180, second_page_end_y)); ctx.draw_state.drawing_box.origin.x += 180; ctx.draw_state.clip_box.origin.x += 180; } cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); } void test_graphics_draw_text_flow__no_infinite_loop2(void) { // replicates the bug described in PBL-29267 noticed in the notification app // the following values are those we measured in GDB when it entered the infinite loop cl_assert(text_resources_init_font(0, RESOURCE_ID_GOTHIC_24_BOLD, 0, &s_font_info)); TextLayoutExtended layout = { .flow_data = { .perimeter.impl = &(GPerimeter){.callback = prv_perimeter_for_display_round,}, .perimeter.inset = 8, .paging = { .origin_on_screen = GPoint(12, 83), .page_on_screen.origin_y = 24, .page_on_screen.size_h = 140, } }, }; char text[] = "Late again? Can you be on time ever? Seriosly? Dude!!!"; prv_prepare_fb_steps_xy(GSize(180, 360), 1, 1); ctx.draw_state.avoid_text_orphans = true; GRect box = (GRect){.origin = {.x = 12, .y = 59}, .size = {.w = 156, .h = 2480}}; graphics_draw_text(&ctx, text, &s_font_info, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, (GTextAttributes *const) &layout); cl_check(gbitmap_pbi_eq(s_dest_bitmap, TEST_PBI_FILE)); }