| //+------------------------------------------------------------------+
|
| //| AI Canvas Render.mqh |
|
| //| Copyright 2026, Allan Munene Mutiiria. |
|
| //| https://t.me/Forex_Algo_Trader |
|
| //+------------------------------------------------------------------+
|
| #property copyright "Copyright 2026, Allan Munene Mutiiria."
|
| #property link "https://t.me/Forex_Algo_Trader"
|
|
|
| //--- Include guard
|
| #ifndef AI_CANVAS_RENDER_MQH
|
| #define AI_CANVAS_RENDER_MQH
|
|
|
| //--- Include required modules
|
| #include <Canvas/Canvas.mqh>
|
| #include "AI Canvas Theme.mqh"
|
| #include "AI Canvas State.mqh"
|
| #include "AI Canvas Primitives.mqh"
|
| #include "AI Canvas Scrollbar.mqh"
|
| #include "AI Canvas Editor.mqh"
|
|
|
| //+------------------------------------------------------------------+
|
| //| Canvas Objects |
|
| //+------------------------------------------------------------------+
|
| CAiCanvasFast g_ai_canvMain; // Main dashboard canvas (direct-buffer fast subclass)
|
| CCanvas g_ai_canvSmallPopup; // Small history popup canvas
|
| CCanvas g_ai_canvBigPopup; // Big popup canvas
|
| CCanvas g_ai_canvSearchPopup; // Search popup canvas
|
| CAiCanvasFast g_ai_canvPrompt; // Prompt-pane overlay canvas (direct-buffer fast subclass)
|
| bool g_ai_canvPromptExists = false; // Prompt overlay init flag
|
|
|
| //+------------------------------------------------------------------+
|
| //| Persistent Off-screen Clip Buffers |
|
| //+------------------------------------------------------------------+
|
| CAiCanvasFast g_ai_canvChatTmp; // Chat-pane scratch canvas
|
| bool g_ai_canvChatTmpReady = false; // Chat scratch init flag
|
| int g_ai_canvChatTmpW = 0; // Chat scratch width
|
| int g_ai_canvChatTmpH = 0; // Chat scratch height
|
|
|
| CAiCanvasFast g_ai_canvSearchTmp; // Search popup scratch canvas
|
| bool g_ai_canvSearchTmpReady = false; // Search scratch init flag
|
| int g_ai_canvSearchTmpW = 0; // Search scratch width
|
| int g_ai_canvSearchTmpH = 0; // Search scratch height
|
|
|
| //--- Popup-free backdrop snapshot used to repaint overlays cheaply
|
| CAiCanvasFast g_ai_canvBackdrop;
|
| bool g_ai_canvBackdropExists = false;
|
| int g_ai_canvBackdropW = 0;
|
| int g_ai_canvBackdropH = 0;
|
|
|
| bool g_ai_canvMainExists = false; // Main canvas init flag
|
| bool g_ai_canvSmallExists = false; // Small popup init flag
|
| bool g_ai_canvBigExists = false; // Big popup init flag
|
| bool g_ai_canvSearchExists = false; // Search popup init flag
|
|
|
| //+------------------------------------------------------------------+
|
| //| Drawing Primitives and Editor Instances |
|
| //+------------------------------------------------------------------+
|
| CAiCanvasPrimitives g_ai_prim; // Drawing primitives helper
|
| CAiEditor g_ai_editor; // Main prompt editor
|
| CAiEditor g_ai_searchEditor; // Search query editor
|
|
|
| //+------------------------------------------------------------------+
|
| //| Test if prompt editor is empty or whitespace-only |
|
| //+------------------------------------------------------------------+
|
| bool Ai_PromptIsEmpty()
|
| {
|
| //--- Read editor text and check length
|
| const string txt = g_ai_editor.GetText();
|
| const int n = StringLen(txt);
|
| if(n == 0)
|
| return true;
|
| //--- Walk characters looking for non-whitespace
|
| for(int i = 0; i < n; i++)
|
| {
|
| const ushort c = StringGetCharacter(txt, i);
|
| if(c != ' ' && c != '\t' && c != '\n' && c != '\r')
|
| return false;
|
| }
|
| return true;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Scroll States |
|
| //+------------------------------------------------------------------+
|
| AiScrollState g_ai_chatScroll; // Chat pane scroll state
|
| AiScrollState g_ai_bigScroll; // Big popup scroll state
|
| AiScrollState g_ai_searchScroll; // Search popup scroll state
|
|
|
| //+------------------------------------------------------------------+
|
| //| Image Pixel Buffers |
|
| //+------------------------------------------------------------------+
|
| //--- Identify sidebar icons by id since they draw as vectors, not cached buffers
|
| #define AI_ICON_SEARCH 0
|
| #define AI_ICON_NEWCHAT 1
|
| #define AI_ICON_CLEAR 2
|
| #define AI_ICON_HISTORY 3
|
| bool g_ai_imagesLoaded = false; // Images loaded flag
|
|
|
| //+------------------------------------------------------------------+
|
| //| Hover Code Constants |
|
| //+------------------------------------------------------------------+
|
| #define AI_HOV_NONE 0 // No hover
|
| #define AI_HOV_CLOSE 1 // Close button
|
| #define AI_HOV_TOGGLE 2 // Sidebar toggle
|
| #define AI_HOV_NEW_CHAT 3 // New chat button
|
| #define AI_HOV_CLEAR 4 // Clear chat button
|
| #define AI_HOV_HISTORY 5 // History button
|
| #define AI_HOV_SEARCH 6 // Search button
|
| #define AI_HOV_CHART 7 // Chart action
|
| #define AI_HOV_SIGNAL 8 // Signal action half
|
| #define AI_HOV_SEND 9 // Send button
|
| #define AI_HOV_REGEN 10 // Regenerate button
|
| #define AI_HOV_EXPORT 11 // Export button
|
| #define AI_HOV_BIG_CLOSE 12 // Big popup close
|
| #define AI_HOV_SEARCH_CLOSE 13 // Search popup close
|
| #define AI_HOV_SEE_MORE 14 // See more link
|
| #define AI_HOV_THEME 15 // Theme toggle
|
| #define AI_HOV_DRAG 16 // Header drag area
|
| #define AI_HOV_FOOTER_DD 17 // Footer dropdown anchor
|
| #define AI_HOV_SIGNAL_DD 18 // Signal chevron half
|
| #define AI_HOV_SCROLL_FAB 19 // Scroll-to-bottom FAB
|
| #define AI_HOV_COMPOSE_REMOVE 20 // Compose chip remove (x)
|
| #define AI_HOV_COMPOSE_THUMB 21 // Compose chip thumbnail
|
| #define AI_HOV_VIEWER_CLOSE 22 // Full-image viewer close (x)
|
| #define AI_HOV_ACTION_APPROVE 23 // Approval card approve button
|
| #define AI_HOV_ACTION_REJECT 24 // Approval card reject button
|
| #define AI_HOV_SIDE_CHAT_BASE 100 // Sidebar chat row base
|
| #define AI_HOV_SIDE_DEL_BASE 200 // Sidebar delete base
|
| #define AI_HOV_SMALL_CHAT_BASE 300 // Small popup chat row base
|
| #define AI_HOV_SMALL_DEL_BASE 400 // Small popup delete base
|
| #define AI_HOV_BIG_CHAT_BASE 500 // Big popup chat row base
|
| #define AI_HOV_BIG_DEL_BASE 600 // Big popup delete base
|
| #define AI_HOV_SEARCH_CHAT_BASE 700 // Search popup chat row base
|
| #define AI_HOV_SEARCH_DEL_BASE 800 // Search popup delete base
|
| #define AI_HOV_FOOTER_DD_ITEM_BASE 900 // Footer dropdown item base
|
| #define AI_HOV_USER_EDIT_BASE 1000 // User edit pencil base
|
| #define AI_HOV_CHAT_IMAGE_BASE 1100 // Inline chat image base
|
|
|
| //--- Map each hover code to its panel region bitmask for the fast hover frame
|
| #define AI_RG_HEADER 1
|
| #define AI_RG_SIDEBAR 2
|
| #define AI_RG_FOOTER 4
|
| #define AI_RG_CHAT 8
|
| #define AI_RG_UNKNOWN 16
|
| //+------------------------------------------------------------------+
|
| //| Map a hover code to its panel region bitmask |
|
| //+------------------------------------------------------------------+
|
| int Ai_HovRegionMask(int h)
|
| {
|
| if(h == AI_HOV_NONE)
|
| return 0;
|
| if(h == AI_HOV_CLOSE || h == AI_HOV_THEME || h == AI_HOV_DRAG)
|
| return AI_RG_HEADER;
|
| if(h == AI_HOV_TOGGLE || h == AI_HOV_NEW_CHAT || h == AI_HOV_CLEAR
|
| || h == AI_HOV_HISTORY || h == AI_HOV_SEARCH)
|
| return AI_RG_SIDEBAR;
|
| if(h >= AI_HOV_SIDE_CHAT_BASE && h < AI_HOV_SIDE_DEL_BASE + 100)
|
| return AI_RG_SIDEBAR;
|
| if(h == AI_HOV_SIGNAL || h == AI_HOV_SIGNAL_DD || h == AI_HOV_SEND
|
| || h == AI_HOV_REGEN || h == AI_HOV_EXPORT)
|
| return AI_RG_FOOTER;
|
| if(h == AI_HOV_SCROLL_FAB)
|
| return AI_RG_CHAT;
|
| if(h >= AI_HOV_USER_EDIT_BASE && h < AI_HOV_USER_EDIT_BASE + 100)
|
| return AI_RG_CHAT;
|
| if(h >= AI_HOV_CHAT_IMAGE_BASE && h < AI_HOV_CHAT_IMAGE_BASE + 100)
|
| return AI_RG_CHAT;
|
| return AI_RG_UNKNOWN;
|
| }
|
|
|
| //--- Width of chevron-half zone in split signal button
|
| #define AI_SIGNAL_DD_ZONE_W 28
|
|
|
| //+------------------------------------------------------------------+
|
| //| Hover and Mouse Tracking |
|
| //+------------------------------------------------------------------+
|
| int g_ai_hover = AI_HOV_NONE; // Current hover code
|
| int g_ai_mouseLx = -1; // Last canvas-local mouse X
|
| int g_ai_mouseLy = -1; // Last canvas-local mouse Y
|
|
|
| //+------------------------------------------------------------------+
|
| //| Action Button Rectangles |
|
| //+------------------------------------------------------------------+
|
| int g_ai_regenL = 0, g_ai_regenT = 0, g_ai_regenR = 0, g_ai_regenB = 0; // Regenerate button rect
|
| int g_ai_apprL = 0, g_ai_apprT = 0, g_ai_apprR = 0, g_ai_apprB = 0; // Approval approve button rect
|
| int g_ai_rejL = 0, g_ai_rejT = 0, g_ai_rejR = 0, g_ai_rejB = 0; // Approval reject button rect
|
| int g_ai_exportL = 0, g_ai_exportT = 0, g_ai_exportR = 0, g_ai_exportB = 0; // Export button rect
|
|
|
| //+------------------------------------------------------------------+
|
| //| Scroll-to-bottom FAB Rectangle |
|
| //+------------------------------------------------------------------+
|
| int g_ai_scrollFabL = 0, g_ai_scrollFabT = 0, g_ai_scrollFabR = 0, g_ai_scrollFabB = 0; // FAB rect
|
| bool g_ai_scrollFabVisible = false; // FAB visibility flag
|
|
|
| //+------------------------------------------------------------------+
|
| //| Per-User-Message Edit Pencil State |
|
| //+------------------------------------------------------------------+
|
| int g_ai_userEditRectL[]; // Wide hover rect left bounds
|
| int g_ai_userEditRectT[]; // Wide hover rect top bounds
|
| int g_ai_userEditRectR[]; // Wide hover rect right bounds
|
| int g_ai_userEditRectB[]; // Wide hover rect bottom bounds
|
| int g_ai_userEditClickL[]; // Narrow click rect left bounds
|
| int g_ai_userEditClickT[]; // Narrow click rect top bounds
|
| int g_ai_userEditClickR[]; // Narrow click rect right bounds
|
| int g_ai_userEditClickB[]; // Narrow click rect bottom bounds
|
| string g_ai_userEditPrompt[]; // Cached prompt text per pencil
|
|
|
| bool g_ai_chatScrollPin = false; // Chat scroll auto-pin to bottom flag
|
|
|
| //+------------------------------------------------------------------+
|
| //| Compute theme-aware border color for given fill |
|
| //+------------------------------------------------------------------+
|
| color AiBorderForBg(color bg)
|
| {
|
| //--- Lighten on dark theme, darken on light theme
|
| if(g_ai_darkTheme)
|
| return AiLightenColor(bg, 0.35);
|
| return AiDarkenColor(bg, 0.78);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get current sidebar width |
|
| //+------------------------------------------------------------------+
|
| int Ai_SidebarW()
|
| {
|
| //--- Return expanded or collapsed width
|
| return g_ai_sidebarExpanded ? AI_SIDEBAR_W_EXPANDED : AI_SIDEBAR_W_COLLAPSED;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get current dashboard width |
|
| //+------------------------------------------------------------------+
|
| int Ai_DashboardW()
|
| {
|
| //--- Sum sidebar and main pane widths
|
| return Ai_SidebarW() + AI_MAIN_W;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get current dashboard height |
|
| //+------------------------------------------------------------------+
|
| int Ai_DashboardH()
|
| {
|
| //--- Sum all vertical sections
|
| return AI_HEADER_H + AI_PAD + AI_DISPLAY_H + AI_PAD + AI_PROMPT_H + AI_PAD + AI_FOOTER_H;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Load and scale all UI bitmap resources |
|
| //+------------------------------------------------------------------+
|
| bool Ai_LoadImages()
|
| {
|
| //--- Skip loading since all marks draw as vectors; kept as a no-op for callers
|
| g_ai_imagesLoaded = true;
|
| return true;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get close button rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetCloseBtnRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Compute button dimensions and anchor to top-right
|
| const int btnH = AI_HEADER_H - 7;
|
| const int btnW = (int)(btnH * 1.5);
|
| outR = Ai_DashboardW();
|
| outL = outR - btnW;
|
| outT = 0;
|
| outB = outT + btnH;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get theme toggle button rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetThemeBtnRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Anchor to left of close button
|
| const int btnH = AI_HEADER_H - 7;
|
| const int btnW = btnH;
|
| int cL, cT, cR, cB;
|
| Ai_GetCloseBtnRect(cL, cT, cR, cB);
|
| outR = cL;
|
| outL = outR - btnW;
|
| outT = 0;
|
| outB = btnH;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Compute Y position of sidebar item by index |
|
| //+------------------------------------------------------------------+
|
| int Ai_SidebarItemY(int i)
|
| {
|
| //--- Account for logo height and prior items
|
| int logoH = g_ai_sidebarExpanded ? 81 : 30;
|
| int y = AI_HEADER_H + 10 + logoH + 8;
|
| y += i * (AI_BUTTON_H + 6);
|
| return y;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get sidebar button rectangle by index |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetSidebarBtnRect(int idx, int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Compute rect using sidebar width and item Y
|
| const int sw = Ai_SidebarW();
|
| outL = 5;
|
| outR = sw - 5;
|
| outT = Ai_SidebarItemY(idx);
|
| outB = outT + AI_BUTTON_H;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get sidebar toggle button rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetToggleBtnRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Anchor to bottom of sidebar
|
| const int sw = Ai_SidebarW();
|
| outL = 5;
|
| outR = sw - 5;
|
| outB = Ai_DashboardH() - 8;
|
| outT = outB - AI_BUTTON_H;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get top of sidebar chat list |
|
| //+------------------------------------------------------------------+
|
| int Ai_SidebarChatListTop()
|
| {
|
| //--- Below history button
|
| return Ai_SidebarItemY(3) + AI_BUTTON_H + 8;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get sidebar chat row rectangle by row index |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetSidebarChatRowRect(int row, int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Compute row layout
|
| const int sw = Ai_SidebarW();
|
| const int rowH = 24;
|
| outL = 8;
|
| outR = sw - 8;
|
| outT = Ai_SidebarChatListTop() + row * (rowH + 3);
|
| outB = outT + rowH;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get main pane left X |
|
| //+------------------------------------------------------------------+
|
| int Ai_MainContentX()
|
| {
|
| //--- Return the sidebar width as the main pane left edge
|
| return Ai_SidebarW();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get chat pane top Y |
|
| //+------------------------------------------------------------------+
|
| int Ai_ChatPaneT()
|
| {
|
| //--- Return the header bottom plus padding
|
| return AI_HEADER_H + AI_PAD;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get chat pane bottom Y |
|
| //+------------------------------------------------------------------+
|
| int Ai_ChatPaneB()
|
| {
|
| //--- Return the chat pane top plus display height
|
| return Ai_ChatPaneT() + AI_DISPLAY_H;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get prompt pane top Y |
|
| //+------------------------------------------------------------------+
|
| int Ai_PromptPaneT()
|
| {
|
| //--- Return the chat pane bottom plus padding
|
| return Ai_ChatPaneB() + AI_PAD;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get prompt pane bottom Y |
|
| //+------------------------------------------------------------------+
|
| int Ai_PromptPaneB()
|
| {
|
| //--- Return the prompt pane top plus prompt height
|
| return Ai_PromptPaneT() + AI_PROMPT_H;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get footer top Y |
|
| //+------------------------------------------------------------------+
|
| int Ai_FooterT()
|
| {
|
| //--- Return the prompt pane bottom plus padding
|
| return Ai_PromptPaneB() + AI_PAD;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get footer bottom Y |
|
| //+------------------------------------------------------------------+
|
| int Ai_FooterB()
|
| {
|
| //--- Return the footer top plus footer height
|
| return Ai_FooterT() + AI_FOOTER_H;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get chat pane rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetChatPaneRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Inset main pane bounds by side padding
|
| outL = Ai_MainContentX() + AI_SIDE_PAD;
|
| outT = Ai_ChatPaneT();
|
| outR = Ai_DashboardW() - AI_SIDE_PAD;
|
| outB = Ai_ChatPaneB();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get prompt pane rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetPromptPaneRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Inset main pane bounds by side padding
|
| outL = Ai_MainContentX() + AI_SIDE_PAD;
|
| outT = Ai_PromptPaneT();
|
| outR = Ai_DashboardW() - AI_SIDE_PAD;
|
| outB = Ai_PromptPaneB();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Compute footer dropdown / signal button width |
|
| //+------------------------------------------------------------------+
|
| int Ai_GetFooterDropdownWidth()
|
| {
|
| //--- Find longest action label width
|
| int longestPx = 0;
|
| for(int i = 0; i < AI_FOOTER_DD_COUNT; i++)
|
| {
|
| int w = AiTextWidth(AI_FOOTER_DD_LABELS[i], "Arial Bold", 9);
|
| if(w > longestPx)
|
| longestPx = w;
|
| }
|
| //--- Compose action zone + chevron zone width
|
| const int actionZone = 8 + 18 + 8 + longestPx + 4;
|
| int total = actionZone + AI_SIGNAL_DD_ZONE_W;
|
| //--- Floor to minimum width
|
| if(total < 140)
|
| total = 140;
|
| return total;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get signal button rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetSignalBtnRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Compute width and vertically center in footer
|
| const int btnH = 32;
|
| outL = Ai_MainContentX() + AI_SIDE_PAD;
|
| outR = outL + Ai_GetFooterDropdownWidth();
|
| outT = Ai_FooterT() + (AI_FOOTER_H - btnH) / 2;
|
| outB = outT + btnH;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get send button rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetSendBtnRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Anchor to right edge of main pane
|
| const int btnH = 32;
|
| outR = Ai_DashboardW() - AI_SIDE_PAD;
|
| outL = outR - 44;
|
| outT = Ai_FooterT() + (AI_FOOTER_H - btnH) / 2;
|
| outB = outT + btnH;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Compute X coordinate of signal button separator |
|
| //+------------------------------------------------------------------+
|
| int Ai_SignalSeparatorX()
|
| {
|
| //--- Subtract chevron zone width from right edge
|
| int sL, sT, sR, sB;
|
| Ai_GetSignalBtnRect(sL, sT, sR, sB);
|
| return sR - AI_SIGNAL_DD_ZONE_W;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get signal button action half rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetSignalActionRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Use signal button bounds with separator as right edge
|
| int sL, sT, sR, sB;
|
| Ai_GetSignalBtnRect(sL, sT, sR, sB);
|
| outL = sL;
|
| outT = sT;
|
| outR = Ai_SignalSeparatorX();
|
| outB = sB;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get signal button chevron half rectangle |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetSignalDdRect(int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Use separator as left edge of dd half
|
| int sL, sT, sR, sB;
|
| Ai_GetSignalBtnRect(sL, sT, sR, sB);
|
| outL = Ai_SignalSeparatorX();
|
| outT = sT;
|
| outR = sR;
|
| outB = sB;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render dashboard header |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderHeader()
|
| {
|
| //--- Fill header background
|
| const int dW = Ai_DashboardW();
|
| g_ai_prim.FillRoundRectHR(g_ai_canvMain, 0, 0, dW, AI_HEADER_H, 0,
|
| ColorToARGB(g_ai_headerBg, 255));
|
| //--- Draw the MQL5 wordmark as vector text (was a stamped 104x40 bitmap)
|
| AiDrawLogoMQL5(g_ai_canvMain, AI_SIDE_PAD, (AI_HEADER_H - 40) / 2, 104, 40);
|
| //--- Stamp title text
|
| const string title = "ChatGPT AI EA";
|
| int titleY = (AI_HEADER_H - AiTextHeight("Arial Bold", AI_FONT_TITLE)) / 2;
|
| AiStampTextAA(g_ai_canvMain, AI_SIDE_PAD + 104 + 8, titleY,
|
| title, "Arial Bold", AI_FONT_TITLE, g_ai_titleText);
|
| //--- Header server-time readout removed to avoid a per-frame text stamp
|
| //--- Render theme toggle button
|
| int tL, tT, tR, tB;
|
| Ai_GetThemeBtnRect(tL, tT, tR, tB);
|
| const bool themeHov = (g_ai_hover == AI_HOV_THEME);
|
| if(themeHov)
|
| {
|
| const color hoverBg = AiDarkenColor(g_ai_headerBg, 0.85);
|
| g_ai_canvMain.FillRectangle(tL, tT, tR - 1, tB - 1, ColorToARGB(hoverBg, 255));
|
| }
|
| //--- Show a SUN in light mode and a MOON in dark so the toggle previews the switch
|
| const int themeSz = 16;
|
| const int thX = tL + (tR - tL - themeSz) / 2;
|
| const int thY = tT + (tB - tT - themeSz) / 2;
|
| const color themeBtnBg = themeHov ? AiDarkenColor(g_ai_headerBg, 0.85) : g_ai_headerBg;
|
| if(g_ai_darkTheme)
|
| AiDrawIconMoon(g_ai_canvMain, thX, thY, themeSz,
|
| ColorToARGB(g_ai_titleText, 255), ColorToARGB(themeBtnBg, 255));
|
| else
|
| AiDrawIconSun(g_ai_canvMain, thX, thY, themeSz, ColorToARGB(g_ai_titleText, 255));
|
| //--- Render close button
|
| int cL, cT, cR, cB;
|
| Ai_GetCloseBtnRect(cL, cT, cR, cB);
|
| const bool closeHov = (g_ai_hover == AI_HOV_CLOSE);
|
| color closeFg = closeHov ? g_ai_closeColorHover : g_ai_closeColor;
|
| if(closeHov)
|
| {
|
| const uint hoverArgb = ColorToARGB(g_ai_closeBgHover, 255);
|
| g_ai_canvMain.FillRectangle(cL, cT, cR - 1, cB - 1, hoverArgb);
|
| }
|
| //--- Draw the close X as a vector sized against the button
|
| const int closeSz = 20;
|
| AiDrawIconX(g_ai_canvMain, cL + (cR - cL - closeSz) / 2,
|
| cT + (cB - cT - closeSz) / 2, closeSz, ColorToARGB(closeFg, 255));
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render sidebar background and divider line |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderSidebarBg()
|
| {
|
| //--- Fill background and draw right divider
|
| const int sw = Ai_SidebarW();
|
| const int dh = Ai_DashboardH();
|
| g_ai_prim.FillRoundRectHR(g_ai_canvMain, 0, AI_HEADER_H, sw, dh - AI_HEADER_H, 0,
|
| ColorToARGB(g_ai_sidebarBg, 255));
|
| g_ai_canvMain.LineVertical(sw - 1, AI_HEADER_H, dh - 1,
|
| ColorToARGB(g_ai_border, 255));
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render sidebar logo at appropriate size |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderSidebarLogo()
|
| {
|
| //--- Pick logo by sidebar expansion state
|
| const int sw = Ai_SidebarW();
|
| //--- Draw the orb vector at native resolution for both sizes
|
| if(g_ai_sidebarExpanded)
|
| AiDrawLogoOrb(g_ai_canvMain, (sw - 81) / 2, AI_HEADER_H + 10, 81);
|
| else
|
| AiDrawLogoOrb(g_ai_canvMain, (sw - 30) / 2, AI_HEADER_H + 10, 30);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render a single sidebar button |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderSidebarBtn(int idx, color bgIdle, color bgHover, int hovId,
|
| const int iconId, const string label)
|
| {
|
| //--- Get button rect and pick fill color by hover state
|
| int bL, bT, bR, bB;
|
| Ai_GetSidebarBtnRect(idx, bL, bT, bR, bB);
|
| color bg = (g_ai_hover == hovId) ? bgHover : bgIdle;
|
| //--- Fill button and draw border
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, bL, bT, bR - bL, bB - bT, 6,
|
| ColorToARGB(bg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, bL, bT, bR - bL, bB - bT, 6, 1,
|
| ColorToARGB(AiBorderForBg(bg), 255));
|
| //--- Position icon based on expansion state
|
| const int sw = Ai_SidebarW();
|
| int iconX = g_ai_sidebarExpanded ? (bL + 8) : (bL + (bR - bL - 22) / 2);
|
| int iconY = bT + (bB - bT - 22) / 2;
|
| //--- vector icon, drawn at 22px in its brand color
|
| switch(iconId)
|
| {
|
| case AI_ICON_SEARCH:
|
| AiDrawIconSearch(g_ai_canvMain, iconX, iconY, 22, ColorToARGB(C'53,52,54', 255));
|
| break;
|
| case AI_ICON_NEWCHAT:
|
| AiDrawIconNewChat(g_ai_canvMain, iconX, iconY, 22, ColorToARGB(C'28,153,246', 255));
|
| break;
|
| case AI_ICON_CLEAR:
|
| AiDrawIconClear(g_ai_canvMain, iconX, iconY, 22, ColorToARGB(C'244,66,54', 255));
|
| break;
|
| case AI_ICON_HISTORY:
|
| AiDrawIconHistory(g_ai_canvMain, iconX, iconY, 22, ColorToARGB(C'251,36,18', 255));
|
| break;
|
| }
|
| //--- Stamp label only when expanded
|
| if(g_ai_sidebarExpanded)
|
| {
|
| int lblY = bT + (bB - bT - AiTextHeight("Arial", AI_FONT_BUTTON)) / 2;
|
| AiStampTextAA(g_ai_canvMain, iconX + 22 + 6, lblY,
|
| label, "Arial", AI_FONT_BUTTON, g_ai_titleText);
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render sidebar chat list |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderSidebarChats()
|
| {
|
| //--- Bail when sidebar is collapsed
|
| if(!g_ai_sidebarExpanded)
|
| return;
|
| //--- Cap visible rows at 7
|
| const int n = MathMin(ArraySize(g_ai_chats), 7);
|
| const int sw = Ai_SidebarW();
|
| //--- Render each chat row
|
| for(int i = 0; i < n; i++)
|
| {
|
| //--- Compute chat index (most recent first)
|
| int chatIdx = ArraySize(g_ai_chats) - 1 - i;
|
| int bL, bT, bR, bB;
|
| Ai_GetSidebarChatRowRect(i, bL, bT, bR, bB);
|
| //--- Determine hover state
|
| bool hovRow = (g_ai_hover == AI_HOV_SIDE_CHAT_BASE + i)
|
| || (g_ai_hover == AI_HOV_SIDE_DEL_BASE + i);
|
| bool hovDel = (g_ai_hover == AI_HOV_SIDE_DEL_BASE + i);
|
| color bg = hovRow ? g_ai_chatItemBgHover : g_ai_chatItemBg;
|
| //--- Fill row and draw border
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, bL, bT, bR - bL, bB - bT, 4,
|
| ColorToARGB(bg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, bL, bT, bR - bL, bB - bT, 4, 1,
|
| ColorToARGB(AiBorderForBg(bg), 255));
|
| //--- Fit title text to available width
|
| string fullText = g_ai_chats[chatIdx].title;
|
| const int sbDeleteIconW = 14;
|
| const int sbAvailW = (bR - bL) - 18 - sbDeleteIconW;
|
| string labelText = Ai_FitTextToWidth(fullText, "Arial", AI_FONT_LABEL, sbAvailW);
|
| //--- Pick text color based on active state
|
| color textCol = (g_ai_chats[chatIdx].id == g_ai_currentChatId)
|
| ? g_ai_chatItemActiveText : g_ai_titleText;
|
| //--- Stamp title text
|
| int lblY = bT + (bB - bT - AiTextHeight("Arial", AI_FONT_LABEL)) / 2;
|
| AiStampTextAA(g_ai_canvMain, bL + 6, lblY, labelText,
|
| "Arial", AI_FONT_LABEL, textCol);
|
| //--- Stamp delete glyph on hover
|
| if(hovRow)
|
| {
|
| color xCol = hovDel ? g_ai_chatItemDelHover : g_ai_subText;
|
| int xW = 14, xH = 14; // vector X footprint (was a Wingdings 2 glyph)
|
| AiDrawIconX(g_ai_canvMain, bR - 6 - xW, bT + (bB - bT - xH) / 2, 14, ColorToARGB(xCol, 255));
|
| }
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render sidebar toggle button |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderToggleBtn()
|
| {
|
| //--- Get rect and pick fill by hover state
|
| int bL, bT, bR, bB;
|
| Ai_GetToggleBtnRect(bL, bT, bR, bB);
|
| color bg = (g_ai_hover == AI_HOV_TOGGLE) ? g_ai_buttonToggleBgHover : g_ai_buttonToggleBg;
|
| //--- Fill and border button
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, bL, bT, bR - bL, bB - bT, 6,
|
| ColorToARGB(bg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, bL, bT, bR - bL, bB - bT, 6, 1,
|
| ColorToARGB(AiBorderForBg(bg), 255));
|
| //--- Draw the sidebar toggle mark as a vector sized against the button
|
| const int tgSz = 26;
|
| AiDrawIconToggle(g_ai_canvMain, bL + (bR - bL - tgSz) / 2, bT + (bB - bT - tgSz) / 2,
|
| tgSz, g_ai_sidebarExpanded, ColorToARGB(C'30,100,220', 255)); // same blue as the Get Chart Data pill
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render entire sidebar |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderSidebar()
|
| {
|
| //--- Render sidebar components in order
|
| Ai_RenderSidebarBg();
|
| Ai_RenderSidebarLogo();
|
| Ai_RenderSidebarBtn(0, g_ai_buttonSearchBg, g_ai_buttonSearchBgHover,
|
| AI_HOV_SEARCH, AI_ICON_SEARCH, "Search");
|
| Ai_RenderSidebarBtn(1, g_ai_buttonNewChatBg, g_ai_buttonNewChatBgHover,
|
| AI_HOV_NEW_CHAT, AI_ICON_NEWCHAT, "New Chat");
|
| Ai_RenderSidebarBtn(2, g_ai_buttonClearBg, g_ai_buttonClearBgHover,
|
| AI_HOV_CLEAR, AI_ICON_CLEAR, "Clear");
|
| Ai_RenderSidebarBtn(3, g_ai_buttonHistoryBg, g_ai_buttonHistoryBgHover,
|
| AI_HOV_HISTORY, AI_ICON_HISTORY, "History");
|
| Ai_RenderSidebarChats();
|
| Ai_RenderToggleBtn();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Inline chat image support: decode and cache BMP thumbnails |
|
| //+------------------------------------------------------------------+
|
| #define AI_IMG_CACHE_MAX 16
|
|
|
| struct AiImgThumb
|
| {
|
| string key; // marker base name
|
| uint px[]; // ARGB pixels, top-down (w*h)
|
| int w;
|
| int h;
|
| uint used; // last-access tick (LRU)
|
| bool valid;
|
| };
|
| AiImgThumb g_ai_imgCache[AI_IMG_CACHE_MAX];
|
|
|
| //+------------------------------------------------------------------+
|
| //| Read a little-endian int from a byte buffer |
|
| //+------------------------------------------------------------------+
|
| int Ai_LE32(const uchar &b[], int p)
|
| {
|
| return (int)b[p] | ((int)b[p+1] << 8) | ((int)b[p+2] << 16) | ((int)b[p+3] << 24);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Decode an uncompressed BMP into top-down ARGB pixels |
|
| //+------------------------------------------------------------------+
|
| bool Ai_DecodeBMP(string file, uint &px[], int &w, int &h)
|
| {
|
| int fh = FileOpen(file, FILE_READ | FILE_BIN);
|
| if(fh == INVALID_HANDLE)
|
| return false;
|
| uchar b[];
|
| int sz = (int)FileReadArray(fh, b, 0, WHOLE_ARRAY);
|
| FileClose(fh);
|
| if(sz < 54 || b[0] != 'B' || b[1] != 'M')
|
| return false;
|
| int dataOff = Ai_LE32(b, 10);
|
| int bw = Ai_LE32(b, 18);
|
| int bhRaw = Ai_LE32(b, 22);
|
| int bpp = (int)b[28] | ((int)b[29] << 8);
|
| bool topDown = (bhRaw < 0);
|
| int bh = topDown ? -bhRaw : bhRaw;
|
| if(bw <= 0 || bh <= 0 || (bpp != 24 && bpp != 32))
|
| return false;
|
| int bpx = bpp / 8;
|
| int rowSize = ((bw * bpx + 3) / 4) * 4; // rows padded to 4 bytes
|
| w = bw;
|
| h = bh;
|
| ArrayResize(px, bw * bh);
|
| for(int y = 0; y < bh; y++)
|
| {
|
| int srcRow = topDown ? y : (bh - 1 - y); // BMP is bottom-up by default
|
| int rowBase = dataOff + srcRow * rowSize;
|
| int dstBase = y * bw;
|
| for(int x = 0; x < bw; x++)
|
| {
|
| int p = rowBase + x * bpx;
|
| if(p + 2 >= sz)
|
| {
|
| px[dstBase + x] = 0xFF000000;
|
| continue;
|
| }
|
| uint bl = b[p], gr = b[p+1], re = b[p+2];
|
| px[dstBase + x] = 0xFF000000 | (re << 16) | (gr << 8) | bl;
|
| }
|
| }
|
| return true;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get or decode the thumbnail slot for a marker base |
|
| //+------------------------------------------------------------------+
|
| int Ai_GetImageThumb(string base)
|
| {
|
| //--- Already cached?
|
| for(int i = 0; i < AI_IMG_CACHE_MAX; i++)
|
| if(g_ai_imgCache[i].valid && g_ai_imgCache[i].key == base)
|
| { g_ai_imgCache[i].used = GetTickCount(); return i; }
|
| //--- Decode from disk
|
| uint px[];
|
| int w = 0, h = 0;
|
| if(!Ai_DecodeBMP(AI_IMG_DIR + base + ".bmp", px, w, h))
|
| return -1;
|
| //--- Downscale the native BMP to a light thumbnail for cheap inline caching
|
| const int cap = 640;
|
| if(w > cap || h > cap)
|
| {
|
| double s = MathMin((double)cap / (double)w, (double)cap / (double)h);
|
| int nw = (int)MathRound(w * s);
|
| int nh = (int)MathRound(h * s);
|
| if(nw < 1)
|
| nw = 1;
|
| if(nh < 1)
|
| nh = 1;
|
| uint sp[];
|
| ArrayResize(sp, nw * nh);
|
| for(int y = 0; y < nh; y++)
|
| {
|
| int sy = (int)((double)y * (double)h / (double)nh);
|
| if(sy >= h)
|
| sy = h - 1;
|
| int srow = sy * w, drow = y * nw;
|
| for(int x = 0; x < nw; x++)
|
| {
|
| int sx = (int)((double)x * (double)w / (double)nw);
|
| if(sx >= w)
|
| sx = w - 1;
|
| sp[drow + x] = px[srow + sx];
|
| }
|
| }
|
| ArrayResize(px, nw * nh);
|
| ArrayCopy(px, sp);
|
| w = nw;
|
| h = nh;
|
| }
|
| //--- Pick a slot: first invalid, else least-recently-used
|
| int slot = -1;
|
| uint oldest = UINT_MAX;
|
| for(int i = 0; i < AI_IMG_CACHE_MAX; i++)
|
| {
|
| if(!g_ai_imgCache[i].valid)
|
| {
|
| slot = i;
|
| break;
|
| }
|
| if(g_ai_imgCache[i].used < oldest)
|
| {
|
| oldest = g_ai_imgCache[i].used;
|
| slot = i;
|
| }
|
| }
|
| g_ai_imgCache[slot].key = base;
|
| ArrayResize(g_ai_imgCache[slot].px, ArraySize(px));
|
| ArrayCopy(g_ai_imgCache[slot].px, px);
|
| g_ai_imgCache[slot].w = w;
|
| g_ai_imgCache[slot].h = h;
|
| g_ai_imgCache[slot].used = GetTickCount();
|
| g_ai_imgCache[slot].valid = true;
|
| return slot;
|
| }
|
|
|
| //--- On-screen thumbnail rects for this frame, rebuilt each chat render for hit-testing
|
| string g_ai_imgClickBase[];
|
| int g_ai_imgClickL[];
|
| int g_ai_imgClickT[];
|
| int g_ai_imgClickR[];
|
| int g_ai_imgClickB[];
|
|
|
| //+------------------------------------------------------------------+
|
| //| Nearest-neighbour scale and clip blit of a cached thumbnail |
|
| //+------------------------------------------------------------------+
|
| void Ai_BlitThumb(CAiCanvasFast &canvas, int slot, int dx, int dy, int dw, int dh,
|
| int clipL, int clipT, int clipR, int clipB)
|
| {
|
| if(slot < 0 || slot >= AI_IMG_CACHE_MAX || !g_ai_imgCache[slot].valid)
|
| return;
|
| int sw = g_ai_imgCache[slot].w;
|
| int sh = g_ai_imgCache[slot].h;
|
| if(sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0)
|
| return;
|
| for(int y = 0; y < dh; y++)
|
| {
|
| int py = dy + y;
|
| if(py < clipT || py >= clipB)
|
| continue;
|
| int sy = (int)((double)y * (double)sh / (double)dh);
|
| if(sy >= sh)
|
| sy = sh - 1;
|
| int srcRow = sy * sw;
|
| for(int x = 0; x < dw; x++)
|
| {
|
| int px = dx + x;
|
| if(px < clipL || px >= clipR)
|
| continue;
|
| int sx = (int)((double)x * (double)sw / (double)dw);
|
| if(sx >= sw)
|
| sx = sw - 1;
|
| canvas.PixelSet(px, py, g_ai_imgCache[slot].px[srcRow + sx]);
|
| }
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Register a visible thumbnail on-screen rect for hit-testing |
|
| //+------------------------------------------------------------------+
|
| void Ai_RegisterImgRect(string base, int x, int y, int w, int h, int paneTop, int paneBot)
|
| {
|
| int t = MathMax(y, paneTop);
|
| int b = MathMin(y + h, paneBot);
|
| if(b <= t)
|
| return; // fully clipped away
|
| int n = ArraySize(g_ai_imgClickBase);
|
| ArrayResize(g_ai_imgClickBase, n + 1);
|
| ArrayResize(g_ai_imgClickL, n + 1);
|
| ArrayResize(g_ai_imgClickT, n + 1);
|
| ArrayResize(g_ai_imgClickR, n + 1);
|
| ArrayResize(g_ai_imgClickB, n + 1);
|
| g_ai_imgClickBase[n] = base;
|
| g_ai_imgClickL[n] = x;
|
| g_ai_imgClickT[n] = t;
|
| g_ai_imgClickR[n] = x + w;
|
| g_ai_imgClickB[n] = b;
|
| }
|
|
|
| //--- Full-image viewer state (native-resolution, pan with the real H/V pills)
|
| bool g_ai_imgFullView = false;
|
| string g_ai_imgFullBase = "";
|
| uint g_ai_fullPx[]; // decoded native pixels
|
| int g_ai_fullW = 0, g_ai_fullH = 0;
|
| AiScrollState g_ai_fullVScroll; // vertical pan (reuses the app scrollbar)
|
| AiScrollState g_ai_fullHScroll; // horizontal pan
|
| int g_ai_fullVpL = 0, g_ai_fullVpT = 0, g_ai_fullVpR = 0, g_ai_fullVpB = 0; // viewport
|
| int g_ai_fullCloseL = 0, g_ai_fullCloseT = 0, g_ai_fullCloseR = 0, g_ai_fullCloseB = 0;
|
|
|
| //+------------------------------------------------------------------+
|
| //| Open the full-image viewer for a marker base |
|
| //+------------------------------------------------------------------+
|
| void Ai_OpenImageFull(string base)
|
| {
|
| g_ai_imgFullBase = base;
|
| AiScrollInit(g_ai_fullVScroll);
|
| AiScrollInit(g_ai_fullHScroll);
|
| int w = 0, h = 0;
|
| if(Ai_DecodeBMP(AI_IMG_DIR + base + ".bmp", g_ai_fullPx, w, h))
|
| { g_ai_fullW = w; g_ai_fullH = h; }
|
| else
|
| { g_ai_fullW = 0; g_ai_fullH = 0; ArrayResize(g_ai_fullPx, 0); }
|
| g_ai_imgFullView = true;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Close the full-image viewer |
|
| //+------------------------------------------------------------------+
|
| void Ai_CloseImageFull()
|
| {
|
| g_ai_imgFullView = false;
|
| g_ai_imgFullBase = "";
|
| ArrayResize(g_ai_fullPx, 0);
|
| g_ai_fullW = 0;
|
| g_ai_fullH = 0;
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Compute the viewer viewport, scroll tracks and close button |
|
| //+------------------------------------------------------------------+
|
| void Ai_ViewerLayout()
|
| {
|
| const int dW = Ai_DashboardW();
|
| const int dH = Ai_DashboardH();
|
| const int margin = 12, headerH = 30, sbW = 4, gap = 4, cbSz = 20;
|
| //--- Close button inset in the header, top-right
|
| g_ai_fullCloseR = dW - margin;
|
| g_ai_fullCloseL = g_ai_fullCloseR - cbSz;
|
| g_ai_fullCloseT = margin;
|
| g_ai_fullCloseB = g_ai_fullCloseT + cbSz;
|
| //--- Viewport strictly below the header (never under the close button)
|
| int vpL = margin, vpT = margin + headerH, vpR = dW - margin, vpB = dH - margin;
|
| bool vOn = (g_ai_fullH > (vpB - vpT));
|
| bool hOn = (g_ai_fullW > (vpR - vpL));
|
| if(vOn)
|
| vpR -= sbW + gap;
|
| if(hOn)
|
| vpB -= sbW + gap;
|
| g_ai_fullVpL = vpL;
|
| g_ai_fullVpT = vpT;
|
| g_ai_fullVpR = vpR;
|
| g_ai_fullVpB = vpB;
|
| const int vpW = vpR - vpL, vpH = vpB - vpT;
|
| //--- Vertical pan scrollbar (right gutter)
|
| g_ai_fullVScroll.trackL = vpR + gap;
|
| g_ai_fullVScroll.trackR = vpR + gap + sbW;
|
| g_ai_fullVScroll.trackT = vpT;
|
| g_ai_fullVScroll.trackB = vpB;
|
| g_ai_fullVScroll.viewportH = vpH;
|
| g_ai_fullVScroll.totalH = g_ai_fullH;
|
| AiScrollClamp(g_ai_fullVScroll);
|
| //--- Horizontal pan scrollbar (bottom gutter). viewportH/totalH hold widths.
|
| g_ai_fullHScroll.trackL = vpL;
|
| g_ai_fullHScroll.trackR = vpR;
|
| g_ai_fullHScroll.trackT = vpB + gap;
|
| g_ai_fullHScroll.trackB = vpB + gap + sbW;
|
| g_ai_fullHScroll.viewportH = vpW;
|
| g_ai_fullHScroll.totalH = g_ai_fullW;
|
| AiScrollClamp(g_ai_fullHScroll);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Copy the visible native image region into the viewport |
|
| //+------------------------------------------------------------------+
|
| void Ai_BlitFullRegion(CAiCanvasFast &canvas, int dstL, int dstT, int dstW, int dstH)
|
| {
|
| const int sX = g_ai_fullHScroll.scrollPx;
|
| const int sY = g_ai_fullVScroll.scrollPx;
|
| for(int y = 0; y < dstH; y++)
|
| {
|
| int sy = sY + y;
|
| if(sy < 0 || sy >= g_ai_fullH)
|
| continue;
|
| int srow = sy * g_ai_fullW, dy = dstT + y;
|
| for(int x = 0; x < dstW; x++)
|
| {
|
| int sx = sX + x;
|
| if(sx < 0 || sx >= g_ai_fullW)
|
| continue;
|
| canvas.PixelSet(dstL + x, dy, g_ai_fullPx[srow + sx]);
|
| }
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Paint the viewport image and both scrollbar pills |
|
| //+------------------------------------------------------------------+
|
| void Ai_PaintViewerBody()
|
| {
|
| const uint backArgb = ColorToARGB(g_ai_panelAlt, 255);
|
| const int vpL = g_ai_fullVpL, vpT = g_ai_fullVpT, vpR = g_ai_fullVpR, vpB = g_ai_fullVpB;
|
| g_ai_canvMain.FillRectangle(vpL, vpT, vpR - 1, vpB - 1, backArgb);
|
| Ai_BlitFullRegion(g_ai_canvMain, vpL, vpT, vpR - vpL, vpB - vpT);
|
| g_ai_canvMain.Rectangle(vpL - 1, vpT - 1, vpR, vpB, ColorToARGB(g_ai_borderAccent, 255));
|
| //--- Clear the scrollbar gutters to the backdrop first to avoid alpha buildup
|
| const uint gutterArgb = ColorToARGB(g_ai_bg, 255);
|
| if(AiScrollVisible(g_ai_fullVScroll))
|
| g_ai_canvMain.FillRectangle(g_ai_fullVScroll.trackL, g_ai_fullVScroll.trackT,
|
| g_ai_fullVScroll.trackR, g_ai_fullVScroll.trackB, gutterArgb);
|
| if(AiScrollVisible(g_ai_fullHScroll))
|
| g_ai_canvMain.FillRectangle(g_ai_fullHScroll.trackL, g_ai_fullHScroll.trackT,
|
| g_ai_fullHScroll.trackR, g_ai_fullHScroll.trackB, gutterArgb);
|
| //--- Keep scrollbars visible whenever the image overflows for a position reference
|
| AiScrollDraw(g_ai_canvMain, g_ai_fullVScroll, g_ai_prim, true);
|
| AiScrollDrawH(g_ai_canvMain, g_ai_fullHScroll, g_ai_prim, true);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Draw the viewer close button with hover state |
|
| //+------------------------------------------------------------------+
|
| void Ai_DrawViewerClose()
|
| {
|
| const int l = g_ai_fullCloseL, t = g_ai_fullCloseT, r = g_ai_fullCloseR, b = g_ai_fullCloseB;
|
| //--- Clear to backdrop first (removes any stale hover fill)
|
| g_ai_canvMain.FillRectangle(l - 1, t - 1, r + 1, b + 1, ColorToARGB(g_ai_bg, 255));
|
| const bool hov = (g_ai_hover == AI_HOV_VIEWER_CLOSE);
|
| if(hov)
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, l, t, r - l, b - t, 5, ColorToARGB(g_ai_closeBgHover, 255));
|
| const int sz = 14;
|
| const color fg = hov ? g_ai_closeColorHover : g_ai_closeColor;
|
| AiDrawIconX(g_ai_canvMain, l + (r - l - sz) / 2, t + (b - t - sz) / 2, sz, ColorToARGB(fg, 255));
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Draw the full-image viewer over the whole panel |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderImageViewer()
|
| {
|
| if(!g_ai_imgFullView || !g_ai_canvMainExists)
|
| return;
|
| const int dW = Ai_DashboardW();
|
| const int dH = Ai_DashboardH();
|
| //--- Themed backdrop over the whole panel (matches light/dark like the rest)
|
| g_ai_canvMain.FillRectangle(1, 1, dW - 2, dH - 2, ColorToARGB(g_ai_bg, 255));
|
| Ai_ViewerLayout();
|
| if(g_ai_fullW > 0 && g_ai_fullH > 0 && ArraySize(g_ai_fullPx) > 0)
|
| {
|
| const string dimStr = IntegerToString(g_ai_fullW) + " x " + IntegerToString(g_ai_fullH) + " (true size)";
|
| AiStampTextAA(g_ai_canvMain, 12, 14, dimStr, "Arial", AI_FONT_TIMESTAMP, g_ai_subText);
|
| Ai_PaintViewerBody();
|
| }
|
| else
|
| {
|
| const string na = "Image not available";
|
| const int naW = AiTextWidth(na, "Arial", AI_FONT_BODY);
|
| AiStampTextAA(g_ai_canvMain, (dW - naW) / 2, dH / 2, na, "Arial", AI_FONT_BODY, g_ai_subText);
|
| }
|
| Ai_DrawViewerClose();
|
| Ai_DrawPanelBorder(dW, dH);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Fast partial repaint for viewer pan and hover |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderViewerScroll()
|
| {
|
| if(!g_ai_imgFullView || !g_ai_canvMainExists)
|
| return;
|
| Ai_ViewerLayout();
|
| Ai_PaintViewerBody();
|
| Ai_DrawViewerClose();
|
| g_ai_canvMain.Update();
|
| ChartRedraw();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render the chat conversation pane |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderChatPane()
|
| {
|
| //--- Get pane rect and dimensions
|
| int rL, rT, rR, rB;
|
| Ai_GetChatPaneRect(rL, rT, rR, rB);
|
| const int paneW = rR - rL;
|
| const int paneH = rB - rT;
|
| //--- Restore the solid dashboard background behind the pane before the rounded fill
|
| g_ai_canvMain.FillRectangle(rL, rT, rR - 1, rB - 1, ColorToARGB(g_ai_bg, 255));
|
| //--- Fill pane background and draw border
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, rL, rT, paneW, paneH, 6,
|
| ColorToARGB(g_ai_panelAlt, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, rL, rT, paneW, paneH, 6, 1,
|
| ColorToARGB(g_ai_border, 255));
|
| //--- Conversation parse cache - static across renders
|
| static string s_cacheRoles[];
|
| static string s_cacheContents[];
|
| static string s_cacheTimestamps[];
|
| static int s_cacheLen = -1;
|
| static int s_cacheCharQ1 = -1;
|
| static int s_cacheCharQ3 = -1;
|
| //--- Compute cache fingerprint from history
|
| const int curLen = StringLen(g_ai_conversationHistory);
|
| const int curQ1 = (curLen >= 4)
|
| ? (int)StringGetCharacter(g_ai_conversationHistory, curLen / 4)
|
| : -1;
|
| const int curQ3 = (curLen >= 4)
|
| ? (int)StringGetCharacter(g_ai_conversationHistory, (curLen * 3) / 4)
|
| : -1;
|
| const bool cacheHit = (s_cacheLen == curLen)
|
| && (s_cacheCharQ1 == curQ1)
|
| && (s_cacheCharQ3 == curQ3);
|
| //--- Working arrays for parsed messages
|
| string msgRoles[];
|
| string msgContents[];
|
| string msgTimestamps[];
|
| //--- Cache hit path - reuse parsed arrays
|
| if(cacheHit)
|
| {
|
| const int cn = ArraySize(s_cacheRoles);
|
| ArrayResize(msgRoles, cn);
|
| ArrayResize(msgContents, cn);
|
| ArrayResize(msgTimestamps, cn);
|
| for(int ci = 0; ci < cn; ci++)
|
| {
|
| msgRoles[ci] = s_cacheRoles[ci];
|
| msgContents[ci] = s_cacheContents[ci];
|
| msgTimestamps[ci] = s_cacheTimestamps[ci];
|
| }
|
| }
|
| //--- Cache miss path - parse and snapshot
|
| else
|
| {
|
| //--- Split history into lines
|
| string parts[];
|
| const int numParts = StringSplit(g_ai_conversationHistory, '\n', parts);
|
| //--- Initialize parser state
|
| string curRole = "";
|
| string curContent = "";
|
| string curTs = "";
|
| //--- Walk lines and group into role-tagged messages
|
| for(int p = 0; p < numParts; p++)
|
| {
|
| string line = parts[p];
|
| string trimmed = line;
|
| StringTrimLeft(trimmed);
|
| StringTrimRight(trimmed);
|
| //--- Blank lines belong to current content
|
| if(StringLen(trimmed) == 0)
|
| {
|
| if(curRole != "")
|
| curContent += "\n";
|
| continue;
|
| }
|
| //--- New user block
|
| if(StringFind(trimmed, "You: ") == 0)
|
| {
|
| if(curRole != "")
|
| {
|
| int sz = ArraySize(msgRoles);
|
| ArrayResize(msgRoles, sz + 1);
|
| ArrayResize(msgContents, sz + 1);
|
| ArrayResize(msgTimestamps, sz + 1);
|
| msgRoles[sz] = curRole;
|
| msgContents[sz] = curContent;
|
| msgTimestamps[sz] = curTs;
|
| }
|
| curRole = "User";
|
| curContent = StringSubstr(line, StringFind(line, "You: ") + 5);
|
| curTs = "";
|
| continue;
|
| }
|
| //--- New AI block
|
| if(StringFind(trimmed, "AI: ") == 0)
|
| {
|
| if(curRole != "")
|
| {
|
| int sz = ArraySize(msgRoles);
|
| ArrayResize(msgRoles, sz + 1);
|
| ArrayResize(msgContents, sz + 1);
|
| ArrayResize(msgTimestamps, sz + 1);
|
| msgRoles[sz] = curRole;
|
| msgContents[sz] = curContent;
|
| msgTimestamps[sz] = curTs;
|
| }
|
| curRole = "AI";
|
| curContent = StringSubstr(line, StringFind(line, "AI: ") + 4);
|
| curTs = "";
|
| continue;
|
| }
|
| //--- Timestamp ends current block
|
| if(AiIsTimestamp(trimmed))
|
| {
|
| curTs = trimmed;
|
| int sz = ArraySize(msgRoles);
|
| ArrayResize(msgRoles, sz + 1);
|
| ArrayResize(msgContents, sz + 1);
|
| ArrayResize(msgTimestamps, sz + 1);
|
| msgRoles[sz] = curRole;
|
| msgContents[sz] = curContent;
|
| msgTimestamps[sz] = curTs;
|
| curRole = "";
|
| curContent = "";
|
| curTs = "";
|
| continue;
|
| }
|
| //--- Continuation line
|
| if(curRole != "")
|
| curContent += "\n" + line;
|
| }
|
| //--- Flush trailing block
|
| if(curRole != "")
|
| {
|
| int sz = ArraySize(msgRoles);
|
| ArrayResize(msgRoles, sz + 1);
|
| ArrayResize(msgContents, sz + 1);
|
| ArrayResize(msgTimestamps, sz + 1);
|
| msgRoles[sz] = curRole;
|
| msgContents[sz] = curContent;
|
| msgTimestamps[sz] = curTs;
|
| }
|
| //--- Snapshot to cache
|
| const int sn = ArraySize(msgRoles);
|
| ArrayResize(s_cacheRoles, sn);
|
| ArrayResize(s_cacheContents, sn);
|
| ArrayResize(s_cacheTimestamps, sn);
|
| for(int si = 0; si < sn; si++)
|
| {
|
| s_cacheRoles[si] = msgRoles[si];
|
| s_cacheContents[si] = msgContents[si];
|
| s_cacheTimestamps[si] = msgTimestamps[si];
|
| }
|
| s_cacheLen = curLen;
|
| s_cacheCharQ1 = curQ1;
|
| s_cacheCharQ3 = curQ3;
|
| }
|
| //--- Find index of last AI message for action row placement
|
| const int numMessages = ArraySize(msgRoles);
|
| //--- Pull the inline-image marker out of each message and remember its thumbnail dims
|
| string msgImgBase[];
|
| int msgImgW[];
|
| int msgImgH[];
|
| ArrayResize(msgImgBase, numMessages);
|
| ArrayResize(msgImgW, numMessages);
|
| ArrayResize(msgImgH, numMessages);
|
| for(int mi = 0; mi < numMessages; mi++)
|
| {
|
| msgImgBase[mi] = "";
|
| msgImgW[mi] = 0;
|
| msgImgH[mi] = 0;
|
| int ip = StringFind(msgContents[mi], "[img:");
|
| if(ip >= 0)
|
| {
|
| int ep = StringFind(msgContents[mi], "]", ip);
|
| if(ep > ip)
|
| {
|
| string inner = StringSubstr(msgContents[mi], ip + 5, ep - (ip + 5));
|
| //--- inner = base:w:h
|
| string parts[];
|
| int np = StringSplit(inner, ':', parts);
|
| if(np >= 1)
|
| msgImgBase[mi] = parts[0];
|
| if(np >= 3)
|
| {
|
| msgImgW[mi] = (int)StringToInteger(parts[1]);
|
| msgImgH[mi] = (int)StringToInteger(parts[2]);
|
| }
|
| string bef = StringSubstr(msgContents[mi], 0, ip);
|
| string aft = StringSubstr(msgContents[mi], ep + 1);
|
| msgContents[mi] = bef + aft;
|
| StringTrimRight(msgContents[mi]);
|
| }
|
| }
|
| }
|
| int lastAiIdx = -1;
|
| for(int li = numMessages - 1; li >= 0; li--)
|
| {
|
| if(StringFind(msgRoles[li], "AI") < 0)
|
| continue;
|
| if(StringFind(msgContents[li], "Thinking") >= 0)
|
| continue;
|
| if(StringFind(msgContents[li], "Preparing the Request") >= 0)
|
| continue;
|
| lastAiIdx = li;
|
| break;
|
| }
|
| //--- Detect the approval card awaiting a decision as the last message
|
| int actionCardIdx = -1;
|
| if(g_ai_actionActive && g_ai_actionChatId == g_ai_currentChatId && numMessages > 0)
|
| {
|
| //--- Require an AI message that starts with the card header
|
| const int lm = numMessages - 1;
|
| if(StringFind(msgRoles[lm], "AI") >= 0
|
| && StringFind(msgContents[lm], AI_ACTION_CARD_HEADER) == 0)
|
| actionCardIdx = lm;
|
| }
|
| //--- Empty conversation - render placeholder text
|
| if(numMessages == 0)
|
| {
|
| const string empty = "Type your prompt and click Send to chat with the AI.";
|
| int eW = AiTextWidth(empty, "Arial", AI_FONT_BODY);
|
| int eH = AiTextHeight("Arial", AI_FONT_BODY);
|
| AiStampTextAA(g_ai_canvMain, rL + (paneW - eW) / 2, rT + (paneH - eH) / 2,
|
| empty, "Arial", AI_FONT_BODY, g_ai_subText);
|
| g_ai_chatScroll.totalH = 0;
|
| g_ai_chatScroll.viewportH = paneH - 2 * AI_TEXT_PAD;
|
| AiScrollClamp(g_ai_chatScroll);
|
| return;
|
| }
|
| //--- Setup content rect and bubble layout constants
|
| const int contentL = rL + AI_TEXT_PAD;
|
| const int contentT = rT + AI_TEXT_PAD;
|
| const int contentR = rR - AI_TEXT_PAD;
|
| const int contentB = rB - AI_TEXT_PAD;
|
| const int sbW = 4;
|
| const int sbRightPad = 2;
|
| const int textRight = contentR - sbW - sbRightPad - 4;
|
| const int contentW = textRight - contentL;
|
| const int bubbleMaxW = (int)((double)contentW * 0.78);
|
| const int bubblePadX = 10;
|
| const int bubblePadY = 6;
|
| const int bubbleTextW = bubbleMaxW - 2 * bubblePadX;
|
| const int lineHBody = AiTextHeight("Arial", AI_FONT_BODY) + 2;
|
| const int lineHTime = AiTextHeight("Arial", AI_FONT_TIMESTAMP) + 1;
|
| const int msgGap = 8;
|
| //--- Per-message wrapped text and bubble dimensions
|
| string msgWrapped[];
|
| int msgBubbleW[];
|
| int msgBubbleH[];
|
| string msgRespTime[];
|
| ArrayResize(msgWrapped, numMessages);
|
| ArrayResize(msgBubbleW, numMessages);
|
| ArrayResize(msgBubbleH, numMessages);
|
| ArrayResize(msgRespTime, numMessages);
|
| //--- Cache the layout since the wrap pass is a pure function of its inputs
|
| static string s_layWrapped[];
|
| static int s_layBubbleW[];
|
| static int s_layBubbleH[];
|
| static string s_layRespTime[];
|
| static int s_layLen = -1;
|
| static int s_layQ1 = -1;
|
| static int s_layQ3 = -1;
|
| static int s_layTextW = -1;
|
| const bool layoutHit = (s_layLen == curLen)
|
| && (s_layQ1 == curQ1)
|
| && (s_layQ3 == curQ3)
|
| && (s_layTextW == bubbleTextW)
|
| && (ArraySize(s_layWrapped) == numMessages);
|
| //--- Layout cache hit - restore wrapped output and skip the wrap pass
|
| if(layoutHit)
|
| {
|
| for(int lm = 0; lm < numMessages; lm++)
|
| {
|
| msgWrapped[lm] = s_layWrapped[lm];
|
| msgBubbleW[lm] = s_layBubbleW[lm];
|
| msgBubbleH[lm] = s_layBubbleH[lm];
|
| msgRespTime[lm] = s_layRespTime[lm];
|
| }
|
| }
|
| else
|
| {
|
| //--- Wrap each message and compute bubble size
|
| for(int m = 0; m < numMessages; m++)
|
| {
|
| //--- Extract response time note if present
|
| msgRespTime[m] = "";
|
| string raw = msgContents[m];
|
| int rtPos = StringFind(raw, "(Response in ");
|
| if(rtPos >= 0)
|
| {
|
| int rtEnd = StringFind(raw, ")", rtPos);
|
| if(rtEnd > rtPos)
|
| {
|
| msgRespTime[m] = StringSubstr(raw, rtPos, rtEnd - rtPos + 1);
|
| int strip = rtPos;
|
| if(strip > 0 && StringGetCharacter(raw, strip - 1) == '\n')
|
| strip--;
|
| string before = StringSubstr(raw, 0, strip);
|
| string after = StringSubstr(raw, rtEnd + 1);
|
| raw = before + after;
|
| }
|
| }
|
| //--- Skip dot-replacement for loading placeholders
|
| const bool isLoadingPlaceholder =
|
| (StringFind(raw, "Preparing the Request") >= 0
|
| || StringFind(raw, "Thinking") >= 0);
|
| string processed = isLoadingPlaceholder
|
| ? raw
|
| : AiReplaceExactDoublePeriods(raw);
|
| //--- Markdown line-kind preprocessing for AI bubbles
|
| const bool isUserMsg = (StringFind(msgRoles[m], "User") >= 0);
|
| const bool isAiMsg = !isUserMsg && !isLoadingPlaceholder;
|
| if(isAiMsg)
|
| {
|
| string mdLines[];
|
| StringSplit(processed, '\n', mdLines);
|
| string rebuilt = "";
|
| for(int li = 0; li < ArraySize(mdLines); li++)
|
| {
|
| string ln = mdLines[li];
|
| //--- Find leading whitespace count for prefix detection
|
| int wsCount = 0;
|
| const int lnLen = StringLen(ln);
|
| while(wsCount < lnLen)
|
| {
|
| ushort c = StringGetCharacter(ln, wsCount);
|
| if(c == ' ' || c == '\t')
|
| wsCount++;
|
| else
|
| break;
|
| }
|
| const string body = StringSubstr(ln, wsCount);
|
| string sentLine = ln;
|
| //--- Replace heading prefixes with sentinel chars
|
| if(StringSubstr(body, 0, 4) == "### ")
|
| {
|
| sentLine = ShortToString(0x03) + StringSubstr(body, 4);
|
| }
|
| else
|
| if(StringSubstr(body, 0, 3) == "## ")
|
| {
|
| sentLine = ShortToString(0x02) + StringSubstr(body, 3);
|
| }
|
| else
|
| if(StringSubstr(body, 0, 2) == "# ")
|
| {
|
| sentLine = ShortToString(0x01) + StringSubstr(body, 2);
|
| }
|
| if(li > 0)
|
| rebuilt += "\n";
|
| rebuilt += sentLine;
|
| }
|
| processed = rebuilt;
|
| }
|
| //--- Split into paragraphs and wrap each
|
| string para[];
|
| StringSplit(processed, '\n', para);
|
| string wrapped = "";
|
| int maxLineW = 0;
|
| int lineCount = 0;
|
| int totalLineH = 0;
|
| //--- Process each paragraph
|
| for(int ls = 0; ls < ArraySize(para); ls++)
|
| {
|
| string p = para[ls];
|
| //--- Empty paragraph emits blank line
|
| if(StringLen(p) == 0)
|
| {
|
| if(wrapped != "")
|
| wrapped += "\n";
|
| lineCount++;
|
| totalLineH += lineHBody;
|
| continue;
|
| }
|
| //--- Setup default kind and font
|
| int kind = AI_MD_KIND_BODY;
|
| string lineFont = "Arial";
|
| int lineSize = AI_FONT_BODY;
|
| int lineH = lineHBody;
|
| int lineIndent = 0;
|
| string numPrefix = "";
|
| //--- Detect heading sentinel for AI messages
|
| if(isAiMsg && StringLen(p) >= 1)
|
| {
|
| const ushort sent = StringGetCharacter(p, 0);
|
| if(sent == 0x01)
|
| {
|
| kind = AI_MD_KIND_H1;
|
| lineFont = "Arial Bold";
|
| lineSize = 13;
|
| }
|
| else
|
| if(sent == 0x02)
|
| {
|
| kind = AI_MD_KIND_H2;
|
| lineFont = "Arial Bold";
|
| lineSize = 12;
|
| }
|
| else
|
| if(sent == 0x03)
|
| {
|
| kind = AI_MD_KIND_H3;
|
| lineFont = "Arial Bold";
|
| lineSize = 11;
|
| }
|
| if(kind != AI_MD_KIND_BODY)
|
| {
|
| p = StringSubstr(p, 1);
|
| lineH = AiTextHeightCached(lineFont, lineSize) + 2;
|
| }
|
| }
|
| //--- Detect numbered list prefix
|
| if(isAiMsg && kind == AI_MD_KIND_BODY)
|
| {
|
| const int pl = StringLen(p);
|
| int dEnd = 0;
|
| while(dEnd < pl)
|
| {
|
| const ushort dc = StringGetCharacter(p, dEnd);
|
| if(dc >= '0' && dc <= '9')
|
| dEnd++;
|
| else
|
| break;
|
| }
|
| if(dEnd > 0
|
| && dEnd + 1 < pl
|
| && StringGetCharacter(p, dEnd) == '.'
|
| && StringGetCharacter(p, dEnd + 1) == ' ')
|
| {
|
| numPrefix = StringSubstr(p, 0, dEnd + 2);
|
| p = StringSubstr(p, dEnd + 2);
|
| lineIndent = AiTextWidth(numPrefix, "Arial Bold", AI_FONT_BODY);
|
| kind = AI_MD_KIND_NUMBERED;
|
| }
|
| }
|
| //--- Detect dash or star bullet prefix
|
| if(isAiMsg && kind == AI_MD_KIND_BODY)
|
| {
|
| const int pl4 = StringLen(p);
|
| if(pl4 >= 2)
|
| {
|
| const ushort d0 = StringGetCharacter(p, 0);
|
| const ushort d1 = StringGetCharacter(p, 1);
|
| const bool isDashStart = (d0 == '-' && d1 == ' ');
|
| const bool isStarStart = (d0 == '*' && d1 == ' ');
|
| if(isDashStart || isStarStart)
|
| {
|
| numPrefix = StringSubstr(p, 0, 2);
|
| p = StringSubstr(p, 2);
|
| lineIndent = AiTextWidth(numPrefix, "Arial", AI_FONT_BODY);
|
| kind = AI_MD_KIND_NUMBERED;
|
| }
|
| }
|
| }
|
| //--- Detect leading whitespace for hanging indent
|
| string leadingWhitespace = "";
|
| if(isAiMsg && kind == AI_MD_KIND_BODY)
|
| {
|
| int wsCount = 0;
|
| const int pl3 = StringLen(p);
|
| while(wsCount < pl3)
|
| {
|
| const ushort wc = StringGetCharacter(p, wsCount);
|
| if(wc == ' ' || wc == '\t')
|
| wsCount++;
|
| else
|
| break;
|
| }
|
| if(wsCount > 0 && wsCount < pl3)
|
| {
|
| leadingWhitespace = StringSubstr(p, 0, wsCount);
|
| p = StringSubstr(p, wsCount);
|
| //--- Snap indent up to numbered-prefix reference width
|
| const int wsW = AiTextWidth(leadingWhitespace, "Arial", AI_FONT_BODY);
|
| const int refW = AiTextWidth("3. ", "Arial Bold", AI_FONT_BODY);
|
| lineIndent = (wsW > refW) ? wsW : refW;
|
| }
|
| }
|
| //--- Compute available width accounting for indent
|
| const int availW = bubbleTextW - lineIndent;
|
| int paraLen = StringLen(p);
|
| int start = 0;
|
| bool isFirstWrap = true;
|
| //--- Inline-style state for cross-wrap continuity
|
| bool openBold = false, openItalic = false;
|
| //--- Wrap paragraph into visual lines
|
| while(start < paraLen)
|
| {
|
| //--- Find next visual line break point
|
| string sub = StringSubstr(p, start, paraLen - start);
|
| int w = AiTextWidth(sub, lineFont, lineSize);
|
| string visualLine;
|
| int consume;
|
| if(w <= availW)
|
| {
|
| visualLine = sub;
|
| consume = paraLen - start;
|
| }
|
| else
|
| {
|
| //--- Binary search for max prefix that fits
|
| int lo = 1, hi = paraLen - start, fit = 1;
|
| while(lo <= hi)
|
| {
|
| int mid = (lo + hi) / 2;
|
| string pre = StringSubstr(p, start, mid);
|
| int pw = AiTextWidth(pre, lineFont, lineSize);
|
| if(pw <= availW)
|
| {
|
| fit = mid;
|
| lo = mid + 1;
|
| }
|
| else
|
| {
|
| hi = mid - 1;
|
| }
|
| }
|
| //--- Prefer breaking at last space
|
| int breakAt = fit;
|
| bool foundSp = false;
|
| for(int k = fit; k >= 1; k--)
|
| {
|
| ushort cc = StringGetCharacter(p, start + k - 1);
|
| if(cc == ' ')
|
| {
|
| breakAt = k - 1;
|
| foundSp = true;
|
| break;
|
| }
|
| }
|
| if(foundSp)
|
| {
|
| visualLine = StringSubstr(p, start, breakAt);
|
| consume = breakAt + 1;
|
| }
|
| else
|
| {
|
| visualLine = StringSubstr(p, start, fit);
|
| consume = fit;
|
| }
|
| }
|
| //--- Track maximum line width including indent
|
| int vlW = AiTextWidth(visualLine, lineFont, lineSize);
|
| int effectiveW = vlW + lineIndent;
|
| if(effectiveW > maxLineW)
|
| maxLineW = effectiveW;
|
| //--- Compute markers to reopen styles on continuation
|
| const string reopenMarkers = isFirstWrap
|
| ? ""
|
| : AiMdReopenMarkers(openBold, openItalic);
|
| //--- Compose output line with appropriate sentinels
|
| string outLine;
|
| if(kind == AI_MD_KIND_H1
|
| || kind == AI_MD_KIND_H2
|
| || kind == AI_MD_KIND_H3)
|
| {
|
| outLine = ShortToString((ushort)kind) + reopenMarkers + visualLine;
|
| }
|
| else
|
| if(kind == AI_MD_KIND_NUMBERED)
|
| {
|
| if(isFirstWrap)
|
| {
|
| outLine = numPrefix + reopenMarkers + visualLine;
|
| }
|
| else
|
| {
|
| outLine = ShortToString(0x06) + IntegerToString(lineIndent)
|
| + ":" + reopenMarkers + visualLine;
|
| }
|
| }
|
| else
|
| {
|
| //--- Body paragraphs with leading whitespace get indent sentinel
|
| if(lineIndent > 0)
|
| {
|
| outLine = ShortToString(0x06) + IntegerToString(lineIndent)
|
| + ":" + reopenMarkers + visualLine;
|
| }
|
| else
|
| {
|
| outLine = reopenMarkers + visualLine;
|
| }
|
| }
|
| //--- Append to wrapped text
|
| if(wrapped != "")
|
| wrapped += "\n";
|
| wrapped += outLine;
|
| lineCount++;
|
| totalLineH += lineH;
|
| //--- Update inline-style end state for next iteration
|
| AiMdComputeEndState(visualLine, openBold, openItalic);
|
| start += consume;
|
| isFirstWrap = false;
|
| }
|
| }
|
| //--- Floor at one line for empty messages
|
| if(lineCount == 0)
|
| {
|
| lineCount = 1;
|
| totalLineH = lineHBody;
|
| }
|
| //--- Store wrapped text and bubble dimensions
|
| msgWrapped[m] = wrapped;
|
| msgBubbleW[m] = MathMin(bubbleMaxW, maxLineW + 2 * bubblePadX);
|
| msgBubbleH[m] = totalLineH + 2 * bubblePadY;
|
| }
|
| //--- Snapshot the wrap output into the layout cache
|
| ArrayResize(s_layWrapped, numMessages);
|
| ArrayResize(s_layBubbleW, numMessages);
|
| ArrayResize(s_layBubbleH, numMessages);
|
| ArrayResize(s_layRespTime, numMessages);
|
| for(int sm = 0; sm < numMessages; sm++)
|
| {
|
| s_layWrapped[sm] = msgWrapped[sm];
|
| s_layBubbleW[sm] = msgBubbleW[sm];
|
| s_layBubbleH[sm] = msgBubbleH[sm];
|
| s_layRespTime[sm] = msgRespTime[sm];
|
| }
|
| s_layLen = curLen;
|
| s_layQ1 = curQ1;
|
| s_layQ3 = curQ3;
|
| s_layTextW = bubbleTextW;
|
| //--- Close the layout-cache miss block
|
| }
|
| //--- Reserve space for inline image thumbnails from the deterministic marker dims
|
| int msgImgDispW[];
|
| int msgImgDispH[];
|
| ArrayResize(msgImgDispW, numMessages);
|
| ArrayResize(msgImgDispH, numMessages);
|
| for(int m = 0; m < numMessages; m++)
|
| {
|
| msgImgDispW[m] = 0;
|
| msgImgDispH[m] = 0;
|
| if(StringLen(msgImgBase[m]) == 0)
|
| continue;
|
| int tw = msgImgW[m], th = msgImgH[m];
|
| if(tw <= 0 || th <= 0)
|
| continue;
|
| //--- Fit within the bubble's inner text width, preserving aspect
|
| double scale = (tw > bubbleTextW) ? (double)bubbleTextW / (double)tw : 1.0;
|
| int dispW = (int)MathRound(tw * scale);
|
| int dispH = (int)MathRound(th * scale);
|
| if(dispW < 1)
|
| dispW = 1;
|
| if(dispH < 1)
|
| dispH = 1;
|
| msgImgDispW[m] = dispW;
|
| msgImgDispH[m] = dispH;
|
| //--- Widen the bubble to hold the image and add its height + a gap
|
| int needW = dispW + 2 * bubblePadX;
|
| if(msgBubbleW[m] < needW)
|
| msgBubbleW[m] = needW;
|
| msgBubbleH[m] += dispH + bubblePadY;
|
| }
|
| //--- Compute total content height
|
| const int actionRowH = 22;
|
| const int apprRowH = 26;
|
| int totalH = 0;
|
| for(int m = 0; m < numMessages; m++)
|
| {
|
| totalH += msgBubbleH[m];
|
| if(StringLen(msgTimestamps[m]) > 0)
|
| totalH += lineHTime + 2;
|
| if(m == lastAiIdx && m != actionCardIdx)
|
| totalH += actionRowH + 2;
|
| if(m == actionCardIdx)
|
| totalH += apprRowH + 2;
|
| totalH += msgGap;
|
| }
|
| //--- Update scroll state
|
| g_ai_chatScroll.totalH = totalH;
|
| g_ai_chatScroll.viewportH = contentB - contentT;
|
| if(g_ai_chatScrollPin)
|
| {
|
| g_ai_chatScroll.scrollPx = AiScrollMax(g_ai_chatScroll);
|
| g_ai_chatScrollPin = false;
|
| }
|
| AiScrollClamp(g_ai_chatScroll);
|
| //--- Ensure persistent chat scratch canvas matches main size
|
| const int cw = g_ai_canvMain.Width();
|
| const int chC = g_ai_canvMain.Height();
|
| if(!g_ai_canvChatTmpReady || g_ai_canvChatTmpW < cw || g_ai_canvChatTmpH < chC)
|
| {
|
| if(g_ai_canvChatTmpReady)
|
| g_ai_canvChatTmp.Destroy();
|
| const int newW = MathMax(cw, g_ai_canvChatTmpW);
|
| const int newH = MathMax(chC, g_ai_canvChatTmpH);
|
| if(!g_ai_canvChatTmp.CreateBitmap("AiChatPaneTmpPersistent", 0, 0, newW, newH, COLOR_FORMAT_ARGB_NORMALIZE))
|
| return;
|
| g_ai_canvChatTmpW = newW;
|
| g_ai_canvChatTmpH = newH;
|
| g_ai_canvChatTmpReady = true;
|
| }
|
| //--- Compute clip region and seed scratch canvas
|
| const int clipL = rL + 1;
|
| const int clipT = rT + 1;
|
| const int clipR = rR - 1;
|
| const int clipB = rB - 1;
|
| const int seedL = MathMax(clipL, 0);
|
| const int seedT = MathMax(clipT, 0);
|
| const int seedR = MathMin(clipR, cw);
|
| const int seedB = MathMin(clipB, chC);
|
| //--- Copy current pane pixels to scratch for clip blending
|
| g_ai_canvChatTmp.CopyRectFromCanvas(g_ai_canvMain, seedL, seedT, seedR, seedB);
|
| //--- Alias scratch canvas as tmp for the render block
|
| #define tmp g_ai_canvChatTmp
|
| //--- Initialize render Y cursor and reset edit-pencil arrays
|
| int curY = contentT - g_ai_chatScroll.scrollPx;
|
| ArrayResize(g_ai_userEditRectL, 0);
|
| ArrayResize(g_ai_userEditRectT, 0);
|
| ArrayResize(g_ai_userEditRectR, 0);
|
| ArrayResize(g_ai_userEditRectB, 0);
|
| ArrayResize(g_ai_userEditClickL, 0);
|
| ArrayResize(g_ai_userEditClickT, 0);
|
| ArrayResize(g_ai_userEditClickR, 0);
|
| ArrayResize(g_ai_userEditClickB, 0);
|
| ArrayResize(g_ai_userEditPrompt, 0);
|
| //--- Reset inline-image click rects for this frame
|
| ArrayResize(g_ai_imgClickBase, 0);
|
| //--- Reset approval button rects for this frame
|
| g_ai_apprL = 0;
|
| g_ai_apprT = 0;
|
| g_ai_apprR = 0;
|
| g_ai_apprB = 0;
|
| g_ai_rejL = 0;
|
| g_ai_rejT = 0;
|
| g_ai_rejR = 0;
|
| g_ai_rejB = 0;
|
| ArrayResize(g_ai_imgClickL, 0);
|
| ArrayResize(g_ai_imgClickT, 0);
|
| ArrayResize(g_ai_imgClickR, 0);
|
| ArrayResize(g_ai_imgClickB, 0);
|
| //--- Render each message bubble
|
| for(int m = 0; m < numMessages; m++)
|
| {
|
| //--- Determine role and pick colors
|
| bool isUser = (StringFind(msgRoles[m], "User") >= 0);
|
| string processed = msgWrapped[m];
|
| color bubBg = isUser ? g_ai_userBubbleBg : g_ai_aiBubbleBg;
|
| color textCol = isUser ? g_ai_userBubbleText : g_ai_aiBubbleText;
|
| //--- Re-detect placeholder flag and adjust colors
|
| const bool isLoadingPlaceholder =
|
| (StringFind(processed, "Preparing the Request") >= 0
|
| || StringFind(processed, "Thinking") >= 0);
|
| if(StringFind(processed, "Preparing the Request") >= 0)
|
| textCol = g_ai_loadingPrep;
|
| if(StringFind(processed, "Thinking...") >= 0)
|
| textCol = g_ai_loadingThink;
|
| //--- Compute bubble position
|
| const int bubW = msgBubbleW[m];
|
| const int bubH = msgBubbleH[m];
|
| const int bubX = isUser ? (textRight - bubW) : contentL;
|
| const int bubY = curY;
|
| //--- Skip rendering for offscreen bubbles
|
| if(bubY + bubH < contentT || bubY > contentB)
|
| {
|
| curY += bubH;
|
| if(StringLen(msgTimestamps[m]) > 0)
|
| curY += lineHTime + 2;
|
| curY += msgGap;
|
| continue;
|
| }
|
| //--- Fill bubble background
|
| g_ai_prim.FillRoundRectSharp(tmp, bubX, bubY, bubW, bubH, 8,
|
| ColorToARGB(bubBg, 255));
|
| //--- Outline approval card bubbles with the accent border
|
| if(!isUser && StringFind(msgContents[m], AI_ACTION_CARD_HEADER) == 0)
|
| g_ai_prim.DrawRoundRectBorderSharp(tmp, bubX, bubY, bubW, bubH, 8, 1,
|
| ColorToARGB(g_ai_actionCardBorder, 255));
|
| //--- Render each visible line within bubble
|
| string visLines[];
|
| int nl = StringSplit(processed, '\n', visLines);
|
| int textY = bubY + bubblePadY;
|
| for(int i = 0; i < nl; i++)
|
| {
|
| string ln = visLines[i];
|
| //--- User and placeholder bubbles use plain text path
|
| if(isUser || isLoadingPlaceholder)
|
| {
|
| int lW = AiTextWidth(ln, "Arial", AI_FONT_BODY);
|
| int lX = isUser ? (bubX + bubW - bubblePadX - lW) : (bubX + bubblePadX);
|
| AiStampTextAA(tmp, lX, textY, ln, "Arial", AI_FONT_BODY, textCol);
|
| textY += lineHBody;
|
| continue;
|
| }
|
| //--- Detect and strip line-kind sentinel
|
| int kind = AI_MD_KIND_BODY;
|
| int lineSize = AI_FONT_BODY;
|
| int lineH = lineHBody;
|
| int contIndent = 0;
|
| string body = ln;
|
| if(StringLen(body) >= 1)
|
| {
|
| const ushort sent = StringGetCharacter(body, 0);
|
| if(sent == 0x01)
|
| {
|
| kind = AI_MD_KIND_H1;
|
| lineSize = 13;
|
| body = StringSubstr(body, 1);
|
| lineH = AiTextHeightCached("Arial Bold", lineSize) + 2;
|
| }
|
| else
|
| if(sent == 0x02)
|
| {
|
| kind = AI_MD_KIND_H2;
|
| lineSize = 12;
|
| body = StringSubstr(body, 1);
|
| lineH = AiTextHeightCached("Arial Bold", lineSize) + 2;
|
| }
|
| else
|
| if(sent == 0x03)
|
| {
|
| kind = AI_MD_KIND_H3;
|
| lineSize = 11;
|
| body = StringSubstr(body, 1);
|
| lineH = AiTextHeightCached("Arial Bold", lineSize) + 2;
|
| }
|
| else
|
| if(sent == 0x06)
|
| {
|
| //--- Numbered continuation - parse indent width
|
| int colonAt = StringFind(body, ":", 1);
|
| if(colonAt > 1)
|
| {
|
| contIndent = (int)StringToInteger(StringSubstr(body, 1, colonAt - 1));
|
| body = StringSubstr(body, colonAt + 1);
|
| kind = AI_MD_KIND_NUMBERED;
|
| }
|
| else
|
| {
|
| body = StringSubstr(body, 1);
|
| }
|
| }
|
| }
|
| //--- Detect first-line list prefix (numbered or dash/star)
|
| string numPrefix = "";
|
| if(kind == AI_MD_KIND_BODY)
|
| {
|
| const int bl = StringLen(body);
|
| //--- Try numbered first
|
| int dEnd = 0;
|
| while(dEnd < bl)
|
| {
|
| const ushort dc = StringGetCharacter(body, dEnd);
|
| if(dc >= '0' && dc <= '9')
|
| dEnd++;
|
| else
|
| break;
|
| }
|
| if(dEnd > 0
|
| && dEnd + 1 < bl
|
| && StringGetCharacter(body, dEnd) == '.'
|
| && StringGetCharacter(body, dEnd + 1) == ' ')
|
| {
|
| numPrefix = StringSubstr(body, 0, dEnd + 2);
|
| body = StringSubstr(body, dEnd + 2);
|
| kind = AI_MD_KIND_NUMBERED;
|
| }
|
| //--- Otherwise try dash or star
|
| else
|
| if(bl >= 2)
|
| {
|
| const ushort r0 = StringGetCharacter(body, 0);
|
| const ushort r1 = StringGetCharacter(body, 1);
|
| const bool isDash = (r0 == '-' && r1 == ' ');
|
| const bool isStar = (r0 == '*' && r1 == ' ');
|
| if(isDash || isStar)
|
| {
|
| numPrefix = StringSubstr(body, 0, 2);
|
| body = StringSubstr(body, 2);
|
| kind = AI_MD_KIND_NUMBERED;
|
| }
|
| }
|
| }
|
| //--- Compute starting X with prefix or continuation indent
|
| int lX = bubX + bubblePadX;
|
| if(kind == AI_MD_KIND_NUMBERED)
|
| {
|
| if(StringLen(numPrefix) > 0)
|
| {
|
| //--- Pick prefix font and color based on type
|
| const ushort firstCh = StringGetCharacter(numPrefix, 0);
|
| const bool isNumbered = (firstCh >= '0' && firstCh <= '9');
|
| const string prefixFont = isNumbered ? "Arial Bold" : "Arial";
|
| const color prefixCol = isNumbered ? g_ai_accent : textCol;
|
| AiStampTextAA(tmp, lX, textY, numPrefix, prefixFont,
|
| AI_FONT_BODY, prefixCol);
|
| lX += AiTextWidth(numPrefix, prefixFont, AI_FONT_BODY);
|
| }
|
| else
|
| {
|
| lX += contIndent;
|
| }
|
| }
|
| //--- Parse inline runs and stamp them
|
| AiMdRun runs[];
|
| AiMdParseInline(body, runs);
|
| if(kind == AI_MD_KIND_H1 || kind == AI_MD_KIND_H2 || kind == AI_MD_KIND_H3)
|
| {
|
| for(int r = 0; r < ArraySize(runs); r++)
|
| runs[r].bold = true;
|
| }
|
| AiMdStampRuns(tmp, lX, textY, runs, lineSize, textCol);
|
| textY += lineH;
|
| }
|
| //--- Draw the inline chart thumbnail at the bottom of the bubble
|
| if(msgImgDispW[m] > 0)
|
| {
|
| const int imgW = msgImgDispW[m];
|
| const int imgH = msgImgDispH[m];
|
| const int imgX = bubX + bubblePadX;
|
| const int imgY = bubY + bubH - bubblePadY - imgH;
|
| //--- Index this thumbnail will occupy in the click array = its hover id
|
| const int imgIdx = ArraySize(g_ai_imgClickBase);
|
| const bool imgHov = (g_ai_hover == AI_HOV_CHAT_IMAGE_BASE + imgIdx);
|
| int slot = Ai_GetImageThumb(msgImgBase[m]);
|
| if(slot >= 0)
|
| Ai_BlitThumb(tmp, slot, imgX, imgY, imgW, imgH, clipL, clipT, clipR, clipB);
|
| else
|
| g_ai_prim.FillRoundRectSharp(tmp, imgX, imgY, imgW, imgH, 4,
|
| ColorToARGB(g_ai_border, 255)); // missing-file placeholder
|
| //--- Draw a 1px dodger-blue outline to signal the image is clickable
|
| if(imgHov)
|
| tmp.Rectangle(imgX - 1, imgY - 1, imgX + imgW, imgY + imgH,
|
| ColorToARGB((color)C'30,144,255', 255));
|
| Ai_RegisterImgRect(msgImgBase[m], imgX, imgY, imgW, imgH, contentT, contentB);
|
| }
|
| //--- Advance past bubble
|
| curY += bubH;
|
| //--- Render timestamp if present
|
| if(StringLen(msgTimestamps[m]) > 0)
|
| {
|
| curY += 2;
|
| int tsW = AiTextWidth(msgTimestamps[m], "Arial", AI_FONT_TIMESTAMP);
|
| int tX = isUser ? (textRight - tsW) : contentL;
|
| AiStampTextAA(tmp, tX, curY, msgTimestamps[m],
|
| "Arial", AI_FONT_TIMESTAMP, g_ai_timestampText);
|
| curY += lineHTime;
|
| }
|
| //--- Render approve and reject buttons under the approval card
|
| if(m == actionCardIdx)
|
| {
|
| //--- Layout the two pill buttons at the content left edge
|
| curY += 2;
|
| const int btnH = apprRowH;
|
| const int btnGap = 8;
|
| const int apprW = AiTextWidth("Approve", "Arial Bold", AI_FONT_BODY) + 24;
|
| const int rejW = AiTextWidth("Reject", "Arial Bold", AI_FONT_BODY) + 24;
|
| const int aL = contentL;
|
| const int aT = curY;
|
| const int aR = aL + apprW;
|
| const int aB = aT + btnH;
|
| const int jL = aR + btnGap;
|
| const int jT = curY;
|
| const int jR = jL + rejW;
|
| const int jB = jT + btnH;
|
| //--- Cache rects for hit-testing
|
| g_ai_apprL = aL;
|
| g_ai_apprT = aT;
|
| g_ai_apprR = aR;
|
| g_ai_apprB = aB;
|
| g_ai_rejL = jL;
|
| g_ai_rejT = jT;
|
| g_ai_rejR = jR;
|
| g_ai_rejB = jB;
|
| //--- Pick fill colors by hover state
|
| color apprBg = (g_ai_hover == AI_HOV_ACTION_APPROVE)
|
| ? g_ai_actionApproveBgHover : g_ai_actionApproveBg;
|
| color rejBg = (g_ai_hover == AI_HOV_ACTION_REJECT)
|
| ? g_ai_actionRejectBgHover : g_ai_actionRejectBg;
|
| //--- Fill and border each button
|
| g_ai_prim.FillRoundRectSharp(tmp, aL, aT, apprW, btnH, 6,
|
| ColorToARGB(apprBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(tmp, aL, aT, apprW, btnH, 6, 1,
|
| ColorToARGB(AiBorderForBg(apprBg), 255));
|
| g_ai_prim.FillRoundRectSharp(tmp, jL, jT, rejW, btnH, 6,
|
| ColorToARGB(rejBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(tmp, jL, jT, rejW, btnH, 6, 1,
|
| ColorToARGB(AiBorderForBg(rejBg), 255));
|
| //--- Stamp the centered button captions
|
| const int capH = AiTextHeightCached("Arial Bold", AI_FONT_BODY);
|
| const int apprTxW = AiTextWidth("Approve", "Arial Bold", AI_FONT_BODY);
|
| const int rejTxW = AiTextWidth("Reject", "Arial Bold", AI_FONT_BODY);
|
| AiStampTextAA(tmp, aL + (apprW - apprTxW) / 2, aT + (btnH - capH) / 2,
|
| "Approve", "Arial Bold", AI_FONT_BODY, g_ai_actionApproveText);
|
| AiStampTextAA(tmp, jL + (rejW - rejTxW) / 2, jT + (btnH - capH) / 2,
|
| "Reject", "Arial Bold", AI_FONT_BODY, g_ai_actionRejectText);
|
| curY += btnH;
|
| }
|
| //--- Edit pencil affordance for user messages
|
| if(isUser && StringLen(msgContents[m]) > 0)
|
| {
|
| //--- Compute pencil rect to left of bubble
|
| const int peSz = actionRowH;
|
| const int peGlyph = 14;
|
| const int peGap = 4;
|
| const int peR = bubX - peGap;
|
| const int peL = peR - peSz;
|
| const int peT = bubY + (bubH - peSz) / 2;
|
| const int peB = peT + peSz;
|
| //--- Compute wider hover region covering bubble + pencil
|
| const int hovL = peL - 2;
|
| const int hovT = bubY;
|
| const int hovR = bubX + bubW;
|
| const int hovB = bubY + bubH;
|
| //--- Clip hover region to visible chat content area
|
| const int hovTClip = MathMax(hovT, contentT);
|
| const int hovBClip = MathMin(hovB, contentB);
|
| const bool inHoverRegion =
|
| (g_ai_mouseLx >= hovL && g_ai_mouseLx < hovR
|
| && g_ai_mouseLy >= hovTClip && g_ai_mouseLy < hovBClip);
|
| //--- Register hit rects when pencil has horizontal room
|
| if(peL >= contentL && hovBClip > hovTClip)
|
| {
|
| //--- Append entries to all parallel arrays
|
| const int peIdx = ArraySize(g_ai_userEditRectL);
|
| ArrayResize(g_ai_userEditRectL, peIdx + 1);
|
| ArrayResize(g_ai_userEditRectT, peIdx + 1);
|
| ArrayResize(g_ai_userEditRectR, peIdx + 1);
|
| ArrayResize(g_ai_userEditRectB, peIdx + 1);
|
| ArrayResize(g_ai_userEditClickL, peIdx + 1);
|
| ArrayResize(g_ai_userEditClickT, peIdx + 1);
|
| ArrayResize(g_ai_userEditClickR, peIdx + 1);
|
| ArrayResize(g_ai_userEditClickB, peIdx + 1);
|
| ArrayResize(g_ai_userEditPrompt, peIdx + 1);
|
| //--- Wide hover rect
|
| g_ai_userEditRectL[peIdx] = hovL;
|
| g_ai_userEditRectT[peIdx] = hovTClip;
|
| g_ai_userEditRectR[peIdx] = hovR;
|
| g_ai_userEditRectB[peIdx] = hovBClip;
|
| //--- Narrow click rect (pencil icon only)
|
| g_ai_userEditClickL[peIdx] = peL;
|
| g_ai_userEditClickT[peIdx] = peT;
|
| g_ai_userEditClickR[peIdx] = peR;
|
| g_ai_userEditClickB[peIdx] = peB;
|
| g_ai_userEditPrompt[peIdx] = msgContents[m];
|
| //--- Paint pencil only when cursor is in hover region
|
| if(inHoverRegion)
|
| {
|
| //--- Detect hover on icon itself for darkened tint
|
| const bool peHov =
|
| (g_ai_mouseLx >= peL && g_ai_mouseLx < peR
|
| && g_ai_mouseLy >= peT && g_ai_mouseLy < peB);
|
| color peBg = peHov ? AiDarkenColor(g_ai_userBubbleBg, 0.92)
|
| : g_ai_userBubbleBg;
|
| g_ai_prim.FillRoundRectSharp(tmp, peL, peT, peSz, peSz, 4,
|
| ColorToARGB(peBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(tmp, peL, peT, peSz, peSz, 4, 1,
|
| ColorToARGB(AiBorderForBg(peBg), 255));
|
| const int glyphInset = (peSz - peGlyph) / 2;
|
| AiDrawIconEdit(tmp, peL + glyphInset, peT + glyphInset,
|
| peGlyph, ColorToARGB(g_ai_titleText, 230));
|
| }
|
| }
|
| }
|
| //--- Render action row (regen + export) after last AI message
|
| if(m == lastAiIdx && m != actionCardIdx)
|
| {
|
| curY += 2;
|
| const int icSz = actionRowH;
|
| const int glyphSz = 14;
|
| const int icGap = 6;
|
| //--- Stamp response time text
|
| int rtX = contentL;
|
| int rtY = curY + (icSz - AiTextHeightCached("Arial", AI_FONT_TIMESTAMP)) / 2;
|
| int rtW = 0;
|
| if(StringLen(msgRespTime[m]) > 0)
|
| {
|
| rtW = AiTextWidth(msgRespTime[m], "Arial", AI_FONT_TIMESTAMP);
|
| AiStampTextAA(tmp, rtX, rtY, msgRespTime[m],
|
| "Arial", AI_FONT_TIMESTAMP, g_ai_responseTimeNote);
|
| }
|
| //--- Compute regen and export button rects
|
| int afterText = (rtW > 0) ? (rtX + rtW + 8) : contentL;
|
| int regL = afterText;
|
| int regT = curY;
|
| int regR = regL + icSz;
|
| int regB = regT + icSz;
|
| int expL = regR + icGap;
|
| int expT = curY;
|
| int expR = expL + icSz;
|
| int expB = expT + icSz;
|
| //--- Cache rects for hit-testing
|
| g_ai_regenL = regL;
|
| g_ai_regenT = regT;
|
| g_ai_regenR = regR;
|
| g_ai_regenB = regB;
|
| g_ai_exportL = expL;
|
| g_ai_exportT = expT;
|
| g_ai_exportR = expR;
|
| g_ai_exportB = expB;
|
| //--- Pick fill colors by hover state
|
| color regBg = (g_ai_hover == AI_HOV_REGEN)
|
| ? AiDarkenColor(g_ai_aiBubbleBg, 0.92) : g_ai_aiBubbleBg;
|
| color expBg = (g_ai_hover == AI_HOV_EXPORT)
|
| ? AiDarkenColor(g_ai_aiBubbleBg, 0.92) : g_ai_aiBubbleBg;
|
| //--- Fill and border each button
|
| g_ai_prim.FillRoundRectSharp(tmp, regL, regT, icSz, icSz, 4,
|
| ColorToARGB(regBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(tmp, regL, regT, icSz, icSz, 4, 1,
|
| ColorToARGB(AiBorderForBg(regBg), 255));
|
| g_ai_prim.FillRoundRectSharp(tmp, expL, expT, icSz, icSz, 4,
|
| ColorToARGB(expBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(tmp, expL, expT, icSz, icSz, 4, 1,
|
| ColorToARGB(AiBorderForBg(expBg), 255));
|
| //--- Draw button icons
|
| const int glyphInsetReg = (icSz - glyphSz) / 2;
|
| AiDrawIconRegen(tmp, regL + glyphInsetReg, regT + glyphInsetReg,
|
| glyphSz, ColorToARGB(g_ai_titleText, 230));
|
| const int glyphInsetExp = (icSz - glyphSz) / 2;
|
| AiDrawIconExport(tmp, expL + glyphInsetExp, expT + glyphInsetExp,
|
| glyphSz, ColorToARGB(g_ai_titleText, 230));
|
| curY += icSz;
|
| }
|
| //--- Add gap before next message
|
| curY += msgGap;
|
| }
|
| //--- Copy clipped chat content back to main canvas
|
| tmp.CopyRectToCanvas(g_ai_canvMain, seedL, seedT, seedR, seedB);
|
| //--- Undefine scratch alias
|
| #undef tmp
|
| //--- Draw chat scrollbar when content overflows
|
| if(AiScrollVisible(g_ai_chatScroll))
|
| {
|
| g_ai_chatScroll.trackL = contentR - sbW - sbRightPad;
|
| g_ai_chatScroll.trackT = contentT;
|
| g_ai_chatScroll.trackR = g_ai_chatScroll.trackL + sbW;
|
| g_ai_chatScroll.trackB = contentB;
|
| AiScrollDraw(g_ai_canvMain, g_ai_chatScroll, g_ai_prim);
|
| }
|
| //--- Render scroll-to-bottom FAB
|
| {
|
| //--- Compute FAB geometry
|
| const int fabSize = 30;
|
| const int fabRadius = fabSize / 2;
|
| const int fabIconSize = 16;
|
| const int fabMargin = 8;
|
| //--- Position centered horizontally near bottom
|
| const int fabCenterX = (contentL + contentR) / 2;
|
| const int fabL = fabCenterX - fabSize / 2;
|
| const int fabB = contentB - fabMargin;
|
| const int fabT = fabB - fabSize;
|
| const int fabR = fabL + fabSize;
|
| //--- Determine visibility from scroll distance
|
| const int distFromBottom = AiScrollMax(g_ai_chatScroll) - g_ai_chatScroll.scrollPx;
|
| const int viewportH = contentB - contentT;
|
| const bool farFromBottom = (distFromBottom > viewportH / 2);
|
| const bool shouldShow = AiScrollVisible(g_ai_chatScroll) && farFromBottom;
|
| g_ai_scrollFabVisible = shouldShow;
|
| g_ai_scrollFabL = fabL;
|
| g_ai_scrollFabT = fabT;
|
| g_ai_scrollFabR = fabR;
|
| g_ai_scrollFabB = fabB;
|
| //--- Render FAB when visible
|
| if(shouldShow)
|
| {
|
| //--- Pick alpha and bg based on hover
|
| const bool fabHov = (g_ai_hover == AI_HOV_SCROLL_FAB);
|
| const uchar fabAlpha = fabHov ? (uchar)255 : (uchar)191;
|
| color fabBg = fabHov ? AiDarkenColor(g_ai_panelAlt, 0.85)
|
| : g_ai_panelAlt;
|
| //--- Fill FAB background and border
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, fabL, fabT,
|
| fabSize, fabSize, fabRadius,
|
| ColorToARGB(fabBg, fabAlpha));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, fabL, fabT,
|
| fabSize, fabSize, fabRadius, 1,
|
| ColorToARGB(g_ai_borderAccent, fabAlpha));
|
| //--- Draw centered arrow icon
|
| const int glyphInset = (fabSize - fabIconSize) / 2;
|
| const uchar arrowAlpha = (uchar)((230 * fabAlpha) / 255);
|
| AiDrawIconArrowDown(g_ai_canvMain, fabL + glyphInset, fabT + glyphInset,
|
| fabIconSize, ColorToARGB(g_ai_titleText, arrowAlpha));
|
| }
|
| }
|
| }
|
| //+------------------------------------------------------------------+
|
| //| Compose-area attachment chip: staged screenshot with remove badge|
|
| //+------------------------------------------------------------------+
|
| int Ai_ComposeChipH()
|
| {
|
| return (g_ai_attachPending ? 52 : 0);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get the compose chip thumbnail rect |
|
| //+------------------------------------------------------------------+
|
| bool Ai_GetComposeThumbRect(int &l, int &t, int &r, int &b)
|
| {
|
| if(!g_ai_attachPending)
|
| return false;
|
| int rL, rT, rR, rB;
|
| Ai_GetPromptPaneRect(rL, rT, rR, rB);
|
| const int thumbH = 40;
|
| double aspect = (g_ai_attachH > 0) ? (double)g_ai_attachW / (double)g_ai_attachH : 1.6;
|
| int thumbW = (int)MathRound(thumbH * aspect);
|
| if(thumbW < 24)
|
| thumbW = 24;
|
| if(thumbW > 96)
|
| thumbW = 96;
|
| l = rL + 8;
|
| t = rT + 6;
|
| r = l + thumbW;
|
| b = t + thumbH;
|
| return true;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get the compose chip remove badge rect |
|
| //+------------------------------------------------------------------+
|
| bool Ai_GetComposeRemoveRect(int &l, int &t, int &r, int &b)
|
| {
|
| int tl, tt, tr, tb;
|
| if(!Ai_GetComposeThumbRect(tl, tt, tr, tb))
|
| return false;
|
| const int sz = 14, m = 3; // fully inset inside the thumbnail's top-right
|
| r = tr - m;
|
| t = tt + m;
|
| l = r - sz;
|
| b = t + sz;
|
| return true;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Draw the compose attachment chip into a canvas |
|
| //+------------------------------------------------------------------+
|
| void Ai_DrawComposeChip(CAiCanvasFast &canvas, int dx, int dy, int clipR, int clipB)
|
| {
|
| if(!g_ai_attachPending)
|
| return;
|
| int tl, tt, tr, tb;
|
| if(!Ai_GetComposeThumbRect(tl, tt, tr, tb))
|
| return;
|
| const int l = tl + dx, t = tt + dy, r = tr + dx, b = tb + dy;
|
| //--- Thumbnail (or placeholder) + frame
|
| int slot = Ai_GetImageThumb(g_ai_attachBase);
|
| if(slot >= 0)
|
| Ai_BlitThumb(canvas, slot, l, t, r - l, b - t, 0, 0, clipR, clipB);
|
| else
|
| canvas.FillRectangle(l, t, r - 1, b - 1, ColorToARGB(g_ai_border, 255));
|
| canvas.Rectangle(l - 1, t - 1, r, b, ColorToARGB(g_ai_borderAccent, 255));
|
| //--- "Chart attached" label to the right of the thumbnail
|
| const int lh = AiTextHeight("Arial", 8);
|
| AiStampTextAA(canvas, r + 10, t + (b - t - lh) / 2, "Chart attached", "Arial", 8, g_ai_subText);
|
| //--- Draw the remove badge dark at rest and themed red on hover, keeping the X visible
|
| int xl, xt, xr, xb;
|
| if(Ai_GetComposeRemoveRect(xl, xt, xr, xb))
|
| {
|
| const int bl = xl + dx, bt = xt + dy;
|
| const int bsz = xr - xl;
|
| const bool hov = (g_ai_hover == AI_HOV_COMPOSE_REMOVE);
|
| const uint badgeBg = hov ? ColorToARGB(g_ai_closeBgHover, 255)
|
| : ColorToARGB((color)C'25,28,36', 235);
|
| g_ai_prim.FillRoundRectSharp(canvas, bl - 2, bt - 2, bsz + 4, bsz + 4, 4, badgeBg);
|
| const color xcol = hov ? g_ai_closeColorHover : (color)C'235,238,245';
|
| AiDrawIconX(canvas, bl, bt, bsz, ColorToARGB(xcol, 255));
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render the prompt/compose pane |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderPromptPane()
|
| {
|
| //--- Get pane rect
|
| int rL, rT, rR, rB;
|
| Ai_GetPromptPaneRect(rL, rT, rR, rB);
|
| //--- Restore the solid dashboard background behind the pane first
|
| g_ai_canvMain.FillRectangle(rL, rT, rR - 1, rB - 1, ColorToARGB(g_ai_bg, 255));
|
| //--- Fill background and draw border
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, rL, rT, rR - rL, rB - rT, 6,
|
| ColorToARGB(g_ai_promptBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, rL, rT, rR - rL, rB - rT, 6, 1,
|
| ColorToARGB(g_ai_border, 255));
|
| //--- Draw the compose attachment chip (if any) and shift the editor below it
|
| const int chipH_m = Ai_ComposeChipH();
|
| if(chipH_m > 0)
|
| Ai_DrawComposeChip(g_ai_canvMain, 0, 0, rR, rB);
|
| //--- Render editor inside padded inner rect (below the chip strip)
|
| g_ai_editor.Render(g_ai_canvMain, rL + 4, rT + 4 + chipH_m, rR - 4, rB - 4, g_ai_prim);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render prompt pane to overlay canvas (fast-path) |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderPromptPaneOverlay(bool blank = false)
|
| {
|
| //--- Bail when overlay does not exist
|
| if(!g_ai_canvPromptExists)
|
| return;
|
| //--- Blank mode - clear overlay so popups underneath show through
|
| if(blank)
|
| {
|
| g_ai_canvPrompt.Erase(0x00000000);
|
| g_ai_canvPrompt.Update();
|
| return;
|
| }
|
| //--- Get pane rect and dimensions
|
| int rL, rT, rR, rB;
|
| Ai_GetPromptPaneRect(rL, rT, rR, rB);
|
| const int paneW = rR - rL;
|
| const int paneH = rB - rT;
|
| //--- Erase overlay to transparent
|
| g_ai_canvPrompt.Erase(0x00000000);
|
| //--- Render prompt pane in local coords
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvPrompt, 0, 0, paneW, paneH, 6,
|
| ColorToARGB(g_ai_promptBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvPrompt, 0, 0, paneW, paneH, 6, 1,
|
| ColorToARGB(g_ai_border, 255));
|
| //--- Draw the compose chip on the overlay and shift the editor beneath it
|
| const int chipH_o = Ai_ComposeChipH();
|
| if(chipH_o > 0)
|
| Ai_DrawComposeChip(g_ai_canvPrompt, -rL, -rT, paneW, paneH);
|
| //--- Render the editor onto the overlay and restore main-local scrollbar track coords
|
| const int svTrackL = g_ai_editor.scroll.trackL;
|
| const int svTrackT = g_ai_editor.scroll.trackT;
|
| const int svTrackR = g_ai_editor.scroll.trackR;
|
| const int svTrackB = g_ai_editor.scroll.trackB;
|
| g_ai_editor.Render(g_ai_canvPrompt, 4, 4 + chipH_o, paneW - 4, paneH - 4, g_ai_prim);
|
| g_ai_editor.scroll.trackL = svTrackL;
|
| g_ai_editor.scroll.trackT = svTrackT;
|
| g_ai_editor.scroll.trackR = svTrackR;
|
| g_ai_editor.scroll.trackB = svTrackB;
|
| //--- Push overlay bitmap to chart
|
| g_ai_canvPrompt.Update();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render footer with split signal button and send button |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderFooter()
|
| {
|
| //--- Fill footer background
|
| g_ai_canvMain.FillRectangle(Ai_MainContentX(), Ai_FooterT(),
|
| Ai_DashboardW() - 1, Ai_FooterB() - 1,
|
| ColorToARGB(g_ai_bg, 255));
|
| //--- Get signal button rect and split position
|
| int sL, sT, sR, sB;
|
| Ai_GetSignalBtnRect(sL, sT, sR, sB);
|
| const int sepX = Ai_SignalSeparatorX();
|
| const bool hovAct = (g_ai_hover == AI_HOV_SIGNAL);
|
| const bool hovDd = (g_ai_hover == AI_HOV_SIGNAL_DD);
|
| //--- Setup styling constants
|
| const color sigBlue = (color)C'30,100,220';
|
| const uchar fillIdle = 76;
|
| const uchar fillHov = 128;
|
| //--- Fill whole pill at base or action-hover alpha
|
| {
|
| const uchar a = hovAct ? fillHov : fillIdle;
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, sL, sT, sR - sL, sB - sT, 4,
|
| ColorToARGB(sigBlue, a));
|
| }
|
| //--- Add hover tint to chevron half when hovered
|
| if(hovDd && !hovAct)
|
| {
|
| const uchar extra = (uchar)(fillHov - fillIdle);
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, sepX, sT, sR - sepX, sB - sT, 4,
|
| ColorToARGB(sigBlue, extra));
|
| }
|
| //--- Lift chevron half back to idle when action half is hovered
|
| if(hovAct)
|
| {
|
| const uchar lift = (uchar)(fillHov - fillIdle);
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, sepX, sT, sR - sepX, sB - sT, 4,
|
| ColorToARGB(g_ai_bg, lift));
|
| }
|
| //--- Draw outer pill border
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, sL, sT, sR - sL, sB - sT, 4, 1,
|
| ColorToARGB(sigBlue, 255));
|
| //--- Draw vertical separator between halves
|
| const uint sepArgb = ColorToARGB(sigBlue, 255);
|
| for(int yy = sT + 1; yy <= sB - 2; yy++)
|
| AiBlendPixel(g_ai_canvMain, sepX, yy, sepArgb);
|
| //--- Render action half content (icon + label)
|
| const int ddi = MathMax(0, MathMin(g_ai_footerDropdownSelectedIdx, AI_FOOTER_DD_COUNT - 1));
|
| const string ddLbl = AI_FOOTER_DD_LABELS[ddi];
|
| const int contentH = sB - sT;
|
| const int aIcSz = 18;
|
| const int aIcX = sL + 8;
|
| const int aIcY = sT + (contentH - aIcSz) / 2;
|
| const uint aIcArgb = ColorToARGB(sigBlue, 255);
|
| //--- Draw selected action's icon
|
| const int aIconId = AI_FOOTER_DD_ICONS[ddi];
|
| if(aIconId == 0)
|
| AiDrawIconChart(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 1)
|
| AiDrawIconTwinBars(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 2)
|
| AiDrawIconLightning(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 3)
|
| AiDrawIconDay(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 4)
|
| AiDrawIconTrend(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 5)
|
| AiDrawIconLevel(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 6)
|
| AiDrawIconClose(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| else
|
| if(aIconId == 7)
|
| AiDrawIconImage(g_ai_canvMain, aIcX, aIcY, aIcSz, aIcArgb);
|
| //--- Stamp action label text
|
| const int sLH = AiTextHeight("Arial Bold", 9);
|
| AiStampTextAA(g_ai_canvMain, aIcX + aIcSz + 8, sT + (contentH - sLH) / 2,
|
| ddLbl, "Arial Bold", 9, sigBlue);
|
| //--- Draw chevron in chevron half
|
| const int chCx = sepX + (sR - sepX) / 2;
|
| const int chCy = sT + contentH / 2;
|
| const uint chevArgb = ColorToARGB(sigBlue, 255);
|
| AiDrawChevron(g_ai_canvMain, chCx, chCy, g_ai_showFooterDropdown, chevArgb);
|
| //--- Get send button rect and disabled state
|
| int xL, xT, xR, xB;
|
| Ai_GetSendBtnRect(xL, xT, xR, xB);
|
| const bool sendDisabled = Ai_PromptIsEmpty();
|
| //--- Track last-rendered send disabled state for fast-path tracking
|
| g_ai_lastRenderedSendDisabled = sendDisabled;
|
| //--- Pick send button colors based on disabled and hover state
|
| color xBg;
|
| uint xIconArgb;
|
| if(sendDisabled)
|
| {
|
| xBg = g_ai_buttonDisabledBg;
|
| xIconArgb = ColorToARGB(g_ai_buttonDisabledIcon, 255);
|
| }
|
| else
|
| {
|
| xBg = (g_ai_hover == AI_HOV_SEND) ? g_ai_buttonSendBgHover : g_ai_buttonSendBg;
|
| xIconArgb = ColorToARGB(clrWhite, 255);
|
| }
|
| //--- Fill and border send button
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, xL, xT, xR - xL, xB - xT, 6,
|
| ColorToARGB(xBg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, xL, xT, xR - xL, xB - xT, 6, 1,
|
| ColorToARGB(AiBorderForBg(xBg), 255));
|
| //--- Draw send icon
|
| const int sendSz = 18;
|
| const int sendIcX = xL + (xR - xL - sendSz) / 2;
|
| const int sendIcY = xT + (xB - xT - sendSz) / 2;
|
| AiDrawIconSend(g_ai_canvMain, sendIcX, sendIcY, sendSz, xIconArgb);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Popup State |
|
| //+------------------------------------------------------------------+
|
| int g_ai_popupL = 0, g_ai_popupT = 0, g_ai_popupR = 0, g_ai_popupB = 0; // Popup outer rect
|
| int g_ai_popupHovRow = -1; // Hovered row index
|
| bool g_ai_popupHovDel = false; // Hovered delete zone flag
|
| int g_ai_popupRowL[]; // Row left edges
|
| int g_ai_popupRowT[]; // Row top edges
|
| int g_ai_popupRowR[]; // Row right edges
|
| int g_ai_popupRowB[]; // Row bottom edges
|
| int g_ai_popupRowChatIdx[]; // Mapping from row to chat index
|
|
|
| //+------------------------------------------------------------------+
|
| //| Get popup rectangle for given anchor button |
|
| //+------------------------------------------------------------------+
|
| void Ai_GetPopupRect(int anchorIdx, int &outL, int &outT, int &outR, int &outB)
|
| {
|
| //--- Search popup spans full main area
|
| if(anchorIdx == 0)
|
| {
|
| g_ai_searchQuery = g_ai_searchEditor.GetText();
|
| outL = Ai_MainContentX();
|
| outT = AI_HEADER_H + 1;
|
| outR = Ai_DashboardW() - 1;
|
| outB = Ai_DashboardH() - 1;
|
| return;
|
| }
|
| //--- History popup anchored to sidebar button
|
| int bL, bT, bR, bB;
|
| Ai_GetSidebarBtnRect(anchorIdx, bL, bT, bR, bB);
|
| const int popupW = 280;
|
| const int rowH = 44;
|
| const int titleArea = 28;
|
| const int bottomPad = 8;
|
| //--- Cap visible rows at 8
|
| int nRows = ArraySize(g_ai_chats);
|
| if(nRows > 8)
|
| nRows = 8;
|
| if(nRows < 1)
|
| nRows = 1;
|
| const int popupH = titleArea + nRows * rowH + bottomPad;
|
| //--- Position popup with bounds clamping
|
| outL = bR - 3;
|
| outT = bT;
|
| const int dH = Ai_DashboardH();
|
| if(outT + popupH > dH - 8)
|
| outT = dH - 8 - popupH;
|
| if(outT < AI_HEADER_H + 4)
|
| outT = AI_HEADER_H + 4;
|
| outR = outL + popupW;
|
| outB = outT + popupH;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Extract first user prompt snippet from chat history |
|
| //+------------------------------------------------------------------+
|
| string Ai_FirstPromptSnippet(const string history)
|
| {
|
| //--- Bail on empty input
|
| const int lenH = StringLen(history);
|
| if(lenH == 0)
|
| return "";
|
| //--- Memoize this parse by a cheap fingerprint so scrolling reuses the result
|
| static string s_snipKey[];
|
| static string s_snipVal[];
|
| string fp = (string)lenH + "|"
|
| + (string)(int)StringGetCharacter(history, 0) + ","
|
| + (string)(int)StringGetCharacter(history, lenH / 2) + ","
|
| + (string)(int)StringGetCharacter(history, lenH - 1);
|
| const int scN = ArraySize(s_snipKey);
|
| for(int si = 0; si < scN; si++)
|
| if(s_snipKey[si] == fp)
|
| return s_snipVal[si];
|
| //--- Locate first content marker
|
| int p = StringFind(history, "You: ");
|
| int prefixLen = 5;
|
| if(p < 0)
|
| {
|
| p = StringFind(history, "AI: ");
|
| prefixLen = 4;
|
| if(p < 0)
|
| return "";
|
| }
|
| const int contentStart = p + prefixLen;
|
| //--- Walk lines collecting prompt content until terminator
|
| string tail = StringSubstr(history, contentStart);
|
| string lines[];
|
| int n = StringSplit(tail, '\n', lines);
|
| string out = "";
|
| for(int i = 0; i < n; i++)
|
| {
|
| string line = lines[i];
|
| StringTrimLeft(line);
|
| StringTrimRight(line);
|
| //--- Stop on blank, timestamp, or block boundary
|
| if(StringLen(line) == 0)
|
| break;
|
| if(AiIsTimestamp(line))
|
| break;
|
| if(StringFind(line, "You: ") == 0)
|
| break;
|
| if(StringFind(line, "AI: ") == 0)
|
| break;
|
| //--- Append line with separator
|
| if(StringLen(out) > 0)
|
| out += " | ";
|
| out += line;
|
| }
|
| //--- Trim, store in memo, and return
|
| StringTrimLeft(out);
|
| StringTrimRight(out);
|
| ArrayResize(s_snipKey, scN + 1);
|
| ArrayResize(s_snipVal, scN + 1);
|
| s_snipKey[scN] = fp;
|
| s_snipVal[scN] = out;
|
| return out;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Search Box Rect State |
|
| //+------------------------------------------------------------------+
|
| int g_ai_popupSearchL = 0, g_ai_popupSearchT = 0; // Search box top-left
|
| int g_ai_popupSearchR = 0, g_ai_popupSearchB = 0; // Search box bottom-right
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render search or history popup |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderPopup(int anchorIdx)
|
| {
|
| //--- Get popup rect and store globally
|
| int pL, pT, pR, pB;
|
| Ai_GetPopupRect(anchorIdx, pL, pT, pR, pB);
|
| g_ai_popupL = pL;
|
| g_ai_popupT = pT;
|
| g_ai_popupR = pR;
|
| g_ai_popupB = pB;
|
| const int popupW = pR - pL;
|
| const int popupH = pB - pT;
|
| //--- Fill popup background (square for search, rounded for history)
|
| if(anchorIdx == 0)
|
| {
|
| g_ai_canvMain.FillRectangle(pL, pT, pR - 1, pB - 1,
|
| ColorToARGB(g_ai_panelAlt, 255));
|
| }
|
| else
|
| {
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, pL, pT, popupW, popupH, 8,
|
| ColorToARGB(g_ai_panelAlt, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, pL, pT, popupW, popupH, 8, 1,
|
| ColorToARGB(g_ai_borderAccent, 255));
|
| }
|
| //--- Stamp popup title
|
| const string title = (anchorIdx == 0) ? "Search Chats" : "Recent Chats";
|
| AiStampTextAA(g_ai_canvMain, pL + 16, pT + 12, title,
|
| "Arial Bold", AI_FONT_LABEL, g_ai_titleText);
|
| //--- Compute layout heights
|
| const int rowH = 44;
|
| const int titleArea = (anchorIdx == 0) ? 36 : 28;
|
| const int searchArea = (anchorIdx == 0) ? 38 : 0;
|
| //--- Render search input box for search popup
|
| if(anchorIdx == 0)
|
| {
|
| //--- Compute search box rect
|
| int siL = pL + 16;
|
| int siT = pT + titleArea;
|
| int siR = pR - 16;
|
| int siB = siT + searchArea - 6;
|
| g_ai_popupSearchL = siL;
|
| g_ai_popupSearchT = siT;
|
| g_ai_popupSearchR = siR;
|
| g_ai_popupSearchB = siB;
|
| //--- Fill search box and draw border
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, siL, siT, siR - siL, siB - siT, 4,
|
| ColorToARGB(g_ai_bg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, siL, siT, siR - siL, siB - siT, 4, 1,
|
| ColorToARGB(g_ai_borderAccent, 255));
|
| //--- Vertically center the search editor
|
| const int boxH = siB - siT;
|
| const int lineH = AiTextHeightCached("Arial", AI_FONT_BODY) + 2;
|
| const int edL = siL + 8;
|
| const int edT = siT + (boxH - lineH) / 2 - g_ai_searchEditor.padY;
|
| const int edR = siR - 4;
|
| const int edB = edT + lineH + 2 * g_ai_searchEditor.padY;
|
| g_ai_searchEditor.Render(g_ai_canvMain, edL, edT, edR, edB, g_ai_prim);
|
| }
|
| else
|
| {
|
| //--- Reset search box rect when not search popup
|
| g_ai_popupSearchL = g_ai_popupSearchT = g_ai_popupSearchR = g_ai_popupSearchB = 0;
|
| }
|
| //--- Build filtered chat list
|
| int filteredIdx[];
|
| const int total = ArraySize(g_ai_chats);
|
| string queryLower = "";
|
| if(anchorIdx == 0 && StringLen(g_ai_searchQuery) > 0)
|
| {
|
| queryLower = g_ai_searchQuery;
|
| StringToLower(queryLower);
|
| }
|
| //--- Filter and lowercase cache (static across renders)
|
| #define AI_SEARCH_HISTORY_CAP 4096
|
| static string s_cacheLowerTitles[];
|
| static string s_cacheLowerHistories[];
|
| static int s_cacheChatCount = -1;
|
| static int s_cacheTitleLenSum = -1;
|
| static int s_cacheHistoryLenSum = -1;
|
| static string s_cacheFilterQuery = "\x01";
|
| static int s_cacheFilterIdx[];
|
| //--- Compute current chats fingerprint
|
| int curTitleLenSum = 0;
|
| int curHistoryLenSum = 0;
|
| for(int fi = 0; fi < total; fi++)
|
| {
|
| curTitleLenSum += StringLen(g_ai_chats[fi].title);
|
| const int hLen = StringLen(g_ai_chats[fi].history);
|
| curHistoryLenSum += (hLen < AI_SEARCH_HISTORY_CAP) ? hLen : AI_SEARCH_HISTORY_CAP;
|
| }
|
| const bool chatsCacheValid =
|
| (s_cacheChatCount == total)
|
| && (s_cacheTitleLenSum == curTitleLenSum)
|
| && (s_cacheHistoryLenSum == curHistoryLenSum);
|
| //--- Rebuild lowercase cache when chats change
|
| if(!chatsCacheValid)
|
| {
|
| ArrayResize(s_cacheLowerTitles, total);
|
| ArrayResize(s_cacheLowerHistories, total);
|
| for(int li = 0; li < total; li++)
|
| {
|
| //--- Lowercase title
|
| string tL = g_ai_chats[li].title;
|
| StringToLower(tL);
|
| //--- Lowercase capped history head
|
| string hRaw = g_ai_chats[li].history;
|
| if(StringLen(hRaw) > AI_SEARCH_HISTORY_CAP)
|
| hRaw = StringSubstr(hRaw, 0, AI_SEARCH_HISTORY_CAP);
|
| StringToLower(hRaw);
|
| //--- Store in cache
|
| s_cacheLowerTitles[li] = tL;
|
| s_cacheLowerHistories[li] = hRaw;
|
| }
|
| s_cacheChatCount = total;
|
| s_cacheTitleLenSum = curTitleLenSum;
|
| s_cacheHistoryLenSum = curHistoryLenSum;
|
| //--- Invalidate filter cache too
|
| s_cacheFilterQuery = "\x01";
|
| }
|
| //--- Use filter cache when query and chats are unchanged
|
| if(anchorIdx == 0
|
| && chatsCacheValid
|
| && s_cacheFilterQuery == g_ai_searchQuery)
|
| {
|
| //--- Reuse cached filter result
|
| const int cn = ArraySize(s_cacheFilterIdx);
|
| ArrayResize(filteredIdx, cn);
|
| for(int ci = 0; ci < cn; ci++)
|
| filteredIdx[ci] = s_cacheFilterIdx[ci];
|
| }
|
| else
|
| {
|
| //--- Filter chats with title-first short-circuit
|
| for(int i = total - 1; i >= 0; i--)
|
| {
|
| if(StringLen(queryLower) > 0)
|
| {
|
| if(StringFind(s_cacheLowerTitles[i], queryLower) >= 0)
|
| {
|
| //--- Title hit - include without scanning history
|
| }
|
| else
|
| if(StringFind(s_cacheLowerHistories[i], queryLower) < 0)
|
| {
|
| continue;
|
| }
|
| }
|
| //--- Append index to filtered list
|
| int sz = ArraySize(filteredIdx);
|
| ArrayResize(filteredIdx, sz + 1);
|
| filteredIdx[sz] = i;
|
| }
|
| //--- Snapshot to filter cache
|
| if(anchorIdx == 0)
|
| {
|
| const int sn = ArraySize(filteredIdx);
|
| ArrayResize(s_cacheFilterIdx, sn);
|
| for(int si = 0; si < sn; si++)
|
| s_cacheFilterIdx[si] = filteredIdx[si];
|
| s_cacheFilterQuery = g_ai_searchQuery;
|
| }
|
| }
|
| //--- Compute row content area bounds
|
| const int rowsTop = pT + titleArea + searchArea;
|
| const int rowsBot = pB - 8;
|
| const int viewportH = MathMax(0, rowsBot - rowsTop);
|
| const int totalFiltered = ArraySize(filteredIdx);
|
| //--- Determine layout based on popup type
|
| bool useScrollbar = false;
|
| int visStart = 0;
|
| int visCount = 0;
|
| const int sbW = 4;
|
| //--- Search popup uses virtualized list with scrollbar
|
| if(anchorIdx == 0)
|
| {
|
| const int contentH = totalFiltered * rowH;
|
| g_ai_searchScroll.totalH = contentH;
|
| g_ai_searchScroll.viewportH = viewportH;
|
| if(AiScrollVisible(g_ai_searchScroll))
|
| useScrollbar = true;
|
| AiScrollClamp(g_ai_searchScroll);
|
| //--- Compute first partially visible row and count
|
| visStart = MathMax(0, g_ai_searchScroll.scrollPx / rowH);
|
| const int startScreenY = rowsTop + visStart * rowH - g_ai_searchScroll.scrollPx;
|
| const int yRoom = rowsBot - startScreenY;
|
| visCount = MathMin(totalFiltered - visStart, (yRoom + rowH - 1) / rowH + 1);
|
| if(visCount < 0)
|
| visCount = 0;
|
| if(visStart + visCount > totalFiltered)
|
| visCount = totalFiltered - visStart;
|
| }
|
| else
|
| {
|
| //--- History popup caps at 8 rows
|
| visStart = 0;
|
| visCount = MathMin(totalFiltered, 8);
|
| }
|
| //--- Allocate row hit-test arrays
|
| ArrayResize(g_ai_popupRowL, visCount);
|
| ArrayResize(g_ai_popupRowT, visCount);
|
| ArrayResize(g_ai_popupRowR, visCount);
|
| ArrayResize(g_ai_popupRowB, visCount);
|
| ArrayResize(g_ai_popupRowChatIdx, visCount);
|
| //--- Prepare clip canvas for search popup virtualization
|
| const bool useClipCanvas = (anchorIdx == 0 && visCount > 0);
|
| if(useClipCanvas)
|
| {
|
| //--- Ensure search scratch canvas matches main size
|
| const int needW = g_ai_canvMain.Width();
|
| const int needH = g_ai_canvMain.Height();
|
| if(!g_ai_canvSearchTmpReady
|
| || g_ai_canvSearchTmpW < needW
|
| || g_ai_canvSearchTmpH < needH)
|
| {
|
| if(g_ai_canvSearchTmpReady)
|
| g_ai_canvSearchTmp.Destroy();
|
| const int newW = MathMax(needW, g_ai_canvSearchTmpW);
|
| const int newH = MathMax(needH, g_ai_canvSearchTmpH);
|
| if(g_ai_canvSearchTmp.CreateBitmap("AiSearchPopupTmpPersistent",
|
| 0, 0, newW, newH,
|
| COLOR_FORMAT_ARGB_NORMALIZE))
|
| {
|
| g_ai_canvSearchTmpW = newW;
|
| g_ai_canvSearchTmpH = newH;
|
| g_ai_canvSearchTmpReady = true;
|
| }
|
| }
|
| }
|
| //--- Seed scratch canvas with main pixels for clip blending
|
| if(useClipCanvas && g_ai_canvSearchTmpReady)
|
| {
|
| g_ai_canvSearchTmp.CopyRectFromCanvas(g_ai_canvMain, pL, rowsTop, pR, rowsBot);
|
| }
|
| //--- Render each visible row
|
| for(int v = 0; v < visCount; v++)
|
| {
|
| //--- Get chat index for this row
|
| const int filterPos = visStart + v;
|
| const int chatIdx = filteredIdx[filterPos];
|
| //--- Compute row coords with scroll offset
|
| const int naturalY = rowsTop + filterPos * rowH;
|
| const int rT = naturalY - ((anchorIdx == 0) ? g_ai_searchScroll.scrollPx : 0);
|
| const int rB = rT + rowH - 2;
|
| const int rL = pL + 10;
|
| const int rR = pR - 10;
|
| //--- Clip hit rect to visible row band
|
| const int hitT = MathMax(rT, rowsTop);
|
| const int hitB = MathMin(rB, rowsBot);
|
| g_ai_popupRowL[v] = rL;
|
| g_ai_popupRowT[v] = hitT;
|
| g_ai_popupRowR[v] = rR;
|
| g_ai_popupRowB[v] = hitB;
|
| g_ai_popupRowChatIdx[v] = chatIdx;
|
| //--- Skip painting offscreen rows
|
| if(hitB <= hitT)
|
| continue;
|
| //--- Determine hover state and pick fill
|
| bool hov = (g_ai_popupHovRow == v);
|
| bool hovDel = (hov && g_ai_popupHovDel);
|
| color bg = hov ? g_ai_chatItemBgHover : g_ai_chatItemBg;
|
| //--- Render to clip canvas or main directly
|
| if(useClipCanvas && g_ai_canvSearchTmpReady)
|
| {
|
| //--- Fill row and draw border on scratch
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvSearchTmp, rL, rT, rR - rL, rB - rT, 4,
|
| ColorToARGB(bg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvSearchTmp, rL, rT, rR - rL, rB - rT, 4, 1,
|
| ColorToARGB(AiBorderForBg(bg), 255));
|
| //--- Compute available width for text
|
| const int hpDeleteIconW = 14;
|
| const int hpAvailW = (rR - rL) - 24 - hpDeleteIconW;
|
| //--- Stamp title on top line
|
| string titleText = Ai_FitTextToWidth(g_ai_chats[chatIdx].title,
|
| "Arial", AI_FONT_LABEL, hpAvailW);
|
| color titleCol = (g_ai_chats[chatIdx].id == g_ai_currentChatId)
|
| ? g_ai_chatItemActiveText : g_ai_titleText;
|
| AiStampTextAA(g_ai_canvSearchTmp, rL + 8, rT + 6, titleText,
|
| "Arial", AI_FONT_LABEL, titleCol);
|
| //--- Stamp snippet on bottom line
|
| string snippetRaw = Ai_FirstPromptSnippet(g_ai_chats[chatIdx].history);
|
| string snippetText = Ai_FitTextToWidth(snippetRaw, "Arial", AI_FONT_SNIPPET, hpAvailW);
|
| const int snippetY = rT + 6 + AiTextHeightCached("Arial", AI_FONT_LABEL) + 4;
|
| AiStampTextAA(g_ai_canvSearchTmp, rL + 8, snippetY, snippetText,
|
| "Arial", AI_FONT_SNIPPET, g_ai_subText);
|
| //--- Stamp delete glyph on hover
|
| if(hov)
|
| {
|
| color xCol = hovDel ? g_ai_chatItemDelHover : g_ai_subText;
|
| int xW = 14, xH = 14; // vector X footprint
|
| AiDrawIconX(g_ai_canvSearchTmp, rR - 8 - xW, rT + ((rB - rT) - xH) / 2, 14,
|
| ColorToARGB(xCol, 255));
|
| }
|
| }
|
| else
|
| {
|
| //--- Render directly to main canvas (history popup path)
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, rL, rT, rR - rL, rB - rT, 4,
|
| ColorToARGB(bg, 255));
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, rL, rT, rR - rL, rB - rT, 4, 1,
|
| ColorToARGB(AiBorderForBg(bg), 255));
|
| //--- Compute available width for text
|
| const int hp2DeleteIconW = 14;
|
| const int hp2AvailW = (rR - rL) - 24 - hp2DeleteIconW;
|
| //--- Stamp title and snippet
|
| string titleText = Ai_FitTextToWidth(g_ai_chats[chatIdx].title,
|
| "Arial", AI_FONT_LABEL, hp2AvailW);
|
| color titleCol = (g_ai_chats[chatIdx].id == g_ai_currentChatId)
|
| ? g_ai_chatItemActiveText : g_ai_titleText;
|
| AiStampTextAA(g_ai_canvMain, rL + 8, rT + 6, titleText,
|
| "Arial", AI_FONT_LABEL, titleCol);
|
| string snippetRaw = Ai_FirstPromptSnippet(g_ai_chats[chatIdx].history);
|
| string snippetText = Ai_FitTextToWidth(snippetRaw, "Arial", AI_FONT_SNIPPET, hp2AvailW);
|
| const int snippetY = rT + 6 + AiTextHeightCached("Arial", AI_FONT_LABEL) + 4;
|
| AiStampTextAA(g_ai_canvMain, rL + 8, snippetY, snippetText,
|
| "Arial", AI_FONT_SNIPPET, g_ai_subText);
|
| //--- Stamp delete glyph on hover
|
| if(hov)
|
| {
|
| color xCol = hovDel ? g_ai_chatItemDelHover : g_ai_subText;
|
| int xW = 14, xH = 14; // vector X footprint (was a Wingdings 2 glyph)
|
| AiDrawIconX(g_ai_canvMain, rR - 8 - xW, rT + ((rB - rT) - xH) / 2, 14, ColorToARGB(xCol, 255));
|
| }
|
| }
|
| }
|
| //--- Copy clipped rows back to main canvas
|
| if(useClipCanvas && g_ai_canvSearchTmpReady)
|
| {
|
| g_ai_canvSearchTmp.CopyRectToCanvas(g_ai_canvMain, pL, rowsTop, pR, rowsBot);
|
| }
|
| //--- Draw search popup scrollbar when needed
|
| if(anchorIdx == 0 && useScrollbar)
|
| {
|
| g_ai_searchScroll.trackL = pR - 7;
|
| g_ai_searchScroll.trackT = rowsTop;
|
| g_ai_searchScroll.trackR = g_ai_searchScroll.trackL + sbW;
|
| g_ai_searchScroll.trackB = rowsBot;
|
| AiScrollDraw(g_ai_canvMain, g_ai_searchScroll, g_ai_prim);
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Footer Dropdown State |
|
| //+------------------------------------------------------------------+
|
| int g_ai_footerDdL = 0, g_ai_footerDdT = 0, g_ai_footerDdR = 0, g_ai_footerDdB = 0; // Dropdown rect
|
| int g_ai_footerDdItemL[]; // Item left edges
|
| int g_ai_footerDdItemT[]; // Item top edges
|
| int g_ai_footerDdItemR[]; // Item right edges
|
| int g_ai_footerDdItemB[]; // Item bottom edges
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render footer dropdown popup |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderFooterDropdown()
|
| {
|
| //--- Get anchor rect and compute popup geometry
|
| int aL, aT, aR, aB;
|
| Ai_GetSignalBtnRect(aL, aT, aR, aB);
|
| const int itemH = 32;
|
| const int padTop = 4;
|
| const int padBot = 4;
|
| const int popupW = aR - aL;
|
| const int popupH = padTop + AI_FOOTER_DD_COUNT * itemH + padBot;
|
| //--- Decide whether to place above or below anchor
|
| const int dH = Ai_DashboardH();
|
| bool placeBelow = (aB + 4 + popupH) <= (dH - 6);
|
| //--- Compute popup rect and store globally
|
| int pL = aL;
|
| int pT = placeBelow ? (aB + 4) : (aT - 4 - popupH);
|
| int pR = pL + popupW;
|
| int pB = pT + popupH;
|
| g_ai_footerDdL = pL;
|
| g_ai_footerDdT = pT;
|
| g_ai_footerDdR = pR;
|
| g_ai_footerDdB = pB;
|
| //--- Fill popup body and border
|
| const uint bodyArgb = ColorToARGB(g_ai_panelAlt, 200);
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, pL, pT, popupW, popupH, 6, bodyArgb);
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, pL, pT, popupW, popupH, 6, 1,
|
| ColorToARGB(g_ai_borderAccent, 255));
|
| //--- Allocate item rect arrays
|
| ArrayResize(g_ai_footerDdItemL, AI_FOOTER_DD_COUNT);
|
| ArrayResize(g_ai_footerDdItemT, AI_FOOTER_DD_COUNT);
|
| ArrayResize(g_ai_footerDdItemR, AI_FOOTER_DD_COUNT);
|
| ArrayResize(g_ai_footerDdItemB, AI_FOOTER_DD_COUNT);
|
| //--- Render each dropdown item
|
| for(int i = 0; i < AI_FOOTER_DD_COUNT; i++)
|
| {
|
| //--- Compute and store item rect
|
| int iL = pL + 1;
|
| int iT = pT + padTop + i * itemH;
|
| int iR = pR - 1;
|
| int iB = iT + itemH;
|
| g_ai_footerDdItemL[i] = iL;
|
| g_ai_footerDdItemT[i] = iT;
|
| g_ai_footerDdItemR[i] = iR;
|
| g_ai_footerDdItemB[i] = iB;
|
| //--- Determine hover and active state
|
| bool itemHov = (g_ai_hover == AI_HOV_FOOTER_DD_ITEM_BASE + i);
|
| bool itemActive = (i == g_ai_footerDropdownSelectedIdx);
|
| //--- Paint hover or active highlight
|
| if(itemHov)
|
| {
|
| const uint hovArgb = ColorToARGB(g_ai_titleText, 30);
|
| for(int yy = iT; yy < iB; yy++)
|
| for(int xx = iL; xx < iR; xx++)
|
| AiBlendPixel(g_ai_canvMain, xx, yy, hovArgb);
|
| }
|
| else
|
| if(itemActive)
|
| {
|
| const uint actArgb = ColorToARGB(g_ai_titleText, 18);
|
| for(int yy = iT; yy < iB; yy++)
|
| for(int xx = iL; xx < iR; xx++)
|
| AiBlendPixel(g_ai_canvMain, xx, yy, actArgb);
|
| }
|
| //--- Draw item icon and label
|
| const int iconSz = 18;
|
| const int iconX = iL + 8;
|
| const int iconY = iT + (itemH - iconSz) / 2;
|
| const uint iconArgb = ColorToARGB(g_ai_titleText, 235);
|
| //--- Pick icon by item id
|
| const int iconId = AI_FOOTER_DD_ICONS[i];
|
| if(iconId == 0)
|
| AiDrawIconChart(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 1)
|
| AiDrawIconTwinBars(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 2)
|
| AiDrawIconLightning(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 3)
|
| AiDrawIconDay(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 4)
|
| AiDrawIconTrend(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 5)
|
| AiDrawIconLevel(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 6)
|
| AiDrawIconClose(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| else
|
| if(iconId == 7)
|
| AiDrawIconImage(g_ai_canvMain, iconX, iconY, iconSz, iconArgb);
|
| //--- Stamp item label
|
| int lY = iT + (itemH - AiTextHeight("Arial Bold", 9)) / 2;
|
| AiStampTextAA(g_ai_canvMain, iconX + iconSz + 8, lY,
|
| AI_FOOTER_DD_LABELS[i], "Arial Bold", 9, g_ai_titleText);
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Set toast notification text and start timer |
|
| //+------------------------------------------------------------------+
|
| void Ai_ShowToast(string text, bool isError)
|
| {
|
| //--- Store toast text and 5-second expiry
|
| g_ai_toastText = text;
|
| g_ai_toastIsError = isError;
|
| g_ai_toastExpiryMs = GetTickCount64() + 5000;
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render toast notification overlay |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderToast()
|
| {
|
| //--- Bail when no active toast
|
| if(StringLen(g_ai_toastText) == 0)
|
| return;
|
| const ulong now = GetTickCount64();
|
| if(now > g_ai_toastExpiryMs)
|
| return;
|
| //--- Setup padding and font
|
| const int padX = 16;
|
| const int padY = 8;
|
| const string toastFont = "Arial Bold";
|
| const int toastSize = 10;
|
| //--- Measure text dimensions
|
| const int textW = AiTextWidth(g_ai_toastText, toastFont, toastSize);
|
| const int textH = AiTextHeight(toastFont, toastSize);
|
| //--- Setup progress bar geometry
|
| const int barH = 2;
|
| const int barGap = 6;
|
| const int boxW = textW + 2 * padX;
|
| const int boxH = textH + barGap + barH + 2 * padY;
|
| //--- Center horizontally below header
|
| const int mainContentL = Ai_MainContentX();
|
| const int mainContentR = Ai_DashboardW();
|
| const int boxL = mainContentL + ((mainContentR - mainContentL) - boxW) / 2;
|
| const int boxT = AI_HEADER_H + 6;
|
| //--- Fill toast box
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, boxL, boxT, boxW, boxH, 8,
|
| ColorToARGB(g_ai_toastBg, 255));
|
| //--- Draw box border
|
| g_ai_prim.DrawRoundRectBorderSharp(g_ai_canvMain, boxL, boxT, boxW, boxH, 8, 1,
|
| ColorToARGB(g_ai_toastBorder, 255));
|
| //--- Stamp toast text with success/error color
|
| const color textCol = g_ai_toastIsError ? g_ai_toastError : g_ai_toastSuccess;
|
| AiStampTextAA(g_ai_canvMain, boxL + padX, boxT + padY,
|
| g_ai_toastText, toastFont, toastSize, textCol);
|
| //--- Compute progress bar fill width from remaining lifetime
|
| const int trackL = boxL + padX;
|
| const int trackR = boxL + boxW - padX;
|
| const int trackW = trackR - trackL;
|
| const int trackY = boxT + padY + textH + barGap;
|
| const ulong totalLifeMs = 5000;
|
| const long remaining = (long)g_ai_toastExpiryMs - (long)now;
|
| double ratio = (double)remaining / (double)totalLifeMs;
|
| if(ratio < 0.0)
|
| ratio = 0.0;
|
| if(ratio > 1.0)
|
| ratio = 1.0;
|
| const int fillW = (int)(trackW * ratio);
|
| const int fillL = trackL + (trackW - fillW) / 2;
|
| //--- Draw track background
|
| g_ai_canvMain.FillRectangle(trackL, trackY, trackR - 1, trackY + barH - 1,
|
| ColorToARGB(g_ai_toastBorder, 255));
|
| //--- Draw shrinking fill segment
|
| if(fillW > 0)
|
| {
|
| g_ai_canvMain.FillRectangle(fillL, trackY, fillL + fillW - 1, trackY + barH - 1,
|
| ColorToARGB(textCol, 255));
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Snapshot the current canvas as the popup-free backdrop |
|
| //+------------------------------------------------------------------+
|
| void Ai_SnapshotBackdrop()
|
| {
|
| if(!g_ai_canvMainExists)
|
| return;
|
| const int w = g_ai_canvMain.Width();
|
| const int h = g_ai_canvMain.Height();
|
| if(w <= 0 || h <= 0)
|
| return;
|
| //--- Lazily create / grow the backdrop canvas
|
| if(!g_ai_canvBackdropExists || g_ai_canvBackdropW < w || g_ai_canvBackdropH < h)
|
| {
|
| if(g_ai_canvBackdropExists)
|
| g_ai_canvBackdrop.Destroy();
|
| const int nw = MathMax(w, g_ai_canvBackdropW);
|
| const int nh = MathMax(h, g_ai_canvBackdropH);
|
| if(!g_ai_canvBackdrop.CreateBitmap("AiBackdrop", 0, 0, nw, nh, COLOR_FORMAT_ARGB_NORMALIZE))
|
| return;
|
| g_ai_canvBackdropW = nw;
|
| g_ai_canvBackdropH = nh;
|
| g_ai_canvBackdropExists = true;
|
| }
|
| //--- Fast block copy of the whole panel (main UI + border, no overlays yet)
|
| g_ai_canvBackdrop.CopyRectFromCanvas(g_ai_canvMain, 0, 0, w, h);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Clear corner pixels and stroke the outer rounded border |
|
| //+------------------------------------------------------------------+
|
| void Ai_DrawPanelBorder(int dW, int dH, bool arcTL = true, bool arcTR = true,
|
| bool arcBL = true, bool arcBR = true)
|
| {
|
| //--- Corner-clear pixel mask cache (tagged by corner 0=TL 1=TR 2=BL 3=BR)
|
| const int crClear = 10;
|
| static int s_cornerClearX[];
|
| static int s_cornerClearY[];
|
| static int s_cornerClearC[];
|
| static int s_cornerClearW = -1;
|
| static int s_cornerClearH = -1;
|
| //--- Rebuild mask when dashboard size changes
|
| if(s_cornerClearW != dW || s_cornerClearH != dH)
|
| {
|
| const double rdC = (double)crClear;
|
| ArrayResize(s_cornerClearX, 0);
|
| ArrayResize(s_cornerClearY, 0);
|
| ArrayResize(s_cornerClearC, 0);
|
| int n = 0;
|
| //--- Walk all four corners and collect outside-radius pixels
|
| for(int corner = 0; corner < 4; corner++)
|
| {
|
| int cxC = (corner == 0 || corner == 2) ? crClear : (dW - 1 - crClear);
|
| int cyC = (corner == 0 || corner == 1) ? crClear : (dH - 1 - crClear);
|
| int xMin = (corner == 0 || corner == 2) ? 0 : (dW - crClear - 1);
|
| int xMax = (corner == 0 || corner == 2) ? crClear : (dW - 1);
|
| int yMin = (corner == 0 || corner == 1) ? 0 : (dH - crClear - 1);
|
| int yMax = (corner == 0 || corner == 1) ? crClear : (dH - 1);
|
| for(int yy = yMin; yy <= yMax; yy++)
|
| {
|
| for(int xx = xMin; xx <= xMax; xx++)
|
| {
|
| if(xx < 0 || xx >= dW || yy < 0 || yy >= dH)
|
| continue;
|
| const double ddx = (double)(xx - cxC);
|
| const double ddy = (double)(yy - cyC);
|
| const double dist = MathSqrt(ddx * ddx + ddy * ddy);
|
| if(dist > rdC + 0.5)
|
| {
|
| ArrayResize(s_cornerClearX, n + 1);
|
| ArrayResize(s_cornerClearY, n + 1);
|
| ArrayResize(s_cornerClearC, n + 1);
|
| s_cornerClearX[n] = xx;
|
| s_cornerClearY[n] = yy;
|
| s_cornerClearC[n] = corner;
|
| n++;
|
| }
|
| }
|
| }
|
| }
|
| s_cornerClearW = dW;
|
| s_cornerClearH = dH;
|
| }
|
| //--- Clear and re-stroke a corner arc only when its region was repainted, avoiding alpha buildup
|
| const bool arcFlag[4] = { arcTL, arcTR, arcBL, arcBR };
|
| const int ccN = ArraySize(s_cornerClearX);
|
| for(int cci = 0; cci < ccN; cci++)
|
| if(arcFlag[s_cornerClearC[cci]])
|
| g_ai_canvMain.PixelSet(s_cornerClearX[cci], s_cornerClearY[cci], 0x00000000);
|
| //--- Draw straight edges always since solid overwrites do not accumulate
|
| const uint borderArgb = ColorToARGB(g_ai_borderAccent, 255);
|
| g_ai_prim.DrawRoundRectBorderObStyle(g_ai_canvMain, 0, 0, dW, dH, 10, borderArgb,
|
| true, true, true, true,
|
| arcTL, arcTR, arcBL, arcBR);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Fast hover repaint that re-stamps only changed chrome regions |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderHoverFrame(bool sideDirty, bool headDirty, bool footDirty,
|
| bool chatDirty, bool promptDirty)
|
| {
|
| if(!g_ai_canvMainExists)
|
| return;
|
| const int dW = Ai_DashboardW();
|
| //--- Re-stamp only the chrome regions whose hover actually changed
|
| if(sideDirty)
|
| Ai_RenderSidebar();
|
| if(headDirty)
|
| Ai_RenderHeader();
|
| if(footDirty)
|
| Ai_RenderFooter();
|
| //--- Redraw dividers cheaply, clipped to the sidebar, header and footer
|
| g_ai_canvMain.LineHorizontal(0, dW - 1, AI_HEADER_H, ColorToARGB(g_ai_border, 255));
|
| g_ai_canvMain.LineHorizontal(Ai_MainContentX(), dW - 1, Ai_FooterT() - 1,
|
| ColorToARGB(g_ai_border, 255));
|
| //--- Panes only when their own hover-relevant state changed
|
| if(chatDirty)
|
| Ai_RenderChatPane();
|
| if(promptDirty)
|
| Ai_RenderPromptPane();
|
| //--- Re-stamp the panel edge, re-stroking corner arcs only for repainted regions
|
| Ai_DrawPanelBorder(dW, Ai_DashboardH(), headDirty, headDirty, sideDirty, footDirty);
|
| //--- Push the bitmap
|
| g_ai_canvMain.Update();
|
| //--- Keep the prompt overlay canvas in sync only if the prompt changed
|
| if(promptDirty && g_ai_canvPromptExists)
|
| Ai_RenderPromptPaneOverlay(false);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Render entire dashboard |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderAll()
|
| {
|
| //--- Bail when main canvas does not exist
|
| if(!g_ai_canvMainExists)
|
| return;
|
| //--- Get dashboard dimensions
|
| const int dW = Ai_DashboardW();
|
| const int dH = Ai_DashboardH();
|
| //--- Erase canvas and fill rounded background
|
| g_ai_canvMain.Erase(0x00000000);
|
| g_ai_prim.FillRoundRectSharp(g_ai_canvMain, 0, 0, dW, dH, 10,
|
| ColorToARGB(g_ai_bg, 255));
|
| //--- Render sidebar and header
|
| Ai_RenderSidebar();
|
| Ai_RenderHeader();
|
| //--- Draw header divider line
|
| g_ai_canvMain.LineHorizontal(0, dW - 1, AI_HEADER_H,
|
| ColorToARGB(g_ai_border, 255));
|
| //--- Skip main panes when search popup covers them
|
| if(!g_ai_showSearch)
|
| {
|
| Ai_RenderChatPane();
|
| Ai_RenderPromptPane();
|
| Ai_RenderFooter();
|
| //--- Draw footer divider line
|
| g_ai_canvMain.LineHorizontal(Ai_MainContentX(), dW - 1, Ai_FooterT() - 1,
|
| ColorToARGB(g_ai_border, 255));
|
| }
|
| //--- Render search popup overlay
|
| if(g_ai_showSearch)
|
| Ai_RenderPopup(0);
|
| //--- Clear outside-radius corners and stroke the outer rounded border
|
| Ai_DrawPanelBorder(dW, dH);
|
| //--- Render top-level overlay popups after snapshotting the popup-free backdrop
|
| if(g_ai_showFooterDropdown || g_ai_showSmallHistory)
|
| Ai_SnapshotBackdrop();
|
| if(g_ai_showFooterDropdown)
|
| Ai_RenderFooterDropdown();
|
| if(g_ai_showSmallHistory)
|
| Ai_RenderPopup(3);
|
| //--- Render toast last so it overlays everything
|
| Ai_RenderToast();
|
| //--- Full-image viewer sits on top of the toast and everything else
|
| Ai_RenderImageViewer();
|
| //--- Push main canvas bitmap to chart
|
| g_ai_canvMain.Update();
|
| //--- Sync prompt overlay - blank when popup obscures, full otherwise
|
| if(g_ai_canvPromptExists)
|
| {
|
| const bool anyPopupUp =
|
| g_ai_showSearch
|
| || g_ai_showFooterDropdown
|
| || g_ai_showSmallHistory
|
| || g_ai_imgFullView
|
| || (StringLen(g_ai_toastText) > 0);
|
| Ai_RenderPromptPaneOverlay(anyPopupUp);
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Pace interaction frames, capping rasterization near 60fps |
|
| //+------------------------------------------------------------------+
|
| #ifndef AI_UI_FRAME_MS
|
| #define AI_UI_FRAME_MS 16 // ~60fps repaint cap
|
| #endif
|
| uint g_ai_lastFrameMs = 0; // Timestamp of last painted frame
|
| bool g_ai_frameDirty = false; // A capped frame was skipped
|
| bool g_ai_timerFast = false; // Millisecond timer is in fast mode
|
|
|
| //--- Repaint only the changed pane over the persistent buffer instead of the whole canvas
|
| #define AI_RT_NONE 0
|
| #define AI_RT_CHAT 1
|
| #define AI_RT_SEARCH 2
|
| #define AI_RT_PROMPT 3
|
| int g_ai_partialTarget = AI_RT_NONE; // Pane to flush on the next timer tick
|
|
|
| //+------------------------------------------------------------------+
|
| //| Request a repaint, painting now or arming the fast flush timer |
|
| //+------------------------------------------------------------------+
|
| void Ai_RequestFrame()
|
| {
|
| //--- Paint immediately when outside the frame cap window
|
| const uint nowMs = GetTickCount();
|
| if(nowMs - g_ai_lastFrameMs >= AI_UI_FRAME_MS)
|
| {
|
| g_ai_lastFrameMs = nowMs;
|
| g_ai_frameDirty = false;
|
| g_ai_partialTarget = AI_RT_NONE; // full paint covers every pane
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Inside the cap - defer to the timer flush
|
| g_ai_frameDirty = true;
|
| if(!g_ai_timerFast)
|
| {
|
| EventSetMillisecondTimer(AI_UI_FRAME_MS);
|
| g_ai_timerFast = true;
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Re-render a single scrolling pane over the persistent canvas |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderPartial(int target)
|
| {
|
| if(!g_ai_canvMainExists)
|
| return;
|
| if(target == AI_RT_CHAT)
|
| Ai_RenderChatPane();
|
| else
|
| if(target == AI_RT_SEARCH)
|
| Ai_RenderPopup(0);
|
| else
|
| if(target == AI_RT_PROMPT)
|
| {
|
| Ai_RenderPromptPane();
|
| if(g_ai_canvPromptExists)
|
| {
|
| const bool anyPopupUp = g_ai_showSearch || g_ai_showFooterDropdown
|
| || g_ai_showSmallHistory || (StringLen(g_ai_toastText) > 0);
|
| Ai_RenderPromptPaneOverlay(anyPopupUp);
|
| }
|
| }
|
| //--- Re-stamp the panel edge since only the search popup reaches a corner
|
| const bool brDirty = (target == AI_RT_SEARCH);
|
| Ai_DrawPanelBorder(Ai_DashboardW(), Ai_DashboardH(), false, false, false, brDirty);
|
| //--- Push the (mostly unchanged) bitmap to the chart
|
| g_ai_canvMain.Update();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Throttled partial repaint for scroll bursts |
|
| //+------------------------------------------------------------------+
|
| void Ai_RequestPartial(int target)
|
| {
|
| //--- Paint immediately when outside the frame cap window
|
| const uint nowMs = GetTickCount();
|
| if(nowMs - g_ai_lastFrameMs >= AI_UI_FRAME_MS)
|
| {
|
| g_ai_lastFrameMs = nowMs;
|
| g_ai_partialTarget = AI_RT_NONE;
|
| Ai_RenderPartial(target);
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Inside the cap - defer this pane to the timer flush
|
| g_ai_partialTarget = target;
|
| if(!g_ai_timerFast)
|
| {
|
| EventSetMillisecondTimer(AI_UI_FRAME_MS);
|
| g_ai_timerFast = true;
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Repaint only an open top-overlay over the frozen backdrop |
|
| //+------------------------------------------------------------------+
|
| void Ai_RenderOverlayPartial()
|
| {
|
| if(!g_ai_canvMainExists)
|
| return;
|
| //--- Force a full render when no popup-free backdrop was captured yet
|
| if(!g_ai_canvBackdropExists)
|
| {
|
| Ai_RenderAll();
|
| return;
|
| }
|
| const int m = 2; // small margin so any 1px AA fringe is covered
|
| if(g_ai_showFooterDropdown)
|
| {
|
| g_ai_canvMain.CopyRectFromCanvas(g_ai_canvBackdrop,
|
| g_ai_footerDdL - m, g_ai_footerDdT - m,
|
| g_ai_footerDdR + m, g_ai_footerDdB + m);
|
| Ai_RenderFooterDropdown();
|
| }
|
| if(g_ai_showSmallHistory)
|
| {
|
| g_ai_canvMain.CopyRectFromCanvas(g_ai_canvBackdrop,
|
| g_ai_popupL - m, g_ai_popupT - m,
|
| g_ai_popupR + m, g_ai_popupB + m);
|
| Ai_RenderPopup(3);
|
| }
|
| g_ai_canvMain.Update();
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Throttled overlay repaint with full-flush fallback |
|
| //+------------------------------------------------------------------+
|
| void Ai_RequestOverlayFrame()
|
| {
|
| const uint nowMs = GetTickCount();
|
| if(nowMs - g_ai_lastFrameMs >= AI_UI_FRAME_MS)
|
| {
|
| g_ai_lastFrameMs = nowMs;
|
| g_ai_frameDirty = false;
|
| g_ai_partialTarget = AI_RT_NONE;
|
| Ai_RenderOverlayPartial();
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Skipped by the cap: defer to a full flush (guaranteed correct)
|
| g_ai_frameDirty = true;
|
| if(!g_ai_timerFast)
|
| {
|
| EventSetMillisecondTimer(AI_UI_FRAME_MS);
|
| g_ai_timerFast = true;
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Throttled fast-hover repaint with full-flush fallback |
|
| //+------------------------------------------------------------------+
|
| void Ai_RequestHoverFrame(bool sideDirty, bool headDirty, bool footDirty,
|
| bool chatDirty, bool promptDirty)
|
| {
|
| const uint nowMs = GetTickCount();
|
| if(nowMs - g_ai_lastFrameMs >= AI_UI_FRAME_MS)
|
| {
|
| //--- Absorb any pending scroll pane so an in-flight scroll is not lost
|
| if(g_ai_partialTarget == AI_RT_CHAT)
|
| chatDirty = true;
|
| if(g_ai_partialTarget == AI_RT_PROMPT)
|
| promptDirty = true;
|
| g_ai_lastFrameMs = nowMs;
|
| g_ai_frameDirty = false;
|
| g_ai_partialTarget = AI_RT_NONE;
|
| Ai_RenderHoverFrame(sideDirty, headDirty, footDirty, chatDirty, promptDirty);
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Skipped by the cap: defer to a full flush (guaranteed correct)
|
| g_ai_frameDirty = true;
|
| if(!g_ai_timerFast)
|
| {
|
| EventSetMillisecondTimer(AI_UI_FRAME_MS);
|
| g_ai_timerFast = true;
|
| }
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Flush a skipped frame and restore the slow timer after the burst |
|
| //+------------------------------------------------------------------+
|
| void Ai_FlushFrame()
|
| {
|
| //--- A full-frame request supersedes any pending partial pane
|
| if(g_ai_frameDirty)
|
| {
|
| g_ai_frameDirty = false;
|
| g_ai_partialTarget = AI_RT_NONE;
|
| g_ai_lastFrameMs = GetTickCount();
|
| Ai_RenderAll();
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Otherwise flush the pending scroll pane, if any
|
| if(g_ai_partialTarget != AI_RT_NONE)
|
| {
|
| const int t = g_ai_partialTarget;
|
| g_ai_partialTarget = AI_RT_NONE;
|
| g_ai_lastFrameMs = GetTickCount();
|
| Ai_RenderPartial(t);
|
| ChartRedraw();
|
| return;
|
| }
|
| //--- Burst over - drop the timer back to its idle cadence
|
| if(g_ai_timerFast)
|
| {
|
| EventSetMillisecondTimer(500);
|
| g_ai_timerFast = false;
|
| }
|
| }
|
|
|
| #endif // AI_CANVAS_RENDER_MQH
|
| //+------------------------------------------------------------------+
|