1 /**
2     Markdown support
3 
4     This is a port of https://github.com/juliettef/imgui_markdown which is under the zlib license!
5 
6     Copyright (c) 2019 Juliette Foucaut and Doug Binks
7 
8     This software is provided 'as-is', without any express or implied
9     warranty. In no event will the authors be held liable for any damages
10     arising from the use of this software.
11 
12     Permission is granted to anyone to use this software for any purpose,
13     including commercial applications, and to alter it and redistribute it
14     freely, subject to the following restrictions:
15 
16     1. The origin of this software must not be misrepresented; you must not
17     claim that you wrote the original software. If you use this software
18     in a product, an acknowledgement in the product documentation would be
19     appreciated but is not required.
20     2. Altered source versions must be plainly marked as such, and must not be
21     misrepresented as being the original software.
22     3. This notice may not be removed or altered from any source distribution.
23 */
24 module creator.widgets.markdown;
25 import creator.widgets.dummy;
26 import bindbc.imgui;
27 
28 struct MarkdownLinkCallbackData {
29     string text;
30     string link;
31     void* userData;
32     bool isImage;
33 }
34 
35 struct MarkdownTooltipCallbackData {
36     MarkdownLinkCallbackData linkData;
37     string linkIcon;
38 }
39 
40 struct MarkdownImageData {
41     bool                    isValid = false;                    // if true, will draw the image
42     bool                    useLinkCallback = false;            // if true, linkCallback will be called when image is clicked
43     ImTextureID             userTextureId = null;                  // see ImGui::Image
44     ImVec2                  size = ImVec2( 100.0f, 100.0f );    // see ImGui::Image
45     ImVec2                  uv0 = ImVec2( 0, 0 );               // see ImGui::Image
46     ImVec2                  uv1 = ImVec2( 1, 1 );               // see ImGui::Image
47     ImVec4                  tint_col = ImVec4( 1, 1, 1, 1 );    // see ImGui::Image
48     ImVec4                  border_col = ImVec4( 0, 0, 0, 0 );  // see ImGui::Image
49 }
50 
51 enum MarkdownFormatType {
52         NormalText,
53         Heading,
54         UnorderedList,
55         Link,
56         Emphasis,
57 }
58 
59 struct MarkdownFormatInfo {
60     MarkdownFormatType      type    = MarkdownFormatType.NormalText;
61     int                     level   = 0;                               // Set for headings: 1 for H1, 2 for H2 etc.
62     bool                    itemHovered = false;                       // Currently only set for links when mouse hovered, only valid when start_ == false
63     MarkdownConfig          config;
64 }
65 
66 struct MarkdownHeadingFormat
67 {   
68     float                   scale     = 1;                      // ImGui font
69     bool                    separator = true;                   // if true, an underlined separator is drawn after the header
70 }
71 
72 // Configuration struct for Markdown
73 // - linkCallback is called when a link is clicked on
74 // - linkIcon is a string which encode a "Link" icon, if available in the current font (e.g. linkIcon = ICON_FA_LINK with FontAwesome + IconFontCppHeaders https://github.com/juliettef/IconFontCppHeaders)
75 // - headingFormats controls the format of heading H1 to H3, those above H3 use H3 format
76 struct MarkdownConfig
77 {
78     enum NUMHEADINGS = 3;
79 
80     incMarkdownLinkCallback              linkCallback;
81     incMarkdownTooltipCallback           tooltipCallback;
82     incMarkdownImageCallback             imageCallback;
83     string                               linkIcon = "";     // icon displayd in link tooltip
84     MarkdownHeadingFormat[NUMHEADINGS]   headingFormats;
85     void*                                userData;
86     incMarkdownFormatCallback            formatCallback = &markdownFmtDefault;
87 }
88 
89 /**
90     Global callback for opening links
91 */
92 alias incMarkdownLinkCallback = void function(MarkdownLinkCallbackData data);
93 
94 /**
95     Global callback for opening image links
96 */
97 alias incMarkdownImageCallback = MarkdownImageData function(MarkdownLinkCallbackData data) ;
98 
99 /**
100     Global callback for displaying tooltips
101 */
102 alias incMarkdownTooltipCallback = void function(MarkdownTooltipCallbackData data);
103 
104 /**
105     Global callback for displaying tooltips
106 */
107 alias incMarkdownFormatCallback = void function(ref MarkdownFormatInfo info, bool start);
108 
109 private {
110     void markdownFmtDefault(ref MarkdownFormatInfo info, bool start) {
111         switch(info.type) {
112             case MarkdownFormatType.Heading:
113                 MarkdownHeadingFormat fmt;
114 
115                 if (info.level > MarkdownConfig.NUMHEADINGS) fmt = info.config.headingFormats[MarkdownConfig.NUMHEADINGS-1];
116                 else fmt = info.config.headingFormats[info.level-1];
117         
118                 if (start) {
119                     igSetWindowFontScale(fmt.scale);
120                     // igNewLine();
121                 } else {
122                     if(fmt.separator) igSeparator();
123                     // igNewLine();
124                     igSetWindowFontScale(1);
125                 }
126                 break;
127             case MarkdownFormatType.Link:
128                 if(start)
129                 {
130                     igPushStyleColor(ImGuiCol.Text, igGetStyle().Colors[ImGuiCol.Button]);
131                 }
132                 else
133                 {
134                     igPopStyleColor();
135                     if(info.itemHovered) incUnderLine(igGetStyle().Colors[ImGuiCol.ButtonHovered]);
136                     else incUnderLine(igGetStyle().Colors[ImGuiCol.Button]);   
137                 }
138                 break;
139             default:
140                 break;
141         }
142     }
143 
144     // Text that starts after a new line (or at beginning) and ends with a newline (or at end)
145     struct Line {
146         bool isHeading = false;
147         bool isEmphasis = false;
148         bool isUnorderedListStart = false;
149         bool isLeadingSpace = true;     // spaces at start of line
150         int  leadSpaceCount = 0;
151         int  headingCount = 0;
152         int  emphasisCount = 0;
153         int  lineStart = 0;
154         int  lineEnd   = 0;
155         int  lastRenderPosition = 0;     // lines may get rendered in multiple pieces
156     }
157 
158     // struct TextBlock {                  // subset of line
159     //     int start = 0;
160     //     int stop  = 0;
161     //     int size() const{
162     //         return stop - start;
163     //     }
164     // }
165 
166     struct Link {
167         enum LinkState {
168             NoLink,
169             HasSquareBracketOpen,
170             HasSquareBrackets,
171             HasSquareBracketsRoundBracketOpen,
172         }
173         LinkState state = LinkState.NoLink;
174         string text;
175         string url;
176         bool isImage = false;
177         int numBracketsOpen = 0;
178     }
179 
180 	struct Emphasis {
181 		enum EmphasisState {
182 			None,
183 			Left,
184 			Middle,
185 			Right,
186 		}
187         EmphasisState state = EmphasisState.None;
188         string text;
189         char sym;
190 	}
191 
192     struct TextRegion {
193     private:
194         float indentX = 0;
195 
196     public:
197         void renderTextWrapped(string textSlice, bool indentToHere) {
198             const(char)* sliceStart = textSlice.ptr;
199             const(char)* sliceEnd = textSlice.ptr+textSlice.length;
200 
201             float scale = igGetIO().FontGlobalScale;
202             float widthLeft = incAvailableSpace().x;
203             ImFont_CalcWordWrapPositionA(igGetFont(), scale, sliceStart, sliceEnd, widthLeft);
204             igTextUnformatted(sliceStart, sliceEnd);
205             
206             // Handle indenting
207             if(indentToHere) {
208                 float indentNeeded = incAvailableSpace().x - widthLeft;
209                 if (indentNeeded) {
210                     igIndent(indentNeeded);
211                     indentX += indentNeeded;
212                 }
213             }
214 
215             widthLeft = incAvailableSpace().x;
216             while(sliceEnd < textSlice.ptr+textSlice.length) {
217                 sliceStart = sliceEnd;
218                 sliceEnd = textSlice.ptr+textSlice.length;
219 
220                 if (*sliceStart == ' ' ) ++sliceStart;
221                 ImFont_CalcWordWrapPositionA(igGetFont(), scale, sliceStart, sliceEnd, widthLeft);
222                 if (sliceStart == sliceEnd) sliceEnd++;
223                 igTextUnformatted(sliceStart, sliceEnd);
224             }
225         }
226 
227         void renderListTextWrapped(string textSlice) {
228             igBullet();
229             igSameLine();
230             renderTextWrapped(textSlice, true);
231         }
232 
233         void renderLinkTextWrapped(string textSlice, ref Link link, string markdown, ref MarkdownConfig cfg, out string linkHoverStart, bool indentToHere) {
234             const(char)* sliceStart = textSlice.ptr;
235             const(char)* sliceEnd = textSlice.ptr+textSlice.length;
236 
237             float scale = igGetIO().FontGlobalScale;
238             float widthLeft = incAvailableSpace().x;
239             ImFont_CalcWordWrapPositionA(igGetFont(), scale, sliceStart, sliceEnd, widthLeft);
240             bool bHovered = renderLinkText(textSlice, link, markdown, cfg, linkHoverStart);
241             if (indentToHere) {
242                 float indentNeeded = incAvailableSpace().x - widthLeft;
243                 if (indentNeeded) {
244                     igIndent(indentNeeded);
245                     indentX += indentNeeded;
246                 }
247             }
248             
249             widthLeft = incAvailableSpace().x;
250             while(sliceEnd < textSlice.ptr+textSlice.length) {
251                 sliceStart = sliceEnd;
252                 sliceEnd = textSlice.ptr+textSlice.length;
253 
254                 if (*sliceStart == ' ' ) ++sliceStart;
255                 ImFont_CalcWordWrapPositionA(igGetFont(), scale, sliceStart, sliceEnd, widthLeft);
256                 
257                 if (sliceStart == sliceEnd) sliceEnd++;
258                 
259                 bool bThisLineHovered = renderLinkText(textSlice, link, markdown, cfg, linkHoverStart);
260                 bHovered = bHovered || bThisLineHovered;
261             }
262             if (bHovered) igSetMouseCursor(ImGuiMouseCursor.Hand);
263 
264             if (!bHovered && linkHoverStart == link.text) {
265                 linkHoverStart = null;
266             }
267         }
268 
269         bool renderLinkText(string textSlice, ref Link link, string markdown, ref MarkdownConfig cfg, out string linkHoverStart) {
270             MarkdownFormatInfo formatInfo;
271             formatInfo.config = cfg;
272             formatInfo.type = MarkdownFormatType.Link;
273             cfg.formatCallback(formatInfo, true);
274 
275             igPushTextWrapPos(-1);
276             igTextUnformatted(textSlice.ptr, textSlice.ptr+textSlice.length);
277             igPopTextWrapPos();
278 
279             bool bIsHovered = igIsItemHovered();
280             if (bIsHovered) {
281                 linkHoverStart = link.text;
282             }
283             bool bHovered = bIsHovered || (linkHoverStart == link.text);
284 
285             formatInfo.itemHovered = bHovered;
286             cfg.formatCallback(formatInfo, false);
287             
288             if (bHovered) {
289                 igSetMouseCursor(ImGuiMouseCursor.Hand);
290                 
291                 if (igIsMouseReleased(ImGuiMouseButton.Left) && cfg.linkCallback) {
292                     cfg.linkCallback(MarkdownLinkCallbackData(link.text, link.url, cfg.userData, false));
293                 }
294 
295                 if (cfg.tooltipCallback) {
296                     cfg.tooltipCallback(MarkdownTooltipCallbackData(
297                         MarkdownLinkCallbackData(
298                             link.text, link.url, cfg.userData, false
299                         ),
300                         cfg.linkIcon
301                     ));
302                 }
303             }
304 
305             return bIsHovered;
306         }
307 
308         void resetIndent() {
309             if (indentX > 0) {
310                 igUnindent(indentX);
311             }
312             indentX = 0;
313         }
314     }
315 
316     void incUnderLine(ImVec4 color) {
317         ImVec2 min, max;
318         igGetItemRectMin(&min);
319         igGetItemRectMax(&max);
320         min.y = max.y;
321         ImDrawList_AddLine(igGetWindowDrawList(), min, max, igGetColorU32(color), 1);
322     }
323 
324     void incRenderLine(string markdown, ref Line line, ref TextRegion textRegion, ref MarkdownConfig cfg) {
325         int indentStart = 0;
326         if (line.isUnorderedListStart) indentStart = 1;
327         for(int j = indentStart; j < line.leadSpaceCount / 2; ++j) igIndent();
328         import std.stdio : writeln;
329 
330         MarkdownFormatInfo formatInfo;
331         formatInfo.config = cfg;
332         int textStart = line.lastRenderPosition+1;
333         int textSize = line.lineEnd - textStart;
334         if (line.isUnorderedListStart) {
335             formatInfo.type = MarkdownFormatType.UnorderedList;
336             cfg.formatCallback(formatInfo, true);
337             string text = markdown[textStart+1..textStart+textSize];
338             textRegion.renderListTextWrapped(text);
339         } else if (line.isHeading) {
340             formatInfo.level = line.headingCount;
341             formatInfo.type = MarkdownFormatType.Heading;
342             cfg.formatCallback(formatInfo, true);
343             string text = markdown[textStart+1..textStart+textSize];
344             textRegion.renderTextWrapped(text, false);
345         } else if (line.isEmphasis) {
346             formatInfo.level = line.emphasisCount;
347             formatInfo.type = MarkdownFormatType.Emphasis;
348             cfg.formatCallback(formatInfo, true);
349             string text = markdown[textStart+1..textStart+textSize];
350             textRegion.renderTextWrapped(text, false);
351         } else {
352             formatInfo.type = MarkdownFormatType.NormalText;
353             cfg.formatCallback(formatInfo, true);
354             string text = markdown[textStart..textStart+textSize];
355             textRegion.renderTextWrapped(text, false);
356         }
357         cfg.formatCallback(formatInfo, false);
358 
359         for(int j = indentStart; j < line.leadSpaceCount / 2; ++j) igUnindent();
360     }
361 }
362 
363 void incMarkdown(string markdown, ref MarkdownConfig cfg) {
364     static string linkHoverStart;
365     ImGuiStyle* style = igGetStyle();
366     Line line;
367     Link link;
368     Emphasis em;
369     TextRegion textRegion;
370 
371     char c;
372     for(int i; i < markdown.length; ++i) {
373         c = markdown[i];
374         
375         if (line.isLeadingSpace) {
376             if (c == ' ') {
377                 ++line.leadSpaceCount;
378                 continue;
379             }
380 
381             line.isLeadingSpace = false;
382             line.lastRenderPosition = i - 1;
383             if (c == '*' && line.leadSpaceCount >= 1) {
384                 if (markdown.length > i+1 && markdown[i+1] == ' ') {
385                     line.isUnorderedListStart = true;
386                     ++i;
387                     ++line.lastRenderPosition;
388                 }
389             } else if (c == '#') {
390                 line.headingCount++;
391                 bool bContinueChecking = true;
392                 
393                 int j = i;
394                 while (++j < markdown.length && bContinueChecking) {
395                     c = markdown[j];
396                     switch(c) {
397                         case '#':
398                             line.headingCount++;
399                             break;
400                         case ' ':
401                             line.lastRenderPosition = j-1;
402                             i = j;
403                             line.isHeading = true;
404                             bContinueChecking = false;
405                             break;
406                         default:
407                             line.isHeading = false;
408                             bContinueChecking = false;
409                             break;
410                     }
411                 }
412 
413                 if (line.isHeading) {
414                     em = Emphasis();
415                     continue;
416                 }
417             }
418         }
419 
420         switch(link.state) {
421             default:
422                 break;
423             case Link.LinkState.NoLink:
424                 if (c == '[' && !line.isHeading) {
425                     link.state = Link.LinkState.HasSquareBracketOpen;
426                     link.text = markdown[i+1..$];
427                     if (i > 0 && markdown[i-1] == '!') {
428                         link.isImage = true;
429                     }
430                 }
431                 break;
432             case Link.LinkState.HasSquareBracketOpen:
433                 if (c == ']') {
434                     link.state = Link.LinkState.HasSquareBrackets;
435                     link.text = markdown[link.text.ptr-markdown.ptr..i];
436                 }
437                 break;
438             case Link.LinkState.HasSquareBrackets:
439                 if (c == '(') {
440                     link.state = Link.LinkState.HasSquareBracketsRoundBracketOpen;
441                     link.url = markdown[i+1..$];
442                     link.numBracketsOpen = 1;
443                 }
444                 break;
445             case Link.LinkState.HasSquareBracketsRoundBracketOpen:
446                 if (c == '(') ++link.numBracketsOpen;
447                 else if (c == ')') --link.numBracketsOpen;
448 
449                 if (link.numBracketsOpen == 0) { 
450                     em = Emphasis();
451 
452                     line.lineEnd = cast(int)(link.text.ptr-markdown.ptr) - (link.isImage ? 2 : 1);
453                     
454                     incRenderLine(markdown, line, textRegion, cfg);
455 
456                     line.leadSpaceCount = 0;
457                     link.url = markdown[link.url.ptr-markdown.ptr..i];
458                     line.isUnorderedListStart = false;
459                     igSameLine(0, 0);
460                     if (link.isImage) {
461                         bool drawnImage = false;
462                         bool useLinkCallback = false;
463                         if (cfg.imageCallback) {
464                             MarkdownImageData imageData = cfg.imageCallback(MarkdownLinkCallbackData(
465                                 link.text, link.url, cfg.userData, true
466                             ));
467                             useLinkCallback = imageData.useLinkCallback;
468                             if (imageData.isValid) {
469                                 igImage(imageData.userTextureId, imageData.size, imageData.uv0, imageData.uv1, imageData.tint_col, imageData.border_col);
470                                 drawnImage = true;
471                             }
472                         }
473                         if (!drawnImage) {
474                             igText("\ubeef");
475                         }
476                         if (igIsItemHovered()) {
477                             if (igIsMouseReleased(ImGuiMouseButton.Left) && cfg.linkCallback) {
478                                 cfg.linkCallback(MarkdownLinkCallbackData(link.text, link.url, cfg.userData, true));
479                             }
480 
481                             if (cfg.tooltipCallback) {
482                                 cfg.tooltipCallback(MarkdownTooltipCallbackData(
483                                     MarkdownLinkCallbackData(
484                                         link.text, link.url, cfg.userData, true
485                                     ),
486                                     cfg.linkIcon
487                                 ));
488                             }
489                         }
490                     } else textRegion.renderLinkTextWrapped(link.text, link, markdown, cfg, linkHoverStart, false);
491                     igSameLine();
492                     link = Link();
493                     line.lastRenderPosition = i;
494                 } 
495                 break;
496         }
497 
498         switch(em.state) {
499             default:
500                 break;
501         }
502 
503         if (c == '\n') {
504             line.lineEnd = i;
505             if (em.state == Emphasis.EmphasisState.Middle && line.emphasisCount >= 3 && (line.lineStart + line.emphasisCount) == i) {
506                 igSeparator();
507             } else {
508                 incRenderLine(markdown, line, textRegion, cfg);
509             }
510 
511             line = Line();
512             em = Emphasis();
513 
514             line.lineStart = i+1;
515             line.lastRenderPosition = i;
516             textRegion.resetIndent();
517 
518             link = Link();
519         }
520     }
521 
522     if (em.state == Emphasis.EmphasisState.Left && line.emphasisCount >= 3) {
523         igSeparator();
524     } else {
525         if (markdown.length && line.lineStart < markdown.length && markdown[line.lineStart] != 0) {
526             line.lineEnd = cast(int)markdown.length;
527             if (0 == markdown[line.lineEnd-1]) --line.lineEnd;
528             incRenderLine(markdown, line, textRegion, cfg);
529         }
530     }
531 }