From f53305ce42aff67ae259dda0bddbf69507fd7248 Mon Sep 17 00:00:00 2001 From: Ellpeck Date: Mon, 13 Jun 2022 23:52:10 +0200 Subject: [PATCH] Added UiMarkdownParser --- CHANGELOG.md | 1 + Demos/Content/Content.mgcb | 15 +- Demos/Content/Markdown.md | 23 +++ Demos/UiDemo.cs | 9 + MLEM.Ui/Parsers/UiMarkdownParser.cs | 292 ++++++++++++++++++++++++++++ 5 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 Demos/Content/Markdown.md create mode 100644 MLEM.Ui/Parsers/UiMarkdownParser.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f13e47f..c0bd88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Improvements ### MLEM.Ui Additions - Added Element.AutoNavGroup which allows forming groups for auto-navigation +- Added UiMarkdownParser Improvements - Ensure that Element.IsMouseOver is always accurate by making it an auto-property diff --git a/Demos/Content/Content.mgcb b/Demos/Content/Content.mgcb index 98cff09..8ef5041 100644 --- a/Demos/Content/Content.mgcb +++ b/Demos/Content/Content.mgcb @@ -13,6 +13,13 @@ #---------------------------------- Content ---------------------------------# +#begin Fonts/MonospacedFont.spritefont +/importer:FontDescriptionImporter +/processor:FontDescriptionProcessor +/processorParam:PremultiplyAlpha=True +/processorParam:TextureFormat=Compressed +/build:Fonts/MonospacedFont.spritefont + #begin Fonts/TestFont.spritefont /importer:FontDescriptionImporter /processor:FontDescriptionProcessor @@ -34,12 +41,8 @@ /processorParam:TextureFormat=Compressed /build:Fonts/TestFontItalic.spritefont -#begin Fonts/MonospacedFont.spritefont -/importer:FontDescriptionImporter -/processor:FontDescriptionProcessor -/processorParam:PremultiplyAlpha=True -/processorParam:TextureFormat=Compressed -/build:Fonts/MonospacedFont.spritefont +#begin Markdown.md +/copy:Markdown.md #begin Textures/Anim.png /importer:TextureImporter diff --git a/Demos/Content/Markdown.md b/Demos/Content/Markdown.md new file mode 100644 index 0000000..76b495b --- /dev/null +++ b/Demos/Content/Markdown.md @@ -0,0 +1,23 @@ +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Italics with *asterisks* or _underscores_. +Bold with **asterisks** or __underscores__. +Strikethrough with ~~two tildes~~. + +[I'm an inline-style link](https://www.google.com) + + +![](https://raw.githubusercontent.com/Ellpeck/MLEM/main/Media/Logo.png) + +Some `inline code` right here + +```js +function codeBlock() { + +} +``` \ No newline at end of file diff --git a/Demos/UiDemo.cs b/Demos/UiDemo.cs index 98ac9f5..861e621 100644 --- a/Demos/UiDemo.cs +++ b/Demos/UiDemo.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Coroutine; using Microsoft.Xna.Framework; @@ -14,6 +15,7 @@ using MLEM.Startup; using MLEM.Textures; using MLEM.Ui; using MLEM.Ui.Elements; +using MLEM.Ui.Parsers; using MLEM.Ui.Style; namespace Demos { @@ -223,6 +225,13 @@ namespace Demos { alignPar.Alignment = alignment; }; + this.root.AddChild(new VerticalSpace(3)); + this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "MLEM.Ui also contains a simple Markdown parser, which can be useful for displaying things like changelogs in your game.")); + this.root.AddChild(new VerticalSpace(3)); + var parser = new UiMarkdownParser {GraphicsDevice = this.GraphicsDevice}; + using (var reader = new StreamReader(TitleContainer.OpenStream("Content/Markdown.md"))) + parser.ParseInto(reader.ReadToEnd(), this.root); + this.root.AddChild(new VerticalSpace(3)); this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "The code for this demo contains some examples for how to query element data. This is the output of that:")); diff --git a/MLEM.Ui/Parsers/UiMarkdownParser.cs b/MLEM.Ui/Parsers/UiMarkdownParser.cs new file mode 100644 index 0000000..612c85d --- /dev/null +++ b/MLEM.Ui/Parsers/UiMarkdownParser.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MLEM.Formatting; +using MLEM.Misc; +using MLEM.Textures; +using MLEM.Ui.Elements; +using MLEM.Ui.Style; + +namespace MLEM.Ui.Parsers { + /// + /// A class for parsing Markdown strings into a set of MLEM.Ui elements with styling for each individual . + /// To parse, use or . To style the parsed output, use before parsing. + /// + /// + /// Note that this parser is rather rudimentary and doesn't deal well with very complex Markdown documents. Missing features are as follows: + /// + /// Lines that end without a double space are still converted to distinct lines rather than being merged with the next line + /// Better list handling, including nested lists + /// Horizontal rules + /// Tables + /// + /// + public class UiMarkdownParser { + + private static readonly ElementType[] ElementTypes = EnumHelper.GetValues().ToArray(); + + /// + /// The base path for markdown images, which is prepended to the image link. + /// Note that this is only applied for local images (ones whose link doesn't start with http). + /// + public string ImageBasePath; + /// + /// An action that is invoked when an image fails to load while parsing. + /// This action receives the expected location of the image, as well as the that occured. + /// + public Action ImageExceptionHandler; + /// + /// The graphics device that should be used when loading images and other graphics-dependent content. + /// + public GraphicsDevice GraphicsDevice; + /// + /// The name of the font used for inline code as well as code blocks. + /// This only has an effect if a font with this name is added to the used 's . + /// This defaults to "Monospaced" if default styling is applied in . + /// + public string CodeFont; + + private readonly Dictionary> elementStyles = new Dictionary>(); + + /// + /// Creates a new UI markdown parser and optionally initializes some default style settings. + /// + /// Whether default style settings should be applied. + public UiMarkdownParser(bool applyDefaultStyling = true) { + if (applyDefaultStyling) { + this.CodeFont = "Monospaced"; + for (var i = 0; i < 6; i++) { + var level = i; + this.Style(ElementTypes[Array.IndexOf(ElementTypes, ElementType.Header1) + i], p => { + p.Alignment = TextAlignment.Center; + p.TextScaleMultiplier = 2 - level * 0.15F; + }); + } + } + } + + /// + /// Parses the given markdown string into a set of elements (using ) and adds them as children to the givem . + /// During this process, the element stylings specified using are also applied. + /// + /// The markdown to parse. + /// The element to add the parsed elements to. + /// The , for chaining. + public Element ParseInto(string markdown, Element element) { + foreach (var el in this.Parse(markdown)) + element.AddChild(el); + return element; + } + + /// + /// Parses the given markdown string into a set of elements and returns them. + /// During this process, the element stylings specified using are also applied. + /// + /// The markdown to parse. + /// The parsed elements. + public IEnumerable Parse(string markdown) { + foreach (var (type, element) in this.ParseUnstyled(markdown)) { + if (this.elementStyles.TryGetValue(type, out var style)) + style.Invoke(element); + yield return element; + } + } + + /// + /// Specifies an action to be invoked when a new element with the given is parsed in or . + /// These actions can be used to modify the style properties of the created elements. + /// + /// The element types that should be styled. Can be a combined flag. + /// The action that styles the elements with the given element type. + /// Whether the function should be added to the existing style settings, or replace them. + /// The type of elements that the given flags are expected to be. + /// This parser, for chaining. + public UiMarkdownParser Style(ElementType types, Action style, bool add = false) where T : Element { + foreach (var type in ElementTypes) { + if (types.HasFlag(type)) { + if (add && this.elementStyles.ContainsKey(type)) { + this.elementStyles[type] += Action; + } else { + this.elementStyles[type] = Action; + } + } + } + return this; + + void Action(Element e) { + style.Invoke(e as T ?? throw new ArgumentException($"Expected {typeof(T)} for style action but got {e.GetType()}")); + } + } + + private IEnumerable<(ElementType, Element)> ParseUnstyled(string markdown) { + var inCodeBlock = false; + foreach (var line in markdown.Split('\n')) { + // code blocks + if (line.Trim().StartsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + // code block content + if (inCodeBlock) { + yield return (ElementType.CodeBlock, new Paragraph(Anchor.AutoLeft, 1, $"{line}")); + continue; + } + + // quotes + if (line.StartsWith(">")) { + yield return (ElementType.Quote, new Paragraph(Anchor.AutoLeft, 1, line.Substring(1).Trim())); + continue; + } + + // vertical space (empty lines) + if (line.Trim().Length <= 0) { + yield return (ElementType.VerticalSpace, new VerticalSpace(4)); + continue; + } + + // images + var imageMatch = Regex.Match(line, @"!\[\]\(([^)]+)\)"); + if (imageMatch.Success) { + if (this.GraphicsDevice == null) + throw new NullReferenceException("A markdown parser requires a GraphicsDevice for parsing images"); + + TextureRegion image = null; + LoadImageAsync(); + yield return (ElementType.Image, new Image(Anchor.AutoLeft, new Vector2(1, -1), _ => image) { + OnDisposed = e => image?.Texture.Dispose() + }); + + async void LoadImageAsync() { + var loc = imageMatch.Groups[1].Value; + try { + Texture2D tex; + if (loc.StartsWith("http")) { + using (var client = new HttpClient()) { + using (var src = await client.GetStreamAsync(loc)) + tex = Texture2D.FromStream(this.GraphicsDevice, src); + } + } else { + if (this.ImageBasePath != null) + loc = $"{this.ImageBasePath}/{loc}"; + using (var stream = Path.IsPathRooted(loc) ? File.OpenRead(loc) : TitleContainer.OpenStream(loc)) + tex = Texture2D.FromStream(this.GraphicsDevice, stream); + } + image = new TextureRegion(tex); + } catch (Exception e) { + if (this.ImageExceptionHandler != null) { + this.ImageExceptionHandler.Invoke(loc, e); + } else { + throw new Exception($"Couldn't parse image {loc}, and no ImageExceptionHandler was set", e); + } + } + } + continue; + } + + // headers + var parsedHeader = false; + for (var h = 6; h >= 1; h--) { + if (line.StartsWith(new string('#', h))) { + var type = ElementTypes[Array.IndexOf(ElementTypes, ElementType.Header1) + h - 1]; + yield return (type, new Paragraph(Anchor.AutoLeft, 1, line.Substring(h).Trim())); + parsedHeader = true; + break; + } + } + if (parsedHeader) + continue; + + // parse everything else as a paragraph (with formatting) + var par = line; + // replace links + par = Regex.Replace(par, @"<([^>]+)>", "$1"); + par = Regex.Replace(par, @"\[([^\]]+)\]\(([^)]+)\)", "$1"); + // replace formatting + par = Regex.Replace(par, @"\*\*([^\*]+)\*\*", "$1"); + par = Regex.Replace(par, @"__([^_]+)__", "$1"); + par = Regex.Replace(par, @"\*([^\*]+)\*", "$1"); + par = Regex.Replace(par, @"_([^_]+)_", "$1"); + par = Regex.Replace(par, @"~~([^~]+)~~", "$1"); + // replace inline code with custom code font + par = Regex.Replace(par, @"`([^`]+)`", $"$1"); + yield return (ElementType.Paragraph, new Paragraph(Anchor.AutoLeft, 1, par)); + } + } + + /// + /// A flags enumeration used by that contains the types of elements that can be parsed and returned in or . + /// This is a flags enumeration so that can have multiple element types being styled at the same time. + /// + [Flags] + public enum ElementType { + + /// + /// A blockquote. + /// This element type is a . + /// + Quote = 1, + /// + /// A vertical space, which is a gap between multiple markdown paragraphs. + /// This element type is a . + /// + VerticalSpace = 2, + /// + /// An image. + /// This element type is an . + /// + Image = 4, + /// + /// A header with header level 1. + /// This element type is a . + /// + Header1 = 8, + /// + /// A header with header level 2. + /// This element type is a . + /// + Header2 = 16, + /// + /// A header with header level 3. + /// This element type is a . + /// + Header3 = 32, + /// + /// A header with header level 4. + /// This element type is a . + /// + Header4 = 64, + /// + /// A header with header level 5. + /// This element type is a . + /// + Header5 = 128, + /// + /// A header with header level 6. + /// This element type is a . + /// + Header6 = 256, + /// + /// A combined flag that contains through . + /// This element type is a . + /// + Headers = Header1 | Header2 | Header3 | Header4 | Header5 | Header6, + /// + /// A paragraph, which is one line of markdown text. + /// This element type is a . + /// + Paragraph = 512, + /// + /// A single line of a code block. + /// This element type is a . + /// + CodeBlock = 1024 + + } + + } +} \ No newline at end of file