You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

321 lines
14 KiB

  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text;
  5. using System.Threading.Tasks;
  6. using UnityEngine;
  7. using CommonMark;
  8. using CommonMark.Syntax;
  9. using UnityEngine.UI;
  10. using TMPro;
  11. using CustomUI.BeatSaber;
  12. namespace BSIPA_ModList.UI.ViewControllers
  13. {
  14. [RequireComponent(typeof(RectTransform), typeof(ScrollRect))]
  15. public class MarkdownView : MonoBehaviour
  16. {
  17. private class TagTypeComponent : MonoBehaviour
  18. {
  19. internal BlockTag Tag;
  20. internal HeadingData hData;
  21. }
  22. private string markdown = "";
  23. public string Markdown
  24. {
  25. get => markdown;
  26. set
  27. {
  28. markdown = value;
  29. UpdateMd();
  30. }
  31. }
  32. public RectTransform rectTransform => GetComponent<RectTransform>();
  33. private ScrollRect view;
  34. private RectTransform content;
  35. private RectTransform viewport;
  36. private Scrollbar scrollbar;
  37. private CommonMarkSettings settings;
  38. public MarkdownView()
  39. {
  40. settings = CommonMarkSettings.Default.Clone();
  41. settings.AdditionalFeatures = CommonMarkAdditionalFeatures.All;
  42. settings.RenderSoftLineBreaksAsLineBreaks = false;
  43. settings.UriResolver = ResolveUri;
  44. }
  45. public Func<string, bool> HasEmbeddedImage;
  46. private string ResolveUri(string arg)
  47. {
  48. var name = arg.Substring(3);
  49. if (!arg.StartsWith("!::") && !arg.StartsWith("w::"))
  50. { // !:: means embedded, w:: means web
  51. // this block is for when neither is specified
  52. Logger.md.Debug($"Resolving nonspecific URI {arg}");
  53. // check if its embedded
  54. if (HasEmbeddedImage != null && HasEmbeddedImage(arg))
  55. return "!::" + arg;
  56. else
  57. return "w::" + arg;
  58. }
  59. Logger.md.Debug($"Resolved specific URI {arg}");
  60. return arg;
  61. }
  62. protected void Awake()
  63. {
  64. rectTransform.sizeDelta = new Vector2(100f, 100f);
  65. view = GetComponent<ScrollRect>();
  66. view.verticalScrollbarVisibility = ScrollRect.ScrollbarVisibility.AutoHide;
  67. view.vertical = true;
  68. view.horizontal = false;
  69. view.scrollSensitivity = 0f;
  70. view.movementType = ScrollRect.MovementType.Clamped;
  71. scrollbar = new GameObject("Scrollbar", typeof(RectTransform)).AddComponent<Scrollbar>();
  72. scrollbar.transform.SetParent(transform);
  73. scrollbar.direction = Scrollbar.Direction.TopToBottom;
  74. scrollbar.interactable = true;
  75. view.verticalScrollbar = scrollbar;
  76. var vpgo = new GameObject("Viewport");
  77. viewport = vpgo.AddComponent<RectTransform>();
  78. viewport.SetParent(transform);
  79. viewport.localPosition = Vector2.zero;
  80. viewport.anchorMin = Vector2.zero;
  81. viewport.anchorMax = Vector2.one;
  82. vpgo.AddComponent<Mask>();
  83. view.viewport = viewport;
  84. content = new GameObject("Content Wrapper").AddComponent<RectTransform>();
  85. content.SetParent(viewport);
  86. content.localPosition = Vector2.zero;
  87. content.anchorMin = Vector2.zero;
  88. content.anchorMax = Vector2.one;
  89. var contentLayout = content.gameObject.AddComponent<LayoutElement>();
  90. var contentFitter = content.gameObject.AddComponent<ContentSizeFitter>();
  91. contentFitter.horizontalFit = ContentSizeFitter.FitMode.MinSize;
  92. contentFitter.verticalFit = ContentSizeFitter.FitMode.Unconstrained;
  93. contentLayout.preferredWidth = contentLayout.minWidth = 100f; // to be adjusted
  94. contentLayout.preferredHeight = 0f;
  95. content.gameObject.AddComponent<TagTypeComponent>();
  96. view.content = content;
  97. }
  98. private static Sprite whitePixel;
  99. private static Sprite WhitePixel
  100. {
  101. get
  102. {
  103. if (whitePixel == null)
  104. whitePixel = Resources.FindObjectsOfTypeAll<Sprite>().First(s => s.name == "WhitePixel");
  105. return whitePixel;
  106. }
  107. }
  108. #if DEBUG
  109. #if UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  110. private byte tbreakSettings = 0;
  111. #endif
  112. public void Update()
  113. {
  114. #if UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  115. if (Input.GetKeyDown(KeyCode.K))
  116. {
  117. tbreakSettings = (byte)((tbreakSettings + 1) % 16);
  118. UpdateMd();
  119. Logger.md.Info(tbreakSettings.ToString());
  120. }
  121. #endif
  122. }
  123. #endif
  124. private void UpdateMd()
  125. {
  126. Clear();
  127. var doc = CommonMarkConverter.Parse(markdown, settings);
  128. Stack<RectTransform> layout = new Stack<RectTransform>();
  129. layout.Push(content);
  130. TextMeshProUGUI currentText = null;
  131. foreach (var node in doc.AsEnumerable())
  132. {
  133. Logger.md.Debug($"node {node}");
  134. if (node.Block != null)
  135. {
  136. var block = node.Block;
  137. HorizontalOrVerticalLayoutGroup BlockNode(string name, float spacing, bool isVertical, Action<TagTypeComponent> apply = null, bool isDoc = false)
  138. {
  139. var type = isVertical ? typeof(VerticalLayoutGroup) : typeof(HorizontalLayoutGroup);
  140. if (node.IsOpening)
  141. {
  142. Logger.md.Debug($"Creating block container {name}");
  143. currentText = null;
  144. var go = new GameObject(name, typeof(RectTransform), type);
  145. var vlayout = go.GetComponent<RectTransform>();
  146. vlayout.SetParent(layout.Peek());
  147. vlayout.anchoredPosition = Vector2.zero;
  148. vlayout.localScale = Vector3.one;
  149. vlayout.localPosition = Vector3.zero;
  150. var tt = go.AddComponent<TagTypeComponent>();
  151. tt.Tag = block.Tag;
  152. apply?.Invoke(tt);
  153. layout.Push(vlayout);
  154. HorizontalOrVerticalLayoutGroup l;
  155. if (isVertical)
  156. l = go.GetComponent<VerticalLayoutGroup>();
  157. else
  158. l = go.GetComponent<HorizontalLayoutGroup>();
  159. l.childControlHeight = l.childControlWidth = true;
  160. l.childForceExpandHeight = l.childForceExpandWidth = false;
  161. l.childForceExpandWidth = isDoc;
  162. l.spacing = spacing;
  163. return l;
  164. }
  165. else if (node.IsClosing)
  166. {
  167. currentText = null;
  168. layout.Pop();
  169. }
  170. return null;
  171. }
  172. switch (block.Tag)
  173. {
  174. case BlockTag.Document:
  175. BlockNode("DocumentRoot", .2f, true, isDoc: true);
  176. break;
  177. case BlockTag.SetextHeading:
  178. var l = BlockNode("SeHeading", .1f, false, t => t.hData = block.Heading);
  179. if (l) l.childAlignment = TextAnchor.UpperCenter; // TODO: fix centering
  180. break;
  181. case BlockTag.AtxHeading:
  182. l = BlockNode("AtxHeading", .1f, false, t => t.hData = block.Heading);
  183. if (l && block.Heading.Level == 1)
  184. l.childAlignment = TextAnchor.UpperCenter;
  185. break;
  186. case BlockTag.Paragraph:
  187. BlockNode("Paragraph", .1f, false);
  188. break;
  189. case BlockTag.ThematicBreak:
  190. { // TODO: fix this, it doesn't want to actually show up properly
  191. const float BreakHeight = .5f;
  192. var go = new GameObject("ThematicBreak", typeof(RectTransform), typeof(HorizontalLayoutGroup));
  193. var vlayout = go.GetComponent<RectTransform>();
  194. vlayout.SetParent(layout.Peek());
  195. vlayout.anchoredPosition = Vector2.zero;
  196. l = go.GetComponent<HorizontalLayoutGroup>();
  197. #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  198. l.childControlHeight = (tbreakSettings & 0b0001) != 0; // if set, not well behaved
  199. l.childControlWidth = (tbreakSettings & 0b0010) != 0;
  200. l.childForceExpandHeight = (tbreakSettings & 0b0100) != 0; // if set, not well behaved
  201. l.childForceExpandWidth = (tbreakSettings & 0b1000) != 0;
  202. #else
  203. l.childControlHeight = false;
  204. l.childControlWidth = false;
  205. l.childForceExpandHeight = false;
  206. l.childForceExpandWidth = false;
  207. #endif
  208. l.spacing = 0f;
  209. currentText = null;
  210. go = new GameObject("ThematicBreakBar", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
  211. var im = go.GetComponent<Image>();
  212. im.color = Color.white;
  213. im.sprite = WhitePixel;
  214. im.material = new Material(CustomUI.Utilities.UIUtilities.NoGlowMaterial);
  215. var rt = go.GetComponent<RectTransform>();
  216. rt.SetParent(vlayout);
  217. rt.anchorMin = Vector2.zero;
  218. rt.anchorMax = Vector2.one;
  219. rt.sizeDelta = new Vector2(100f, BreakHeight);
  220. var le = go.GetComponent<LayoutElement>();
  221. le.minWidth = le.preferredWidth = 100f;
  222. le.minHeight = le.preferredHeight = BreakHeight;
  223. le.flexibleHeight = le.flexibleWidth = 1f;
  224. }
  225. break;
  226. // TODO: add the rest of the tag types
  227. }
  228. }
  229. else if (node.Inline != null)
  230. { // inline element
  231. var inl = node.Inline;
  232. const float PSize = 3.5f;
  233. const float H1Size = 4.8f;
  234. const float HLevelDecrease = 0.5f;
  235. switch (inl.Tag)
  236. {
  237. case InlineTag.String:
  238. if (currentText == null)
  239. {
  240. Logger.md.Debug($"Adding new text element");
  241. var tt = layout.Peek().gameObject.GetComponent<TagTypeComponent>();
  242. currentText = BeatSaberUI.CreateText(layout.Peek(), "", Vector2.zero);
  243. //var le = currentText.gameObject.AddComponent<LayoutElement>();
  244. switch (tt.Tag)
  245. {
  246. case BlockTag.List:
  247. case BlockTag.ListItem:
  248. case BlockTag.Paragraph:
  249. currentText.fontSize = PSize;
  250. currentText.enableWordWrapping = true;
  251. break;
  252. case BlockTag.AtxHeading:
  253. var size = H1Size;
  254. size -= HLevelDecrease * (tt.hData.Level - 1);
  255. currentText.fontSize = size;
  256. currentText.enableWordWrapping = true;
  257. break;
  258. case BlockTag.SetextHeading:
  259. currentText.fontSize = H1Size;
  260. currentText.enableWordWrapping = true;
  261. break;
  262. // TODO: add other relevant types
  263. }
  264. }
  265. Logger.md.Debug($"Appending '{inl.LiteralContent}' to current element");
  266. currentText.text += inl.LiteralContent;
  267. break;
  268. }
  269. }
  270. }
  271. }
  272. private void Clear()
  273. {
  274. content.gameObject.SetActive(false);
  275. void Clear(Transform target)
  276. {
  277. foreach (Transform child in target)
  278. {
  279. Clear(child);
  280. Logger.md.Debug($"Destroying {child.name}");
  281. Destroy(child.gameObject);
  282. }
  283. }
  284. Clear(content);
  285. content.gameObject.SetActive(true);
  286. }
  287. }
  288. }