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.

602 lines
26 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. using System.Reflection;
  14. using UnityEngine.EventSystems;
  15. using System.Diagnostics;
  16. using System.Collections;
  17. namespace BSIPA_ModList.UI.ViewControllers
  18. {
  19. [RequireComponent(typeof(RectTransform))]
  20. public class MarkdownView : MonoBehaviour
  21. {
  22. private class TagTypeComponent : MonoBehaviour
  23. {
  24. internal BlockTag Tag;
  25. internal HeadingData hData;
  26. }
  27. private string markdown = "";
  28. private bool mdDirty = false;
  29. public string Markdown
  30. {
  31. get => markdown;
  32. set
  33. {
  34. markdown = value;
  35. mdDirty = true;
  36. }
  37. }
  38. public RectTransform rectTransform => GetComponent<RectTransform>();
  39. private ScrollView scrView;
  40. private RectTransform content;
  41. private RectTransform viewport;
  42. private CommonMarkSettings settings;
  43. public MarkdownView()
  44. {
  45. settings = CommonMarkSettings.Default.Clone();
  46. settings.AdditionalFeatures = CommonMarkAdditionalFeatures.All;
  47. settings.RenderSoftLineBreaksAsLineBreaks = false;
  48. settings.UriResolver = ResolveUri;
  49. }
  50. public Func<string, bool> HasEmbeddedImage;
  51. private string ResolveUri(string arg)
  52. {
  53. var name = arg.Substring(3);
  54. if (!arg.StartsWith("!::") && !arg.StartsWith("w::"))
  55. { // !:: means embedded, w:: means web
  56. // this block is for when neither is specified
  57. Logger.md.Debug($"Resolving nonspecific URI {arg}");
  58. // check if its embedded
  59. if (HasEmbeddedImage != null && HasEmbeddedImage(arg))
  60. return "!::" + arg;
  61. else
  62. return "w::" + arg;
  63. }
  64. Logger.md.Debug($"Resolved specific URI {arg}");
  65. return arg;
  66. }
  67. private static string GetLinkUri(string uri)
  68. {
  69. if (uri[0] == '!')
  70. {
  71. Logger.md.Error($"Cannot link to embedded resource in mod description");
  72. return null;
  73. }
  74. else
  75. return uri.Substring(3);
  76. }
  77. private static AssetBundle _bundle;
  78. private static AssetBundle Bundle
  79. {
  80. get
  81. {
  82. if (_bundle == null)
  83. _bundle = AssetBundle.LoadFromStream(Assembly.GetExecutingAssembly().GetManifestResourceStream("BSIPA_ModList.Bundles.consolas.font"));
  84. return _bundle;
  85. }
  86. }
  87. private static TMP_FontAsset _consolas;
  88. private static TMP_FontAsset Consolas
  89. {
  90. get
  91. {
  92. if (_consolas == null)
  93. {
  94. _consolas = Bundle?.LoadAsset<TMP_FontAsset>("CONSOLAS");
  95. if (_consolas != null)
  96. {
  97. var originalFont = Resources.FindObjectsOfTypeAll<TMP_FontAsset>().Last(f => f.name == "Teko-Medium SDF No Glow");
  98. var matCopy = Instantiate(originalFont.material);
  99. matCopy.mainTexture = _consolas.material.mainTexture;
  100. matCopy.mainTextureOffset = _consolas.material.mainTextureOffset;
  101. matCopy.mainTextureScale = _consolas.material.mainTextureScale;
  102. _consolas.material = matCopy;
  103. MaterialReferenceManager.AddFontAsset(_consolas);
  104. }
  105. }
  106. return _consolas;
  107. }
  108. }
  109. protected void Awake()
  110. {
  111. if (Consolas == null)
  112. Logger.md.Error($"Loading of Consolas font failed");
  113. gameObject.SetActive(false);
  114. var vpgo = new GameObject("Viewport");
  115. viewport = vpgo.AddComponent<RectTransform>();
  116. viewport.SetParent(transform);
  117. viewport.localPosition = Vector2.zero;
  118. viewport.anchorMin = Vector2.zero;
  119. viewport.anchorMax = Vector2.one;
  120. viewport.anchoredPosition = new Vector2(.5f, .5f);
  121. viewport.sizeDelta = Vector2.zero;
  122. var vpmask = vpgo.AddComponent<Mask>();
  123. var vpim = vpgo.AddComponent<Image>(); // supposedly Mask needs an Image?
  124. vpmask.showMaskGraphic = false;
  125. vpim.color = Color.white;
  126. vpim.sprite = WhitePixel;
  127. vpim.material = CustomUI.Utilities.UIUtilities.NoGlowMaterial;
  128. content = new GameObject("Content Wrapper").AddComponent<RectTransform>();
  129. content.SetParent(viewport);
  130. content.gameObject.AddComponent<TagTypeComponent>();
  131. content.localPosition = Vector2.zero;
  132. content.anchorMin = new Vector2(0f, 1f);
  133. content.anchorMax = new Vector2(1f, 1f);
  134. content.anchoredPosition = Vector2.zero;
  135. var contentLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
  136. var contentFitter = content.gameObject.AddComponent<ContentSizeFitter>();
  137. contentFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
  138. contentFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
  139. contentLayout.childControlHeight = true;
  140. contentLayout.childControlWidth = false;
  141. contentLayout.childForceExpandHeight = false;
  142. contentLayout.childForceExpandWidth = true;
  143. contentLayout.childAlignment = TextAnchor.UpperCenter;
  144. contentLayout.spacing = 0f;
  145. var pageUp = Instantiate(Resources.FindObjectsOfTypeAll<Button>().Last((Button x) => x.name == "PageUpButton"), rectTransform, false);
  146. var pageDown = Instantiate(Resources.FindObjectsOfTypeAll<Button>().Last((Button x) => x.name == "PageDownButton"), rectTransform, false);
  147. {
  148. var pup_rt = pageUp.transform as RectTransform;
  149. var pup_sof = pup_rt.sizeDelta.y;
  150. var pup_xoff = (rectTransform.sizeDelta.x / 2) + (pup_sof / 2);
  151. pup_rt.anchoredPosition = new Vector2(pup_xoff, pup_rt.anchoredPosition.y);
  152. var pup_bg_rt = pup_rt.Find("BG") as RectTransform;
  153. pup_bg_rt.sizeDelta = new Vector2(pup_bg_rt.sizeDelta.y, pup_bg_rt.sizeDelta.y);
  154. // fix hitbox
  155. pup_rt.anchorMin = new Vector2(.5f, pup_rt.anchorMin.y);
  156. pup_rt.anchorMax = new Vector2(.5f, pup_rt.anchorMax.y);
  157. pup_rt.sizeDelta = new Vector2(pup_rt.sizeDelta.y, pup_rt.sizeDelta.y);
  158. }
  159. {
  160. var pdn_rt = pageDown.transform as RectTransform;
  161. var pdn_sof = pdn_rt.sizeDelta.y;
  162. var pdn_xoff = (rectTransform.sizeDelta.x / 2) + (pdn_sof / 2);
  163. pdn_rt.anchoredPosition = new Vector2(pdn_xoff, pdn_rt.anchoredPosition.y);
  164. var pdn_bg_rt = pdn_rt.Find("BG") as RectTransform;
  165. pdn_bg_rt.sizeDelta = new Vector2(pdn_bg_rt.sizeDelta.y, pdn_bg_rt.sizeDelta.y);
  166. // fix hitbox
  167. pdn_rt.anchorMin = new Vector2(.5f, pdn_rt.anchorMin.y);
  168. pdn_rt.anchorMax = new Vector2(.5f, pdn_rt.anchorMax.y);
  169. pdn_rt.sizeDelta = new Vector2(pdn_rt.sizeDelta.y, pdn_rt.sizeDelta.y);
  170. }
  171. scrView = gameObject.AddComponent<ScrollView>();
  172. scrView.SetPrivateField("_pageUpButton", pageUp);
  173. scrView.SetPrivateField("_pageDownButton", pageDown);
  174. scrView.SetPrivateField("_contentRectTransform", content);
  175. scrView.SetPrivateField("_viewport", viewport);
  176. gameObject.SetActive(true);
  177. }
  178. private static Sprite whitePixel;
  179. private static Sprite WhitePixel
  180. {
  181. get
  182. {
  183. if (whitePixel == null)
  184. whitePixel = Resources.FindObjectsOfTypeAll<Sprite>().First(s => s.name == "WhitePixel");
  185. return whitePixel;
  186. }
  187. }
  188. #if DEBUG
  189. #if UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  190. private byte tbreakSettings = 0;
  191. #endif
  192. #endif
  193. public void Update()
  194. {
  195. #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  196. if (Input.GetKeyDown(KeyCode.K))
  197. {
  198. tbreakSettings = (byte)((tbreakSettings + 1) % 16);
  199. UpdateMd();
  200. Logger.md.Info(tbreakSettings.ToString());
  201. }
  202. #endif
  203. if (mdDirty)
  204. StartCoroutine(UpdateMd());
  205. }
  206. [Flags]
  207. private enum CurrentTextFlags
  208. {
  209. None = 0, Bold = 1, Italic = 2, Underline = 4, Strikethrough = 8,
  210. }
  211. private const string LinkDefaultColor = "#0061ff";
  212. private const string LinkHoverColor = "#009dff";
  213. private IEnumerator UpdateMd()
  214. {
  215. mdDirty = false;
  216. Clear();
  217. // enable so it will set stuff up
  218. content.gameObject.GetComponent<VerticalLayoutGroup>().enabled = true;
  219. var doc = CommonMarkConverter.Parse(markdown, settings);
  220. Stack<RectTransform> layout = new Stack<RectTransform>();
  221. layout.Push(content);
  222. TextMeshProUGUI currentText = null;
  223. List<TextMeshProUGUI> texts = new List<TextMeshProUGUI>();
  224. CurrentTextFlags textFlags = 0;
  225. foreach (var node in doc.AsEnumerable())
  226. {
  227. Logger.md.Debug($"node {node}");
  228. if (node.Block != null)
  229. {
  230. var block = node.Block;
  231. const float BreakHeight = .5f;
  232. const int TextInset = 1;
  233. void Spacer(float size = 1.5f)
  234. {
  235. var go = new GameObject("Spacer", typeof(RectTransform));
  236. var vlayout = go.GetComponent<RectTransform>();
  237. vlayout.SetParent(layout.Peek());
  238. vlayout.anchorMin = new Vector2(.5f, .5f);
  239. vlayout.anchorMax = new Vector2(.5f, .5f);
  240. vlayout.localScale = Vector3.one;
  241. vlayout.localPosition = Vector3.zero;
  242. var l = go.AddComponent<LayoutElement>();
  243. l.minHeight = l.preferredHeight = size;
  244. }
  245. HorizontalOrVerticalLayoutGroup BlockNode(string name, float spacing, bool isVertical, Action<TagTypeComponent> apply = null, float? spacer = null, bool isDoc = false)
  246. {
  247. if (node.IsOpening)
  248. {
  249. Logger.md.Debug($"Creating block container {name}");
  250. currentText = null;
  251. var go = new GameObject(name, typeof(RectTransform));
  252. var vlayout = go.GetComponent<RectTransform>();
  253. vlayout.SetParent(layout.Peek());
  254. //vlayout.anchoredPosition = new Vector2(.5f, .5f);
  255. vlayout.anchorMin = new Vector2(.5f, .5f);
  256. vlayout.anchorMax = new Vector2(.5f, .5f);
  257. vlayout.localScale = Vector3.one;
  258. vlayout.localPosition = Vector3.zero;
  259. if (isDoc)
  260. {
  261. vlayout.sizeDelta = new Vector2(rectTransform.rect.width, 0f);
  262. vlayout.anchorMin = new Vector2(0f, 1f);
  263. vlayout.anchorMax = new Vector2(1f, 1f);
  264. //vlayout.anchoredPosition = new Vector2(0f, -30f); // no idea where this -30 comes from, but it works for my use
  265. }
  266. var tt = go.AddComponent<TagTypeComponent>();
  267. tt.Tag = block.Tag;
  268. apply?.Invoke(tt);
  269. layout.Push(vlayout);
  270. HorizontalOrVerticalLayoutGroup l;
  271. if (isVertical)
  272. l = go.AddComponent<VerticalLayoutGroup>();
  273. else
  274. l = go.AddComponent<HorizontalLayoutGroup>();
  275. l.childControlHeight = l.childControlWidth = true;
  276. l.childForceExpandHeight = l.childForceExpandWidth = false;
  277. l.childForceExpandWidth = isDoc;
  278. l.spacing = spacing;
  279. if (isDoc)
  280. {
  281. vlayout.anchoredPosition = new Vector2(0f, -vlayout.rect.height);
  282. }
  283. return l;
  284. }
  285. else if (node.IsClosing)
  286. {
  287. currentText = null;
  288. layout.Pop();
  289. if (spacer.HasValue)
  290. Spacer(spacer.Value);
  291. }
  292. return null;
  293. }
  294. void ThematicBreak()
  295. { // TODO: Fix positioning
  296. var go = new GameObject("ThematicBreak", typeof(RectTransform), typeof(HorizontalLayoutGroup));
  297. var vlayout = go.GetComponent<RectTransform>();
  298. vlayout.SetParent(layout.Peek());
  299. var l = go.GetComponent<HorizontalLayoutGroup>();
  300. #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK
  301. l.childControlHeight = (tbreakSettings & 0b0001) != 0; // if set, not well behaved
  302. l.childControlWidth = (tbreakSettings & 0b0010) != 0;
  303. l.childForceExpandHeight = (tbreakSettings & 0b0100) != 0; // if set, not well behaved
  304. l.childForceExpandWidth = (tbreakSettings & 0b1000) != 0;
  305. #else
  306. l.childControlHeight = false;
  307. l.childControlWidth = false;
  308. l.childForceExpandHeight = false;
  309. l.childForceExpandWidth = false;
  310. #endif
  311. l.childAlignment = TextAnchor.UpperCenter;
  312. l.spacing = 0f;
  313. vlayout.localScale = Vector3.one;
  314. vlayout.anchoredPosition = Vector2.zero;
  315. vlayout.anchorMin = new Vector2(.5f, .5f);
  316. vlayout.anchorMax = new Vector2(.5f, .5f);
  317. vlayout.sizeDelta = new Vector2(layout.Peek().rect.width, BreakHeight);
  318. vlayout.localPosition = Vector3.zero;
  319. currentText = null;
  320. go = new GameObject("ThematicBreak Bar", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
  321. var im = go.GetComponent<Image>();
  322. im.color = Color.white;
  323. // i think i need to copy the sprite because i'm using the same one for the mask
  324. im.sprite = Sprite.Create(WhitePixel.texture, WhitePixel.rect, Vector2.zero);
  325. im.material = CustomUI.Utilities.UIUtilities.NoGlowMaterial;
  326. var rt = go.GetComponent<RectTransform>();
  327. rt.SetParent(vlayout);
  328. var le = go.GetComponent<LayoutElement>();
  329. le.minWidth = le.preferredWidth = layout.Peek().rect.width;
  330. le.minHeight = le.preferredHeight = BreakHeight;
  331. le.flexibleHeight = le.flexibleWidth = 1f;
  332. rt.localScale = Vector3.one;
  333. rt.localPosition = Vector3.zero;
  334. rt.anchoredPosition = Vector3.zero;
  335. rt.anchorMin = Vector2.zero;
  336. rt.anchorMax = Vector2.one;
  337. rt.sizeDelta = new Vector2(layout.Peek().rect.width, BreakHeight);
  338. Spacer(1f);
  339. }
  340. switch (block.Tag)
  341. {
  342. case BlockTag.Document:
  343. BlockNode("DocumentRoot", .5f, true, isDoc: true);
  344. break;
  345. case BlockTag.SetextHeading:
  346. var l = BlockNode("SeHeading", .1f, false, t => t.hData = block.Heading);
  347. if (l)
  348. {
  349. l.childAlignment = TextAnchor.UpperCenter;
  350. l.padding = new RectOffset(TextInset, TextInset, 0, 0);
  351. }
  352. else ThematicBreak();
  353. break;
  354. case BlockTag.AtxHeading:
  355. l = BlockNode("AtxHeading", .1f, false, t => t.hData = block.Heading);
  356. if (l) l.padding = new RectOffset(TextInset, TextInset, 0, 0);
  357. if (l && block.Heading.Level == 1)
  358. l.childAlignment = TextAnchor.UpperCenter;
  359. break;
  360. case BlockTag.Paragraph:
  361. l = BlockNode("Paragraph", .1f, false, spacer: 1.5f);
  362. if (l) l.padding = new RectOffset(TextInset, TextInset, 0, 0);
  363. break;
  364. case BlockTag.ThematicBreak:
  365. ThematicBreak();
  366. break;
  367. // TODO: add the rest of the tag types
  368. }
  369. }
  370. else if (node.Inline != null)
  371. { // inline element
  372. var inl = node.Inline;
  373. void Flag(CurrentTextFlags flag)
  374. {
  375. if (node.IsOpening)
  376. textFlags |= flag;
  377. else if (node.IsClosing)
  378. textFlags &= ~flag;
  379. }
  380. const float PSize = 3.5f;
  381. const float H1Size = 4.8f;
  382. const float HLevelDecrease = 0.5f;
  383. void EnsureText()
  384. {
  385. if (currentText == null)
  386. {
  387. Logger.md.Debug($"Adding new text element");
  388. var tt = layout.Peek().gameObject.GetComponent<TagTypeComponent>();
  389. currentText = BeatSaberUI.CreateText(layout.Peek(), "", Vector2.zero);
  390. currentText.gameObject.AddComponent<TextLinkDecoder>();
  391. switch (tt.Tag)
  392. {
  393. case BlockTag.List:
  394. case BlockTag.ListItem:
  395. case BlockTag.Paragraph:
  396. currentText.fontSize = PSize;
  397. currentText.enableWordWrapping = true;
  398. break;
  399. case BlockTag.AtxHeading:
  400. var size = H1Size;
  401. size -= HLevelDecrease * (tt.hData.Level - 1);
  402. currentText.fontSize = size;
  403. currentText.enableWordWrapping = true;
  404. if (tt.hData.Level == 1)
  405. currentText.alignment = TextAlignmentOptions.Center;
  406. break;
  407. case BlockTag.SetextHeading:
  408. currentText.fontSize = H1Size;
  409. currentText.enableWordWrapping = true;
  410. currentText.alignment = TextAlignmentOptions.Center;
  411. break;
  412. // TODO: add other relevant types
  413. }
  414. texts.Add(currentText);
  415. }
  416. }
  417. switch (inl.Tag)
  418. {
  419. case InlineTag.String:
  420. EnsureText();
  421. string head = "<noparse>", tail = "</noparse>";
  422. if (textFlags.HasFlag(CurrentTextFlags.Bold))
  423. { head = "<b>" + head; tail += "</b>"; }
  424. if (textFlags.HasFlag(CurrentTextFlags.Italic))
  425. { head = "<i>" + head; tail += "</i>"; }
  426. if (textFlags.HasFlag(CurrentTextFlags.Strikethrough))
  427. { head = "<s>" + head; tail += "</s>"; }
  428. if (textFlags.HasFlag(CurrentTextFlags.Underline))
  429. { head = "<u>" + head; tail += "</u>"; }
  430. currentText.text += head + inl.LiteralContent + tail;
  431. break;
  432. case InlineTag.Strong:
  433. Flag(CurrentTextFlags.Bold);
  434. break;
  435. case InlineTag.Strikethrough:
  436. Flag(CurrentTextFlags.Strikethrough);
  437. break;
  438. case InlineTag.Emphasis:
  439. Flag(CurrentTextFlags.Italic);
  440. break;
  441. case InlineTag.Code:
  442. EnsureText();
  443. currentText.text += $"<font=\"CONSOLAS\"><size=80%><mark=#A0A0C080><noparse>{inl.LiteralContent}</noparse></mark></size></font>";
  444. break;
  445. case InlineTag.SoftBreak:
  446. EnsureText();
  447. currentText.text += " "; // soft breaks translate to a space
  448. break;
  449. case InlineTag.Link:
  450. EnsureText();
  451. Flag(CurrentTextFlags.Underline);
  452. if (node.IsOpening)
  453. currentText.text += $"<color={LinkDefaultColor}><link=\"{ResolveUri(inl.TargetUrl)}\">";
  454. else if (node.IsClosing)
  455. currentText.text += "</link></color>";
  456. break;
  457. }
  458. }
  459. }
  460. yield return null; // delay one frame
  461. scrView.Setup();
  462. // this is the bullshit I have to use to make it work properly
  463. content.gameObject.GetComponent<VerticalLayoutGroup>().enabled = false;
  464. var childRt = content.GetChild(0) as RectTransform;
  465. childRt.anchoredPosition = new Vector2(0f, childRt.anchoredPosition.y);
  466. }
  467. private class TextLinkDecoder : MonoBehaviour, IPointerClickHandler
  468. {
  469. private TextMeshProUGUI tmp;
  470. public void Awake()
  471. {
  472. tmp = GetComponent<TextMeshProUGUI>();
  473. }
  474. public void OnPointerClick(PointerEventData eventData)
  475. {
  476. // this may not actually get me what i want
  477. int linkIndex = TMP_TextUtilities.FindIntersectingLink(tmp, eventData.pointerPressRaycast.worldPosition, null);
  478. if (linkIndex != -1)
  479. { // was a link clicked?
  480. TMP_LinkInfo linkInfo = tmp.textInfo.linkInfo[linkIndex];
  481. // open the link id as a url, which is the metadata we added in the text field
  482. var qualifiedUrl = linkInfo.GetLinkID();
  483. if (qualifiedUrl.StartsWith("$$"))
  484. return; // this means its used for something else
  485. Logger.md.Debug($"Link pressed {qualifiedUrl}");
  486. var uri = GetLinkUri(qualifiedUrl);
  487. if (uri != null)
  488. Process.Start(uri);
  489. }
  490. }
  491. private List<Color32[]> SetLinkToColor(int linkIndex, Color32 color)
  492. {
  493. TMP_LinkInfo linkInfo = tmp.textInfo.linkInfo[linkIndex];
  494. var oldVertColors = new List<Color32[]>(); // store the old character colors
  495. for (int i = 0; i < linkInfo.linkTextLength; i++)
  496. { // for each character in the link string
  497. int characterIndex = linkInfo.linkTextfirstCharacterIndex + i; // the character index into the entire text
  498. var charInfo = tmp.textInfo.characterInfo[characterIndex];
  499. int meshIndex = charInfo.materialReferenceIndex; // Get the index of the material / sub text object used by this character.
  500. int vertexIndex = charInfo.vertexIndex; // Get the index of the first vertex of this character.
  501. Color32[] vertexColors = tmp.textInfo.meshInfo[meshIndex].colors32; // the colors for this character
  502. oldVertColors.Add(vertexColors.ToArray());
  503. if (charInfo.isVisible)
  504. {
  505. vertexColors[vertexIndex + 0] = color;
  506. vertexColors[vertexIndex + 1] = color;
  507. vertexColors[vertexIndex + 2] = color;
  508. vertexColors[vertexIndex + 3] = color;
  509. }
  510. }
  511. // Update Geometry
  512. tmp.UpdateVertexData(TMP_VertexDataUpdateFlags.All);
  513. return oldVertColors;
  514. }
  515. }
  516. private void Clear()
  517. {
  518. content.gameObject.SetActive(false);
  519. void Clear(Transform target)
  520. {
  521. foreach (Transform child in target)
  522. {
  523. Clear(child);
  524. Logger.md.Debug($"Destroying {child.name}");
  525. child.SetParent(null);
  526. Destroy(child.gameObject);
  527. }
  528. }
  529. Clear(content);
  530. content.gameObject.SetActive(true);
  531. }
  532. }
  533. }