Mercurial
diff infinite_canvas/canvas.c @ 280:49e9e591c9bb
Add persistent dictation, prewarmed WebRTC speech input, Copilot SDK routing, animated conversation lifecycle controls, parking, and architecture coverage.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 18 Aug 2026 19:14:53 -0700 |
| parents | 8d560f50ed4c |
| children | c57149ad216e |
line wrap: on
line diff
--- a/infinite_canvas/canvas.c Mon Aug 17 22:22:36 2026 -0700 +++ b/infinite_canvas/canvas.c Tue Aug 18 19:14:53 2026 -0700 @@ -38,6 +38,10 @@ #define CANVAS_TEXT_AREA_FONT_SIZE 16.0f #define CANVAS_TEXT_AREA_SPACING 0.1f #define CANVAS_TEXT_AREA_LINE_HEIGHT 22.0f +#define CANVAS_CONVERSATION_EXPANDED_HEIGHT 300.0f +#define CANVAS_CONVERSATION_COLLAPSED_HEIGHT 58.0f +#define CANVAS_PARKING_LOT_X 2400.0f +#define CANVAS_PARKING_LOT_Y -600.0f typedef struct { int32 start; @@ -46,6 +50,13 @@ float width; } Canvas_Text_Line; +static int32 Canvas_Text_Build_Lines( + Font font, + const char *p_text, + float max_width, + Canvas_Text_Line *p_lines, + int32 line_capacity); + static float Canvas_Clamp(float value, float min_value, float max_value) { if (value < min_value) return min_value; @@ -743,6 +754,28 @@ }; } +static Rectangle Canvas_Conversation_Collapse_Bounds( + const Canvas_Entity *p_entity) +{ + return (Rectangle){ + p_entity->position.x + p_entity->size.x - 74.0f, + p_entity->position.y + 12.0f, + 28.0f, + 28.0f, + }; +} + +static Rectangle Canvas_Conversation_Park_Bounds( + const Canvas_Entity *p_entity) +{ + return (Rectangle){ + p_entity->position.x + p_entity->size.x - 40.0f, + p_entity->position.y + 12.0f, + 28.0f, + 28.0f, + }; +} + static Rectangle Canvas_Entity_Bounds(const Canvas_Entity *p_entity) { if (p_entity->type == CANVAS_ENTITY_CIRCLE) { @@ -753,6 +786,7 @@ p_entity->size.x * 2.0f, }; } + if (p_entity->type == CANVAS_ENTITY_LINE) { float end_x = p_entity->position.x + p_entity->size.x; float end_y = p_entity->position.y + p_entity->size.y; @@ -949,6 +983,16 @@ Canvas_Context_Append(p_writer, "url="); Canvas_Context_Append_Quoted(p_writer, p_entity->text); break; + case CANVAS_ENTITY_CONVERSATION: + Canvas_Context_Append(p_writer, "title="); + Canvas_Context_Append_Quoted(p_writer, p_entity->label); + Canvas_Context_Append(p_writer, " transcript="); + Canvas_Context_Append_Quoted(p_writer, p_entity->text); + Canvas_Context_Append( + p_writer, + " collapsed=%s", + p_entity->active ? "true" : "false"); + break; case CANVAS_ENTITY_LUCIDE_GALLERY: Canvas_Context_Append( p_writer, @@ -997,6 +1041,14 @@ p_entity->animation_amount <= 0.01f) { continue; } + if (p_entity->type == CANVAS_ENTITY_TEXT_AREA && + strcmp(p_entity->label, "Dictation") == 0) { + continue; + } + if (p_entity->type == CANVAS_ENTITY_CONVERSATION && + p_entity->parked) { + continue; + } if (!CheckCollisionRecs(viewport, Canvas_Entity_Bounds(p_entity))) continue; snapshot.visible_count++; @@ -1124,6 +1176,14 @@ entity.active = TRUE; snprintf(entity.text, sizeof(entity.text), "https://mrjunejune.com"); } + if (type == CANVAS_ENTITY_CONVERSATION) { + entity.size = (Vector2){ + 320.0f, + CANVAS_CONVERSATION_EXPANDED_HEIGHT, + }; + entity.animation_amount = 1.0f; + snprintf(entity.label, sizeof(entity.label), "Agent session"); + } if (type == CANVAS_ENTITY_LUCIDE_GALLERY) { entity.size = (Vector2){1440.0f, 1040.0f}; } @@ -1132,6 +1192,188 @@ return TRUE; } +uint32 Canvas_Scene_Add_Conversation( + Canvas_Scene *p_scene, + const char *p_title, + const char *p_prompt, + const char *p_response, + Vector2 position) +{ + if (!Canvas_Scene_Add( + p_scene, + CANVAS_ENTITY_CONVERSATION, + position)) { + return 0; + } + Canvas_Entity *p_entity = + &p_scene->p_entities[Dowa_Array_Length(p_scene->p_entities) - 1]; + p_entity->size = (Vector2){420.0f, 300.0f}; + snprintf( + p_entity->label, + sizeof(p_entity->label), + "%s", + p_title && p_title[0] ? p_title : "New agent session"); + snprintf( + p_entity->text, + sizeof(p_entity->text), + "You\n%s\n\nCopilot\n%s", + p_prompt, + p_response); + p_scene->selected_index = + (int32)Dowa_Array_Length(p_scene->p_entities) - 1; + return p_entity->id; +} + +boolean Canvas_Scene_Append_Conversation( + Canvas_Scene *p_scene, + uint32 entity_id, + const char *p_prompt, + const char *p_response) +{ + for (size_t index = 0; + index < Dowa_Array_Length(p_scene->p_entities); + index++) { + Canvas_Entity *p_entity = &p_scene->p_entities[index]; + if (p_entity->id != entity_id || + p_entity->type != CANVAS_ENTITY_CONVERSATION) { + continue; + } + size_t length = strlen(p_entity->text); + snprintf( + p_entity->text + length, + sizeof(p_entity->text) - length, + "\n\nYou\n%s\n\nCopilot\n%s", + p_prompt, + p_response); + p_scene->selected_index = (int32)index; + return TRUE; + } + return FALSE; +} + +static float Canvas_Conversation_Max_Scroll( + const Canvas_Entity *p_entity, + Font font) +{ + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + int32 line_count = Canvas_Text_Build_Lines( + font, + p_entity->text, + p_entity->size.x - 32.0f, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + return fmaxf( + 0.0f, + (float)line_count * 18.0f - + (CANVAS_CONVERSATION_EXPANDED_HEIGHT - 82.0f)); +} + +void Canvas_Conversation_Scroll_To_End( + Canvas_Entity *p_entity, + Font font) +{ + if (!p_entity || p_entity->type != CANVAS_ENTITY_CONVERSATION) return; + p_entity->value = (int32)Canvas_Conversation_Max_Scroll(p_entity, font); +} + +void Canvas_Conversation_Set_Collapsed( + Canvas_Entity *p_entity, + boolean collapsed) +{ + if (!p_entity || p_entity->type != CANVAS_ENTITY_CONVERSATION) return; + p_entity->active = collapsed; +} + +size_t Canvas_Scene_Parked_Conversation_Count( + const Canvas_Scene *p_scene) +{ + size_t count = 0; + 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_CONVERSATION && + p_entity->parked) { + count++; + } + } + return count; +} + +boolean Canvas_Scene_Set_Conversation_Parked( + Canvas_Scene *p_scene, + uint32 entity_id, + boolean parked) +{ + Canvas_Entity *p_entity = NULL; + for (size_t index = 0; + index < Dowa_Array_Length(p_scene->p_entities); + index++) { + if (p_scene->p_entities[index].id == entity_id && + p_scene->p_entities[index].type == CANVAS_ENTITY_CONVERSATION) { + p_entity = &p_scene->p_entities[index]; + break; + } + } + if (!p_entity || p_entity->parked == parked) return FALSE; + if (parked) { + size_t slot = Canvas_Scene_Parked_Conversation_Count(p_scene); + p_entity->parked_restore_position = p_entity->position; + p_entity->position = (Vector2){ + CANVAS_PARKING_LOT_X + (float)(slot % 3) * 460.0f, + CANVAS_PARKING_LOT_Y + (float)(slot / 3) * 340.0f, + }; + p_entity->parked = TRUE; + p_entity->pinned = FALSE; + } else { + p_entity->position = p_entity->parked_restore_position; + p_entity->parked = FALSE; + } + return TRUE; +} + +boolean Canvas_Scene_Show_Parking_Lot( + Canvas_Scene *p_scene, + Canvas_Camera *p_camera) +{ + Rectangle bounds = {0}; + boolean found = FALSE; + for (size_t index = 0; + index < Dowa_Array_Length(p_scene->p_entities); + index++) { + Canvas_Entity *p_entity = &p_scene->p_entities[index]; + if (p_entity->type != CANVAS_ENTITY_CONVERSATION || + !p_entity->parked) { + continue; + } + Rectangle entity_bounds = Canvas_Entity_Bounds(p_entity); + if (!found) { + bounds = entity_bounds; + found = TRUE; + } else { + float right = fmaxf( + bounds.x + bounds.width, + entity_bounds.x + entity_bounds.width); + float bottom = fmaxf( + bounds.y + bounds.height, + entity_bounds.y + entity_bounds.height); + bounds.x = fminf(bounds.x, entity_bounds.x); + bounds.y = fminf(bounds.y, entity_bounds.y); + bounds.width = right - bounds.x; + bounds.height = bottom - bounds.y; + } + } + if (!found) return FALSE; + Canvas_Camera_Fit_Bounds( + p_camera, + bounds, + 80.0f, + 80.0f, + 80.0f, + 80.0f); + return TRUE; +} + void Canvas_Scene_Clear(Canvas_Scene *p_scene) { Dowa_Array_Clear(p_scene->p_entities); @@ -1205,6 +1447,18 @@ Canvas_Scene_Add(p_scene, CANVAS_ENTITY_IMAGE, (Vector2){-550.0f, 220.0f}); Canvas_Scene_Add(p_scene, CANVAS_ENTITY_WEB_CONTENT, (Vector2){-150.0f, 120.0f}); + Canvas_Scene_Add(p_scene, CANVAS_ENTITY_CONVERSATION, (Vector2){610.0f, 220.0f}); + Canvas_Entity *p_conversation = + &p_scene->p_entities[Dowa_Array_Length(p_scene->p_entities) - 1]; + snprintf( + p_conversation->label, + sizeof(p_conversation->label), + "Spatial agent sessions"); + snprintf( + p_conversation->text, + sizeof(p_conversation->text), + "System\nPress M to dictate and Enter to send, or select the text " + "area and press Ctrl/Cmd+Enter to ask Copilot."); } void Canvas_Scene_Add_Browser_Grid(Canvas_Scene *p_scene) @@ -1322,7 +1576,8 @@ case CANVAS_ENTITY_SWITCH: case CANVAS_ENTITY_TABLE: case CANVAS_ENTITY_SCROLL_AREA: - case CANVAS_ENTITY_IMAGE: { + case CANVAS_ENTITY_IMAGE: + case CANVAS_ENTITY_CONVERSATION: { float height = p_entity->size.y; if (p_entity->type == CANVAS_ENTITY_DROPDOWN && p_entity->active) { height += 8.0f + 3.0f * 40.0f; @@ -1613,11 +1868,12 @@ static Rectangle Canvas_Text_Area_Content_Bounds( const Canvas_Entity *p_entity) { + float header_height = p_entity->label[0] ? 26.0f : 0.0f; return (Rectangle){ p_entity->position.x + CANVAS_TEXT_AREA_PADDING, - p_entity->position.y + CANVAS_TEXT_AREA_PADDING, + p_entity->position.y + CANVAS_TEXT_AREA_PADDING + header_height, p_entity->size.x - CANVAS_TEXT_AREA_PADDING * 2.0f, - p_entity->size.y - CANVAS_TEXT_AREA_PADDING * 2.0f, + p_entity->size.y - CANVAS_TEXT_AREA_PADDING * 2.0f - header_height, }; } @@ -1798,6 +2054,7 @@ 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); @@ -1807,6 +2064,31 @@ max_scroll); } +void Canvas_Text_Area_Set_Content( + Canvas_Entity *p_entity, + Font font, + const char *p_text, + boolean muted) +{ + if (!p_entity || p_entity->type != CANVAS_ENTITY_TEXT_AREA) return; + snprintf(p_entity->text, sizeof(p_entity->text), "%s", p_text ? p_text : ""); + p_entity->text_cursor = (int32)strlen(p_entity->text); + p_entity->text_selection_anchor = p_entity->text_cursor; + p_entity->text_muted = muted; + + 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); + p_entity->text_scroll_y = fmaxf( + 0.0f, + (float)line_count * CANVAS_TEXT_AREA_LINE_HEIGHT - content.height); +} + static void Canvas_Text_Update_Keyboard(Canvas_Entity *p_entity, Font font) { boolean shortcut = Canvas_Control_Is_Down(); @@ -2130,11 +2412,14 @@ for (size_t index = 0; index < Dowa_Array_Length(p_scene->p_entities); index++) { Canvas_Entity *p_entity = &p_scene->p_entities[index]; if (p_entity->type != CANVAS_ENTITY_ACCORDION && - p_entity->type != CANVAS_ENTITY_SWITCH) { + p_entity->type != CANVAS_ENTITY_SWITCH && + p_entity->type != CANVAS_ENTITY_CONVERSATION) { continue; } - float target = p_entity->active ? 1.0f : 0.0f; + float target = p_entity->type == CANVAS_ENTITY_CONVERSATION + ? (p_entity->active ? 0.0f : 1.0f) + : (p_entity->active ? 1.0f : 0.0f); float speed = p_entity->type == CANVAS_ENTITY_SWITCH ? 7.0f : 4.5f; float step = GetFrameTime() * speed; if (p_entity->animation_amount < target) { @@ -2149,6 +2434,21 @@ float amount = p_entity->animation_amount; float eased = amount * amount * (3.0f - 2.0f * amount); if (p_entity->type == CANVAS_ENTITY_SWITCH) continue; + if (p_entity->type == CANVAS_ENTITY_CONVERSATION) { + float height = CANVAS_CONVERSATION_COLLAPSED_HEIGHT + + (CANVAS_CONVERSATION_EXPANDED_HEIGHT - + CANVAS_CONVERSATION_COLLAPSED_HEIGHT) * eased; + if (p_entity->pinned) { + float pinned_scale = + p_entity->pinned_screen_size.x / p_entity->size.x; + p_entity->pinned_screen_size.y = height * pinned_scale; + p_entity->size.y = + p_entity->pinned_screen_size.y / p_camera->zoom; + } else { + p_entity->size.y = height; + } + continue; + } if (p_entity->pinned) { float pinned_scale = p_entity->pinned_screen_size.x / 300.0f; p_entity->pinned_screen_size.y = @@ -2217,6 +2517,27 @@ 240.0f); consumed_scroll = TRUE; } + } else if (p_hovered->type == CANVAS_ENTITY_CONVERSATION && + !control_down) { + float wheel = GetMouseWheelMove(); + if (wheel != 0.0f) { + Canvas_Text_Line lines[CANVAS_ENTITY_TEXT_CAPACITY]; + int32 line_count = Canvas_Text_Build_Lines( + font, + p_hovered->text, + p_hovered->size.x - 32.0f, + lines, + CANVAS_ENTITY_TEXT_CAPACITY); + float max_scroll = fmaxf( + 0.0f, + (float)line_count * 18.0f - + (p_hovered->size.y - 82.0f)); + p_hovered->value = (int32)Canvas_Clamp( + (float)p_hovered->value - wheel * 36.0f, + 0.0f, + max_scroll); + consumed_scroll = TRUE; + } } else if (p_hovered->type == CANVAS_ENTITY_LUCIDE_GALLERY && !control_down) { float wheel = GetMouseWheelMove(); @@ -2256,6 +2577,27 @@ if (p_scene->selected_index >= 0) { Canvas_Entity *p_entity = &p_scene->p_entities[p_scene->selected_index]; + if (p_entity->type == CANVAS_ENTITY_CONVERSATION && + CheckCollisionPointRec( + world_pointer, + Canvas_Conversation_Park_Bounds(p_entity))) { + Canvas_Scene_Set_Conversation_Parked( + p_scene, + p_entity->id, + p_entity->parked ? FALSE : TRUE); + p_scene->pressed_index = -1; + return TRUE; + } + if (p_entity->type == CANVAS_ENTITY_CONVERSATION && + CheckCollisionPointRec( + world_pointer, + Canvas_Conversation_Collapse_Bounds(p_entity))) { + Canvas_Conversation_Set_Collapsed( + p_entity, + p_entity->active ? FALSE : TRUE); + p_scene->pressed_index = -1; + return TRUE; + } if (p_entity->type == CANVAS_ENTITY_LUCIDE_GALLERY) { p_entity->active = CheckCollisionPointRec( world_pointer, @@ -2676,7 +3018,8 @@ case CANVAS_ENTITY_TABLE: case CANVAS_ENTITY_NOTIFICATION: case CANVAS_ENTITY_SCROLL_AREA: - case CANVAS_ENTITY_IMAGE: { + case CANVAS_ENTITY_IMAGE: + case CANVAS_ENTITY_CONVERSATION: { DrawRectangleRoundedLinesEx( (Rectangle){ p_entity->position.x - padding, @@ -2751,13 +3094,23 @@ { Rectangle content = Canvas_Text_Area_Content_Bounds(p_entity); if (!p_entity->text[0]) { + boolean dictation = strcmp(p_entity->label, "Dictation") == 0; DrawTextEx( font, - "Write something...", + dictation ? "Speak now..." : "Type a thought for Copilot...", (Vector2){content.x, content.y}, CANVAS_TEXT_AREA_FONT_SIZE, CANVAS_TEXT_AREA_SPACING, p_theme->text_muted); + DrawTextEx( + font, + dictation + ? "Enter to send | Backspace to clear" + : "Ctrl/Cmd+Enter to send", + (Vector2){content.x, content.y + 22.0f}, + 12.0f, + 0.0f, + Canvas_Color_Fade(p_theme->text_muted, 0.72f)); if (p_entity->active && fmod(GetTime(), 1.0) < 0.55) { DrawLineEx( (Vector2){content.x, content.y}, @@ -2840,7 +3193,7 @@ (Vector2){content.x, y}, CANVAS_TEXT_AREA_FONT_SIZE, CANVAS_TEXT_AREA_SPACING, - p_theme->text); + p_entity->text_muted ? p_theme->text_muted : p_theme->text); if (p_entity->active && line == cursor_line && @@ -3012,6 +3365,29 @@ 14, (p_entity->active ? 2.0f : 1.0f) / zoom, p_entity->active ? p_theme->accent : p_theme->border); + if (p_entity->label[0]) { + DrawTextEx( + font, + p_entity->label, + (Vector2){ + bounds.x + CANVAS_TEXT_AREA_PADDING, + bounds.y + 11.0f, + }, + 12.0f, + 0.0f, + p_theme->text_muted); + DrawLineEx( + (Vector2){ + bounds.x + bounds.width - 38.0f, + bounds.y + 17.0f, + }, + (Vector2){ + bounds.x + bounds.width - 16.0f, + bounds.y + 17.0f, + }, + 2.0f / zoom, + p_theme->border); + } Canvas_Draw_Text_Area_Content(p_entity, font, p_theme); break; } @@ -3505,6 +3881,122 @@ #endif break; } + case CANVAS_ENTITY_CONVERSATION: { + 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.06f, + 14, + zoom, + 1.0f); + DrawRectangleRounded(bounds, 0.06f, 14, p_theme->surface); + DrawRectangleRoundedLinesEx( + bounds, + 0.06f, + 14, + 1.0f / zoom, + p_theme->border); + DrawRectangleRounded( + (Rectangle){bounds.x, bounds.y, bounds.width, 54.0f}, + 0.06f, + 14, + p_theme->surface_muted); + Canvas_Lucide_Draw_Icon( + Canvas_Lucide_Find_Icon( + p_entity->agent_working ? "sparkles" : "messages-square"), + (Vector2){bounds.x + 16.0f, bounds.y + 15.0f}, + 0.82f, + 1.8f, + p_entity->agent_working ? + p_theme->accent : + p_theme->text); + DrawTextEx( + font, + p_entity->label, + (Vector2){bounds.x + 48.0f, bounds.y + 12.0f}, + 17.0f, + 0.0f, + p_theme->text); + DrawTextEx( + font, + p_entity->parked + ? "Parked session" + : (p_entity->agent_working + ? "Thinking..." + : "Agent session"), + (Vector2){bounds.x + 48.0f, bounds.y + 33.0f}, + 11.0f, + 0.0f, + p_theme->text_muted); + Rectangle collapse = + Canvas_Conversation_Collapse_Bounds(p_entity); + Rectangle park = Canvas_Conversation_Park_Bounds(p_entity); + Canvas_Lucide_Draw_Icon( + Canvas_Lucide_Find_Icon( + p_entity->active ? "chevron-down" : "chevron-up"), + (Vector2){collapse.x + 5.0f, collapse.y + 5.0f}, + 0.68f, + 1.6f, + p_theme->text_muted); + Canvas_Lucide_Draw_Icon( + Canvas_Lucide_Find_Icon( + p_entity->parked ? "archive-restore" : "archive"), + (Vector2){park.x + 5.0f, park.y + 5.0f}, + 0.68f, + 1.6f, + p_theme->text_muted); + + if (p_entity->size.y <= 82.0f) break; + Rectangle content = { + bounds.x + 16.0f, + bounds.y + 68.0f, + bounds.width - 32.0f, + bounds.height - 82.0f, + }; + 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); + for (int32 line_index = 0; + line_index < line_count; + line_index++) { + float y = content.y + (float)line_index * 18.0f - + (float)p_entity->value; + if (y + 18.0f < content.y || + y > content.y + content.height) { + continue; + } + char line_text[CANVAS_ENTITY_TEXT_CAPACITY]; + int32 length = + lines[line_index].end - lines[line_index].start; + memcpy( + line_text, + p_entity->text + lines[line_index].start, + (size_t)length); + line_text[length] = '\0'; + boolean speaker = + strcmp(line_text, "You") == 0 || + strcmp(line_text, "Copilot") == 0 || + strcmp(line_text, "System") == 0; + DrawTextEx( + font, + line_text, + (Vector2){content.x, y}, + 14.0f, + 0.0f, + speaker ? p_theme->accent : p_theme->text); + } + break; + } case CANVAS_ENTITY_LUCIDE_GALLERY: { Rectangle bounds = { p_entity->position.x, @@ -4046,6 +4538,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_CONVERSATION: return "Agent conversation"; case CANVAS_ENTITY_LUCIDE_GALLERY: return "Lucide gallery"; default: return "Unknown"; }