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.

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