diff --git a/Examples/UICatalog/Scenarios/BigText/BigText.cs b/Examples/UICatalog/Scenarios/BigText/BigText.cs new file mode 100644 index 0000000000..1f1b9a1d47 --- /dev/null +++ b/Examples/UICatalog/Scenarios/BigText/BigText.cs @@ -0,0 +1,564 @@ +#nullable enable +using System.Numerics; +using JetBrains.Annotations; +using SixLabors.Fonts; + +namespace UICatalog.Scenarios; + +/// +/// A that renders text in large block letters using . +/// The text is rendered using TrueType fonts converted to vector outlines and approximated as box-drawing characters. +/// +/// +/// +/// uses SixLabors.Fonts to load TrueType fonts and extract glyph outlines as vector paths. +/// These paths are then approximated as horizontal and vertical lines that can be rendered using LineCanvas. +/// The characters are rendered at a larger scale than normal text, making them suitable for titles, headers, or +/// emphasis. +/// +/// +/// The view automatically sizes itself based on the text content and the configured . +/// +/// +/// +/// +/// var bigText = new BigText +/// { +/// Text = "Hello!", +/// GlyphHeight = 8, +/// Style = LineStyle.Double, +/// Font = "Arial" +/// }; +/// +/// +public class BigText : View +{ + private int _glyphHeight = 8; + private LineStyle _style = LineStyle.Single; + private Font? _font; + private string _fontFamily = "Arial"; + private static readonly FontCollection _fontCollection = new (); + + /// + /// Initializes a new instance of the class. + /// + public BigText () + { + CanFocus = false; + Height = Dim.Auto (DimAutoStyle.Content); + Width = Dim.Auto (DimAutoStyle.Content); + + // Try to load system fonts + TryLoadSystemFonts (); + UpdateFont (); + } + + private static bool _systemFontsLoaded; + + private static void TryLoadSystemFonts () + { + if (_systemFontsLoaded) + { + return; + } + + try + { + // Try to add system fonts + if (OperatingSystem.IsWindows ()) + { + string fontsPath = Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.Fonts)); + + if (Directory.Exists (fontsPath)) + { + foreach (string fontFile in Directory.GetFiles (fontsPath, "*.ttf")) + { + try + { + _fontCollection.Add (fontFile); + } + catch + { + // Ignore individual font loading errors + } + } + } + } + else if (OperatingSystem.IsLinux ()) + { + string [] fontPaths = + { + "/usr/share/fonts/truetype", + "/usr/local/share/fonts", + Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".fonts") + }; + + foreach (string fontPath in fontPaths) + { + if (Directory.Exists (fontPath)) + { + foreach (string fontFile in Directory.GetFiles (fontPath, "*.ttf", SearchOption.AllDirectories)) + { + try + { + _fontCollection.Add (fontFile); + } + catch + { + // Ignore individual font loading errors + } + } + } + } + } + else if (OperatingSystem.IsMacOS ()) + { + string [] fontPaths = + { + "/System/Library/Fonts", + "/Library/Fonts", + Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), "Library/Fonts") + }; + + foreach (string fontPath in fontPaths) + { + if (Directory.Exists (fontPath)) + { + foreach (string fontFile in Directory.GetFiles (fontPath, "*.ttf", SearchOption.AllDirectories)) + { + try + { + _fontCollection.Add (fontFile); + } + catch + { + // Ignore individual font loading errors + } + } + } + } + } + + _systemFontsLoaded = true; + } + catch + { + // If we can't load system fonts, we'll just use the built-in SixLabors fonts + } + } + + /// + /// Gets or sets the font family name to use for rendering. Default is "Arial". + /// If the font is not found, falls back to the default SixLabors font. + /// + public string FontFamily + { + get => _fontFamily; + set + { + if (_fontFamily != value) + { + _fontFamily = value; + UpdateFont (); + UpdateContentSize (); + SetNeedsLayout (); + SetNeedsDraw (); + } + } + } + + private void UpdateFont () + { + try + { + FontFamily family; + + if (_fontCollection.TryGet (_fontFamily, out family!)) + { + _font = family.CreateFont (_glyphHeight, FontStyle.Regular); + } + else + { + // Fallback to system default + family = SystemFonts.Get (_fontFamily); + _font = family.CreateFont (_glyphHeight, FontStyle.Regular); + } + } + catch + { + // If font not found, use system default + try + { + FontFamily family = SystemFonts.Families.First (); + _font = family.CreateFont (_glyphHeight, FontStyle.Regular); + } + catch + { + // Last resort - null font will cause fallback rendering + _font = null; + } + } + } + + /// + /// Gets or sets the height of each glyph in terminal cells. Default is 8. + /// + /// + /// This controls how tall the rendered characters will be. Larger values create + /// taller letters but require more vertical space. + /// + public int GlyphHeight + { + get => _glyphHeight; + set + { + if (_glyphHeight != value) + { + _glyphHeight = value; + UpdateFont (); + UpdateContentSize (); + SetNeedsLayout (); + SetNeedsDraw (); + } + } + } + + /// + /// Gets or sets the used to draw the text. Default is . + /// + public LineStyle Style + { + get => _style; + set + { + if (_style != value) + { + _style = value; + SetNeedsDraw (); + } + } + } + + /// + protected override bool OnDrawingContent (DrawContext? context) + { + if (string.IsNullOrEmpty (Text)) + { + return true; + } + + // Render directly to a dictionary of filled cells + var filledCells = new Dictionary (); + DrawText (filledCells, Text, 0, 0, _font); + + // Render the filled cells + foreach (KeyValuePair kvp in filledCells) + { + // Check if position is within viewport + if (kvp.Key.X < 0 || kvp.Key.X >= Viewport.Width || kvp.Key.Y < 0 || kvp.Key.Y >= Viewport.Height) + { + continue; + } + + Move (kvp.Key.X, kvp.Key.Y); + AddStr ("?"); // Full block character + } + + return true; + } + + /// + public override string Text + { + get => base.Text; + set + { + base.Text = value; + UpdateContentSize (); + SetNeedsLayout (); + } + } + + private void UpdateContentSize () + { + // Calculate the size needed for the text + if (!string.IsNullOrEmpty (Text)) + { + Size size = MeasureText (Text, _font); + SetContentSize (size); + } + else + { + SetContentSize (new Size (0, 0)); + } + } + + /// + /// Draws text as filled glyphs at the specified position. + /// + /// Dictionary to store which cells should be filled. + /// The text to draw. + /// Starting X position. + /// Starting Y position. + /// The font to use for rendering. + private static void DrawText (Dictionary filledCells, string text, int x, int y, Font? font) + { + if (font is null || string.IsNullOrEmpty (text)) + { + return; + } + + var glyphRenderer = new FilledGlyphRenderer (filledCells); + var renderer = new TextRenderer (glyphRenderer); + + var options = new TextOptions (font) + { + Origin = new (x, y), + Dpi = 96 + }; + + renderer.RenderText (text, options); + } + + /// + /// Measures the size required to render the given text. + /// + /// The text to measure. + /// The font to use for measuring. + /// The size in terminal cells. + private static Size MeasureText (string text, Font? font) + { + if (string.IsNullOrEmpty (text) || font is null) + { + return Size.Empty; + } + + try + { + FontRectangle bounds = TextMeasurer.MeasureBounds (text, new (font)); + + // Convert font units to terminal cells + // We scale based on the font size + var width = (int)Math.Ceiling (bounds.Width / font.Size * font.Size); + var height = (int)Math.Ceiling (bounds.Height / font.Size * font.Size); + + return new (width, height); + } + catch + { + // Fallback if measurement fails + return new ((int)(text.Length * (font?.Size ?? 8)), (int)(font?.Size ?? 8)); + } + } + + /// + /// A custom glyph renderer that fills font glyph outlines with solid blocks. + /// + private class FilledGlyphRenderer : IGlyphRenderer + { + private readonly Dictionary _filledCells; + private Vector2 _currentPoint; + private readonly List _currentPath = new (); + private readonly int _samplesPerSegment = 10; // Number of line segments to approximate curves + + public FilledGlyphRenderer (Dictionary filledCells) + { + _filledCells = filledCells; + } + + public bool BeginGlyph (in FontRectangle bounds, in GlyphRendererParameters parameters) + { + _currentPath.Clear (); + + return true; + } + + public void BeginFigure () { _currentPath.Clear (); } + + public void MoveTo (Vector2 point) + { + _currentPoint = point; + + if (_currentPath.Count > 0) + { + // Start a new path segment + ProcessPath (); + _currentPath.Clear (); + } + + _currentPath.Add (point); + } + + public void LineTo (Vector2 point) + { + _currentPath.Add (point); + _currentPoint = point; + } + + public void QuadraticBezierTo (Vector2 control, Vector2 end) + { + // Approximate quadratic Bezier with line segments + Vector2 start = _currentPoint; + + for (var i = 1; i <= _samplesPerSegment; i++) + { + float t = i / (float)_samplesPerSegment; + float t1 = 1 - t; + + // Quadratic Bezier formula: B(t) = (1-t)²P? + 2(1-t)tP? + t²P? + Vector2 point = t1 * t1 * start + 2 * t1 * t * control + t * t * end; + _currentPath.Add (point); + } + + _currentPoint = end; + } + + public void CubicBezierTo (Vector2 control1, Vector2 control2, Vector2 end) + { + // Approximate cubic Bezier with line segments + Vector2 start = _currentPoint; + + for (var i = 1; i <= _samplesPerSegment; i++) + { + float t = i / (float)_samplesPerSegment; + float t1 = 1 - t; + + // Cubic Bezier formula: B(t) = (1-t)³P? + 3(1-t)²tP? + 3(1-t)t²P? + t³P? + Vector2 point = t1 * t1 * t1 * start + + 3 * t1 * t1 * t * control1 + + 3 * t1 * t * t * control2 + + t * t * t * end; + _currentPath.Add (point); + } + + _currentPoint = end; + } + + public void EndFigure () { ProcessPath (); } + + public void EndGlyph () + { + // Glyph rendering complete + } + + private void ProcessPath () + { + if (_currentPath.Count < 3) + { + return; + } + + // Find the bounding box of the path + float minY = float.MaxValue; + float maxY = float.MinValue; + + foreach (Vector2 point in _currentPath) + { + minY = Math.Min (minY, point.Y); + maxY = Math.Max (maxY, point.Y); + } + + var startRow = (int)Math.Floor (minY); + var endRow = (int)Math.Ceiling (maxY); + + // For each row (scanline), find where the path outline intersects + // and fill the cells between intersection pairs + for (int row = startRow; row <= endRow; row++) + { + var intersections = new List (); + + // Check each edge of the path polygon + for (var i = 0; i < _currentPath.Count; i++) + { + Vector2 p1 = _currentPath [i]; + Vector2 p2 = _currentPath [(i + 1) % _currentPath.Count]; + + float y1 = p1.Y; + float y2 = p2.Y; + + // Check if this edge crosses the current scanline + if ((y1 < row && y2 >= row) || (y2 < row && y1 >= row)) + { + // Calculate x coordinate where edge intersects this row + if (Math.Abs (y2 - y1) < 0.001f) + { + // Horizontal edge at this row + intersections.Add (p1.X); + intersections.Add (p2.X); + } + else + { + // Interpolate to find x + float t = (row - y1) / (y2 - y1); + float x = p1.X + t * (p2.X - p1.X); + intersections.Add (x); + } + } + } + + // Sort intersections and fill cells between pairs + if (intersections.Count >= 2) + { + intersections.Sort (); + + // Fill cells between pairs of intersections + for (var i = 0; i < intersections.Count - 1; i += 2) + { + var x1 = (int)Math.Round (intersections [i]); + var x2 = (int)Math.Round (intersections [i + 1]); + + // Fill all cells in this horizontal span + for (int x = x1; x <= x2; x++) + { + _filledCells [new Point (x, row)] = true; + } + } + } + } + } + + public void SetColor (GlyphColor color) + { + // Not used for terminal rendering + } + + public void EndText () + { + // All text rendering complete + } + + public void BeginText (in FontRectangle bounds) + { + // Starting text rendering + } + + public TextDecorations EnabledDecorations () + { + // We don't support text decorations like underline, strikethrough, etc. + return TextDecorations.None; + } + + public void SetDecoration (TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness) + { + // Not needed for terminal rendering - we don't support decorations + } + } + + /// + /// Gets the width of a glyph at the specified height. + /// + private static int GetGlyphWidth (char c, int height) + { + // Normalize to lowercase for consistent sizing + char normalized = char.ToLowerInvariant (c); + + return normalized switch + { + ' ' => height / 2, + 'i' or 'l' or '!' or '|' => Math.Max (2, height / 4), + 't' or 'f' or 'j' => Math.Max (3, height / 3), + 'm' or 'w' => Math.Max (5, height * 5 / 8), + _ => Math.Max (4, height / 2) + }; + } +} diff --git a/Examples/UICatalog/Scenarios/BigText/BigTextExample.cs b/Examples/UICatalog/Scenarios/BigText/BigTextExample.cs new file mode 100644 index 0000000000..5ad19f697a --- /dev/null +++ b/Examples/UICatalog/Scenarios/BigText/BigTextExample.cs @@ -0,0 +1,62 @@ +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("BigText", "Demonstrates the BigText view for large text rendering.")] +[ScenarioCategory ("Controls")] +[ScenarioCategory ("Drawing")] +public class BigTextExample : Scenario +{ + public override void Main () + { + Application.Init (); + + Window app = new () { Title = GetQuitKeyAndName () }; + + Label inputLabel = new () { Text = "Enter text to render:" }; + app.Add (inputLabel); + + TextField textField = new () + { + X = Pos.Right (inputLabel) + 1, + Y = Pos.Top (inputLabel), + Width = 40, + Text = "Hello World!" + }; + app.Add (textField); + + Button renderButton = new () + { + X = Pos.Right (textField) + 1, + Y = Pos.Top (inputLabel), + Text = "Render" + }; + app.Add (renderButton); + + BigText dynamicText = new () + { + X = 0, + Y = Pos.Bottom (textField) + 1, + Text = textField.Text, + GlyphHeight = 8, + Style = LineStyle.Single + }; + app.Add (dynamicText); + + renderButton.Accepting += (s, e) => + { + dynamicText.Text = textField.Text; + e.Handled = true; + }; + + Label helpLabel = new () + { + X = Pos.Center (), + Y = Pos.AnchorEnd (), + Text = "BigText uses TTF fonts rendered as LineCanvas box-drawing characters." + }; + app.Add (helpLabel); + + Application.Run (app); + app.Dispose (); + Application.Shutdown (); + } +} diff --git a/Examples/UICatalog/UICatalog.csproj b/Examples/UICatalog/UICatalog.csproj index 88e916f9cf..b0e2821ee0 100644 --- a/Examples/UICatalog/UICatalog.csproj +++ b/Examples/UICatalog/UICatalog.csproj @@ -33,6 +33,7 @@ +