# HG changeset patch # User MrJuneJune # Date 1787030174 25200 # Node ID 8d560f50ed4cbd778214563e5f9fa5057306e37f # Parent 1d99147f520cfc1c09027b012a66adeef5258752 Improve infinite canvas interactions and browser chrome Render Lucide icons directly with Raylib, add searchable icon browsing, robust text editing, entity lifecycle animations, z-order-safe input, semantic themes, and animated editable browser controls. Document rendering, pinning, context, and component extension for future agents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f68442b1-fa8f-46a0-9689-81710613bbd4 diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/BUILD --- a/infinite_canvas/BUILD Mon Aug 17 17:01:40 2026 -0700 +++ b/infinite_canvas/BUILD Mon Aug 17 22:16:14 2026 -0700 @@ -22,7 +22,10 @@ cc_library( name = "canvas", srcs = ["canvas.c"], - hdrs = ["canvas.h"], + hdrs = [ + "canvas.h", + "generated/lucide_data.h", + ], deps = [ "//dowa:dowa", ":theme", @@ -95,6 +98,11 @@ srcs = ["assets/image-view.html"], ) +filegroup( + name = "lucide_license", + srcs = ["assets/LUCIDE_LICENSE.txt"], +) + raylib_binary( name = "app_bin", srcs = ["main.c"], @@ -102,6 +110,7 @@ ":fonts", ":image_placeholder", ":image_view", + ":lucide_license", ] + select({ "//config:linux": [ "@cef//:resources", @@ -129,6 +138,7 @@ ":fonts", ":image_placeholder", ":image_view", + ":lucide_license", ] + select({ "//config:linux": [ "@cef//:resources", @@ -223,6 +233,7 @@ name = "web_bundle", srcs = [ ":infinite_canvas_wasm", + ":lucide_license", ":shell", ], dest = "web", diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/README.md --- a/infinite_canvas/README.md Mon Aug 17 17:01:40 2026 -0700 +++ b/infinite_canvas/README.md Mon Aug 17 22:16:14 2026 -0700 @@ -35,15 +35,21 @@ ## Controls - Middle-mouse drag, or Space + left-mouse drag: pan -- Mouse wheel: zoom around the cursor +- Mouse wheel: scroll vertically +- Ctrl/Cmd + mouse wheel: zoom around the cursor - WASD or arrow keys: pan (disabled while a text area has keyboard focus) - Left-mouse drag on an entity: select and move it - `P`: pin or unpin the selected entity in screen space so it remains visible - while the camera pans and zooms; pinned entities show a blue pin badge + while the camera pans and zooms; pinned entities show a Lucide pin glyph +- `Delete`/`Backspace`: fade out and remove the selected entity - Development toolbar: spawn/clear entities, reset the camera, or restore the - deterministic demo scene + deterministic demo scene; spawned entities fade in and Clear fades all + entities out before removal - `View camera context`: open a live, scrollable snapshot of entities intersecting the camera viewport and their current semantic values +- `Lucide icon gallery`: create or revisit a dedicated canvas region containing + the complete searchable, scrollable Lucide 1.31.0 icon set and fit the camera + to it - `10 browser stress scene`: lay out ten compact independent browser views for performance testing - `White`/`Grid`: toggle the optional grid; the default canvas is pure white @@ -58,8 +64,13 @@ `https://www.google.com` in a CEF off-screen browser whose pixel buffer is uploaded into a Raylib texture. It remains inside the canvas and participates in pan, zoom, clipping, and entity dragging on Windows, macOS, and Linux. Web -views have no simulated browser toolbar: drag their narrow outer edge to move -them and drag the bottom-right handle to resize them. Multiple web entities have independent +views show a Raylib-rendered Lucide back/forward toolbar and current URL only +while selected. The toolbar eases open by pushing the page viewport downward +and reverses the transition when deselected. Click the URL field to edit it; +Enter navigates, Escape cancels, and standard Ctrl/Cmd clipboard and selection +shortcuts work. Drag the remaining outer edge to move the view and drag the +bottom-right handle to resize it. Embedded pages inherit the selected canvas +light or dark color scheme where the browser backend supports it. Multiple web entities have independent browser instances, URLs, focus, input, and rendering. Browser resources are assigned only to visible entities, off-screen views are suspended, and the native CEF pool supports up to twelve live browsers. Small background views @@ -70,6 +81,12 @@ 64-pixel buckets, capped at 1280x800, so resized views become sharper without reallocating continuously during a drag. +The Lucide gallery is one scalable Raylib-rendered canvas entity rather than +thousands of individual scene entities or an embedded browser. Reopening it +reuses the existing entity and moves the camera back to its fitted bounds. Its +ISC-licensed vector data is generated from Lucide 1.31.0 and the visible grid +rows are drawn directly with Raylib primitives. + Tables support row selection, and notifications behave as bottom-right toast cards: multiple toasts overlap compactly, fan into a spaced stack on hover, and slide/fade away from either Undo or close. Position, size, stack expansion, @@ -87,7 +104,10 @@ Buttons animate on hover/press and toggle when clicked. Dropdowns open a real option menu with hover and selection states. Selected text areas accept typing, -Enter, and Backspace. Dragging still moves every primitive. +wrap text, support click/drag selection and scrolling, and provide +Ctrl/Cmd+A/C/X/V clipboard shortcuts plus Shift selection, word navigation, +Home/End, Backspace, and Delete. Drag a text area's narrow outer padding to +move it without changing the selection. Camera context is derived from the visible world rather than a hidden conversation store. Panning entities outside the viewport removes them; @@ -100,6 +120,9 @@ The prototype uses the Inter variable font under the SIL Open Font License. +Agent-oriented architecture and extension notes live in +[`docs/README.md`](docs/README.md). + ## Memory ownership The application creates one Dowa arena. Application state and the fixed-capacity diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/assets/LUCIDE_LICENSE.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/infinite_canvas/assets/LUCIDE_LICENSE.txt Mon Aug 17 22:16:14 2026 -0700 @@ -0,0 +1,60 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, +arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, +arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, +arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, +cast, check, chevron-down, chevron-left, chevron-right, chevron-up, +chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, +clock, code, columns, command, compass, corner-down-left, corner-down-right, +corner-left-down, corner-left-up, corner-right-down, corner-right-up, +corner-up-left, corner-up-right, crosshair, database, divide-circle, +divide-square, dollar-sign, download, external-link, feather, frown, hash, +headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, +loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, +minus-circle, minus-square, minus, monitor, moon, more-horizontal, +more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, +percent, plus-circle, plus-square, plus, power, radio, rss, search, server, +share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, +target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, +x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/canvas.c --- a/infinite_canvas/canvas.c Mon Aug 17 17:01:40 2026 -0700 +++ b/infinite_canvas/canvas.c Mon Aug 17 22:16:14 2026 -0700 @@ -1,4 +1,5 @@ #include "infinite_canvas/canvas.h" +#include "infinite_canvas/generated/lucide_data.h" #include #include @@ -25,6 +26,26 @@ "Synced", }; +#define CANVAS_LUCIDE_COLUMNS 10 +#define CANVAS_LUCIDE_CARD_WIDTH 128.0f +#define CANVAS_LUCIDE_CARD_HEIGHT 96.0f +#define CANVAS_LUCIDE_GAP 12.0f +#define CANVAS_LUCIDE_HEADER_HEIGHT 88.0f +#define CANVAS_LUCIDE_PADDING 24.0f +#define CANVAS_LUCIDE_SEARCH_WIDTH 360.0f +#define CANVAS_LUCIDE_SEARCH_HEIGHT 42.0f +#define CANVAS_TEXT_AREA_PADDING 16.0f +#define CANVAS_TEXT_AREA_FONT_SIZE 16.0f +#define CANVAS_TEXT_AREA_SPACING 0.1f +#define CANVAS_TEXT_AREA_LINE_HEIGHT 22.0f + +typedef struct { + int32 start; + int32 end; + int32 next; + float width; +} Canvas_Text_Line; + static float Canvas_Clamp(float value, float min_value, float max_value) { if (value < min_value) return min_value; @@ -55,6 +76,554 @@ }; } +static Color Canvas_Color_With_Opacity(Color color, float opacity) +{ + color.a = (uint8)((float)color.a * Canvas_Clamp(opacity, 0.0f, 1.0f)); + return color; +} + +static Color Canvas_Color_Fade(Color color, float amount) +{ + return Canvas_Color_With_Opacity(color, amount); +} + +static float Canvas_Entity_Visibility(const Canvas_Entity *p_entity) +{ + float amount = Canvas_Clamp(p_entity->visibility_amount, 0.0f, 1.0f); + return amount * amount * (3.0f - 2.0f * amount); +} + +static Canvas_Theme Canvas_Theme_With_Opacity( + const Canvas_Theme *p_theme, + float opacity) +{ + Canvas_Theme faded = *p_theme; + faded.background = Canvas_Color_With_Opacity(faded.background, opacity); + faded.surface = Canvas_Color_With_Opacity(faded.surface, opacity); + faded.surface_muted = Canvas_Color_With_Opacity(faded.surface_muted, opacity); + faded.border = Canvas_Color_With_Opacity(faded.border, opacity); + faded.text = Canvas_Color_With_Opacity(faded.text, opacity); + faded.text_muted = Canvas_Color_With_Opacity(faded.text_muted, opacity); + faded.accent = Canvas_Color_With_Opacity(faded.accent, opacity); + faded.accent_soft = Canvas_Color_With_Opacity(faded.accent_soft, opacity); + faded.on_accent = Canvas_Color_With_Opacity(faded.on_accent, opacity); + faded.selection = Canvas_Color_With_Opacity(faded.selection, opacity); + faded.entity_outline = Canvas_Color_With_Opacity(faded.entity_outline, opacity); + faded.button = Canvas_Color_With_Opacity(faded.button, opacity); + faded.button_hover = Canvas_Color_With_Opacity(faded.button_hover, opacity); + faded.button_active = Canvas_Color_With_Opacity(faded.button_active, opacity); + faded.button_active_hover = Canvas_Color_With_Opacity( + faded.button_active_hover, + opacity); + faded.switch_track_off = Canvas_Color_With_Opacity( + faded.switch_track_off, + opacity); + faded.switch_track_off_hover = Canvas_Color_With_Opacity( + faded.switch_track_off_hover, + opacity); + faded.switch_track_on = Canvas_Color_With_Opacity( + faded.switch_track_on, + opacity); + faded.switch_track_on_hover = Canvas_Color_With_Opacity( + faded.switch_track_on_hover, + opacity); + faded.switch_thumb = Canvas_Color_With_Opacity(faded.switch_thumb, opacity); + faded.pin = Canvas_Color_With_Opacity(faded.pin, opacity); + faded.danger = Canvas_Color_With_Opacity(faded.danger, opacity); + faded.shadow = Canvas_Color_With_Opacity(faded.shadow, opacity); + faded.toast_surface = Canvas_Color_With_Opacity(faded.toast_surface, opacity); + faded.toast_border = Canvas_Color_With_Opacity(faded.toast_border, opacity); + return faded; +} + +static boolean Canvas_Control_Is_Down(void) +{ + return IsKeyDown(KEY_LEFT_CONTROL) || + IsKeyDown(KEY_RIGHT_CONTROL) || + IsKeyDown(KEY_LEFT_SUPER) || + IsKeyDown(KEY_RIGHT_SUPER); +} + +static boolean Canvas_Shift_Is_Down(void); +static boolean Canvas_Key_Activated(int32 key); + +static char Canvas_Ascii_Lower(char value) +{ + if (value >= 'A' && value <= 'Z') return (char)(value + ('a' - 'A')); + return value; +} + +static boolean Canvas_Lucide_Name_Matches( + const char *p_name, + const char *p_query) +{ + if (!p_query || !p_query[0]) return TRUE; + for (const char *p_start = p_name; *p_start; p_start++) { + const char *p_name_cursor = p_start; + const char *p_query_cursor = p_query; + while (*p_name_cursor && + *p_query_cursor && + Canvas_Ascii_Lower(*p_name_cursor) == + Canvas_Ascii_Lower(*p_query_cursor)) { + p_name_cursor++; + p_query_cursor++; + } + if (!*p_query_cursor) return TRUE; + } + return FALSE; +} + +int32 Canvas_Lucide_Count_Matches(const char *p_query) +{ + int32 count = 0; + for (int32 index = 0; index < CANVAS_LUCIDE_ICON_COUNT; index++) { + if (Canvas_Lucide_Name_Matches( + CANVAS_LUCIDE_ICONS[index].p_name, + p_query)) { + count++; + } + } + return count; +} + +static Rectangle Canvas_Lucide_Search_Bounds(const Canvas_Entity *p_entity) +{ + return (Rectangle){ + p_entity->position.x + p_entity->size.x - + CANVAS_LUCIDE_PADDING - CANVAS_LUCIDE_SEARCH_WIDTH, + p_entity->position.y + + (CANVAS_LUCIDE_HEADER_HEIGHT - CANVAS_LUCIDE_SEARCH_HEIGHT) * 0.5f, + CANVAS_LUCIDE_SEARCH_WIDTH, + CANVAS_LUCIDE_SEARCH_HEIGHT, + }; +} + +static float Canvas_Lucide_Content_Height(const char *p_query) +{ + int32 icon_count = Canvas_Lucide_Count_Matches(p_query); + int32 rows = + (icon_count + CANVAS_LUCIDE_COLUMNS - 1) / + CANVAS_LUCIDE_COLUMNS; + if (rows == 0) { + return CANVAS_LUCIDE_HEADER_HEIGHT + CANVAS_LUCIDE_PADDING * 2.0f; + } + return CANVAS_LUCIDE_HEADER_HEIGHT + + CANVAS_LUCIDE_PADDING + + (float)rows * CANVAS_LUCIDE_CARD_HEIGHT + + (float)(rows - 1) * CANVAS_LUCIDE_GAP + + CANVAS_LUCIDE_PADDING; +} + +static float Canvas_Lucide_Max_Scroll(const Canvas_Entity *p_entity) +{ + return fmaxf( + 0.0f, + Canvas_Lucide_Content_Height(p_entity->text) - p_entity->size.y); +} + +static const Canvas_Lucide_Icon *Canvas_Lucide_Find_Icon( + const char *p_name) +{ + for (int32 index = 0; index < CANVAS_LUCIDE_ICON_COUNT; index++) { + if (strcmp(CANVAS_LUCIDE_ICONS[index].p_name, p_name) == 0) { + return &CANVAS_LUCIDE_ICONS[index]; + } + } + return NULL; +} + +static void Canvas_Lucide_Draw_Icon( + const Canvas_Lucide_Icon *p_icon, + Vector2 origin, + float scale, + float line_thickness, + Color color) +{ + if (!p_icon) return; + for (uint32 segment_index = p_icon->first_segment; + segment_index < p_icon->first_segment + p_icon->segment_count; + segment_index++) { + const Canvas_Lucide_Segment *p_segment = + &CANVAS_LUCIDE_SEGMENTS[segment_index]; + DrawLineEx( + (Vector2){ + origin.x + p_segment->x1 * scale, + origin.y + p_segment->y1 * scale, + }, + (Vector2){ + origin.x + p_segment->x2 * scale, + origin.y + p_segment->y2 * scale, + }, + line_thickness, + color); + } +} + +static Rectangle Canvas_Web_Toolbar_Bounds( + const Canvas_Camera *p_camera, + const Canvas_Entity *p_entity) +{ + float zoom = p_camera->zoom; + return (Rectangle){ + p_entity->position.x + CANVAS_WEB_FRAME_INSET / zoom, + p_entity->position.y + CANVAS_WEB_FRAME_INSET / zoom, + p_entity->size.x - + (CANVAS_WEB_FRAME_INSET + CANVAS_WEB_RESIZE_HANDLE) / zoom, + CANVAS_WEB_TOOLBAR_HEIGHT * + (p_entity->web_chrome_amount * + p_entity->web_chrome_amount * + (3.0f - 2.0f * p_entity->web_chrome_amount)) / + zoom, + }; +} + +static Rectangle Canvas_Web_Toolbar_Button_Bounds( + Rectangle toolbar, + float zoom, + int32 button_index) +{ + return (Rectangle){ + toolbar.x + (6.0f + (float)button_index * 34.0f) / zoom, + toolbar.y + 7.0f / zoom, + 28.0f / zoom, + 28.0f / zoom, + }; +} + +static Rectangle Canvas_Web_Address_Bounds(Rectangle toolbar, float zoom) +{ + return (Rectangle){ + toolbar.x + 76.0f / zoom, + toolbar.y + 7.0f / zoom, + toolbar.width - 82.0f / zoom, + 28.0f / zoom, + }; +} + +static void Canvas_Web_URL_Selection_Bounds( + const Canvas_Scene *p_scene, + int32 *p_start, + int32 *p_end) +{ + *p_start = p_scene->web_url_cursor; + *p_end = p_scene->web_url_selection_anchor; + if (*p_start > *p_end) { + int32 swap = *p_start; + *p_start = *p_end; + *p_end = swap; + } +} + +static void Canvas_Web_URL_Delete_Selection(Canvas_Scene *p_scene) +{ + int32 start = 0; + int32 end = 0; + Canvas_Web_URL_Selection_Bounds(p_scene, &start, &end); + if (start == end) return; + size_t length = strlen(p_scene->web_url_draft); + memmove( + p_scene->web_url_draft + start, + p_scene->web_url_draft + end, + length - (size_t)end + 1); + p_scene->web_url_cursor = start; + p_scene->web_url_selection_anchor = start; +} + +static void Canvas_Web_URL_Insert(Canvas_Scene *p_scene, const char *p_text) +{ + Canvas_Web_URL_Delete_Selection(p_scene); + size_t length = strlen(p_scene->web_url_draft); + size_t available = sizeof(p_scene->web_url_draft) - length - 1; + if (available == 0) return; + char filtered[CANVAS_ENTITY_TEXT_CAPACITY]; + size_t filtered_length = 0; + for (const char *p_cursor = p_text; + *p_cursor && filtered_length < available; + p_cursor++) { + if (*p_cursor >= 32 && *p_cursor <= 126) { + filtered[filtered_length++] = *p_cursor; + } + } + if (filtered_length == 0) return; + int32 cursor = p_scene->web_url_cursor; + memmove( + p_scene->web_url_draft + cursor + filtered_length, + p_scene->web_url_draft + cursor, + length - (size_t)cursor + 1); + memcpy(p_scene->web_url_draft + cursor, filtered, filtered_length); + p_scene->web_url_cursor += (int32)filtered_length; + p_scene->web_url_selection_anchor = p_scene->web_url_cursor; +} + +static int32 Canvas_Web_URL_Cursor_At_Point( + Font font, + const Canvas_Scene *p_scene, + Rectangle address, + float zoom, + Vector2 point) +{ + float font_size = 13.0f / zoom; + float local_x = + point.x - address.x - 9.0f / zoom + p_scene->web_url_scroll_x; + int32 length = (int32)strlen(p_scene->web_url_draft); + for (int32 cursor = 0; cursor < length; cursor++) { + char prefix[CANVAS_ENTITY_TEXT_CAPACITY]; + memcpy(prefix, p_scene->web_url_draft, (size_t)cursor + 1); + prefix[cursor + 1] = '\0'; + float right = MeasureTextEx(font, prefix, font_size, 0.0f).x; + prefix[cursor] = '\0'; + float left = MeasureTextEx(font, prefix, font_size, 0.0f).x; + if (local_x < (left + right) * 0.5f) return cursor; + } + return length; +} + +static void Canvas_Web_URL_Ensure_Cursor_Visible( + Canvas_Scene *p_scene, + Font font, + Rectangle address, + float zoom) +{ + char prefix[CANVAS_ENTITY_TEXT_CAPACITY]; + memcpy( + prefix, + p_scene->web_url_draft, + (size_t)p_scene->web_url_cursor); + prefix[p_scene->web_url_cursor] = '\0'; + float cursor_x = MeasureTextEx(font, prefix, 13.0f / zoom, 0.0f).x; + float visible_width = address.width - 18.0f / zoom; + if (cursor_x < p_scene->web_url_scroll_x) { + p_scene->web_url_scroll_x = cursor_x; + } else if (cursor_x > p_scene->web_url_scroll_x + visible_width) { + p_scene->web_url_scroll_x = cursor_x - visible_width; + } + if (p_scene->web_url_scroll_x < 0.0f) p_scene->web_url_scroll_x = 0.0f; +} + +static void Canvas_Web_URL_Copy_Selection(Canvas_Scene *p_scene) +{ + int32 start = 0; + int32 end = 0; + Canvas_Web_URL_Selection_Bounds(p_scene, &start, &end); + if (start == end) return; + char selected[CANVAS_ENTITY_TEXT_CAPACITY]; + memcpy( + selected, + p_scene->web_url_draft + start, + (size_t)(end - start)); + selected[end - start] = '\0'; + SetClipboardText(selected); +} + +static void Canvas_Web_URL_Normalize(char *p_url, size_t capacity) +{ + if (!p_url[0] || + strstr(p_url, "://") || + strncmp(p_url, "about:", 6) == 0 || + strncmp(p_url, "data:", 5) == 0 || + strncmp(p_url, "file:", 5) == 0) { + return; + } + char normalized[CANVAS_ENTITY_TEXT_CAPACITY]; + snprintf(normalized, sizeof(normalized), "https://%s", p_url); + snprintf(p_url, capacity, "%s", normalized); +} + +Canvas_Web_Chrome_Action Canvas_Web_Chrome_Update_Input( + const Canvas_Camera *p_camera, + Canvas_Scene *p_scene, + Font font, + const char *p_current_url) +{ + if (p_scene->selected_index < 0 || + p_scene->selected_index >= + (int32)Dowa_Array_Length(p_scene->p_entities)) { + p_scene->editing_web_url = FALSE; + return CANVAS_WEB_CHROME_NONE; + } + Canvas_Entity *p_entity = + &p_scene->p_entities[p_scene->selected_index]; + if (p_entity->type != CANVAS_ENTITY_WEB_CONTENT || + p_entity->removing) { + p_scene->editing_web_url = FALSE; + return CANVAS_WEB_CHROME_NONE; + } + + Rectangle toolbar = Canvas_Web_Toolbar_Bounds(p_camera, p_entity); + Rectangle address = Canvas_Web_Address_Bounds(toolbar, p_camera->zoom); + Vector2 pointer = Canvas_Camera_Screen_To_World( + p_camera, + GetMousePosition()); + if (p_scene->editing_web_url && + p_scene->web_url_entity_id == p_entity->id) { + boolean shortcut = Canvas_Control_Is_Down(); + boolean extend = Canvas_Shift_Is_Down(); + int32 length = (int32)strlen(p_scene->web_url_draft); + if (shortcut && IsKeyPressed(KEY_A)) { + p_scene->web_url_selection_anchor = 0; + p_scene->web_url_cursor = length; + } + if (shortcut && IsKeyPressed(KEY_C)) { + Canvas_Web_URL_Copy_Selection(p_scene); + } + if (shortcut && IsKeyPressed(KEY_X)) { + Canvas_Web_URL_Copy_Selection(p_scene); + Canvas_Web_URL_Delete_Selection(p_scene); + } + if (shortcut && IsKeyPressed(KEY_V)) { + const char *p_clipboard = GetClipboardText(); + if (p_clipboard) Canvas_Web_URL_Insert(p_scene, p_clipboard); + } + if (Canvas_Key_Activated(KEY_LEFT)) { + int32 cursor = p_scene->web_url_cursor; + if (!extend && + cursor != p_scene->web_url_selection_anchor) { + int32 end = 0; + Canvas_Web_URL_Selection_Bounds(p_scene, &cursor, &end); + } else { + cursor--; + } + if (cursor < 0) cursor = 0; + p_scene->web_url_cursor = cursor; + if (!extend) p_scene->web_url_selection_anchor = cursor; + } + if (Canvas_Key_Activated(KEY_RIGHT)) { + int32 cursor = p_scene->web_url_cursor; + if (!extend && + cursor != p_scene->web_url_selection_anchor) { + int32 start = 0; + Canvas_Web_URL_Selection_Bounds(p_scene, &start, &cursor); + } else { + cursor++; + } + if (cursor > length) cursor = length; + p_scene->web_url_cursor = cursor; + if (!extend) p_scene->web_url_selection_anchor = cursor; + } + if (Canvas_Key_Activated(KEY_HOME)) { + p_scene->web_url_cursor = 0; + if (!extend) p_scene->web_url_selection_anchor = 0; + } + if (Canvas_Key_Activated(KEY_END)) { + p_scene->web_url_cursor = length; + if (!extend) p_scene->web_url_selection_anchor = length; + } + if (Canvas_Key_Activated(KEY_BACKSPACE)) { + if (p_scene->web_url_cursor != + p_scene->web_url_selection_anchor) { + Canvas_Web_URL_Delete_Selection(p_scene); + } else if (p_scene->web_url_cursor > 0) { + p_scene->web_url_selection_anchor = + p_scene->web_url_cursor - 1; + Canvas_Web_URL_Delete_Selection(p_scene); + } + } + if (Canvas_Key_Activated(KEY_DELETE)) { + if (p_scene->web_url_cursor != + p_scene->web_url_selection_anchor) { + Canvas_Web_URL_Delete_Selection(p_scene); + } else if (p_scene->web_url_cursor < length) { + p_scene->web_url_selection_anchor = + p_scene->web_url_cursor + 1; + Canvas_Web_URL_Delete_Selection(p_scene); + } + } + int32 codepoint = GetCharPressed(); + while (codepoint > 0) { + if (!shortcut && codepoint >= 32 && codepoint <= 126) { + char inserted[2] = {(char)codepoint, '\0'}; + Canvas_Web_URL_Insert(p_scene, inserted); + } + codepoint = GetCharPressed(); + } + Canvas_Web_URL_Ensure_Cursor_Visible( + p_scene, + font, + address, + p_camera->zoom); + if (IsKeyPressed(KEY_ESCAPE)) { + p_scene->editing_web_url = FALSE; + } + if (IsKeyPressed(KEY_ENTER)) { + Canvas_Web_URL_Normalize( + p_scene->web_url_draft, + sizeof(p_scene->web_url_draft)); + snprintf( + p_entity->text, + sizeof(p_entity->text), + "%s", + p_scene->web_url_draft); + p_scene->editing_web_url = FALSE; + return CANVAS_WEB_CHROME_NAVIGATE; + } + } + + if (!CheckCollisionPointRec(pointer, toolbar)) { + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + p_scene->editing_web_url = FALSE; + return CANVAS_WEB_CHROME_NONE; + } + return p_scene->editing_web_url ? + CANVAS_WEB_CHROME_CAPTURE : + CANVAS_WEB_CHROME_NONE; + } + if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + return CANVAS_WEB_CHROME_CAPTURE; + } + if (CheckCollisionPointRec( + pointer, + Canvas_Web_Toolbar_Button_Bounds(toolbar, p_camera->zoom, 0))) { + return CANVAS_WEB_CHROME_BACK; + } + if (CheckCollisionPointRec( + pointer, + Canvas_Web_Toolbar_Button_Bounds(toolbar, p_camera->zoom, 1))) { + return CANVAS_WEB_CHROME_FORWARD; + } + if (CheckCollisionPointRec(pointer, address)) { + if (!p_scene->editing_web_url || + p_scene->web_url_entity_id != p_entity->id) { + snprintf( + p_scene->web_url_draft, + sizeof(p_scene->web_url_draft), + "%s", + p_current_url && p_current_url[0] ? + p_current_url : + p_entity->text); + p_scene->web_url_entity_id = p_entity->id; + p_scene->web_url_cursor = + (int32)strlen(p_scene->web_url_draft); + p_scene->web_url_selection_anchor = 0; + p_scene->web_url_scroll_x = 0.0f; + p_scene->editing_web_url = TRUE; + } else { + int32 cursor = Canvas_Web_URL_Cursor_At_Point( + font, + p_scene, + address, + p_camera->zoom, + pointer); + p_scene->web_url_cursor = cursor; + if (!Canvas_Shift_Is_Down()) { + p_scene->web_url_selection_anchor = cursor; + } + } + Canvas_Web_URL_Ensure_Cursor_Visible( + p_scene, + font, + address, + p_camera->zoom); + return CANVAS_WEB_CHROME_CAPTURE; + } + p_scene->editing_web_url = FALSE; + return CANVAS_WEB_CHROME_CAPTURE; +} + +boolean Canvas_Web_Chrome_Is_Editing(const Canvas_Scene *p_scene) +{ + return p_scene->editing_web_url; +} + void Canvas_Camera_Init(Canvas_Camera *p_camera, int32 width, int32 height) { *p_camera = (Canvas_Camera){ @@ -132,6 +701,48 @@ }; } +void Canvas_Camera_Fit_Bounds( + Canvas_Camera *p_camera, + Rectangle bounds, + float padding_left, + float padding_top, + float padding_right, + float padding_bottom) +{ + float available_width = fmaxf( + 1.0f, + (float)p_camera->viewport_width - padding_left - padding_right); + float available_height = fmaxf( + 1.0f, + (float)p_camera->viewport_height - padding_top - padding_bottom); + float zoom = fminf( + available_width / fmaxf(bounds.width, 1.0f), + available_height / fmaxf(bounds.height, 1.0f)); + p_camera->zoom = Canvas_Clamp( + zoom, + p_camera->min_zoom, + p_camera->max_zoom); + + Vector2 world_center = { + bounds.x + bounds.width * 0.5f, + bounds.y + bounds.height * 0.5f, + }; + Vector2 desired_screen_center = { + padding_left + available_width * 0.5f, + padding_top + available_height * 0.5f, + }; + Vector2 viewport_center = { + (float)p_camera->viewport_width * 0.5f, + (float)p_camera->viewport_height * 0.5f, + }; + p_camera->target = (Vector2){ + world_center.x - + (desired_screen_center.x - viewport_center.x) / p_camera->zoom, + world_center.y - + (desired_screen_center.y - viewport_center.y) / p_camera->zoom, + }; +} + static Rectangle Canvas_Entity_Bounds(const Canvas_Entity *p_entity) { if (p_entity->type == CANVAS_ENTITY_CIRCLE) { @@ -256,8 +867,10 @@ Canvas_Context_Append_Quoted(p_writer, p_entity->text); Canvas_Context_Append( p_writer, - " editing=%s", - p_entity->active ? "true" : "false"); + " editing=%s cursor=%d selection_anchor=%d", + p_entity->active ? "true" : "false", + p_entity->text_cursor, + p_entity->text_selection_anchor); break; case CANVAS_ENTITY_DROPDOWN: Canvas_Context_Append(p_writer, "selected="); @@ -336,6 +949,18 @@ Canvas_Context_Append(p_writer, "url="); Canvas_Context_Append_Quoted(p_writer, p_entity->text); break; + case CANVAS_ENTITY_LUCIDE_GALLERY: + Canvas_Context_Append( + p_writer, + "icons=%d query=", + Canvas_Lucide_Count_Matches(p_entity->text)); + Canvas_Context_Append_Quoted(p_writer, p_entity->text); + Canvas_Context_Append( + p_writer, + " search_focused=%s scroll_offset=%d renderer=\"raylib\"", + p_entity->active ? "true" : "false", + p_entity->value); + break; default: Canvas_Context_Append(p_writer, "value=%d", p_entity->value); break; @@ -377,8 +1002,7 @@ snapshot.visible_count++; Canvas_Context_Append( &writer, - "- id=%u type=\"%s\" position=[%.1f, %.1f] size=[%.1f, %.1f] pinned=%s\n" - " state: ", + "- id=%u type=\"%s\" position=[%.1f, %.1f] size=[%.1f, %.1f] pinned=%s", p_entity->id, Canvas_Entity_Type_Name(p_entity->type), p_entity->position.x, @@ -386,6 +1010,12 @@ p_entity->size.x, p_entity->size.y, p_entity->pinned ? "true" : "false"); + Canvas_Context_Append( + &writer, + " visibility=%.2f removing=%s\n" + " state: ", + p_entity->visibility_amount, + p_entity->removing ? "true" : "false"); Canvas_Context_Append_Entity_State(&writer, p_entity); Canvas_Context_Append(&writer, "\n"); } @@ -430,8 +1060,10 @@ .color = Canvas_Entity_Color(index), .text = {0}, .value = 0, + .visibility_amount = 0.0f, .active = FALSE, .pinned = FALSE, + .removing = FALSE, .hover_amount = 0.0f, .animation_amount = 0.0f, }; @@ -492,6 +1124,9 @@ entity.active = TRUE; snprintf(entity.text, sizeof(entity.text), "https://mrjunejune.com"); } + if (type == CANVAS_ENTITY_LUCIDE_GALLERY) { + entity.size = (Vector2){1440.0f, 1040.0f}; + } Dowa_Array_Push_Arena(p_scene->p_entities, entity, p_scene->p_arena); return TRUE; @@ -506,9 +1141,46 @@ p_scene->hover_index = -1; p_scene->dragging = FALSE; p_scene->resizing = FALSE; + p_scene->selecting_text = FALSE; + p_scene->editing_web_url = FALSE; + p_scene->web_url_entity_id = 0; + p_scene->web_url_draft[0] = '\0'; p_scene->notification_stack_amount = 0.0f; } +void Canvas_Scene_Fade_Out_All(Canvas_Scene *p_scene) +{ + for (size_t index = 0; index < Dowa_Array_Length(p_scene->p_entities); index++) { + p_scene->p_entities[index].removing = TRUE; + p_scene->p_entities[index].active = FALSE; + } + p_scene->selected_index = -1; + p_scene->pressed_index = -1; + p_scene->hover_index = -1; + p_scene->dragging = FALSE; + p_scene->resizing = FALSE; + p_scene->selecting_text = FALSE; + p_scene->editing_web_url = FALSE; +} + +void Canvas_Scene_Fade_Out_Selected(Canvas_Scene *p_scene) +{ + if (p_scene->selected_index < 0 || + p_scene->selected_index >= (int32)Dowa_Array_Length(p_scene->p_entities)) { + return; + } + Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->selected_index]; + p_entity->removing = TRUE; + p_entity->active = FALSE; + p_scene->selected_index = -1; + p_scene->pressed_index = -1; + p_scene->hover_index = -1; + p_scene->dragging = FALSE; + p_scene->resizing = FALSE; + p_scene->selecting_text = FALSE; + p_scene->editing_web_url = FALSE; +} + void Canvas_Scene_Add_Demo(Canvas_Scene *p_scene) { Canvas_Scene_Clear(p_scene); @@ -552,6 +1224,47 @@ } } +boolean Canvas_Scene_Show_Lucide_Gallery( + Canvas_Scene *p_scene, + Canvas_Camera *p_camera) +{ + int32 gallery_index = -1; + for (size_t index = 0; index < Dowa_Array_Length(p_scene->p_entities); index++) { + const Canvas_Entity *p_entity = &p_scene->p_entities[index]; + if (p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY) { + gallery_index = (int32)index; + break; + } + } + + if (gallery_index < 0) { + if (!Canvas_Scene_Add( + p_scene, + CANVAS_ENTITY_LUCIDE_GALLERY, + (Vector2){1200.0f, -520.0f})) { + return FALSE; + } + gallery_index = (int32)Dowa_Array_Length(p_scene->p_entities) - 1; + } + + Canvas_Entity *p_gallery = &p_scene->p_entities[gallery_index]; + p_gallery->removing = FALSE; + p_scene->selected_index = gallery_index; + Canvas_Camera_Fit_Bounds( + p_camera, + (Rectangle){ + p_gallery->position.x, + p_gallery->position.y, + p_gallery->size.x, + p_gallery->size.y, + }, + 320.0f, + 56.0f, + 56.0f, + 56.0f); + return TRUE; +} + static float Canvas_Distance_To_Segment(Vector2 point, Vector2 start, Vector2 end) { Vector2 segment = {end.x - start.x, end.y - start.y}; @@ -623,6 +1336,15 @@ height, }) ? TRUE : FALSE; } + case CANVAS_ENTITY_LUCIDE_GALLERY: + return CheckCollisionPointRec( + point, + (Rectangle){ + p_entity->position.x, + p_entity->position.y, + p_entity->size.x, + p_entity->size.y, + }) ? TRUE : FALSE; case CANVAS_ENTITY_NOTIFICATION: if (p_entity->animation_amount <= 0.01f) return FALSE; return CheckCollisionPointRec( @@ -683,6 +1405,7 @@ { for (int32 index = (int32)Dowa_Array_Length(p_scene->p_entities) - 1; index >= 0; index--) { const Canvas_Entity *p_entity = &p_scene->p_entities[index]; + if (p_entity->removing) continue; if (p_entity->type == CANVAS_ENTITY_DROPDOWN && p_entity->active && Canvas_Entity_Contains(p_entity, world_pointer, zoom)) { @@ -691,11 +1414,43 @@ } for (int32 index = (int32)Dowa_Array_Length(p_scene->p_entities) - 1; index >= 0; index--) { + if (p_scene->p_entities[index].removing) continue; if (Canvas_Entity_Contains(&p_scene->p_entities[index], world_pointer, zoom)) return index; } return -1; } +int32 Canvas_Scene_Topmost_Visual( + const Canvas_Scene *p_scene, + Vector2 world_pointer, + float zoom) +{ + for (int32 index = + (int32)Dowa_Array_Length(p_scene->p_entities) - 1; + index >= 0; + index--) { + const Canvas_Entity *p_entity = &p_scene->p_entities[index]; + if (p_entity->removing) continue; + if (p_entity->type == CANVAS_ENTITY_WEB_CONTENT) { + if (CheckCollisionPointRec( + world_pointer, + (Rectangle){ + p_entity->position.x, + p_entity->position.y, + p_entity->size.x, + p_entity->size.y, + })) { + return index; + } + continue; + } + if (Canvas_Entity_Contains(p_entity, world_pointer, zoom)) { + return index; + } + } + return -1; +} + static void Canvas_Scene_Activate(Canvas_Scene *p_scene, int32 index, Vector2 world_pointer) { if (index < 0 || index >= (int32)Dowa_Array_Length(p_scene->p_entities)) return; @@ -763,30 +1518,536 @@ } } -static void Canvas_Scene_Update_Text_Input(Canvas_Scene *p_scene) +static boolean Canvas_Shift_Is_Down(void) +{ + return IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); +} + +static boolean Canvas_Alt_Is_Down(void) +{ + return IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT); +} + +static boolean Canvas_Super_Is_Down(void) +{ + return IsKeyDown(KEY_LEFT_SUPER) || IsKeyDown(KEY_RIGHT_SUPER); +} + +static boolean Canvas_Key_Activated(int32 key) +{ + return IsKeyPressed(key) || IsKeyPressedRepeat(key); +} + +static float Canvas_Text_Measure_Range( + Font font, + const char *p_text, + int32 start, + int32 end) +{ + if (end <= start) return 0.0f; + char buffer[CANVAS_ENTITY_TEXT_CAPACITY]; + int32 length = end - start; + if (length >= (int32)sizeof(buffer)) length = (int32)sizeof(buffer) - 1; + memcpy(buffer, p_text + start, (size_t)length); + buffer[length] = '\0'; + return MeasureTextEx( + font, + buffer, + CANVAS_TEXT_AREA_FONT_SIZE, + CANVAS_TEXT_AREA_SPACING).x; +} + +static int32 Canvas_Text_Build_Lines( + Font font, + const char *p_text, + float max_width, + Canvas_Text_Line *p_lines, + int32 line_capacity) +{ + int32 length = (int32)strlen(p_text); + int32 line_count = 0; + int32 start = 0; + + while (start <= length && line_count < line_capacity) { + int32 index = start; + int32 last_space = -1; + float width = 0.0f; + boolean ended_with_newline = FALSE; + + while (index < length) { + if (p_text[index] == '\n') { + ended_with_newline = TRUE; + break; + } + float character_width = Canvas_Text_Measure_Range( + font, + p_text, + index, + index + 1); + if (width + character_width > max_width && index > start) { + if (last_space >= start) index = last_space + 1; + break; + } + width += character_width; + if (p_text[index] == ' ' || p_text[index] == '\t') { + last_space = index; + } + index++; + } + + int32 end = index; + int32 next = ended_with_newline ? index + 1 : index; + p_lines[line_count++] = (Canvas_Text_Line){ + .start = start, + .end = end, + .next = next, + .width = Canvas_Text_Measure_Range(font, p_text, start, end), + }; + if (index >= length) break; + start = next; + } + + return line_count; +} + +static Rectangle Canvas_Text_Area_Content_Bounds( + const Canvas_Entity *p_entity) +{ + return (Rectangle){ + p_entity->position.x + CANVAS_TEXT_AREA_PADDING, + p_entity->position.y + CANVAS_TEXT_AREA_PADDING, + p_entity->size.x - CANVAS_TEXT_AREA_PADDING * 2.0f, + p_entity->size.y - CANVAS_TEXT_AREA_PADDING * 2.0f, + }; +} + +static int32 Canvas_Text_Line_For_Cursor( + const Canvas_Text_Line *p_lines, + int32 line_count, + int32 cursor) +{ + for (int32 line = 0; line < line_count; line++) { + if (cursor <= p_lines[line].end) return line; + if (line + 1 < line_count && cursor < p_lines[line + 1].start) { + return line; + } + } + return line_count > 0 ? line_count - 1 : 0; +} + +static int32 Canvas_Text_Cursor_At_Point( + Font font, + const Canvas_Entity *p_entity, + Vector2 point) +{ + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); + int32 line_count = Canvas_Text_Build_Lines( + font, + p_entity->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + int32 line = (int32)floorf( + (point.y - content.y + p_entity->text_scroll_y) / + CANVAS_TEXT_AREA_LINE_HEIGHT); + if (line < 0) line = 0; + if (line >= line_count) line = line_count - 1; + float local_x = point.x - content.x; + if (local_x <= 0.0f) return lines[line].start; + + for (int32 cursor = lines[line].start; cursor < lines[line].end; cursor++) { + float left = Canvas_Text_Measure_Range( + font, + p_entity->text, + lines[line].start, + cursor); + float right = Canvas_Text_Measure_Range( + font, + p_entity->text, + lines[line].start, + cursor + 1); + if (local_x < (left + right) * 0.5f) return cursor; + } + return lines[line].end; +} + +static void Canvas_Text_Selection_Bounds( + const Canvas_Entity *p_entity, + int32 *p_start, + int32 *p_end) +{ + *p_start = p_entity->text_cursor; + *p_end = p_entity->text_selection_anchor; + if (*p_start > *p_end) { + int32 swap = *p_start; + *p_start = *p_end; + *p_end = swap; + } +} + +static boolean Canvas_Text_Has_Selection(const Canvas_Entity *p_entity) +{ + return p_entity->text_cursor != p_entity->text_selection_anchor; +} + +static void Canvas_Text_Delete_Selection(Canvas_Entity *p_entity) +{ + int32 start = 0; + int32 end = 0; + Canvas_Text_Selection_Bounds(p_entity, &start, &end); + if (start == end) return; + size_t length = strlen(p_entity->text); + memmove( + p_entity->text + start, + p_entity->text + end, + length - (size_t)end + 1); + p_entity->text_cursor = start; + p_entity->text_selection_anchor = start; +} + +static void Canvas_Text_Insert( + Canvas_Entity *p_entity, + const char *p_text) +{ + Canvas_Text_Delete_Selection(p_entity); + size_t length = strlen(p_entity->text); + size_t available = + sizeof(p_entity->text) - length - 1; + if (available == 0) return; + + char filtered[CANVAS_ENTITY_TEXT_CAPACITY]; + size_t filtered_length = 0; + for (const char *p_cursor = p_text; + *p_cursor && filtered_length < available; + p_cursor++) { + if (*p_cursor == '\r') continue; + if (*p_cursor == '\n' || *p_cursor == '\t' || + (*p_cursor >= 32 && *p_cursor <= 126)) { + filtered[filtered_length++] = *p_cursor == '\t' ? ' ' : *p_cursor; + } + } + if (filtered_length == 0) return; + + int32 cursor = p_entity->text_cursor; + memmove( + p_entity->text + cursor + filtered_length, + p_entity->text + cursor, + length - (size_t)cursor + 1); + memcpy(p_entity->text + cursor, filtered, filtered_length); + p_entity->text_cursor += (int32)filtered_length; + p_entity->text_selection_anchor = p_entity->text_cursor; +} + +static int32 Canvas_Text_Previous_Word(const char *p_text, int32 cursor) +{ + while (cursor > 0 && p_text[cursor - 1] == ' ') cursor--; + while (cursor > 0 && + p_text[cursor - 1] != ' ' && + p_text[cursor - 1] != '\n') { + cursor--; + } + return cursor; +} + +static int32 Canvas_Text_Next_Word(const char *p_text, int32 cursor) +{ + int32 length = (int32)strlen(p_text); + while (cursor < length && + p_text[cursor] != ' ' && + p_text[cursor] != '\n') { + cursor++; + } + while (cursor < length && p_text[cursor] == ' ') cursor++; + return cursor; +} + +static void Canvas_Text_Move_Cursor( + Canvas_Entity *p_entity, + int32 cursor, + boolean extend_selection) +{ + int32 length = (int32)strlen(p_entity->text); + if (cursor < 0) cursor = 0; + if (cursor > length) cursor = length; + p_entity->text_cursor = cursor; + if (!extend_selection) p_entity->text_selection_anchor = cursor; +} + +static void Canvas_Text_Ensure_Cursor_Visible( + Canvas_Entity *p_entity, + Font font) +{ + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); + int32 line_count = Canvas_Text_Build_Lines( + font, + p_entity->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + int32 line = Canvas_Text_Line_For_Cursor( + lines, + line_count, + p_entity->text_cursor); + float cursor_top = (float)line * CANVAS_TEXT_AREA_LINE_HEIGHT; + float cursor_bottom = cursor_top + CANVAS_TEXT_AREA_LINE_HEIGHT; + if (cursor_top < p_entity->text_scroll_y) { + p_entity->text_scroll_y = cursor_top; + } else if (cursor_bottom > + p_entity->text_scroll_y + content.height) { + p_entity->text_scroll_y = cursor_bottom - content.height; + } + float max_scroll = fmaxf( + 0.0f, + (float)line_count * CANVAS_TEXT_AREA_LINE_HEIGHT - content.height); + p_entity->text_scroll_y = Canvas_Clamp( + p_entity->text_scroll_y, + 0.0f, + max_scroll); +} + +static void Canvas_Text_Update_Keyboard(Canvas_Entity *p_entity, Font font) +{ + boolean shortcut = Canvas_Control_Is_Down(); + boolean control = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); + boolean super = Canvas_Super_Is_Down(); + boolean word_modifier = control || Canvas_Alt_Is_Down(); + boolean extend = Canvas_Shift_Is_Down(); + int32 length = (int32)strlen(p_entity->text); + int32 original_cursor = p_entity->text_cursor; + int32 original_length = length; + + if (shortcut && IsKeyPressed(KEY_A)) { + p_entity->text_selection_anchor = 0; + p_entity->text_cursor = length; + } + if (shortcut && IsKeyPressed(KEY_C) && Canvas_Text_Has_Selection(p_entity)) { + int32 start = 0; + int32 end = 0; + char selected[CANVAS_ENTITY_TEXT_CAPACITY]; + Canvas_Text_Selection_Bounds(p_entity, &start, &end); + memcpy(selected, p_entity->text + start, (size_t)(end - start)); + selected[end - start] = '\0'; + SetClipboardText(selected); + } + if (shortcut && IsKeyPressed(KEY_X) && Canvas_Text_Has_Selection(p_entity)) { + int32 start = 0; + int32 end = 0; + char selected[CANVAS_ENTITY_TEXT_CAPACITY]; + Canvas_Text_Selection_Bounds(p_entity, &start, &end); + memcpy(selected, p_entity->text + start, (size_t)(end - start)); + selected[end - start] = '\0'; + SetClipboardText(selected); + Canvas_Text_Delete_Selection(p_entity); + } + if (shortcut && IsKeyPressed(KEY_V)) { + const char *p_clipboard = GetClipboardText(); + if (p_clipboard) Canvas_Text_Insert(p_entity, p_clipboard); + } + + if (Canvas_Key_Activated(KEY_LEFT)) { + int32 cursor = p_entity->text_cursor; + if (!extend && Canvas_Text_Has_Selection(p_entity)) { + int32 end = 0; + Canvas_Text_Selection_Bounds(p_entity, &cursor, &end); + } else if (super) { + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); + int32 count = Canvas_Text_Build_Lines( + font, + p_entity->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + cursor = lines[Canvas_Text_Line_For_Cursor( + lines, + count, + cursor)].start; + } else { + cursor = word_modifier ? + Canvas_Text_Previous_Word(p_entity->text, cursor) : + cursor - 1; + } + Canvas_Text_Move_Cursor(p_entity, cursor, extend); + } + if (Canvas_Key_Activated(KEY_RIGHT)) { + int32 cursor = p_entity->text_cursor; + if (!extend && Canvas_Text_Has_Selection(p_entity)) { + int32 start = 0; + Canvas_Text_Selection_Bounds(p_entity, &start, &cursor); + } else if (super) { + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); + int32 count = Canvas_Text_Build_Lines( + font, + p_entity->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + cursor = lines[Canvas_Text_Line_For_Cursor( + lines, + count, + cursor)].end; + } else { + cursor = word_modifier ? + Canvas_Text_Next_Word(p_entity->text, cursor) : + cursor + 1; + } + Canvas_Text_Move_Cursor(p_entity, cursor, extend); + } + + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); + int32 line_count = Canvas_Text_Build_Lines( + font, + p_entity->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + int32 current_line = Canvas_Text_Line_For_Cursor( + lines, + line_count, + p_entity->text_cursor); + float cursor_x = Canvas_Text_Measure_Range( + font, + p_entity->text, + lines[current_line].start, + p_entity->text_cursor); + if (Canvas_Key_Activated(KEY_UP) || Canvas_Key_Activated(KEY_DOWN)) { + int32 target_line = current_line + + (IsKeyDown(KEY_UP) ? -1 : 1); + if (target_line < 0) target_line = 0; + if (target_line >= line_count) target_line = line_count - 1; + Vector2 target = { + content.x + cursor_x, + content.y + + (float)target_line * CANVAS_TEXT_AREA_LINE_HEIGHT - + p_entity->text_scroll_y + + CANVAS_TEXT_AREA_LINE_HEIGHT * 0.5f, + }; + Canvas_Text_Move_Cursor( + p_entity, + Canvas_Text_Cursor_At_Point(font, p_entity, target), + extend); + } + if (Canvas_Key_Activated(KEY_HOME)) { + Canvas_Text_Move_Cursor( + p_entity, + lines[current_line].start, + extend); + } + if (Canvas_Key_Activated(KEY_END)) { + Canvas_Text_Move_Cursor( + p_entity, + lines[current_line].end, + extend); + } + + if (Canvas_Key_Activated(KEY_BACKSPACE)) { + if (Canvas_Text_Has_Selection(p_entity)) { + Canvas_Text_Delete_Selection(p_entity); + } else if (p_entity->text_cursor > 0) { + int32 start = p_entity->text_cursor - 1; + if (super) { + start = lines[Canvas_Text_Line_For_Cursor( + lines, + line_count, + p_entity->text_cursor)].start; + } else if (word_modifier) { + start = Canvas_Text_Previous_Word( + p_entity->text, + p_entity->text_cursor); + } + p_entity->text_selection_anchor = start; + Canvas_Text_Delete_Selection(p_entity); + } + } + if (Canvas_Key_Activated(KEY_DELETE)) { + if (Canvas_Text_Has_Selection(p_entity)) { + Canvas_Text_Delete_Selection(p_entity); + } else if (p_entity->text_cursor < length) { + int32 end = p_entity->text_cursor + 1; + if (super) { + end = lines[Canvas_Text_Line_For_Cursor( + lines, + line_count, + p_entity->text_cursor)].end; + } else if (word_modifier) { + end = Canvas_Text_Next_Word( + p_entity->text, + p_entity->text_cursor); + } + p_entity->text_selection_anchor = end; + Canvas_Text_Delete_Selection(p_entity); + } + } + if (IsKeyPressed(KEY_ENTER)) Canvas_Text_Insert(p_entity, "\n"); + + int32 codepoint = GetCharPressed(); + while (codepoint > 0) { + if (!shortcut && codepoint >= 32 && codepoint <= 126) { + char inserted[2] = {(char)codepoint, '\0'}; + Canvas_Text_Insert(p_entity, inserted); + } + codepoint = GetCharPressed(); + } + if (p_entity->text_cursor != original_cursor || + (int32)strlen(p_entity->text) != original_length) { + Canvas_Text_Ensure_Cursor_Visible(p_entity, font); + } +} + +static void Canvas_Scene_Update_Text_Input( + Canvas_Scene *p_scene, + Font font) { if (p_scene->selected_index < 0 || p_scene->selected_index >= (int32)Dowa_Array_Length(p_scene->p_entities)) return; Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->selected_index]; - if (p_entity->type != CANVAS_ENTITY_TEXT_AREA || !p_entity->active) return; + if ((p_entity->type != CANVAS_ENTITY_TEXT_AREA && + p_entity->type != CANVAS_ENTITY_LUCIDE_GALLERY) || + !p_entity->active) { + return; + } + + if (p_entity->type == CANVAS_ENTITY_TEXT_AREA) { + Canvas_Text_Update_Keyboard(p_entity, font); + return; + } size_t length = strlen(p_entity->text); + boolean changed = FALSE; int32 codepoint = GetCharPressed(); while (codepoint > 0) { - if (codepoint >= 32 && codepoint <= 126 && length + 1 < sizeof(p_entity->text)) { - p_entity->text[length++] = (char)codepoint; + size_t input_capacity = p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY ? + 64 : + sizeof(p_entity->text); + if (codepoint >= 32 && + codepoint <= 126 && + length + 1 < input_capacity) { + p_entity->text[length++] = p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY ? + Canvas_Ascii_Lower((char)codepoint) : + (char)codepoint; p_entity->text[length] = '\0'; + changed = TRUE; } codepoint = GetCharPressed(); } - if (IsKeyPressed(KEY_ENTER) && length + 1 < sizeof(p_entity->text)) { + if (p_entity->type == CANVAS_ENTITY_TEXT_AREA && + IsKeyPressed(KEY_ENTER) && + length + 1 < sizeof(p_entity->text)) { p_entity->text[length++] = '\n'; p_entity->text[length] = '\0'; } if (IsKeyPressed(KEY_BACKSPACE) && length > 0) { p_entity->text[length - 1] = '\0'; + changed = TRUE; + } + if (changed && p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY) { + p_entity->value = 0; } } @@ -802,6 +2063,66 @@ }; } +static void Canvas_Scene_Adjust_Index_After_Removal( + int32 *p_index, + int32 removed_index) +{ + if (*p_index == removed_index) { + *p_index = -1; + } else if (*p_index > removed_index) { + (*p_index)--; + } +} + +static void Canvas_Scene_Update_Lifecycle(Canvas_Scene *p_scene) +{ + float follow = 1.0f - expf(-GetFrameTime() * 9.0f); + size_t index = 0; + while (index < Dowa_Array_Length(p_scene->p_entities)) { + Canvas_Entity *p_entity = &p_scene->p_entities[index]; + float target = p_entity->removing ? 0.0f : 1.0f; + p_entity->visibility_amount += + (target - p_entity->visibility_amount) * follow; + p_entity->visibility_amount = Canvas_Clamp( + p_entity->visibility_amount, + 0.0f, + 1.0f); + float chrome_target = + p_entity->type == CANVAS_ENTITY_WEB_CONTENT && + (int32)index == p_scene->selected_index && + !p_entity->removing ? + 1.0f : + 0.0f; + float chrome_follow = 1.0f - expf(-GetFrameTime() * 12.0f); + p_entity->web_chrome_amount += + (chrome_target - p_entity->web_chrome_amount) * chrome_follow; + p_entity->web_chrome_amount = Canvas_Clamp( + p_entity->web_chrome_amount, + 0.0f, + 1.0f); + + if (p_entity->removing && p_entity->visibility_amount <= 0.01f) { + int32 removed_index = (int32)index; + size_t length = Dowa_Array_Length(p_scene->p_entities); + for (size_t move = index + 1; move < length; move++) { + p_scene->p_entities[move - 1] = p_scene->p_entities[move]; + } + (void)Dowa_Array_Pop(p_scene->p_entities); + Canvas_Scene_Adjust_Index_After_Removal( + &p_scene->selected_index, + removed_index); + Canvas_Scene_Adjust_Index_After_Removal( + &p_scene->pressed_index, + removed_index); + Canvas_Scene_Adjust_Index_After_Removal( + &p_scene->hover_index, + removed_index); + continue; + } + index++; + } +} + static void Canvas_Scene_Update_Animations( Canvas_Scene *p_scene, const Canvas_Camera *p_camera) @@ -843,9 +2164,11 @@ boolean Canvas_Scene_Update_Interaction( Canvas_Scene *p_scene, const Canvas_Camera *p_camera, + Font font, boolean block_pointer_input) { - Canvas_Scene_Update_Text_Input(p_scene); + Canvas_Scene_Update_Lifecycle(p_scene); + Canvas_Scene_Update_Text_Input(p_scene, font); Canvas_Scene_Update_Animations(p_scene, p_camera); if (block_pointer_input && p_scene->pressed_index < 0) { @@ -859,7 +2182,33 @@ boolean consumed_scroll = FALSE; if (p_scene->hover_index >= 0) { Canvas_Entity *p_hovered = &p_scene->p_entities[p_scene->hover_index]; - if (p_hovered->type == CANVAS_ENTITY_SCROLL_AREA) { + boolean control_down = Canvas_Control_Is_Down(); + if (p_hovered->type == CANVAS_ENTITY_TEXT_AREA && + p_hovered->active && + !control_down) { + float wheel = GetMouseWheelMove(); + if (wheel != 0.0f) { + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + Rectangle content = Canvas_Text_Area_Content_Bounds(p_hovered); + int32 line_count = Canvas_Text_Build_Lines( + font, + p_hovered->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + float max_scroll = fmaxf( + 0.0f, + (float)line_count * CANVAS_TEXT_AREA_LINE_HEIGHT - + content.height); + p_hovered->text_scroll_y = Canvas_Clamp( + p_hovered->text_scroll_y - + wheel * CANVAS_TEXT_AREA_LINE_HEIGHT * 2.0f, + 0.0f, + max_scroll); + consumed_scroll = TRUE; + } + } else if (p_hovered->type == CANVAS_ENTITY_SCROLL_AREA && + !control_down) { float wheel = GetMouseWheelMove(); if (wheel != 0.0f) { p_hovered->value = (int32)Canvas_Clamp( @@ -868,6 +2217,16 @@ 240.0f); consumed_scroll = TRUE; } + } else if (p_hovered->type == CANVAS_ENTITY_LUCIDE_GALLERY && + !control_down) { + float wheel = GetMouseWheelMove(); + if (wheel != 0.0f) { + p_hovered->value = (int32)Canvas_Clamp( + (float)p_hovered->value - wheel * 72.0f, + 0.0f, + Canvas_Lucide_Max_Scroll(p_hovered)); + consumed_scroll = TRUE; + } } } @@ -889,10 +2248,37 @@ p_scene->p_entities[index].type == CANVAS_ENTITY_DROPDOWN) { p_scene->p_entities[index].active = FALSE; } + if ((int32)index != p_scene->selected_index && + p_scene->p_entities[index].type == CANVAS_ENTITY_LUCIDE_GALLERY) { + p_scene->p_entities[index].active = FALSE; + } } if (p_scene->selected_index >= 0) { Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->selected_index]; + if (p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY) { + p_entity->active = CheckCollisionPointRec( + world_pointer, + Canvas_Lucide_Search_Bounds(p_entity)) ? TRUE : FALSE; + } + p_scene->selecting_text = FALSE; + if (p_entity->type == CANVAS_ENTITY_TEXT_AREA) { + boolean inside_content = CheckCollisionPointRec( + world_pointer, + Canvas_Text_Area_Content_Bounds(p_entity)) ? TRUE : FALSE; + p_entity->active = inside_content; + if (inside_content) { + int32 cursor = Canvas_Text_Cursor_At_Point( + font, + p_entity, + world_pointer); + p_entity->text_cursor = cursor; + if (!Canvas_Shift_Is_Down()) { + p_entity->text_selection_anchor = cursor; + } + p_scene->selecting_text = TRUE; + } + } p_scene->pressed_index = p_scene->selected_index; p_scene->dragging = FALSE; p_scene->resizing = Canvas_Web_Resize_Handle_Contains( @@ -913,10 +2299,25 @@ float screen_distance_squared = (delta.x * p_camera->zoom) * (delta.x * p_camera->zoom) + (delta.y * p_camera->zoom) * (delta.y * p_camera->zoom); - if (screen_distance_squared >= 16.0f) p_scene->dragging = TRUE; + Canvas_Entity *p_pressed = + &p_scene->p_entities[p_scene->pressed_index]; + if (p_scene->selecting_text && + p_pressed->type == CANVAS_ENTITY_TEXT_AREA) { + p_pressed->text_cursor = Canvas_Text_Cursor_At_Point( + font, + p_pressed, + world_pointer); + Canvas_Text_Ensure_Cursor_Visible(p_pressed, font); + } + if (screen_distance_squared >= 16.0f && + !p_scene->selecting_text && + !(p_pressed->type == CANVAS_ENTITY_LUCIDE_GALLERY && + p_pressed->active)) { + p_scene->dragging = TRUE; + } if (p_scene->dragging) { - Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->pressed_index]; + Canvas_Entity *p_entity = p_pressed; if (p_entity->type == CANVAS_ENTITY_NOTIFICATION) { p_scene->dragging = FALSE; } else if (p_scene->resizing) { @@ -936,13 +2337,19 @@ if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { if (p_scene->pressed_index >= 0 && !p_scene->dragging) { - Canvas_Scene_Activate(p_scene, p_scene->pressed_index, world_pointer); Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->pressed_index]; + if (p_entity->type != CANVAS_ENTITY_TEXT_AREA) { + Canvas_Scene_Activate( + p_scene, + p_scene->pressed_index, + world_pointer); + } if (p_entity->pinned) Canvas_Entity_Capture_Pin(p_entity, p_camera); } p_scene->pressed_index = -1; p_scene->dragging = FALSE; p_scene->resizing = FALSE; + p_scene->selecting_text = FALSE; } return p_scene->pressed_index >= 0 || consumed_scroll; } @@ -955,7 +2362,9 @@ } const Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->selected_index]; - return (p_entity->type == CANVAS_ENTITY_TEXT_AREA && p_entity->active) ? TRUE : FALSE; + return ((p_entity->type == CANVAS_ENTITY_TEXT_AREA || + p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY) && + p_entity->active) ? TRUE : FALSE; } void Canvas_Scene_Toggle_Selected_Pin( @@ -1161,10 +2570,16 @@ if (!block_pointer_input) { float wheel = GetMouseWheelMove(); if (wheel != 0.0f) { - Canvas_Camera_Zoom_At( - p_camera, - GetMousePosition(), - powf(1.15f, wheel)); + if (Canvas_Control_Is_Down()) { + Canvas_Camera_Zoom_At( + p_camera, + GetMousePosition(), + powf(1.15f, wheel)); + } else { + Canvas_Camera_Pan( + p_camera, + (Vector2){0.0f, wheel * 72.0f}); + } } } @@ -1189,8 +2604,8 @@ int32 last_x = (int32)ceilf((bounds.x + bounds.width) / spacing); int32 first_y = (int32)floorf(bounds.y / spacing); int32 last_y = (int32)ceilf((bounds.y + bounds.height) / spacing); - Color minor = Fade(p_theme->border, 0.36f); - Color major = Fade(p_theme->border, 0.65f); + Color minor = Canvas_Color_Fade(p_theme->border, 0.36f); + Color major = Canvas_Color_Fade(p_theme->border, 0.65f); Color axis = p_theme->text_muted; for (int32 index = first_x; index <= last_x; index++) { @@ -1214,11 +2629,11 @@ static void Canvas_Draw_Selection( const Canvas_Entity *p_entity, - float zoom) + float zoom, + Color selection) { float line = 2.0f / zoom; float padding = 7.0f / zoom; - Color selection = {0, 122, 255, 230}; switch (p_entity->type) { case CANVAS_ENTITY_RECTANGLE: @@ -1248,7 +2663,7 @@ p_entity->position.y + p_entity->size.y, }, 10.0f / zoom, - Fade(selection, 0.35f)); + Canvas_Color_Fade(selection, 0.35f)); break; case CANVAS_ENTITY_TEXT: case CANVAS_ENTITY_BUTTON: @@ -1275,6 +2690,20 @@ selection); break; } + case CANVAS_ENTITY_LUCIDE_GALLERY: { + DrawRectangleRoundedLinesEx( + (Rectangle){ + p_entity->position.x - padding, + p_entity->position.y - padding, + p_entity->size.x + padding * 2.0f, + p_entity->size.y + padding * 2.0f, + }, + 0.04f, + 10, + line, + selection); + break; + } case CANVAS_ENTITY_WEB_CONTENT: DrawRectangleRoundedLinesEx( (Rectangle){ @@ -1296,7 +2725,7 @@ CANVAS_WEB_RESIZE_HANDLE / zoom, CANVAS_WEB_RESIZE_HANDLE / zoom, }, - Fade(selection, 0.18f)); + Canvas_Color_Fade(selection, 0.18f)); DrawLineEx( (Vector2){ p_entity->position.x + p_entity->size.x - 11.0f / zoom, @@ -1315,6 +2744,124 @@ } +static void Canvas_Draw_Text_Area_Content( + const Canvas_Entity *p_entity, + Font font, + const Canvas_Theme *p_theme) +{ + Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); + if (!p_entity->text[0]) { + DrawTextEx( + font, + "Write something...", + (Vector2){content.x, content.y}, + CANVAS_TEXT_AREA_FONT_SIZE, + CANVAS_TEXT_AREA_SPACING, + p_theme->text_muted); + if (p_entity->active && fmod(GetTime(), 1.0) < 0.55) { + DrawLineEx( + (Vector2){content.x, content.y}, + (Vector2){ + content.x, + content.y + CANVAS_TEXT_AREA_FONT_SIZE + 2.0f, + }, + 1.5f, + p_theme->accent); + } + return; + } + + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + int32 line_count = Canvas_Text_Build_Lines( + font, + p_entity->text, + content.width, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + int32 selection_start = 0; + int32 selection_end = 0; + Canvas_Text_Selection_Bounds( + p_entity, + &selection_start, + &selection_end); + int32 cursor_line = Canvas_Text_Line_For_Cursor( + lines, + line_count, + p_entity->text_cursor); + + for (int32 line = 0; line < line_count; line++) { + float y = content.y + + (float)line * CANVAS_TEXT_AREA_LINE_HEIGHT - + p_entity->text_scroll_y; + if (y < content.y || + y + CANVAS_TEXT_AREA_FONT_SIZE > content.y + content.height) { + continue; + } + + int32 line_selection_start = + selection_start > lines[line].start ? + selection_start : + lines[line].start; + int32 line_selection_end = + selection_end < lines[line].end ? + selection_end : + lines[line].end; + if (line_selection_end > line_selection_start) { + float selection_x = content.x + Canvas_Text_Measure_Range( + font, + p_entity->text, + lines[line].start, + line_selection_start); + float selection_width = Canvas_Text_Measure_Range( + font, + p_entity->text, + line_selection_start, + line_selection_end); + DrawRectangleRec( + (Rectangle){ + selection_x, + y - 1.0f, + fmaxf(selection_width, 2.0f), + CANVAS_TEXT_AREA_LINE_HEIGHT, + }, + p_theme->accent_soft); + } + + char line_text[CANVAS_ENTITY_TEXT_CAPACITY]; + int32 line_length = lines[line].end - lines[line].start; + memcpy( + line_text, + p_entity->text + lines[line].start, + (size_t)line_length); + line_text[line_length] = '\0'; + DrawTextEx( + font, + line_text, + (Vector2){content.x, y}, + CANVAS_TEXT_AREA_FONT_SIZE, + CANVAS_TEXT_AREA_SPACING, + p_theme->text); + + if (p_entity->active && + line == cursor_line && + fmod(GetTime(), 1.0) < 0.55) { + float cursor_x = content.x + Canvas_Text_Measure_Range( + font, + p_entity->text, + lines[line].start, + p_entity->text_cursor); + DrawLineEx( + (Vector2){cursor_x, y}, + (Vector2){ + cursor_x, + y + CANVAS_TEXT_AREA_FONT_SIZE + 2.0f, + }, + 1.5f, + p_theme->accent); + } + } +} + static void Canvas_Draw_Entity( const Canvas_Entity *p_entity, Font font, @@ -1323,6 +2870,16 @@ boolean pressed, const Canvas_Theme *p_theme) { + float opacity = Canvas_Entity_Visibility(p_entity); + if (opacity <= 0.001f) return; + Canvas_Entity faded_entity = *p_entity; + faded_entity.color = Canvas_Color_With_Opacity( + faded_entity.color, + opacity); + Canvas_Theme faded_theme = Canvas_Theme_With_Opacity(p_theme, opacity); + p_entity = &faded_entity; + p_theme = &faded_theme; + float shadow_offset = 6.0f / zoom; Color shadow = p_theme->shadow; static const char *calendar_weekdays[] = { @@ -1352,7 +2909,7 @@ }, 0.14f, 12, - Fade(p_entity->color, 0.92f)); + Canvas_Color_Fade(p_entity->color, 0.92f)); DrawRectangleRoundedLinesEx( (Rectangle){ p_entity->position.x, @@ -1363,7 +2920,7 @@ 0.14f, 12, 1.0f / zoom, - Fade(BLACK, 0.08f)); + p_theme->entity_outline); break; case CANVAS_ENTITY_CIRCLE: DrawCircleV( @@ -1413,11 +2970,11 @@ p_entity->size.y + grow * 2.0f, }; Color base_fill = p_entity->active ? - (Color){35, 99, 235, 255} : - (Color){17, 24, 39, 255}; + p_theme->button_active : + p_theme->button; Color hover_fill = p_entity->active ? - (Color){59, 130, 246, 255} : - (Color){39, 48, 64, 255}; + p_theme->button_active_hover : + p_theme->button_hover; Color fill = Canvas_Color_Lerp(base_fill, hover_fill, hover); Canvas_Theme_Draw_Shadow( p_theme, @@ -1437,7 +2994,7 @@ }, 16.0f, 0.1f, - WHITE); + p_theme->on_accent); break; } case CANVAS_ENTITY_TEXT_AREA: { @@ -1455,19 +3012,7 @@ 14, (p_entity->active ? 2.0f : 1.0f) / zoom, p_entity->active ? p_theme->accent : p_theme->border); - DrawTextEx( - font, - p_entity->text[0] ? p_entity->text : "Write something...", - (Vector2){bounds.x + 16.0f, bounds.y + 15.0f}, - 16.0f, - 0.1f, - p_entity->text[0] ? p_theme->text : p_theme->text_muted); - if (p_entity->active) { - DrawCircleV( - (Vector2){bounds.x + bounds.width - 16.0f, bounds.y + 16.0f}, - 3.0f, - p_theme->accent); - } + Canvas_Draw_Text_Area_Content(p_entity, font, p_theme); break; } case CANVAS_ENTITY_DROPDOWN: { @@ -1552,7 +3097,7 @@ (Vector2){bounds.x, bounds.y + 56.0f}, (Vector2){bounds.x + bounds.width, bounds.y + 56.0f}, 1.0f / zoom, - Fade(p_theme->border, amount)); + Canvas_Color_Fade(p_theme->border, amount)); if (amount > 0.55f) { float body_alpha = Canvas_Clamp( (amount - 0.55f) / 0.45f, @@ -1564,7 +3109,7 @@ (Vector2){bounds.x + 16.0f, bounds.y + 76.0f}, 14.0f, 0.1f, - Fade(p_theme->text_muted, body_alpha)); + Canvas_Color_Fade(p_theme->text_muted, body_alpha)); } } break; @@ -1585,11 +3130,13 @@ 12, p_entity->active ? p_theme->accent_soft : - Fade(p_entity->color, 0.22f)); + Canvas_Color_Fade(p_entity->color, 0.22f)); DrawCircleV( (Vector2){bounds.x + 44.0f, bounds.y + 58.0f}, 18.0f, - p_entity->active ? (Color){37, 99, 235, 255} : p_entity->color); + p_entity->active ? + p_theme->accent : + p_entity->color); DrawTextEx( font, p_entity->text, @@ -1666,7 +3213,9 @@ center.y - label_size.y * 0.5f}, 13.0f, 0.0f, - day == p_entity->value ? WHITE : p_theme->text); + day == p_entity->value ? + p_theme->on_accent : + p_theme->text); } break; } @@ -1679,16 +3228,15 @@ p_entity->size.x, p_entity->size.y, }; - Color track = Canvas_Color_Lerp( - p_theme->border, - p_theme->accent, - eased); - if (!p_entity->active) { - track = Canvas_Color_Lerp( - track, - p_theme->text_muted, - p_entity->hover_amount); - } + Color off_track = Canvas_Color_Lerp( + p_theme->switch_track_off, + p_theme->switch_track_off_hover, + p_entity->hover_amount); + Color on_track = Canvas_Color_Lerp( + p_theme->switch_track_on, + p_theme->switch_track_on_hover, + p_entity->hover_amount); + Color track = Canvas_Color_Lerp(off_track, on_track, eased); Canvas_Theme_Draw_Shadow(p_theme, bounds, 0.50f, 18, zoom, 1.0f); DrawRectangleRounded(bounds, 0.50f, 18, track); float knob_x = bounds.x + 18.0f + @@ -1697,7 +3245,7 @@ DrawCircleV( (Vector2){knob_x, bounds.y + bounds.height * 0.5f}, knob_radius, - WHITE); + p_theme->switch_thumb); break; } case CANVAS_ENTITY_TABLE: { @@ -1784,27 +3332,27 @@ bounds, 0.20f, 16, - Fade(p_theme->toast_surface, opacity)); + Canvas_Color_Fade(p_theme->toast_surface, opacity)); DrawRectangleRoundedLinesEx( bounds, 0.20f, 16, 1.0f / zoom, - Fade(p_theme->toast_border, opacity)); + Canvas_Color_Fade(p_theme->toast_border, opacity)); DrawTextEx( font, p_entity->text, (Vector2){bounds.x + 16.0f * unit, bounds.y + 18.0f * unit}, 15.0f * unit, 0.0f, - Fade(p_theme->text, opacity)); + Canvas_Color_Fade(p_theme->text, opacity)); DrawTextEx( font, "Sunday, December 3 at 9:00 AM", (Vector2){bounds.x + 16.0f * unit, bounds.y + 45.0f * unit}, 14.0f * unit, 0.0f, - Fade(p_theme->text_muted, opacity)); + Canvas_Color_Fade(p_theme->text_muted, opacity)); Rectangle undo = { bounds.x + bounds.width - 108.0f * unit, bounds.y + 24.0f * unit, @@ -1815,20 +3363,20 @@ undo, 0.26f, 10, - Fade(p_theme->surface_muted, opacity)); + Canvas_Color_Fade(p_theme->surface_muted, opacity)); DrawRectangleRoundedLinesEx( undo, 0.26f, 10, 1.0f / zoom, - Fade(p_theme->border, opacity)); + Canvas_Color_Fade(p_theme->border, opacity)); DrawTextEx( font, "Undo", (Vector2){undo.x + 12.0f * unit, undo.y + 10.0f * unit}, 13.0f * unit, 0.0f, - Fade(p_theme->text, opacity)); + Canvas_Color_Fade(p_theme->text, opacity)); Vector2 close = { bounds.x + bounds.width - 24.0f * unit, bounds.y + bounds.height * 0.5f, @@ -1837,12 +3385,12 @@ (Vector2){close.x - 4.0f * unit, close.y - 4.0f * unit}, (Vector2){close.x + 4.0f * unit, close.y + 4.0f * unit}, 1.5f / zoom, - Fade(p_theme->text_muted, opacity)); + Canvas_Color_Fade(p_theme->text_muted, opacity)); DrawLineEx( (Vector2){close.x + 4.0f * unit, close.y - 4.0f * unit}, (Vector2){close.x - 4.0f * unit, close.y + 4.0f * unit}, 1.5f / zoom, - Fade(p_theme->text_muted, opacity)); + Canvas_Color_Fade(p_theme->text_muted, opacity)); break; } case CANVAS_ENTITY_SCROLL_AREA: { @@ -1896,7 +3444,7 @@ (Rectangle){bounds.x + bounds.width - 14.0f, thumb_y, 5.0f, 42.0f}, 0.50f, 6, - Fade(p_theme->text_muted, 0.82f)); + Canvas_Color_Fade(p_theme->text_muted, 0.82f)); break; } case CANVAS_ENTITY_IMAGE: { @@ -1957,25 +3505,195 @@ #endif break; } + case CANVAS_ENTITY_LUCIDE_GALLERY: { + Rectangle bounds = { + p_entity->position.x, + p_entity->position.y, + p_entity->size.x, + p_entity->size.y, + }; + Canvas_Theme_Draw_Shadow(p_theme, bounds, 0.04f, 14, zoom, 1.0f); + DrawRectangleRounded(bounds, 0.04f, 14, p_theme->surface); + DrawRectangleRoundedLinesEx( + bounds, + 0.04f, + 14, + 1.0f / zoom, + p_theme->border); + DrawTextEx( + font, + "Lucide icons", + (Vector2){bounds.x + 24.0f, bounds.y + 22.0f}, + 26.0f, + 0.0f, + p_theme->text); + DrawTextEx( + font, + TextFormat( + "%d of %d icons - Raylib vectors", + Canvas_Lucide_Count_Matches(p_entity->text), + CANVAS_LUCIDE_ICON_COUNT), + (Vector2){bounds.x + 24.0f, bounds.y + 56.0f}, + 13.0f, + 0.0f, + p_theme->text_muted); + Rectangle search = Canvas_Lucide_Search_Bounds(p_entity); + DrawRectangleRounded( + search, + 0.24f, + 10, + p_entity->active ? p_theme->surface : p_theme->surface_muted); + DrawRectangleRoundedLinesEx( + search, + 0.24f, + 10, + (p_entity->active ? 2.0f : 1.0f) / zoom, + p_entity->active ? p_theme->accent : p_theme->border); + const char *p_search_text = + p_entity->text[0] ? p_entity->text : "Search icons"; + DrawTextEx( + font, + p_search_text, + (Vector2){search.x + 16.0f, search.y + 13.0f}, + 14.0f, + 0.0f, + p_entity->text[0] ? p_theme->text : p_theme->text_muted); + if (p_entity->active) { + Vector2 query_size = MeasureTextEx( + font, + p_entity->text, + 14.0f, + 0.0f); + float caret_x = search.x + 16.0f + query_size.x + 1.0f; + DrawLineEx( + (Vector2){caret_x, search.y + 11.0f}, + (Vector2){caret_x, search.y + 31.0f}, + 1.0f / zoom, + p_theme->accent); + } + DrawLineEx( + (Vector2){bounds.x, bounds.y + CANVAS_LUCIDE_HEADER_HEIGHT}, + (Vector2){ + bounds.x + bounds.width, + bounds.y + CANVAS_LUCIDE_HEADER_HEIGHT, + }, + 1.0f / zoom, + p_theme->border); + + float content_top = bounds.y + CANVAS_LUCIDE_HEADER_HEIGHT; + float content_bottom = bounds.y + bounds.height - CANVAS_LUCIDE_PADDING; + int32 visible_index = 0; + for (int32 icon_index = 0; + icon_index < CANVAS_LUCIDE_ICON_COUNT; + icon_index++) { + const Canvas_Lucide_Icon *p_icon = + &CANVAS_LUCIDE_ICONS[icon_index]; + if (!Canvas_Lucide_Name_Matches( + p_icon->p_name, + p_entity->text)) { + continue; + } + int32 row = visible_index / CANVAS_LUCIDE_COLUMNS; + int32 column = visible_index % CANVAS_LUCIDE_COLUMNS; + visible_index++; + float card_x = + bounds.x + CANVAS_LUCIDE_PADDING + + (float)column * + (CANVAS_LUCIDE_CARD_WIDTH + CANVAS_LUCIDE_GAP); + float card_y = + content_top + CANVAS_LUCIDE_PADDING + + (float)row * + (CANVAS_LUCIDE_CARD_HEIGHT + CANVAS_LUCIDE_GAP) - + (float)p_entity->value; + if (card_y < content_top || + card_y + CANVAS_LUCIDE_CARD_HEIGHT > content_bottom) { + continue; + } + Rectangle card = { + card_x, + card_y, + CANVAS_LUCIDE_CARD_WIDTH, + CANVAS_LUCIDE_CARD_HEIGHT, + }; + DrawRectangleRounded(card, 0.10f, 8, p_theme->surface_muted); + DrawRectangleRoundedLinesEx( + card, + 0.10f, + 8, + 1.0f / zoom, + p_theme->border); + + float icon_scale = 2.0f; + Vector2 icon_origin = { + card.x + (card.width - 24.0f * icon_scale) * 0.5f, + card.y + 13.0f, + }; + Canvas_Lucide_Draw_Icon( + p_icon, + icon_origin, + icon_scale, + 1.8f, + p_theme->text); + Vector2 label_size = MeasureTextEx( + font, + p_icon->p_name, + 11.0f, + 0.0f); + float label_x = card.x + (card.width - label_size.x) * 0.5f; + if (label_x < card.x + 5.0f) label_x = card.x + 5.0f; + DrawTextEx( + font, + p_icon->p_name, + (Vector2){label_x, card.y + 73.0f}, + 11.0f, + 0.0f, + p_theme->text_muted); + } + + float max_scroll = Canvas_Lucide_Max_Scroll(p_entity); + if (max_scroll > 0.0f) { + float track_height = bounds.height - CANVAS_LUCIDE_HEADER_HEIGHT - 24.0f; + float thumb_height = fmaxf( + 36.0f, + track_height * bounds.height / + Canvas_Lucide_Content_Height(p_entity->text)); + float thumb_y = + content_top + 12.0f + + ((float)p_entity->value / max_scroll) * + (track_height - thumb_height); + DrawRectangleRounded( + (Rectangle){ + bounds.x + bounds.width - 10.0f, + thumb_y, + 5.0f, + thumb_height, + }, + 0.50f, + 6, + Canvas_Color_Fade(p_theme->text_muted, 0.72f)); + } + break; + } default: break; } if (p_entity->pinned) { - float radius = 7.0f / zoom; - Vector2 center = { - p_entity->position.x + p_entity->size.x - 10.0f / zoom, - p_entity->position.y + 10.0f / zoom, + const Canvas_Lucide_Icon *p_pin = Canvas_Lucide_Find_Icon("pin"); + float scale = 0.72f / zoom; + Vector2 origin = { + p_entity->position.x + p_entity->size.x - 21.0f / zoom, + p_entity->position.y + 3.0f / zoom, }; - DrawCircleV(center, radius, p_theme->accent); - DrawLineEx( - (Vector2){center.x, center.y - 3.5f / zoom}, - (Vector2){center.x, center.y + 4.0f / zoom}, - 1.5f / zoom, - WHITE); + Canvas_Lucide_Draw_Icon( + p_pin, + origin, + scale, + 1.8f / zoom, + p_theme->pin); } if (selected && p_entity->type != CANVAS_ENTITY_NOTIFICATION) { - Canvas_Draw_Selection(p_entity, zoom); + Canvas_Draw_Selection(p_entity, zoom, p_theme->selection); } } @@ -1986,6 +3704,11 @@ float zoom, const Canvas_Theme *p_theme) { + float opacity = Canvas_Entity_Visibility(p_entity); + if (opacity <= 0.001f) return; + Canvas_Theme faded_theme = Canvas_Theme_With_Opacity(p_theme, opacity); + p_theme = &faded_theme; + static const char *options[] = { "Design", "Engineering", @@ -2038,14 +3761,9 @@ } } -void Canvas_Draw_World( - const Canvas_Camera *p_camera, - const Canvas_Scene *p_scene, - Font font, - const Canvas_Theme *p_theme) +static Camera2D Canvas_Raylib_Camera(const Canvas_Camera *p_camera) { - Vector2 world_pointer = Canvas_Camera_Screen_To_World(p_camera, GetMousePosition()); - Camera2D camera = { + return (Camera2D){ .offset = { (float)p_camera->viewport_width * 0.5f, (float)p_camera->viewport_height * 0.5f, @@ -2054,18 +3772,229 @@ .rotation = 0.0f, .zoom = p_camera->zoom, }; +} - BeginMode2D(camera); +void Canvas_Draw_World_Background( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + const Canvas_Theme *p_theme) +{ + BeginMode2D(Canvas_Raylib_Camera(p_camera)); if (p_scene->show_grid) Canvas_Draw_Grid(p_camera, p_theme); - for (size_t index = 0; index < Dowa_Array_Length(p_scene->p_entities); index++) { - Canvas_Draw_Entity( - &p_scene->p_entities[index], - font, - p_camera->zoom, - (int32)index == p_scene->selected_index, - (int32)index == p_scene->pressed_index && IsMouseButtonDown(MOUSE_BUTTON_LEFT), - p_theme); + EndMode2D(); +} + +void Canvas_Draw_World_Entity( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + size_t entity_index, + Font font, + const Canvas_Theme *p_theme) +{ + if (entity_index >= Dowa_Array_Length(p_scene->p_entities)) return; + BeginMode2D(Canvas_Raylib_Camera(p_camera)); + Canvas_Draw_Entity( + &p_scene->p_entities[entity_index], + font, + p_camera->zoom, + (int32)entity_index == p_scene->selected_index, + (int32)entity_index == p_scene->pressed_index && + IsMouseButtonDown(MOUSE_BUTTON_LEFT), + p_theme); + EndMode2D(); +} + +void Canvas_Draw_Web_Chrome_Entity( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + size_t entity_index, + Font font, + const Canvas_Theme *p_theme, + const char *p_url, + boolean can_go_back, + boolean can_go_forward) +{ + if (entity_index >= Dowa_Array_Length(p_scene->p_entities)) return; + const Canvas_Entity *p_entity = &p_scene->p_entities[entity_index]; + if (p_entity->type != CANVAS_ENTITY_WEB_CONTENT || + p_entity->web_chrome_amount <= 0.01f) { + return; + } + + float zoom = p_camera->zoom; + float opacity = Canvas_Entity_Visibility(p_entity); + Canvas_Theme faded_theme = Canvas_Theme_With_Opacity(p_theme, opacity); + p_theme = &faded_theme; + Rectangle toolbar = Canvas_Web_Toolbar_Bounds(p_camera, p_entity); + Rectangle back = Canvas_Web_Toolbar_Button_Bounds(toolbar, zoom, 0); + Rectangle forward = Canvas_Web_Toolbar_Button_Bounds(toolbar, zoom, 1); + Rectangle address = Canvas_Web_Address_Bounds(toolbar, zoom); + Vector2 pointer = Canvas_Camera_Screen_To_World( + p_camera, + GetMousePosition()); + + BeginMode2D(Canvas_Raylib_Camera(p_camera)); + Vector2 toolbar_screen = Canvas_Camera_World_To_Screen( + p_camera, + (Vector2){toolbar.x, toolbar.y}); + BeginScissorMode( + (int32)toolbar_screen.x, + (int32)toolbar_screen.y, + (int32)(toolbar.width * zoom), + (int32)fmaxf(1.0f, toolbar.height * zoom)); + DrawRectangleRounded(toolbar, 0.18f, 10, p_theme->surface); + DrawRectangleRoundedLinesEx( + toolbar, + 0.18f, + 10, + 1.0f / zoom, + p_theme->border); + + Rectangle buttons[] = {back, forward}; + boolean enabled[] = {can_go_back, can_go_forward}; + const char *icon_names[] = {"arrow-left", "arrow-right"}; + for (int32 index = 0; index < 2; index++) { + boolean hovered = + CheckCollisionPointRec(pointer, buttons[index]) && enabled[index]; + if (hovered) { + DrawRectangleRounded( + buttons[index], + 0.28f, + 8, + p_theme->accent_soft); + } + Canvas_Lucide_Draw_Icon( + Canvas_Lucide_Find_Icon(icon_names[index]), + (Vector2){ + buttons[index].x + 5.5f / zoom, + buttons[index].y + 5.5f / zoom, + }, + 0.71f / zoom, + 1.8f / zoom, + enabled[index] ? p_theme->text : p_theme->text_muted); } + + DrawRectangleRounded(address, 0.32f, 8, p_theme->surface_muted); + DrawRectangleRoundedLinesEx( + address, + 0.32f, + 8, + 1.0f / zoom, + p_theme->border); + boolean editing = + p_scene->editing_web_url && + p_scene->web_url_entity_id == p_entity->id; + const char *p_display_url = editing ? + p_scene->web_url_draft : + p_url; + char visible_url[CANVAS_ENTITY_TEXT_CAPACITY] = {0}; + snprintf( + visible_url, + sizeof(visible_url), + "%s", + p_display_url && p_display_url[0] ? p_display_url : "about:blank"); + float font_size = 13.0f / zoom; + float max_width = address.width - 18.0f / zoom; + size_t url_length = strlen(visible_url); + while (!editing && url_length > 3 && + MeasureTextEx(font, visible_url, font_size, 0.0f).x > max_width) { + url_length--; + visible_url[url_length] = '\0'; + } + if (!editing && + p_display_url && + strlen(p_display_url) > url_length && + url_length > 3) { + visible_url[url_length - 3] = '.'; + visible_url[url_length - 2] = '.'; + visible_url[url_length - 1] = '.'; + } + Vector2 address_screen = Canvas_Camera_World_To_Screen( + p_camera, + (Vector2){address.x, address.y}); + float address_clip_height = fminf( + address.height, + toolbar.y + toolbar.height - address.y); + if (address_clip_height < 1.0f / zoom) { + EndScissorMode(); + EndMode2D(); + return; + } + BeginScissorMode( + (int32)address_screen.x, + (int32)address_screen.y, + (int32)(address.width * zoom), + (int32)(address_clip_height * zoom)); + float text_x = address.x + 9.0f / zoom - + (editing ? p_scene->web_url_scroll_x : 0.0f); + if (editing && + p_scene->web_url_cursor != p_scene->web_url_selection_anchor) { + int32 selection_start = 0; + int32 selection_end = 0; + Canvas_Web_URL_Selection_Bounds( + p_scene, + &selection_start, + &selection_end); + char prefix[CANVAS_ENTITY_TEXT_CAPACITY]; + char selected[CANVAS_ENTITY_TEXT_CAPACITY]; + memcpy(prefix, visible_url, (size_t)selection_start); + prefix[selection_start] = '\0'; + memcpy( + selected, + visible_url + selection_start, + (size_t)(selection_end - selection_start)); + selected[selection_end - selection_start] = '\0'; + float selection_x = + text_x + MeasureTextEx(font, prefix, font_size, 0.0f).x; + float selection_width = + MeasureTextEx(font, selected, font_size, 0.0f).x; + DrawRectangleRec( + (Rectangle){ + selection_x, + address.y + 5.0f / zoom, + selection_width, + 18.0f / zoom, + }, + p_theme->accent_soft); + } + DrawTextEx( + font, + visible_url, + (Vector2){ + text_x, + address.y + 7.0f / zoom, + }, + font_size, + 0.0f, + editing ? p_theme->text : p_theme->text_muted); + if (editing && ((int32)(GetTime() * 2.0) % 2) == 0) { + char prefix[CANVAS_ENTITY_TEXT_CAPACITY]; + memcpy( + prefix, + visible_url, + (size_t)p_scene->web_url_cursor); + prefix[p_scene->web_url_cursor] = '\0'; + float caret_x = + text_x + MeasureTextEx(font, prefix, font_size, 0.0f).x; + DrawLineEx( + (Vector2){caret_x, address.y + 6.0f / zoom}, + (Vector2){caret_x, address.y + 22.0f / zoom}, + 1.5f / zoom, + p_theme->accent); + } + EndScissorMode(); + EndMode2D(); +} + +void Canvas_Draw_World_Overlays( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + Font font, + const Canvas_Theme *p_theme) +{ + Vector2 world_pointer = + Canvas_Camera_Screen_To_World(p_camera, GetMousePosition()); + BeginMode2D(Canvas_Raylib_Camera(p_camera)); for (size_t index = 0; index < Dowa_Array_Length(p_scene->p_entities); index++) { const Canvas_Entity *p_entity = &p_scene->p_entities[index]; if (p_entity->type == CANVAS_ENTITY_DROPDOWN && p_entity->active) { @@ -2080,6 +4009,24 @@ EndMode2D(); } +void Canvas_Draw_World( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + Font font, + const Canvas_Theme *p_theme) +{ + Canvas_Draw_World_Background(p_camera, p_scene, p_theme); + for (size_t index = 0; index < Dowa_Array_Length(p_scene->p_entities); index++) { + Canvas_Draw_World_Entity( + p_camera, + p_scene, + index, + font, + p_theme); + } + Canvas_Draw_World_Overlays(p_camera, p_scene, font, p_theme); +} + const char *Canvas_Entity_Type_Name(Canvas_Entity_Type type) { switch (type) { @@ -2099,6 +4046,7 @@ case CANVAS_ENTITY_SCROLL_AREA: return "Scrollable area"; case CANVAS_ENTITY_IMAGE: return "Image"; case CANVAS_ENTITY_WEB_CONTENT: return "Web content"; + case CANVAS_ENTITY_LUCIDE_GALLERY: return "Lucide gallery"; default: return "Unknown"; } } diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/canvas.h --- a/infinite_canvas/canvas.h Mon Aug 17 17:01:40 2026 -0700 +++ b/infinite_canvas/canvas.h Mon Aug 17 22:16:14 2026 -0700 @@ -7,10 +7,12 @@ #define CANVAS_MAX_ENTITIES 1024 #define CANVAS_WEB_FRAME_INSET 8.0f +#define CANVAS_WEB_TOOLBAR_HEIGHT 42.0f #define CANVAS_WEB_RESIZE_HANDLE 18.0f #define CANVAS_WEB_MIN_WIDTH 160.0f #define CANVAS_WEB_MIN_HEIGHT 120.0f #define CANVAS_CONTEXT_MAX_LENGTH (64 * 1024) +#define CANVAS_ENTITY_TEXT_CAPACITY 2048 typedef enum { CANVAS_ENTITY_RECTANGLE = 0, @@ -29,6 +31,7 @@ CANVAS_ENTITY_SCROLL_AREA, CANVAS_ENTITY_IMAGE, CANVAS_ENTITY_WEB_CONTENT, + CANVAS_ENTITY_LUCIDE_GALLERY, CANVAS_ENTITY_TYPE_COUNT, } Canvas_Entity_Type; @@ -38,13 +41,19 @@ Vector2 position; Vector2 size; Color color; - char text[128]; + char text[CANVAS_ENTITY_TEXT_CAPACITY]; int32 value; + int32 text_cursor; + int32 text_selection_anchor; + float text_scroll_y; + float visibility_amount; boolean active; boolean pinned; + boolean removing; boolean screen_space_initialized; float hover_amount; float animation_amount; + float web_chrome_amount; Vector2 pinned_screen_position; Vector2 pinned_screen_size; } Canvas_Entity; @@ -68,8 +77,15 @@ int32 hover_index; boolean dragging; boolean resizing; + boolean selecting_text; + boolean editing_web_url; boolean show_grid; float notification_stack_amount; + uint32 web_url_entity_id; + int32 web_url_cursor; + int32 web_url_selection_anchor; + float web_url_scroll_x; + char web_url_draft[CANVAS_ENTITY_TEXT_CAPACITY]; Vector2 pointer_start; Vector2 entity_start; Vector2 entity_size_start; @@ -81,6 +97,14 @@ boolean truncated; } Canvas_Context_Snapshot; +typedef enum { + CANVAS_WEB_CHROME_NONE = 0, + CANVAS_WEB_CHROME_CAPTURE, + CANVAS_WEB_CHROME_BACK, + CANVAS_WEB_CHROME_FORWARD, + CANVAS_WEB_CHROME_NAVIGATE, +} Canvas_Web_Chrome_Action; + void Canvas_Camera_Init(Canvas_Camera *p_camera, int32 width, int32 height); void Canvas_Camera_Set_Viewport(Canvas_Camera *p_camera, int32 width, int32 height); Vector2 Canvas_Camera_Screen_To_World(const Canvas_Camera *p_camera, Vector2 screen); @@ -91,20 +115,44 @@ Vector2 screen_anchor, float zoom_multiplier); Rectangle Canvas_Camera_Viewport_Bounds(const Canvas_Camera *p_camera); +void Canvas_Camera_Fit_Bounds( + Canvas_Camera *p_camera, + Rectangle bounds, + float padding_left, + float padding_top, + float padding_right, + float padding_bottom); float Canvas_Grid_Spacing(float zoom); +int32 Canvas_Lucide_Count_Matches(const char *p_query); +Canvas_Web_Chrome_Action Canvas_Web_Chrome_Update_Input( + const Canvas_Camera *p_camera, + Canvas_Scene *p_scene, + Font font, + const char *p_current_url); +boolean Canvas_Web_Chrome_Is_Editing(const Canvas_Scene *p_scene); void Canvas_Scene_Init(Canvas_Scene *p_scene, Dowa_Arena *p_arena); boolean Canvas_Scene_Add(Canvas_Scene *p_scene, Canvas_Entity_Type type, Vector2 position); void Canvas_Scene_Clear(Canvas_Scene *p_scene); +void Canvas_Scene_Fade_Out_All(Canvas_Scene *p_scene); +void Canvas_Scene_Fade_Out_Selected(Canvas_Scene *p_scene); void Canvas_Scene_Add_Demo(Canvas_Scene *p_scene); void Canvas_Scene_Add_Browser_Grid(Canvas_Scene *p_scene); +boolean Canvas_Scene_Show_Lucide_Gallery( + Canvas_Scene *p_scene, + Canvas_Camera *p_camera); int32 Canvas_Scene_Pick( const Canvas_Scene *p_scene, Vector2 world_pointer, float zoom); +int32 Canvas_Scene_Topmost_Visual( + const Canvas_Scene *p_scene, + Vector2 world_pointer, + float zoom); boolean Canvas_Scene_Update_Interaction( Canvas_Scene *p_scene, const Canvas_Camera *p_camera, + Font font, boolean block_pointer_input); boolean Canvas_Scene_Is_Text_Editing(const Canvas_Scene *p_scene); void Canvas_Scene_Toggle_Selected_Pin( @@ -131,6 +179,30 @@ const Canvas_Scene *p_scene, Font font, const Canvas_Theme *p_theme); +void Canvas_Draw_World_Background( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + const Canvas_Theme *p_theme); +void Canvas_Draw_World_Entity( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + size_t entity_index, + Font font, + const Canvas_Theme *p_theme); +void Canvas_Draw_Web_Chrome_Entity( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + size_t entity_index, + Font font, + const Canvas_Theme *p_theme, + const char *p_url, + boolean can_go_back, + boolean can_go_forward); +void Canvas_Draw_World_Overlays( + const Canvas_Camera *p_camera, + const Canvas_Scene *p_scene, + Font font, + const Canvas_Theme *p_theme); const char *Canvas_Entity_Type_Name(Canvas_Entity_Type type); diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/canvas_test.c --- a/infinite_canvas/canvas_test.c Mon Aug 17 17:01:40 2026 -0700 +++ b/infinite_canvas/canvas_test.c Mon Aug 17 22:16:14 2026 -0700 @@ -65,6 +65,17 @@ assert(dark.resolved_mode == CANVAS_THEME_DARK); assert(dark.background.r == 15); assert(dark.toast_surface.r == 24); + assert(light.switch_track_on.g > light.switch_track_on.b); + assert(dark.switch_track_on.g > dark.switch_track_on.b); + assert(light.switch_track_on.r != light.accent.r); + assert(dark.switch_track_on.r != dark.accent.r); + assert(light.pin.r > light.pin.b); + assert(dark.pin.r > dark.pin.b); + assert(light.pin.r != light.accent.r); + assert(dark.pin.r != dark.accent.r); + assert(light.button.r != dark.button.r); + assert(light.selection.r != dark.selection.r); + assert(light.danger.r != dark.danger.r); assert(Canvas_Theme_Mode_From_String("light") == CANVAS_THEME_LIGHT); assert(Canvas_Theme_Mode_From_String("dark") == CANVAS_THEME_DARK); assert(Canvas_Theme_Mode_From_String("invalid") == CANVAS_THEME_AUTO); @@ -157,6 +168,33 @@ Dowa_Arena_Free(p_arena); } +static void Test_Overlapping_Entities_Use_Topmost_Z_Order(void) +{ + Dowa_Arena *p_arena = Dowa_Arena_Create(4 * ONE_MEGA_BYTE); + assert(p_arena); + + Canvas_Scene scene; + Canvas_Scene_Init(&scene, p_arena); + Vector2 position = {0.0f, 0.0f}; + Vector2 pointer = {40.0f, 30.0f}; + assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_BUTTON, position)); + assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_BUTTON, position)); + assert(Canvas_Scene_Pick(&scene, pointer, 1.0f) == 1); + assert(Canvas_Scene_Topmost_Visual(&scene, pointer, 1.0f) == 1); + + scene.p_entities[1].removing = TRUE; + assert(Canvas_Scene_Pick(&scene, pointer, 1.0f) == 0); + assert(Canvas_Scene_Topmost_Visual(&scene, pointer, 1.0f) == 0); + + assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_WEB_CONTENT, position)); + assert(Canvas_Scene_Topmost_Visual(&scene, pointer, 1.0f) == 2); + assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_BUTTON, position)); + assert(Canvas_Scene_Pick(&scene, pointer, 1.0f) == 3); + assert(Canvas_Scene_Topmost_Visual(&scene, pointer, 1.0f) == 3); + + Dowa_Arena_Free(p_arena); +} + static void Test_New_Primitive_Defaults(void) { Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE); @@ -206,6 +244,9 @@ Canvas_Scene_Init(&scene, p_arena); assert(!Canvas_Scene_Is_Text_Editing(&scene)); assert(Canvas_Scene_Add(&scene, CANVAS_ENTITY_TEXT_AREA, (Vector2){0.0f, 0.0f})); + assert(sizeof(scene.p_entities[0].text) == CANVAS_ENTITY_TEXT_CAPACITY); + assert(scene.p_entities[0].text_cursor == 0); + assert(scene.p_entities[0].text_selection_anchor == 0); scene.selected_index = 0; assert(!Canvas_Scene_Is_Text_Editing(&scene)); @@ -233,6 +274,37 @@ Dowa_Arena_Free(p_arena); } +static void Test_Entity_Fade_Lifecycle_Commands(void) +{ + Dowa_Arena *p_arena = Dowa_Arena_Create(4 * ONE_MEGA_BYTE); + assert(p_arena); + + Canvas_Scene scene; + Canvas_Scene_Init(&scene, p_arena); + assert(Canvas_Scene_Add( + &scene, + CANVAS_ENTITY_RECTANGLE, + (Vector2){0.0f, 0.0f})); + assert(Canvas_Scene_Add( + &scene, + CANVAS_ENTITY_TEXT, + (Vector2){200.0f, 0.0f})); + assert(Near(scene.p_entities[0].visibility_amount, 0.0f)); + assert(!scene.p_entities[0].removing); + + scene.selected_index = 0; + Canvas_Scene_Fade_Out_Selected(&scene); + assert(scene.p_entities[0].removing); + assert(scene.selected_index == -1); + assert(!scene.p_entities[1].removing); + + Canvas_Scene_Fade_Out_All(&scene); + assert(scene.p_entities[0].removing); + assert(scene.p_entities[1].removing); + + Dowa_Arena_Free(p_arena); +} + static void Test_Browser_Grid(void) { Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE); @@ -251,6 +323,53 @@ Dowa_Arena_Free(p_arena); } +static void Test_Lucide_Gallery_Reuses_Entity_And_Fits_Camera(void) +{ + Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE); + assert(p_arena); + + Canvas_Camera camera; + Canvas_Camera_Init(&camera, 1920, 1200); + Canvas_Scene scene; + Canvas_Scene_Init(&scene, p_arena); + + assert(Canvas_Scene_Show_Lucide_Gallery(&scene, &camera)); + assert(Dowa_Array_Length(scene.p_entities) == 1); + assert(scene.selected_index == 0); + Canvas_Entity *p_gallery = &scene.p_entities[0]; + assert(p_gallery->type == CANVAS_ENTITY_LUCIDE_GALLERY); + assert(strcmp(p_gallery->text, "") == 0); + assert(Near(p_gallery->size.x, 1440.0f)); + assert(Near(p_gallery->size.y, 1040.0f)); + + Vector2 screen_min = + Canvas_Camera_World_To_Screen(&camera, p_gallery->position); + Vector2 screen_max = Canvas_Camera_World_To_Screen( + &camera, + (Vector2){ + p_gallery->position.x + p_gallery->size.x, + p_gallery->position.y + p_gallery->size.y, + }); + assert(screen_min.x >= 319.0f); + assert(screen_min.y >= 55.0f); + assert(screen_max.x <= 1865.0f); + assert(screen_max.y <= 1145.0f); + + assert(Canvas_Scene_Show_Lucide_Gallery(&scene, &camera)); + assert(Dowa_Array_Length(scene.p_entities) == 1); + + Dowa_Arena_Free(p_arena); +} + +static void Test_Lucide_Search_Is_Case_Insensitive(void) +{ + assert(Canvas_Lucide_Count_Matches("") == 1767); + assert(Canvas_Lucide_Count_Matches("pin") > 0); + assert(Canvas_Lucide_Count_Matches("PIN") == + Canvas_Lucide_Count_Matches("pin")); + assert(Canvas_Lucide_Count_Matches("not-a-real-lucide-icon") == 0); +} + static void Test_Demo_Contains_Every_Entity_Type(void) { Dowa_Arena *p_arena = Dowa_Arena_Create(ONE_MEGA_BYTE); @@ -259,13 +378,17 @@ Canvas_Scene scene; Canvas_Scene_Init(&scene, p_arena); Canvas_Scene_Add_Demo(&scene); - assert(Dowa_Array_Length(scene.p_entities) == CANVAS_ENTITY_TYPE_COUNT + 2); + assert(Dowa_Array_Length(scene.p_entities) == CANVAS_ENTITY_TYPE_COUNT + 1); int32 counts[CANVAS_ENTITY_TYPE_COUNT] = {0}; for (size_t index = 0; index < Dowa_Array_Length(scene.p_entities); index++) { counts[scene.p_entities[index].type]++; } for (int32 type = 0; type < CANVAS_ENTITY_TYPE_COUNT; type++) { + if (type == CANVAS_ENTITY_LUCIDE_GALLERY) { + assert(counts[type] == 0); + continue; + } int32 expected = type == CANVAS_ENTITY_NOTIFICATION ? 3 : 1; assert(counts[type] == expected); } @@ -348,6 +471,8 @@ snprintf(scene.p_entities[0].text, sizeof(scene.p_entities[0].text), "Visible note"); scene.p_entities[0].active = TRUE; + scene.p_entities[0].text_cursor = 7; + scene.p_entities[0].text_selection_anchor = 2; scene.p_entities[1].value = 2; scene.p_entities[1].active = TRUE; scene.p_entities[2].active = TRUE; @@ -362,6 +487,8 @@ assert(!snapshot.truncated); assert(strstr(context, "Visible note")); assert(strstr(context, "editing=true")); + assert(strstr(context, "cursor=7")); + assert(strstr(context, "selection_anchor=2")); assert(!strstr(context, "Research")); assert(!strstr(context, "checked=true")); @@ -407,10 +534,14 @@ Test_Viewport_Bounds(); Test_Grid_Spacing(); Test_Entity_Picking(); + Test_Overlapping_Entities_Use_Topmost_Z_Order(); Test_New_Primitive_Defaults(); Test_Text_Editing_Focus(); Test_Entity_Ids_Remain_Unique_After_Clear(); + Test_Entity_Fade_Lifecycle_Commands(); Test_Browser_Grid(); + Test_Lucide_Gallery_Reuses_Entity_And_Fits_Camera(); + Test_Lucide_Search_Is_Case_Insensitive(); Test_Demo_Contains_Every_Entity_Type(); Test_Pinned_Entity_Stays_In_Screen_Space(); Test_Visible_Context_Tracks_Camera_And_Values(); diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/dev_ui.c --- a/infinite_canvas/dev_ui.c Mon Aug 17 17:01:40 2026 -0700 +++ b/infinite_canvas/dev_ui.c Mon Aug 17 22:16:14 2026 -0700 @@ -5,7 +5,7 @@ #define RAYGUI_IMPLEMENTATION #include "third_party/raylib/include/raygui.h" -static const Rectangle DEV_PANEL = {18.0f, 18.0f, 264.0f, 436.0f}; +static const Rectangle DEV_PANEL = {18.0f, 18.0f, 264.0f, 478.0f}; static Rectangle Canvas_Dev_UI_Context_Bounds(void) { @@ -114,7 +114,7 @@ (Vector2){panel.x + 20.0f, panel.y + 48.0f}, 13.0f, 0.0f, - p_snapshot->truncated ? (Color){220, 38, 38, 255} : p_theme->text_muted); + p_snapshot->truncated ? p_theme->danger : p_theme->text_muted); Rectangle close = { panel.x + panel.width - 76.0f, @@ -214,6 +214,10 @@ boolean Canvas_Dev_UI_Contains_Pointer(const Canvas_Dev_UI *p_ui) { + // The expanded dropdown is modal: it draws outside DEV_PANEL and also + // consumes outside clicks to close itself. + if (p_ui->dropdown_open) return TRUE; + Vector2 pointer = GetMousePosition(); if (CheckCollisionPointRec(pointer, DEV_PANEL)) return TRUE; if (p_ui->context_open && @@ -253,6 +257,8 @@ 0.1f, p_theme->text_muted); + if (p_ui->dropdown_open) GuiLock(); + if (GuiButtonRounded((Rectangle){34.0f, 126.0f, 112.0f, 34.0f}, "Add entity", 0.32f, 10)) { Canvas_Scene_Add( p_scene, @@ -260,7 +266,7 @@ p_camera->target); } if (GuiButtonRounded((Rectangle){154.0f, 126.0f, 112.0f, 34.0f}, "Clear", 0.32f, 10)) { - Canvas_Scene_Clear(p_scene); + Canvas_Scene_Fade_Out_All(p_scene); } if (GuiButtonRounded((Rectangle){34.0f, 168.0f, 72.0f, 34.0f}, "Demo", 0.32f, 10)) { Canvas_Scene_Add_Demo(p_scene); @@ -308,19 +314,27 @@ p_ui->context_scroll = 0.0f; } - DrawTextEx(font, TextFormat("%d FPS", GetFPS()), (Vector2){34.0f, 354.0f}, 12.0f, 0.0f, p_theme->text_muted); - DrawTextEx(font, TextFormat("%.2fx zoom", p_camera->zoom), (Vector2){104.0f, 354.0f}, 12.0f, 0.0f, p_theme->text_muted); + if (GuiButtonRounded( + (Rectangle){34.0f, 336.0f, 232.0f, 34.0f}, + "Lucide icon gallery", + 0.32f, + 10)) { + Canvas_Scene_Show_Lucide_Gallery(p_scene, p_camera); + } + + DrawTextEx(font, TextFormat("%d FPS", GetFPS()), (Vector2){34.0f, 396.0f}, 12.0f, 0.0f, p_theme->text_muted); + DrawTextEx(font, TextFormat("%.2fx zoom", p_camera->zoom), (Vector2){104.0f, 396.0f}, 12.0f, 0.0f, p_theme->text_muted); DrawTextEx( font, TextFormat("%d objects", (int)Dowa_Array_Length(p_scene->p_entities)), - (Vector2){196.0f, 354.0f}, + (Vector2){196.0f, 396.0f}, 12.0f, 0.0f, p_theme->text_muted); DrawTextEx( font, "Drag objects directly on the canvas", - (Vector2){34.0f, 400.0f}, + (Vector2){34.0f, 442.0f}, 12.0f, 0.0f, p_theme->text_muted); @@ -334,6 +348,7 @@ } // Draw last so the expanded list overlays the other developer controls. + if (p_ui->dropdown_open) GuiUnlock(); Rectangle dropdown = {34.0f, 84.0f, 232.0f, 34.0f}; if (GuiDropdownBoxRounded( dropdown, diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/docs/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/infinite_canvas/docs/README.md Mon Aug 17 22:16:14 2026 -0700 @@ -0,0 +1,46 @@ +# Infinite Canvas Agent Wiki + +Use this tree before changing rendering, entities, pinning, or context: + +```text +Infinite Canvas +├── Rendering surfaces +│ ├── Raylib world primitives and UI components +│ ├── Raylib-generated Lucide vectors +│ ├── Native CEF image/web textures +│ └── WebAssembly DOM image/iframe overlays +├── Entity model and extension path +│ ├── defaults and retained state +│ ├── picking, interaction, and drawing +│ └── semantic context serialization +├── Screen-space behavior +│ ├── camera transforms +│ ├── pinned entities +│ └── notification stacking +└── Session lineage + ├── original renderer + └── scalability, images, pinning, context, and icons +``` + +- [Rendering surfaces](rendering.md) +- [Entities and components](entities-and-components.md) +- [Pinning and visible context](pinning-and-context.md) +- [Session lineage](session-lineage.md) + +## Runtime frame order + +`main.c` owns the integration order: + +1. Update viewport dimensions. +2. Let native web surfaces translate focused input. +3. Update scene interaction and keyboard focus. +4. Apply camera pan/zoom. +5. Reproject pinned entities and notification stacks. +6. Reconcile web/image resources with visible entities. +7. Draw the Raylib world. +8. Composite native browser textures or position web overlays. +9. Draw the developer UI. + +Preserve this order. In particular, pinned positions must be synchronized before +surface bounds are calculated, and native surface textures must draw after the +Raylib entity frames. diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/docs/entities-and-components.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/infinite_canvas/docs/entities-and-components.md Mon Aug 17 22:16:14 2026 -0700 @@ -0,0 +1,65 @@ +# Entities and Components + +## Retained data, immediate drawing + +`Canvas_Scene` stores an arena-backed array of `Canvas_Entity`. Each entity has: + +- a stable `id` for external resources such as browser views; +- `type`, world `position`, and world `size`; +- generic semantic fields: `text`, `value`, and `active`; +- interaction/animation state; +- pinning state in both world and screen coordinates. + +The scene is retained, but the draw list is rebuilt every frame. This keeps +interaction and context state stable while allowing normal Raylib immediate-mode +drawing. + +Each entity also owns lifecycle state. `visibility_amount` eases from zero to +one after creation. Delete and developer Clear mark entities as `removing`, ease +them back to zero, then compact the retained array. Raylib colors, native CEF +texture tints, and WebAssembly overlay opacity all consume the same eased value. + +## Adding a component + +Wire every relevant surface, not only drawing: + +1. Add the enum value in `canvas.h`. +2. Set useful size, text, and state defaults in `Canvas_Scene_Add()`. +3. Add bounds/picking behavior in `Canvas_Entity_Bounds()` and + `Canvas_Entity_Contains()`. +4. Add click behavior in `Canvas_Scene_Activate()` and specialized update logic + in `Canvas_Scene_Update_Interaction()` when needed. +5. Draw it in `Canvas_Draw_Entity()` and support selection in + `Canvas_Draw_Selection()`. +6. Return a readable label from `Canvas_Entity_Type_Name()`. +7. Serialize meaningful state in `Canvas_Context_Append_Entity_State()`. +8. Add focused tests in `canvas_test.c`. +9. If it owns external pixels or DOM, extend both web-surface backends and key + resources by stable entity ID. + +Prefer a single entity for large compound views. The Lucide gallery is one +entity with virtualized internal rows, not 1,767 independently draggable scene +objects. + +## Input ownership + +Input is resolved from most specialized to most general: + +- developer UI; +- focused native CEF content; +- hovered/pressed scene entity; +- camera. + +Open developer dropdowns are modal because Raygui draws their item list beyond +the panel rectangle and consumes outside clicks to close. While one is open, +neither native web surfaces nor canvas entities receive pointer input behind it. + +Text editing blocks WASD/arrow camera movement. Scrollable entities consume an +unmodified wheel. Ctrl/Cmd + wheel remains available to the camera for anchored +zoom; an unmodified wheel outside a scrollable entity pans vertically. + +Text areas keep cursor, selection anchor, and vertical scroll state on the +entity. Their visual-line layout is shared by drawing, hit testing, vertical +cursor movement, and caret visibility, so wrapped text and mouse selection use +the same geometry. Ctrl on Windows/Linux and Cmd on macOS drive select/copy/ +cut/paste shortcuts; Ctrl or Option performs word navigation. diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/docs/pinning-and-context.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/infinite_canvas/docs/pinning-and-context.md Mon Aug 17 22:16:14 2026 -0700 @@ -0,0 +1,46 @@ +# Pinning and Visible Context + +## Pinning + +Pressing `P` toggles the selected entity through +`Canvas_Scene_Toggle_Selected_Pin()`. + +When pinning starts, `Canvas_Entity_Capture_Pin()` records: + +- the entity's current screen-space origin; +- its current screen-space size. + +After camera input, `Canvas_Scene_Sync_Pinned()` clamps that screen rectangle to +the viewport, converts its origin back into world coordinates, and divides its +screen size by the current zoom. The entity therefore still participates in the +world renderer and picking system, but appears fixed in the camera view while +the camera pans or zooms. Pinned entities are marked with the generated Lucide +`pin` vector, drawn through the same Raylib icon path as the gallery. + +Dragging or activating a pinned entity recaptures its screen-space state. +Notifications are the exception: they use the dedicated animated stack in +`Canvas_Scene_Update_Notification_Stack()` and cannot be toggled with `P`. + +## Visible context + +`Canvas_Scene_Build_Visible_Context()` treats the camera viewport as the context +boundary: + +1. Convert the screen viewport to world bounds. +2. Intersect each entity's semantic bounds with that viewport. +3. Skip dismissed notifications. +4. Append identity, type, position, size, pin state, and type-specific state. +5. Report visible count, bytes written, and truncation. + +Zooming out expands the world-space viewport, so more entities become context. +Panning removes entities that no longer intersect it. The serializer preserves +scene order and concatenates all visible entries into one provisional text +snapshot. + +Examples of semantic state include text-area content/editing, dropdown +selection/open state, switch value, selected calendar date or table row, scroll +offset, image source, browser URL, and Lucide gallery scroll/renderer metadata. + +When adding an entity, never leave it on the generic `value=%d` fallback if an +agent would need richer meaning. Update the serializer and the viewport/context +tests together. diff -r 1d99147f520c -r 8d560f50ed4c infinite_canvas/docs/rendering.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/infinite_canvas/docs/rendering.md Mon Aug 17 22:16:14 2026 -0700 @@ -0,0 +1,84 @@ +# Rendering Surfaces + +## Choose the renderer by content + +| Content | Entity type | Native | WebAssembly | +| --- | --- | --- | --- | +| Shapes, text, controls | regular `CANVAS_ENTITY_*` | Raylib | Raylib/WASM | +| Lucide icons | `CANVAS_ENTITY_LUCIDE_GALLERY` | Raylib line segments | Raylib/WASM line segments | +| Image URL or file | `CANVAS_ENTITY_IMAGE` | CEF off-screen pixels uploaded to a Raylib texture | positioned `` overlay | +| Live web page | `CANVAS_ENTITY_WEB_CONTENT` | CEF off-screen pixels uploaded to a Raylib texture | positioned `