diff --git a/Aaru.Gui/Controls/SpectreTextBlock.cs b/Aaru.Gui/Controls/SpectreTextBlock.cs new file mode 100644 index 000000000..cb1da1988 --- /dev/null +++ b/Aaru.Gui/Controls/SpectreTextBlock.cs @@ -0,0 +1,539 @@ +// /*************************************************************************** +// Aaru Data Preservation Suite +// ---------------------------------------------------------------------------- +// +// Author(s) : Natalia Portillo +// +// Component : Text User Interface. +// +// --[ License ] -------------------------------------------------------------- +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// ---------------------------------------------------------------------------- +// Copyright © 2011-2025 Natalia Portillo +// ****************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; + +namespace Aaru.Gui.Controls; + +public partial class SpectreTextBlock : TextBlock +{ + // Matches color formats like: + // "red on blue", "#ff0000 on blue", "rgb(255,0,0) on blue", etc. + private static readonly Regex _colorMarkupRegex = ColorRegex(); + + // Static mapping of Spectre Console color names to hex values + private static readonly Dictionary _spectreColorMap = new(StringComparer.OrdinalIgnoreCase) + { + // Standard colors (0-15) + ["black"] = "#000000", + ["maroon"] = "#800000", + ["green"] = "#008000", + ["olive"] = "#808000", + ["navy"] = "#000080", + ["purple"] = "#800080", + ["teal"] = "#008080", + ["silver"] = "#c0c0c0", + ["grey"] = "#808080", + ["red"] = "#ff0000", + ["lime"] = "#00ff00", + ["yellow"] = "#ffff00", + ["blue"] = "#0000ff", + ["fuchsia"] = "#ff00ff", + ["aqua"] = "#00ffff", + ["white"] = "#ffffff", + + // Extended colors (16-255) + ["grey0"] = "#000000", + ["navyblue"] = "#00005f", + ["darkblue"] = "#000087", + ["blue3"] = "#0000af", + ["blue3_1"] = "#0000d7", + ["blue1"] = "#0000ff", + ["darkgreen"] = "#005f00", + ["deepskyblue4"] = "#005f5f", + ["deepskyblue4_1"] = "#005f87", + ["deepskyblue4_2"] = "#005faf", + ["dodgerblue3"] = "#005fd7", + ["dodgerblue2"] = "#005fff", + ["green4"] = "#008700", + ["springgreen4"] = "#00875f", + ["turquoise4"] = "#008787", + ["deepskyblue3"] = "#0087af", + ["deepskyblue3_1"] = "#0087d7", + ["dodgerblue1"] = "#0087ff", + ["green3"] = "#00af00", + ["springgreen3"] = "#00af5f", + ["darkcyan"] = "#00af87", + ["lightseagreen"] = "#00afaf", + ["deepskyblue2"] = "#00afd7", + ["deepskyblue1"] = "#00afff", + ["green3_1"] = "#00d700", + ["springgreen3_1"] = "#00d75f", + ["springgreen2"] = "#00d787", + ["cyan3"] = "#00d7af", + ["darkturquoise"] = "#00d7d7", + ["turquoise2"] = "#00d7ff", + ["green1"] = "#00ff00", + ["springgreen2_1"] = "#00ff5f", + ["springgreen1"] = "#00ff87", + ["mediumspringgreen"] = "#00ffaf", + ["cyan2"] = "#00ffd7", + ["cyan1"] = "#00ffff", + ["darkred"] = "#5f0000", + ["deeppink4"] = "#5f005f", + ["purple4"] = "#5f0087", + ["purple4_1"] = "#5f00af", + ["purple3"] = "#5f00d7", + ["blueviolet"] = "#5f00ff", + ["orange4"] = "#5f5f00", + ["grey37"] = "#5f5f5f", + ["mediumpurple4"] = "#5f5f87", + ["slateblue3"] = "#5f5faf", + ["slateblue3_1"] = "#5f5fd7", + ["royalblue1"] = "#5f5fff", + ["chartreuse4"] = "#5f8700", + ["darkseagreen4"] = "#5f875f", + ["paleturquoise4"] = "#5f8787", + ["steelblue"] = "#5f87af", + ["steelblue3"] = "#5f87d7", + ["cornflowerblue"] = "#5f87ff", + ["chartreuse3"] = "#5faf00", + ["darkseagreen4_1"] = "#5faf5f", + ["cadetblue"] = "#5faf87", + ["cadetblue_1"] = "#5fafaf", + ["skyblue3"] = "#5fafd7", + ["steelblue1"] = "#5fafff", + ["chartreuse3_1"] = "#5fd700", + ["palegreen3"] = "#5fd75f", + ["seagreen3"] = "#5fd787", + ["aquamarine3"] = "#5fd7af", + ["mediumturquoise"] = "#5fd7d7", + ["steelblue1_1"] = "#5fd7ff", + ["chartreuse2"] = "#5fff00", + ["seagreen2"] = "#5fff5f", + ["seagreen1"] = "#5fff87", + ["seagreen1_1"] = "#5fffaf", + ["aquamarine1"] = "#5fffd7", + ["darkslategrey2"] = "#5fffff", + ["darkred_1"] = "#870000", + ["deeppink4_1"] = "#87005f", + ["darkmagenta"] = "#870087", + ["darkmagenta_1"] = "#8700af", + ["darkviolet"] = "#8700d7", + ["purple_1"] = "#8700ff", + ["orange4_1"] = "#875f00", + ["lightpink4"] = "#875f5f", + ["plum4"] = "#875f87", + ["mediumpurple3"] = "#875faf", + ["mediumpurple3_1"] = "#875fd7", + ["slateblue1"] = "#875fff", + ["yellow4"] = "#878700", + ["wheat4"] = "#87875f", + ["grey53"] = "#878787", + ["lightslategrey"] = "#8787af", + ["mediumpurple"] = "#8787d7", + ["lightslateblue"] = "#8787ff", + ["yellow4_1"] = "#87af00", + ["darkolivegreen3"] = "#87af5f", + ["darkseagreen"] = "#87af87", + ["lightskyblue3"] = "#87afaf", + ["lightskyblue3_1"] = "#87afd7", + ["skyblue2"] = "#87afff", + ["chartreuse2_1"] = "#87d700", + ["darkolivegreen3_1"] = "#87d75f", + ["palegreen3_1"] = "#87d787", + ["darkseagreen3"] = "#87d7af", + ["darkslategrey3"] = "#87d7d7", + ["skyblue1"] = "#87d7ff", + ["chartreuse1"] = "#87ff00", + ["lightgreen"] = "#87ff5f", + ["lightgreen_1"] = "#87ff87", + ["palegreen1"] = "#87ffaf", + ["aquamarine1_1"] = "#87ffd7", + ["darkslategrey1"] = "#87ffff", + ["red3"] = "#af0000", + ["deeppink4_2"] = "#af005f", + ["mediumvioletred"] = "#af0087", + ["magenta3"] = "#af00af", + ["darkviolet_1"] = "#af00d7", + ["purple_2"] = "#af00ff", + ["darkorange3"] = "#af5f00", + ["indianred"] = "#af5f5f", + ["hotpink3"] = "#af5f87", + ["mediumorchid3"] = "#af5faf", + ["mediumorchid"] = "#af5fd7", + ["mediumpurple2"] = "#af5fff", + ["darkgoldenrod"] = "#af8700", + ["lightsalmon3"] = "#af875f", + ["rosybrown"] = "#af8787", + ["grey63"] = "#af87af", + ["mediumpurple2_1"] = "#af87d7", + ["mediumpurple1"] = "#af87ff", + ["gold3"] = "#afaf00", + ["darkkhaki"] = "#afaf5f", + ["navajowhite3"] = "#afaf87", + ["grey69"] = "#afafaf", + ["lightsteelblue3"] = "#afafd7", + ["lightsteelblue"] = "#afafff", + ["yellow3"] = "#afd700", + ["darkolivegreen3_2"] = "#afd75f", + ["darkseagreen3_1"] = "#afd787", + ["darkseagreen2"] = "#afd7af", + ["lightcyan3"] = "#afd7d7", + ["lightskyblue1"] = "#afd7ff", + ["greenyellow"] = "#afff00", + ["darkolivegreen2"] = "#afff5f", + ["palegreen1_1"] = "#afff87", + ["darkseagreen2_1"] = "#afffaf", + ["darkseagreen1"] = "#afffd7", + ["paleturquoise1"] = "#afffff", + ["red3_1"] = "#d70000", + ["deeppink3"] = "#d7005f", + ["deeppink3_1"] = "#d70087", + ["magenta3_1"] = "#d700af", + ["magenta3_2"] = "#d700d7", + ["magenta2"] = "#d700ff", + ["darkorange3_1"] = "#d75f00", + ["indianred_1"] = "#d75f5f", + ["hotpink3_1"] = "#d75f87", + ["hotpink2"] = "#d75faf", + ["orchid"] = "#d75fd7", + ["mediumorchid1"] = "#d75fff", + ["orange3"] = "#d78700", + ["lightsalmon3_1"] = "#d7875f", + ["lightpink3"] = "#d78787", + ["pink3"] = "#d787af", + ["plum3"] = "#d787d7", + ["violet"] = "#d787ff", + ["gold3_1"] = "#d7af00", + ["lightgoldenrod3"] = "#d7af5f", + ["tan"] = "#d7af87", + ["mistyrose3"] = "#d7afaf", + ["thistle3"] = "#d7afd7", + ["plum2"] = "#d7afff", + ["yellow3_1"] = "#d7d700", + ["khaki3"] = "#d7d75f", + ["lightgoldenrod2"] = "#d7d787", + ["lightyellow3"] = "#d7d7af", + ["grey84"] = "#d7d7d7", + ["lightsteelblue1"] = "#d7d7ff", + ["yellow2"] = "#d7ff00", + ["darkolivegreen1"] = "#d7ff5f", + ["darkolivegreen1_1"] = "#d7ff87", + ["darkseagreen1_1"] = "#d7ffaf", + ["honeydew2"] = "#d7ffd7", + ["lightcyan1"] = "#d7ffff", + ["red1"] = "#ff0000", + ["deeppink2"] = "#ff005f", + ["deeppink1"] = "#ff0087", + ["deeppink1_1"] = "#ff00af", + ["magenta2_1"] = "#ff00d7", + ["magenta1"] = "#ff00ff", + ["orangered1"] = "#ff5f00", + ["indianred1"] = "#ff5f5f", + ["indianred1_1"] = "#ff5f87", + ["hotpink"] = "#ff5faf", + ["hotpink_1"] = "#ff5fd7", + ["mediumorchid1_1"] = "#ff5fff", + ["darkorange"] = "#ff8700", + ["salmon1"] = "#ff875f", + ["lightcoral"] = "#ff8787", + ["palevioletred1"] = "#ff87af", + ["orchid2"] = "#ff87d7", + ["orchid1"] = "#ff87ff", + ["orange1"] = "#ffaf00", + ["sandybrown"] = "#ffaf5f", + ["lightsalmon1"] = "#ffaf87", + ["lightpink1"] = "#ffafaf", + ["pink1"] = "#ffafd7", + ["plum1"] = "#ffafff", + ["gold1"] = "#ffd700", + ["lightgoldenrod2_1"] = "#ffd75f", + ["lightgoldenrod2_2"] = "#ffd787", + ["navajowhite1"] = "#ffd7af", + ["mistyrose1"] = "#ffd7d7", + ["thistle1"] = "#ffd7ff", + ["yellow1"] = "#ffff00", + ["lightgoldenrod1"] = "#ffff5f", + ["khaki1"] = "#ffff87", + ["wheat1"] = "#ffffaf", + ["cornsilk1"] = "#ffffd7", + ["grey100"] = "#ffffff", + ["grey3"] = "#080808", + ["grey7"] = "#121212", + ["grey11"] = "#1c1c1c", + ["grey15"] = "#262626", + ["grey19"] = "#303030", + ["grey23"] = "#3a3a3a", + ["grey27"] = "#444444", + ["grey30"] = "#4e4e4e", + ["grey35"] = "#585858", + ["grey39"] = "#626262", + ["grey42"] = "#6c6c6c", + ["grey46"] = "#767676", + ["grey50"] = "#808080", + ["grey54"] = "#8a8a8a", + ["grey58"] = "#949494", + ["grey62"] = "#9e9e9e", + ["grey66"] = "#a8a8a8", + ["grey70"] = "#b2b2b2", + ["grey74"] = "#bcbcbc", + ["grey78"] = "#c6c6c6", + ["grey82"] = "#d0d0d0", + ["grey85"] = "#dadada", + ["grey89"] = "#e4e4e4", + ["grey93"] = "#eeeeee" + }; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if(change.Property == TextProperty) UpdateMarkup(); + } + + void UpdateMarkup() + { + if(string.IsNullOrEmpty(Text)) + { + Inlines?.Clear(); + + return; + } + + var inlines = new InlineCollection(); + + List markups = ParseMarkups(Text); + + if(markups.Count == 0) + { + inlines.Add(new Run(Text)); + Inlines = inlines; + + return; + } + + // Create a list of tag ranges to exclude from the text + var tagRanges = new List<(int start, int end)>(); + + foreach(MarkupTag markup in markups) + { + // Add opening tag range + tagRanges.Add((markup.Start, markup.OpenTagEnd)); + + // Add closing tag range + tagRanges.Add((markup.CloseTagStart, markup.End)); + } + + // Create breakpoints at all positions (start, end of tags, start and end of text) + var breakpoints = new SortedSet + { + 0, + Text.Length + }; + + // Add all tag boundaries as breakpoints + foreach((int start, int end) range in tagRanges) + { + breakpoints.Add(range.start); + breakpoints.Add(range.end); + } + + var breakpointList = breakpoints.ToList(); + + for(var i = 0; i < breakpointList.Count - 1; i++) + { + int start = breakpointList[i]; + int end = breakpointList[i + 1]; + + // Skip empty segments + if(start == end) continue; + + // Skip this segment if it overlaps with any tag range + bool isInsideTag = tagRanges.Any(range => start >= range.start && start < range.end || + end > range.start && end <= range.end || + start <= range.start && end >= range.end); + + if(isInsideTag) continue; + + // Find which markup tags apply to this content segment + var applicableMarkups = markups.Where(m => m.OpenTagEnd <= start && m.CloseTagStart >= end) + .OrderBy(m => m.Start) // Outermost first (earliest start) + .ThenByDescending(m => m.End) // Then by latest end + .ToList(); + + var run = new Run(Text.Substring(start, end - start)); + + foreach(MarkupTag markup in applicableMarkups) + { + // This is very simple parsing, possibly more complex legal markup would fail + if(markup.Tag.Contains("bold")) run.FontWeight = FontWeight.Bold; + + if(markup.Tag.Contains("italic")) run.FontStyle = FontStyle.Italic; + + if(markup.Tag.Contains("underline")) run.TextDecorations = Avalonia.Media.TextDecorations.Underline; + + if(!_colorMarkupRegex.IsMatch(markup.Tag)) continue; + + Match match = _colorMarkupRegex.Match(markup.Tag); + string foreground = match.Groups["fg"].Value; + string? background = match.Groups["bg"].Success ? match.Groups["bg"].Value : null; + + // Apply foreground color (inner tags will override outer tags) + if(!string.IsNullOrEmpty(foreground)) + { + IBrush? fgBrush = ParseColor(foreground); + if(fgBrush != null) run.Foreground = fgBrush; + } + + // Apply background color (inner tags will override outer tags) + if(string.IsNullOrEmpty(background)) continue; + + IBrush? bgBrush = ParseColor(background); + + if(bgBrush != null) run.Background = bgBrush; + } + + inlines.Add(run); + } + + Inlines = inlines; + } + + static IBrush ParseColor(string color) + { + try + { + string? hexValue; + + // Handle hex colors like #ff0000 + if(color.StartsWith("#")) return new SolidColorBrush(Color.Parse(color)); + + // Handle rgb(r,g,b) format + if(!color.StartsWith("rgb(") || !color.EndsWith(")")) + { + return _spectreColorMap.TryGetValue(color, out hexValue) + ? new SolidColorBrush(Color.Parse(hexValue)) + : + + // Fallback: try to parse as Avalonia named color + new SolidColorBrush(Color.Parse(color)); + } + + string[] values = color.Substring(4, color.Length - 5).Split(','); + + if(values.Length == 3 && + byte.TryParse(values[0].Trim(), out byte r) && + byte.TryParse(values[1].Trim(), out byte g) && + byte.TryParse(values[2].Trim(), out byte b)) + return new SolidColorBrush(Color.FromRgb(r, g, b)); + + // Handle Spectre Console named colors using the mapping + return _spectreColorMap.TryGetValue(color, out hexValue) + ? new SolidColorBrush(Color.Parse(hexValue)) + : + + // Fallback: try to parse as Avalonia named color + new SolidColorBrush(Color.Parse(color)); + } + catch + { + // If parsing fails, return null + } + + return null; + } + + List ParseMarkups(string text) + { + var result = new List(); + var tagStack = new Stack<(int start, int openTagEnd, string tag)>(); + var i = 0; + + while(i < text.Length) + { + if(text[i] == '[') + { + int tagStart = i; + i++; + + // Check if it's a closing tag [/] + if(i < text.Length && text[i] == '/') + { + i++; + + if(i < text.Length && text[i] == ']') + { + // Found [/], close the most recent tag + if(tagStack.Count > 0) + { + (int openStart, int openTagEnd, string tag) = tagStack.Pop(); + int closeTagEnd = i + 1; // After the ']' of [/] + result.Add(new MarkupTag(openStart, closeTagEnd, tag, openTagEnd, tagStart)); + } + + i++; + } + } + else + { + // Parse opening tag like [red], [bold], etc. + int tagNameStart = i; + while(i < text.Length && text[i] != ']' && text[i] != '[') i++; + + if(i >= text.Length || text[i] != ']') continue; + + string tagName = text.Substring(tagNameStart, i - tagNameStart); + + if(!string.IsNullOrWhiteSpace(tagName)) + { + int openTagEnd = i + 1; // After the ']' + tagStack.Push((tagStart, openTagEnd, tagName)); + } + + i++; + } + } + else + i++; + } + + return result; + } + + [GeneratedRegex(@"^(?#[0-9a-fA-F]{6}|rgb\(\d{1,3},\d{1,3},\d{1,3}\)|[a-zA-Z0-9_]+)(\s+on\s+(?#[0-9a-fA-F]{6}|rgb\(\d{1,3},\d{1,3},\d{1,3}\)|[a-zA-Z0-9_]+))?$", + RegexOptions.Compiled)] + private static partial Regex ColorRegex(); + +#region Nested type: MarkupTag + + sealed record MarkupTag(int Start, int End, string Tag, int OpenTagEnd, int CloseTagStart); + +#endregion +} \ No newline at end of file