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.

437 lines
19 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. using IPA.Utilities;
  13. namespace BSIPA_ModList.UI.ViewControllers
  14. {
  15. [RequireComponent(typeof(RectTransform))]
  16. public class MarkdownView : MonoBehaviour
  17. {
  18. private class TagTypeComponent : MonoBehaviour
  19. {
  20. internal BlockTag Tag;
  21. internal HeadingData hData;
  22. }
  23. private string markdown = "";
  24. private bool mdDirty = false;
  25. public string Markdown
  26. {
  27. get => markdown;
  28. set
  29. {
  30. markdown = value;
  31. mdDirty = true;
  32. }
  33. }
  34. public RectTransform rectTransform => GetComponent<RectTransform>();
  35. private ScrollView scrView;
  36. private RectTransform content;
  37. private RectTransform viewport;
  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. gameObject.SetActive(false);
  78. var vpgo = new GameObject("Viewport");
  79. viewport = vpgo.AddComponent<RectTransform>();
  80. viewport.SetParent(transform);
  81. viewport.localPosition = Vector2.zero;
  82. viewport.anchorMin = Vector2.zero;
  83. viewport.anchorMax = Vector2.one;
  84. viewport.anchoredPosition = new Vector2(.5f, .5f);
  85. viewport.sizeDelta = Vector2.zero;
  86. var vpmask = vpgo.AddComponent<Mask>();
  87. var vpim = vpgo.AddComponent<Image>(); // supposedly Mask needs an Image?
  88. vpmask.showMaskGraphic = false;
  89. vpim.color = Color.white;
  90. vpim.sprite = WhitePixel;
  91. vpim.material = CustomUI.Utilities.UIUtilities.NoGlowMaterial;
  92. //view.viewport = viewport;
  93. content = new GameObject("Content Wrapper").AddComponent<RectTransform>();
  94. content.SetParent(viewport);
  95. var contentLayout = content.gameObject.AddComponent<LayoutElement>();
  96. var contentFitter = content.gameObject.AddComponent<ContentSizeFitter>();
  97. contentFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
  98. contentFitter.verticalFit = ContentSizeFitter.FitMode.Unconstrained;
  99. contentLayout.preferredWidth = contentLayout.minWidth = rectTransform.sizeDelta.x; // to be adjusted
  100. content.gameObject.AddComponent<TagTypeComponent>();
  101. content.localPosition = Vector2.zero;
  102. content.anchorMin = new Vector2(.5f, .5f);
  103. content.anchorMax = new Vector2(.5f, .5f);
  104. content.anchoredPosition = Vector2.zero;
  105. //content.sizeDelta = Vector2.zero;
  106. //view.content = content;
  107. var pageUp = Instantiate(Resources.FindObjectsOfTypeAll<Button>().Last((Button x) => x.name == "PageUpButton"), rectTransform, false);
  108. var pageDown = Instantiate(Resources.FindObjectsOfTypeAll<Button>().Last((Button x) => x.name == "PageDownButton"), rectTransform, false);
  109. {
  110. var pup_rt = pageUp.transform as RectTransform;
  111. var pup_sof = pup_rt.sizeDelta.y;
  112. var pup_xoff = (rectTransform.sizeDelta.x / 2) + (pup_sof / 2);
  113. pup_rt.anchoredPosition = new Vector2(pup_xoff, pup_rt.anchoredPosition.y);
  114. var pup_bg_rt = pup_rt.Find("BG") as RectTransform;
  115. pup_bg_rt.sizeDelta = new Vector2(pup_bg_rt.sizeDelta.y, pup_bg_rt.sizeDelta.y);
  116. // fix hitbox
  117. pup_rt.anchorMin = new Vector2(.5f, pup_rt.anchorMin.y);
  118. pup_rt.anchorMax = new Vector2(.5f, pup_rt.anchorMax.y);
  119. pup_rt.sizeDelta = new Vector2(pup_rt.sizeDelta.y, pup_rt.sizeDelta.y);
  120. }
  121. {
  122. var pdn_rt = pageDown.transform as RectTransform;
  123. var pdn_sof = pdn_rt.sizeDelta.y;
  124. var pdn_xoff = (rectTransform.sizeDelta.x / 2) + (pdn_sof / 2);
  125. pdn_rt.anchoredPosition = new Vector2(pdn_xoff, pdn_rt.anchoredPosition.y);
  126. var pdn_bg_rt = pdn_rt.Find("BG") as RectTransform;
  127. pdn_bg_rt.sizeDelta = new Vector2(pdn_bg_rt.sizeDelta.y, pdn_bg_rt.sizeDelta.y);
  128. // fix hitbox
  129. pdn_rt.anchorMin = new Vector2(.5f, pdn_rt.anchorMin.y);
  130. pdn_rt.anchorMax = new Vector2(.5f, pdn_rt.anchorMax.y);
  131. pdn_rt.sizeDelta = new Vector2(pdn_rt.sizeDelta.y, pdn_rt.sizeDelta.y);
  132. }
  133. scrView = gameObject.AddComponent<ScrollView>();
  134. scrView.SetPrivateField("_pageUpButton", pageUp);
  135. scrView.SetPrivateField("_pageDownButton", pageDown);
  136. scrView.SetPrivateField("_contentRectTransform", content);
  137. scrView.SetPrivateField("_viewport", viewport);
  138. gameObject.SetActive(true);
  139. }
  140. private static Sprite whitePixel;
  141. private static Sprite WhitePixel
  142. {
  143. get
  144. {
  145. if (whitePixel == null)
  146. whitePixel = Resources.FindObjectsOfTypeAll<Sprite>().First(s => s.name == "WhitePixel");
  147. return whitePixel;
  148. }
  149. }
  150. #if DEBUG
  151. #if UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  152. private byte tbreakSettings = 0;
  153. #endif
  154. #endif
  155. public void Update()
  156. {
  157. #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  158. if (Input.GetKeyDown(KeyCode.K))
  159. {
  160. tbreakSettings = (byte)((tbreakSettings + 1) % 16);
  161. UpdateMd();
  162. Logger.md.Info(tbreakSettings.ToString());
  163. }
  164. #endif
  165. if (mdDirty)
  166. UpdateMd();
  167. else if (resetContentPosition)
  168. {
  169. resetContentPosition = false;
  170. /*var v = content.anchoredPosition;
  171. v.y = -(content.rect.height / 2);
  172. content.anchoredPosition = v;*/
  173. scrView.Setup();
  174. }
  175. }
  176. private bool resetContentPosition = false;
  177. private void UpdateMd()
  178. {
  179. mdDirty = false;
  180. Clear();
  181. var doc = CommonMarkConverter.Parse(markdown, settings);
  182. Stack<RectTransform> layout = new Stack<RectTransform>();
  183. layout.Push(content);
  184. TextMeshProUGUI currentText = null;
  185. foreach (var node in doc.AsEnumerable())
  186. {
  187. Logger.md.Debug($"node {node}");
  188. if (node.Block != null)
  189. {
  190. var block = node.Block;
  191. const float BreakHeight = .5f;
  192. const int TextInset = 1;
  193. void Spacer(float size = 1.5f)
  194. {
  195. var go = new GameObject("Spacer", typeof(RectTransform));
  196. var vlayout = go.GetComponent<RectTransform>();
  197. vlayout.SetParent(layout.Peek());
  198. vlayout.anchorMin = new Vector2(.5f, .5f);
  199. vlayout.anchorMax = new Vector2(.5f, .5f);
  200. vlayout.localScale = Vector3.one;
  201. vlayout.localPosition = Vector3.zero;
  202. var l = go.AddComponent<LayoutElement>();
  203. l.minHeight = l.preferredHeight = size;
  204. }
  205. HorizontalOrVerticalLayoutGroup BlockNode(string name, float spacing, bool isVertical, Action<TagTypeComponent> apply = null, float? spacer = null, bool isDoc = false)
  206. {
  207. if (node.IsOpening)
  208. {
  209. Logger.md.Debug($"Creating block container {name}");
  210. currentText = null;
  211. var go = new GameObject(name, typeof(RectTransform));
  212. var vlayout = go.GetComponent<RectTransform>();
  213. vlayout.SetParent(layout.Peek());
  214. //vlayout.anchoredPosition = new Vector2(.5f, .5f);
  215. vlayout.anchorMin = new Vector2(.5f, .5f);
  216. vlayout.anchorMax = new Vector2(.5f, .5f);
  217. vlayout.localScale = Vector3.one;
  218. vlayout.localPosition = Vector3.zero;
  219. if (isDoc)
  220. {
  221. vlayout.sizeDelta = Vector2.zero;
  222. vlayout.anchorMin = Vector2.zero;
  223. vlayout.anchorMax = Vector2.one;
  224. vlayout.anchoredPosition = new Vector2(0f, -30f); // no idea where this -30 comes from, but it works for my use
  225. }
  226. var tt = go.AddComponent<TagTypeComponent>();
  227. tt.Tag = block.Tag;
  228. apply?.Invoke(tt);
  229. layout.Push(vlayout);
  230. HorizontalOrVerticalLayoutGroup l;
  231. if (isVertical)
  232. l = go.AddComponent<VerticalLayoutGroup>();
  233. else
  234. l = go.AddComponent<HorizontalLayoutGroup>();
  235. l.childControlHeight = l.childControlWidth = true;
  236. l.childForceExpandHeight = l.childForceExpandWidth = false;
  237. l.childForceExpandWidth = isDoc;
  238. l.spacing = spacing;
  239. /*var cfit = go.AddComponent<ContentSizeFitter>();
  240. cfit.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
  241. cfit.verticalFit = ContentSizeFitter.FitMode.Unconstrained;*/
  242. return l;
  243. }
  244. else if (node.IsClosing)
  245. {
  246. currentText = null;
  247. layout.Pop();
  248. if (spacer.HasValue)
  249. Spacer(spacer.Value);
  250. }
  251. return null;
  252. }
  253. void ThematicBreak()
  254. { // TODO: Fix positioning
  255. var go = new GameObject("ThematicBreak", typeof(RectTransform), typeof(HorizontalLayoutGroup));
  256. var vlayout = go.GetComponent<RectTransform>();
  257. vlayout.SetParent(layout.Peek());
  258. var l = go.GetComponent<HorizontalLayoutGroup>();
  259. #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  260. l.childControlHeight = (tbreakSettings & 0b0001) != 0; // if set, not well behaved
  261. l.childControlWidth = (tbreakSettings & 0b0010) != 0;
  262. l.childForceExpandHeight = (tbreakSettings & 0b0100) != 0; // if set, not well behaved
  263. l.childForceExpandWidth = (tbreakSettings & 0b1000) != 0;
  264. #else
  265. l.childControlHeight = false;
  266. l.childControlWidth = false;
  267. l.childForceExpandHeight = false;
  268. l.childForceExpandWidth = false;
  269. #endif
  270. l.childAlignment = TextAnchor.UpperCenter;
  271. l.spacing = 0f;
  272. vlayout.localScale = Vector3.one;
  273. vlayout.anchoredPosition = Vector2.zero;
  274. vlayout.anchorMin = new Vector2(.5f, .5f);
  275. vlayout.anchorMax = new Vector2(.5f, .5f);
  276. vlayout.sizeDelta = new Vector2(layout.Peek().rect.width, BreakHeight);
  277. vlayout.localPosition = Vector3.zero;
  278. currentText = null;
  279. go = new GameObject("ThematicBreak Bar", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
  280. var im = go.GetComponent<Image>();
  281. im.color = Color.white;
  282. // i think i need to copy the sprite because i'm using the same one for the mask
  283. im.sprite = Sprite.Create(WhitePixel.texture, WhitePixel.rect, Vector2.zero);
  284. im.material = CustomUI.Utilities.UIUtilities.NoGlowMaterial;
  285. var rt = go.GetComponent<RectTransform>();
  286. rt.SetParent(vlayout);
  287. var le = go.GetComponent<LayoutElement>();
  288. le.minWidth = le.preferredWidth = layout.Peek().rect.width;
  289. le.minHeight = le.preferredHeight = BreakHeight;
  290. le.flexibleHeight = le.flexibleWidth = 1f;
  291. rt.localScale = Vector3.one;
  292. rt.localPosition = Vector3.zero;
  293. rt.anchoredPosition = Vector3.zero;
  294. rt.anchorMin = Vector2.zero;
  295. rt.anchorMax = Vector2.one;
  296. rt.sizeDelta = new Vector2(layout.Peek().rect.width, BreakHeight);
  297. Spacer(1f);
  298. }
  299. switch (block.Tag)
  300. {
  301. case BlockTag.Document:
  302. BlockNode("DocumentRoot", .5f, true, isDoc: true);
  303. break;
  304. case BlockTag.SetextHeading:
  305. var l = BlockNode("SeHeading", .1f, false, t => t.hData = block.Heading);
  306. if (l)
  307. {
  308. l.childAlignment = TextAnchor.UpperCenter;
  309. l.padding = new RectOffset(TextInset, TextInset, 0, 0);
  310. }
  311. else ThematicBreak();
  312. break;
  313. case BlockTag.AtxHeading:
  314. l = BlockNode("AtxHeading", .1f, false, t => t.hData = block.Heading);
  315. if (l) l.padding = new RectOffset(TextInset, TextInset, 0, 0);
  316. if (l && block.Heading.Level == 1)
  317. l.childAlignment = TextAnchor.UpperCenter;
  318. break;
  319. case BlockTag.Paragraph:
  320. l = BlockNode("Paragraph", .1f, false, spacer: 1.5f);
  321. if (l) l.padding = new RectOffset(TextInset, TextInset, 0, 0);
  322. break;
  323. case BlockTag.ThematicBreak:
  324. ThematicBreak();
  325. break;
  326. // TODO: add the rest of the tag types
  327. }
  328. }
  329. else if (node.Inline != null)
  330. { // inline element
  331. var inl = node.Inline;
  332. const float PSize = 3.5f;
  333. const float H1Size = 4.8f;
  334. const float HLevelDecrease = 0.5f;
  335. switch (inl.Tag)
  336. {
  337. case InlineTag.String:
  338. if (currentText == null)
  339. {
  340. Logger.md.Debug($"Adding new text element");
  341. var tt = layout.Peek().gameObject.GetComponent<TagTypeComponent>();
  342. currentText = BeatSaberUI.CreateText(layout.Peek(), "", Vector2.zero);
  343. //var le = currentText.gameObject.AddComponent<LayoutElement>();
  344. switch (tt.Tag)
  345. {
  346. case BlockTag.List:
  347. case BlockTag.ListItem:
  348. case BlockTag.Paragraph:
  349. currentText.fontSize = PSize;
  350. currentText.enableWordWrapping = true;
  351. break;
  352. case BlockTag.AtxHeading:
  353. var size = H1Size;
  354. size -= HLevelDecrease * (tt.hData.Level - 1);
  355. currentText.fontSize = size;
  356. currentText.enableWordWrapping = true;
  357. break;
  358. case BlockTag.SetextHeading:
  359. currentText.fontSize = H1Size;
  360. currentText.enableWordWrapping = true;
  361. break;
  362. // TODO: add other relevant types
  363. }
  364. }
  365. Logger.md.Debug($"Appending '{inl.LiteralContent}' to current element");
  366. currentText.text += inl.LiteralContent;
  367. break;
  368. }
  369. }
  370. }
  371. resetContentPosition = true;
  372. }
  373. private void Clear()
  374. {
  375. content.gameObject.SetActive(false);
  376. void Clear(Transform target)
  377. {
  378. foreach (Transform child in target)
  379. {
  380. Clear(child);
  381. Logger.md.Debug($"Destroying {child.name}");
  382. child.SetParent(null);
  383. Destroy(child.gameObject);
  384. }
  385. }
  386. Clear(content);
  387. content.gameObject.SetActive(true);
  388. }
  389. }
  390. }