3563 lines
145 KiB
MQL5
3563 lines
145 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| 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_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_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 colour
|
|
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;
|
|
}
|
|
//--- 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;
|
|
int totalH = 0;
|
|
for(int m = 0; m < numMessages; m++)
|
|
{
|
|
totalH += msgBubbleH[m];
|
|
if(StringLen(msgTimestamps[m]) > 0)
|
|
totalH += lineHTime + 2;
|
|
if(m == lastAiIdx)
|
|
totalH += actionRowH + 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);
|
|
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));
|
|
//--- 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;
|
|
}
|
|
//--- Part 12: 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;
|
|
}
|
|
//--- 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)
|
|
{
|
|
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
|
|
//+------------------------------------------------------------------+
|