Gondwana Game Engine Development (master) - 2.6.1
Gondwana is a cross-platform 2.5D game and rendering engine written in C#/.NET 8. It provides fine-grained control over rendering, timing, and scene composition, with built-in support for parallax, z-ordering, pixel overhang, collision detection, and particle effects. Gondwana targets desktop and web platforms using SkiaSharp for graphics and pluggable audio backends for desktop and browser targets.
Loading...
Searching...
No Matches
TextBlock.cs
1using System.Drawing;
2using System.Runtime.CompilerServices;
3using System.Text.RegularExpressions;
10using SkiaSharp;
11
13
43public class TextBlock : DirectDrawingMovableBase
44{
48 public enum VerticalAlign
49 {
56 }
57
58 #region events
59
64 public event Action<string>? TextRevealed;
65
71 public event Action<string>? TextRevealComplete;
72
73 #endregion events
74
75 private string _text = string.Empty;
76 private readonly List<string> _lines = new();
77 private float _lineHeight;
78 private bool _layoutDirty = true;
79
80 private SKColor _foreColor = SKColors.White;
81 private SKColor _backColor = SKColors.Transparent;
82
83 private float _fontSize = 16f;
84 private float? _minFontSize = null;
85 private SKTypeface? _typeface = null;
86
87 private bool _useOutline = false;
88 private bool _wrapText = true;
89 private int? _maxLines = null;
90
91 private bool _useShadow = false;
92 private float _shadowDx = 2f, _shadowDy = 2f;
93 private byte _shadowAlpha = 128;
94 private float _shadowBlurSigma = 1.5f; // 0 = no blur
95
96 private SKTextAlign _hAlign = SKTextAlign.Left;
97 private VerticalAlign _vAlign = VerticalAlign.Top;
98 private float _verticalScrollOffsetPx;
99 private float _verticalScrollEndPaddingPx;
100
101 // --- Pulse (text color) ---
102 private bool _pulseTextEnabled;
103 private SKColor _pulseFrom, _pulseTo;
104 private float _pulsePeriodSec = 1f;
105 private PulseWave _pulseWave = PulseWave.Sine;
106
107 // timing
108 private long? _pulseLastTick;
109 private float _timeSec;
110
111 // --- Text reveal animation ---
112 private long _revealLastTick;
113 private TextRevealMode _textRevealMode = TextRevealMode.None;
114 private int _revealCharCount; // how many chars currently visible
115 private int _revealTargetCharCount; // cap (usually Text.Length)
116 private float _revealRate; // cps or wps (depending on mode)
117 private float _revealAccum; // accumulator for dt-based stepping
118
119 // precomputed for word-based reveal
120 private int[]? _wordEndCharIndexes; // end indexes (exclusive) per word
121 private int _wordIndex; // words shown so far
122
123 // Punctuation pause tuning
124 private bool _pauseEnabled = true;
125 private float _pauseLongSec = 0.25f;
126 private float _pauseShortSec = 0.10f;
127
128 // current resolved color used for drawing (defaults to _foreColor)
129 private SKColor _resolvedForeColor;
130
141 private TextBlock(RenderSurfaceHostBase renderSurfaceHost,
143 SceneLayer? sceneLayer,
144 View? view,
145 Rectangle? screenBounds,
146 Rectangle? worldBounds,
147 string? nickname = null)
148 : base(renderSurfaceHost, mode, sceneLayer, view, screenBounds, worldBounds, nickname)
149 {
150 _resolvedForeColor = _foreColor;
151 }
152
161 public TextBlock(RenderSurfaceHostBase renderSurfaceHost,
162 SceneLayer sceneLayer,
163 View? view,
164 Rectangle worldBounds,
165 string? nickname = null)
166 : this(renderSurfaceHost,
168 sceneLayer,
169 view,
170 screenBounds: null,
171 worldBounds: worldBounds,
172 nickname: nickname)
173 { }
174
182 public TextBlock(RenderSurfaceHostBase renderSurfaceHost,
183 View view,
184 Rectangle screenBounds,
185 string? nickname = null)
186 : this(renderSurfaceHost,
188 sceneLayer: null,
189 view: view,
190 screenBounds: screenBounds,
191 worldBounds: null,
192 nickname: nickname)
193 { }
194
198 public float LineSpacingMultiplier { get; set; } = 1.0f;
199
203 public float HorizontalPadding { get; set; } = 0f;
204
208 public float VerticalPadding { get; set; } = 0f;
209
218 {
219 get => _verticalScrollOffsetPx;
220 set
221 {
222 if (!float.IsFinite(value) || value < 0f)
223 throw new ArgumentOutOfRangeException(nameof(value));
224
225 if (Math.Abs(_verticalScrollOffsetPx - value) < 0.001f)
226 return;
227
228 _verticalScrollOffsetPx = value;
229 ForceRefresh();
230 }
231 }
232
241 {
242 get => _verticalScrollEndPaddingPx;
243 set
244 {
245 if (!float.IsFinite(value) || value < 0f)
246 throw new ArgumentOutOfRangeException(nameof(value));
247
248 if (Math.Abs(_verticalScrollEndPaddingPx - value) < 0.001f)
249 return;
250
251 _verticalScrollEndPaddingPx = value;
252 ForceRefresh();
253 }
254 }
255
264 {
265 Rectangle bounds = Mode == DirectDrawingMode.View ? ScreenBounds : WorldBounds;
266 float innerWidth = Math.Max(0f, bounds.Width - HorizontalPadding * 2f);
267 float innerHeight = Math.Max(0f, bounds.Height - VerticalPadding * 2f);
268
269 _typeface ??= SKTypeface.Default;
270 using var paint = new SKPaint
271 {
272 Typeface = _typeface,
273 TextSize = _fontSize,
274 TextAlign = _hAlign
275 };
276
277 float fontSize = _fontSize;
278 float minimumFontSize = _minFontSize ?? 0f;
279
280 while (true)
281 {
282 paint.TextSize = fontSize;
283 RebuildLayout(paint, innerWidth);
284
285 int drawableLines = _maxLines.HasValue
286 ? Math.Min(_lines.Count, _maxLines.Value)
287 : _lines.Count;
288 float contentHeight = drawableLines * _lineHeight;
289 bool exceedsHeight = contentHeight > innerHeight;
290 bool exceedsWidth = _lines.Any(line => paint.MeasureText(line) > innerWidth);
291
292 if (_minFontSize.HasValue &&
293 (exceedsHeight || exceedsWidth) &&
294 fontSize > minimumFontSize)
295 {
296 fontSize = Math.Max(minimumFontSize, fontSize - 1f);
297 continue;
298 }
299
300 float maximumOffset = Math.Max(0f, contentHeight - innerHeight);
301 if (maximumOffset > 0f)
302 maximumOffset += VerticalScrollEndPaddingPx;
303
304 // Measurement may have laid out at an auto-shrunk font size. Force the next draw
305 // to rebuild from the configured font settings rather than reusing measurement state.
306 _layoutDirty = true;
307 return maximumOffset;
308 }
309 }
310
317 public TextBlock SetPadding(float horizontal, float vertical)
318 {
319 if (!float.IsFinite(horizontal) || horizontal < 0f)
320 throw new ArgumentOutOfRangeException(nameof(horizontal));
321 if (!float.IsFinite(vertical) || vertical < 0f)
322 throw new ArgumentOutOfRangeException(nameof(vertical));
323
324 HorizontalPadding = horizontal;
325 VerticalPadding = vertical;
326 _layoutDirty = true;
327 ForceRefresh();
328 return this;
329 }
330
336 public TextBlock SetSize(Size size)
337 {
338 if (size.Width <= 0 || size.Height <= 0)
339 throw new ArgumentOutOfRangeException(nameof(size), size, "Text block size must be positive.");
340
341 if (Mode == DirectDrawingMode.View)
342 ScreenBounds = new Rectangle(ScreenBounds.Location, size);
343 else
344 WorldBounds = new Rectangle(WorldBounds.Location, size);
345
346 _layoutDirty = true;
347 ForceRefresh();
348 return this;
349 }
350
358 public TextBlock SetText(string text)
359 {
360 _text = text ?? string.Empty;
361 _layoutDirty = true;
362
363 if (_textRevealMode == TextRevealMode.None)
364 {
365 // when not revealing, default to fully visible
366 _revealCharCount = _text.Length;
367 }
368 else
369 {
370 // keep the animation's target in sync with the new text
371 _revealTargetCharCount = _text.Length;
372
373 // if we had already revealed more than the new length, clamp it
374 if (_revealCharCount > _revealTargetCharCount)
375 _revealCharCount = _revealTargetCharCount;
376
377 // optional: smooth start after text swap
378 _revealAccum = 0f;
379 // if you added a dedicated reveal timer, warm it so first frame doesn't "time-warp"
380 _revealLastTick = 0; // keep this if you're using the separate reveal timer
381 }
382
383 ForceRefresh();
384 return this;
385 }
386
394 public TextBlock SetFont(SKTypeface typeface, float size, float? minSize = null)
395 {
396 _typeface = typeface;
397 _fontSize = size;
398 _minFontSize = minSize;
399 _layoutDirty = true;
400 ForceRefresh();
401 return this;
402 }
403
410 public TextBlock SetColors(SKColor fg, SKColor bg)
411 {
412 _foreColor = fg;
413 _backColor = bg;
414 _resolvedForeColor = fg; // keep resolved in sync
415 ForceRefresh();
416 return this;
417 }
418
425 public TextBlock SetColors(Color fg, Color bg) => SetColors(fg.ToSKColor(), bg.ToSKColor());
426
433 public TextBlock SetAlignment(SKTextAlign h, VerticalAlign v)
434 {
435 _hAlign = h;
436 _vAlign = v;
437 ForceRefresh();
438 return this;
439 }
440
446 public TextBlock UseShadow(bool enable = true)
447 {
448 _useShadow = enable;
449 ForceRefresh();
450 return this;
451 }
452
476 public TextBlock SetShadow(float dx, float dy, byte alpha = 128, float blurSigma = 1.5f)
477 {
478 _shadowDx = dx;
479 _shadowDy = dy;
480 _shadowAlpha = alpha;
481 _shadowBlurSigma = MathF.Max(0f, blurSigma);
482 ForceRefresh();
483 return this;
484 }
485
491 public TextBlock UseOutline(bool enable = true)
492 {
493 _useOutline = enable;
494 ForceRefresh();
495 return this;
496 }
497
503 public TextBlock EnableWrapping(bool enable = true)
504 {
505 _wrapText = enable;
506 _layoutDirty = true;
507 ForceRefresh();
508 return this;
509 }
510
516 public TextBlock SetMaxLines(int? maxLines)
517 {
518 _maxLines = maxLines;
519 ForceRefresh();
520 return this;
521 }
522
532 public TextBlock PulseColor(Color from, Color to, float periodSec, bool enabled = true, bool triangle = false)
533 {
534 _pulseTextEnabled = enabled;
535 _pulseFrom = from.ToSKColor();
536 _pulseTo = to.ToSKColor();
537 _pulsePeriodSec = MathF.Max(0.0001f, periodSec);
538 _pulseWave = triangle ? PulseWave.Triangle : PulseWave.Sine;
539
540 // start from base time; force a redraw
541 _resolvedForeColor = _foreColor;
542 ForceRefresh();
543 return this;
544 }
545
550 public TextBlock StopColorPulse()
551 {
552 _pulseTextEnabled = false;
553 _resolvedForeColor = _foreColor; // restore base
554 ForceRefresh();
555 return this;
556 }
557
567 public TextBlock StartTypewriter(float charsPerSecond,
568 int? maxChars = null,
569 bool enablePauses = true,
570 float longPauseSec = 0.25f,
571 float shortPauseSec = 0.10f)
572 {
573 _textRevealMode = TextRevealMode.CharactersPerSecond;
574 _revealRate = MathF.Max(0f, charsPerSecond);
575 _revealCharCount = 0;
576 _revealTargetCharCount = maxChars.HasValue ? Math.Min(maxChars.Value, _text.Length) : _text.Length;
577 _revealAccum = 0f;
578 _wordEndCharIndexes = null;
579
580 _revealLastTick = 0;
581
582 // punctuation tuning
583 _pauseEnabled = enablePauses;
584 _pauseLongSec = longPauseSec;
585 _pauseShortSec = shortPauseSec;
586
587 ForceRefresh();
588 return this;
589 }
590
599 public TextBlock StartWordReveal(float wordsPerSecond,
600 bool enablePauses = true,
601 float longPauseSec = 0.25f,
602 float shortPauseSec = 0.10f)
603 {
604 _textRevealMode = TextRevealMode.WordsPerSecond;
605 _revealRate = MathF.Max(0f, wordsPerSecond);
606 _revealCharCount = 0;
607 _revealTargetCharCount = _text.Length;
608 _revealAccum = 0f;
609 _wordIndex = 0;
610
611 // split words once; keep trailing punctuation with the word so it reveals naturally
612 // You can refine this regex to match your localization needs.
613 _wordEndCharIndexes = Regex.Matches(_text, @"\S+\s*")
614 .Select(m => m.Index + m.Length)
615 .ToArray();
616
617 _revealLastTick = 0;
618
619 _pauseEnabled = enablePauses;
620 _pauseLongSec = longPauseSec;
621 _pauseShortSec = shortPauseSec;
622
623 ForceRefresh();
624 return this;
625 }
626
634 public TextBlock SetPunctuationPauses(bool enabled, float longPauseSec = 0.25f, float shortPauseSec = 0.10f)
635 {
636 _pauseEnabled = enabled;
637 _pauseLongSec = longPauseSec;
638 _pauseShortSec = shortPauseSec;
639 return this;
640 }
641
647 public TextBlock RevealSetCount(int charCount)
648 {
649 _textRevealMode = TextRevealMode.ManualCount;
650 _revealCharCount = Math.Clamp(charCount, 0, _text.Length);
651 _revealTargetCharCount = _text.Length;
652 ForceRefresh();
653 return this;
654 }
655
660 public TextBlock RevealStop()
661 {
662 _textRevealMode = TextRevealMode.None;
663 _revealCharCount = _text.Length;
664
665 // Fire final events: first the revealed text (full), then the completion event
666 TextRevealed?.Invoke(_text);
667 TextRevealComplete?.Invoke(_text);
668
669 ForceRefresh();
670 return this;
671 }
672
673 private float PauseFor(char c)
674 {
675 if (!_pauseEnabled) return 0f;
676
677 return c switch
678 {
679 '.' or '!' or '?' => _pauseLongSec,
680 ',' or ';' or ':' => _pauseShortSec,
681 _ => 0f
682 };
683 }
684
690 public override void Update(long tick)
691 {
692 if (tick == _lastTick)
693 return;
694
695 // --- PULSE TIMING (independent) ---
696 float dtPulse = 0f;
697 if (_pulseLastTick.HasValue)
698 dtPulse = HighResTimer.GetDuration(_pulseLastTick.Value, tick);
699
700 _pulseLastTick = tick;
701
702 if (dtPulse > 0f && dtPulse < 1f)
703 _timeSec += dtPulse;
704
705 if (_pulseTextEnabled)
706 {
707 float t = PulseT(_timeSec, _pulsePeriodSec, _pulseWave);
708 var c = LerpColor(_pulseFrom, _pulseTo, t);
709 if (c != _resolvedForeColor)
710 {
711 _resolvedForeColor = c;
712 ForceRefresh();
713 }
714 }
715 else
716 {
717 // keep resolved color at base if not pulsing
718 if (_resolvedForeColor != _foreColor)
719 {
720 _resolvedForeColor = _foreColor;
721 ForceRefresh();
722 }
723 }
724
725 // --- REVEAL TIMING (independent and clamped) ---
726 float dtReveal = 0f;
727 if (_revealLastTick == 0)
728 {
729 _revealLastTick = tick; // warm up: first frame uses 0 dt
730 }
731 else
732 {
733 dtReveal = HighResTimer.GetDuration(_revealLastTick, tick);
734 _revealLastTick = tick;
735
736 // Clamp to avoid first-frame "time warp" completing the whole text
737 if (dtReveal > 0.1f) dtReveal = 0.1f; // ~100ms/frame cap
738 if (dtReveal < 0f) dtReveal = 0f;
739 }
740
741 // --- Typewriter / word reveal ---
742 if (_textRevealMode == TextRevealMode.CharactersPerSecond)
743 {
744 _revealAccum += dtReveal;
745 if (_revealRate > 0f)
746 {
747 int step = (int)(_revealAccum * _revealRate);
748 if (step > 0)
749 {
750 _revealAccum -= step / _revealRate;
751
752 int old = _revealCharCount;
753 _revealCharCount = Math.Min(_revealCharCount + step, _revealTargetCharCount);
754
755 // Punctuation pauses for newly revealed chars
756 for (int i = old; i < _revealCharCount; i++)
757 _revealAccum -= PauseFor(_text[i]);
758
759 // Mark dirty + redraw
760 ForceRefresh();
761
762 // Fire TextRevealed with the cumulative revealed text
763 if (_revealCharCount > old)
764 {
765 var revealed = _text.Substring(0, _revealCharCount);
766 TextRevealed?.Invoke(revealed);
767 }
768
769 // If we've reached the target, fire completion (TextRevealed already sent above)
770 if (_revealCharCount >= _revealTargetCharCount)
771 {
772 _textRevealMode = TextRevealMode.None;
773 // Ensure ordering: revealed then complete
774 TextRevealComplete?.Invoke(_text);
775 }
776 }
777 }
778 }
779 else if (_textRevealMode == TextRevealMode.WordsPerSecond && _wordEndCharIndexes is not null)
780 {
781 _revealAccum += dtReveal;
782 if (_revealRate > 0f)
783 {
784 int step = (int)(_revealAccum * _revealRate);
785 if (step > 0)
786 {
787 _revealAccum -= step / _revealRate;
788
789 int oldCount = _revealCharCount;
790
791 _wordIndex = Math.Min(_wordIndex + step, _wordEndCharIndexes.Length);
792 _revealCharCount = _wordIndex == 0 ? 0 : _wordEndCharIndexes[_wordIndex - 1];
793
794 // Simple frontier pause (cheap). Loop oldCount.._revealCharCount-1 if you want per-char.
795 if (_revealCharCount > 0)
796 _revealAccum -= PauseFor(_text[_revealCharCount - 1]);
797
798 ForceRefresh();
799
800 // Fire TextRevealed with the cumulative revealed text
801 if (_revealCharCount > oldCount)
802 {
803 var revealed = _text.Substring(0, _revealCharCount);
804 TextRevealed?.Invoke(revealed);
805 }
806
807 if (_wordIndex >= _wordEndCharIndexes.Length)
808 {
809 _textRevealMode = TextRevealMode.None;
810 // Ensure ordering: revealed then complete
811 TextRevealComplete?.Invoke(_text);
812 }
813 }
814 }
815 }
816
817 // Let the base advance fade tween and set _lastTick
818 base.Update(tick);
819 }
820
826 protected override void OnDraw(BackbufferBase backbuffer, RectangleF destRectScreen)
827 {
828 var canvas = backbuffer.Canvas;
829 var rect = destRectScreen.ToSKRect();
830
831 float zoom = ResolveTextScale(Mode, RenderContext.Current?.ViewportZoom ?? 1f);
832
833 // Scale "pixel-like" adornments with zoom so text behaves like other world-space drawables.
834 float hPad = HorizontalPadding * zoom;
835 float vPad = VerticalPadding * zoom;
836 float shadowDx = _shadowDx * zoom;
837 float shadowDy = _shadowDy * zoom;
838 float shadowBlur = _shadowBlurSigma * zoom;
839 float outlineWidth = 1.5f * zoom;
840
841 // Background
842 if (_backColor.Alpha != 0)
843 {
844 using var bg = new SKPaint { Color = _backColor };
845 canvas.DrawRect(rect, bg);
846 }
847
848 _typeface ??= SKTypeface.Default;
849
850 // Build a paint we can reuse for layout + draw (TEXT SIZE IS IN SCREEN PIXELS)
851 using var paint = new SKPaint
852 {
853 Typeface = _typeface ?? SKTypeface.Default,
854 TextSize = _fontSize * zoom,
855 Color = _resolvedForeColor,
856 IsAntialias = true,
857 IsStroke = false,
858 TextAlign = _hAlign
859 };
860
861 // Auto-shrink: reflow until it fits height (if min size provided)
862 float fontSize = _fontSize * zoom;
863 float minFontSize = _minFontSize.HasValue ? _minFontSize.Value * zoom : 0f;
864
865 float innerW = Math.Max(0, rect.Width - hPad * 2f);
866 float innerH = Math.Max(0, rect.Height - vPad * 2f);
867
868 while (true)
869 {
870 paint.TextSize = fontSize;
871
872 // Rebuild when flagged, and also whenever zoom is in play so wrap/line-height stay correct.
873 if (_layoutDirty || zoom != 1f)
874 RebuildLayout(paint, innerW);
875
876 int drawableLines = _maxLines.HasValue ? Math.Min(_lines.Count, _maxLines.Value) : _lines.Count;
877 float totalH = drawableLines * _lineHeight;
878
879 bool exceedsHeight = totalH > innerH;
880 bool exceedsWidth = _lines.Any(line => paint.MeasureText(line) > innerW);
881
882 if (_minFontSize.HasValue &&
883 (exceedsHeight || exceedsWidth) &&
884 fontSize > minFontSize)
885 {
886 fontSize = Math.Max(minFontSize, fontSize - 1f);
887 _layoutDirty = true;
888
889 if (fontSize <= minFontSize)
890 break;
891
892 continue;
893 }
894
895 break;
896 }
897
898 // Determine how many characters are currently visible (content-driven reveal)
899 int visibleChars = _textRevealMode != TextRevealMode.None
900 ? Math.Clamp(_revealCharCount, 0, _text.Length)
901 : _text.Length;
902
903 // Build the set of lines to draw from the already-laid-out _lines,
904 // truncating at 'visibleChars' so wrapping and alignment still work.
905 List<string> drawLines = new(_lines.Count);
906 if (visibleChars <= 0)
907 {
908 // nothing to show
909 }
910 else if (visibleChars >= _text.Length)
911 {
912 drawLines.AddRange(_lines);
913 }
914 else
915 {
916 int remaining = visibleChars;
917 foreach (var ln in _lines)
918 {
919 if (remaining <= 0)
920 break;
921
922 if (ln.Length <= remaining)
923 {
924 drawLines.Add(ln);
925 remaining -= ln.Length;
926 }
927 else
928 {
929 drawLines.Add(ln.Substring(0, remaining));
930 remaining = 0;
931 }
932 }
933 }
934
935 bool wasTruncatedByMaxLines = _maxLines.HasValue && drawLines.Count > _maxLines.Value;
936
937 // Apply max-lines cap at draw time
938 int linesToDraw = _maxLines.HasValue ? Math.Min(drawLines.Count, _maxLines.Value) : drawLines.Count;
939
940 if (linesToDraw > 0 && wasTruncatedByMaxLines)
941 {
942 drawLines[linesToDraw - 1] = FitLineWithEllipsis(paint, drawLines[linesToDraw - 1], innerW);
943 }
944
945 // Vertical start (Skia draws at baseline, so apply ascent shift)
946 var fm = paint.FontMetrics;
947 float baselineShift = -fm.Ascent;
948
949 float contentH = linesToDraw * _lineHeight;
950 float maximumScrollOffset = Math.Max(0f, contentH - innerH);
951 if (maximumScrollOffset > 0f)
952 maximumScrollOffset += VerticalScrollEndPaddingPx;
953 float yStart = maximumScrollOffset > 0f
954 ? rect.Top + vPad
955 : _vAlign switch
956 {
957 VerticalAlign.Center => rect.Top + vPad + Math.Max(0, (innerH - contentH) * 0.5f),
958 VerticalAlign.Bottom => rect.Bottom - vPad - contentH,
959 _ => rect.Top + vPad
960 };
961
962 float scrollOffset = Math.Min(_verticalScrollOffsetPx * zoom, maximumScrollOffset);
963 yStart -= scrollOffset;
964
965 // Horizontal anchor per line
966 float xAnchorLeft = rect.Left + hPad;
967 float xAnchorCenter = rect.MidX;
968 float xAnchorRight = rect.Right - hPad;
969
970 canvas.Save();
971 canvas.ClipRect(rect);
972
973 float y = yStart;
974 for (int i = 0; i < linesToDraw; i++)
975 {
976 var line = drawLines[i];
977 float x = _hAlign switch
978 {
979 SKTextAlign.Center => xAnchorCenter,
980 SKTextAlign.Right => xAnchorRight,
981 _ => xAnchorLeft
982 };
983
984 if (_useShadow)
985 {
986 using var shadow = paint.Clone();
987 shadow.IsStroke = false;
988 shadow.MaskFilter = shadowBlur > 0f
989 ? SKMaskFilter.CreateBlur(SKBlurStyle.Normal, shadowBlur)
990 : null;
991 shadow.Color = new SKColor(0, 0, 0, _shadowAlpha);
992
993 // manually offset
994 canvas.DrawText(line, x + shadowDx, y + baselineShift + shadowDy, shadow);
995 }
996
997 if (_useOutline)
998 {
999 using var outline = paint.Clone();
1000 outline.IsStroke = true;
1001 outline.StrokeWidth = outlineWidth;
1002 outline.Color = SKColors.Black;
1003 canvas.DrawText(line, x, y + baselineShift, outline);
1004 }
1005
1006 canvas.DrawText(line, x, y + baselineShift, paint);
1007 y += _lineHeight;
1008
1009 // safety clip
1010 if (y > rect.Bottom)
1011 break;
1012 }
1013
1014 canvas.Restore();
1015 }
1016
1017 internal static float ResolveTextScale(DirectDrawingMode mode, float viewportZoom)
1018 {
1019 if (mode != DirectDrawingMode.SceneLayer)
1020 return 1f;
1021
1022 return viewportZoom > 0f ? viewportZoom : 1f;
1023 }
1024
1025 private void RebuildLayout(SKPaint paint, float maxWidth)
1026 {
1027 _lines.Clear();
1028
1029 var fm = paint.FontMetrics;
1030 _lineHeight = ((fm.Descent - fm.Ascent) + fm.Leading) * LineSpacingMultiplier;
1031
1032 var paragraphs = _text.Replace("\r\n", "\n").Split('\n');
1033
1034 foreach (var para in paragraphs)
1035 {
1036 if (string.IsNullOrEmpty(para))
1037 {
1038 _lines.Add(string.Empty);
1039 continue;
1040 }
1041
1042 if (!_wrapText)
1043 {
1044 _lines.Add(para);
1045 continue;
1046 }
1047
1048 var words = para.Split(' ', StringSplitOptions.None);
1049 var current = string.Empty;
1050
1051 foreach (var word in words)
1052 {
1053 string candidate = string.IsNullOrEmpty(current) ? word : current + " " + word;
1054 float candidateWidth = paint.MeasureText(candidate);
1055
1056 if (candidateWidth <= maxWidth)
1057 {
1058 current = candidate;
1059 continue;
1060 }
1061
1062 // If current already has content, commit it first and retry this word on a new line.
1063 if (!string.IsNullOrEmpty(current))
1064 {
1065 _lines.Add(current);
1066 current = string.Empty;
1067 }
1068
1069 // If the single word fits on its own line, use it.
1070 if (paint.MeasureText(word) <= maxWidth)
1071 {
1072 current = word;
1073 continue;
1074 }
1075
1076 // Fallback: hard-break an overlong word by character.
1077 var chunk = string.Empty;
1078 foreach (char ch in word)
1079 {
1080 string next = chunk + ch;
1081 if (paint.MeasureText(next) <= maxWidth || string.IsNullOrEmpty(chunk))
1082 {
1083 chunk = next;
1084 }
1085 else
1086 {
1087 _lines.Add(chunk);
1088 chunk = ch.ToString();
1089 }
1090 }
1091
1092 if (!string.IsNullOrEmpty(chunk))
1093 current = chunk;
1094 }
1095
1096 if (!string.IsNullOrEmpty(current))
1097 _lines.Add(current);
1098 }
1099
1100 _layoutDirty = false;
1101 }
1102
1103 [MethodImpl(MethodImplOptions.AggressiveInlining)]
1104 private static float PulseT(float timeSec, float periodSec, PulseWave wave)
1105 {
1106 float phase = (timeSec / periodSec) % 1f;
1107 return wave == PulseWave.Sine
1108 ? 0.5f * (1f + MathF.Sin(phase * MathF.PI * 2f)) // [0..1]
1109 : (phase < 0.5f ? phase * 2f : 1f - (phase - 0.5f) * 2f); // triangle
1110 }
1111
1112 [MethodImpl(MethodImplOptions.AggressiveInlining)]
1113 private static SKColor LerpColor(SKColor a, SKColor b, float t01)
1114 {
1115 t01 = Math.Clamp(t01, 0f, 1f);
1116 byte r = (byte)(a.Red + (b.Red - a.Red) * t01);
1117 byte g = (byte)(a.Green + (b.Green - a.Green) * t01);
1118 byte bl = (byte)(a.Blue + (b.Blue - a.Blue) * t01);
1119 byte al = (byte)(a.Alpha + (b.Alpha - a.Alpha) * t01);
1120 return new SKColor(r, g, bl, al);
1121 }
1122
1123 private static string FitLineWithEllipsis(SKPaint paint, string text, float maxWidth)
1124 {
1125 const string ellipsis = "...";
1126
1127 if (string.IsNullOrEmpty(text))
1128 return text;
1129
1130 if (paint.MeasureText(text) <= maxWidth)
1131 return text;
1132
1133 float ellipsisWidth = paint.MeasureText(ellipsis);
1134 if (ellipsisWidth > maxWidth)
1135 return string.Empty;
1136
1137 int length = text.Length;
1138 while (length > 0)
1139 {
1140 string candidate = text.Substring(0, length).TrimEnd() + ellipsis;
1141 if (paint.MeasureText(candidate) <= maxWidth)
1142 return candidate;
1143
1144 length--;
1145 }
1146
1147 return ellipsisWidth <= maxWidth ? ellipsis : string.Empty;
1148 }
1149
1150 #region public readonly properties
1151
1153 public string Text => _text;
1154
1156 public List<string> Lines => _lines;
1157
1159 public float LineHeight => _lineHeight;
1160
1162 public SKColor ForeColor => _foreColor;
1163
1165 public SKColor BackColor => _backColor;
1166
1168 public float FontSize => _fontSize;
1169
1171 public float? MinFontSize => _minFontSize;
1172
1174 public SKTypeface? TypeFace => _typeface;
1175
1177 public bool OutlineEnabled => _useOutline;
1178
1180 public bool WrapText => _wrapText;
1181
1183 public int? MaxLines => _maxLines;
1184
1186 public bool ShadowEnabled => _useShadow;
1187
1189 public float ShadowDx => _shadowDx;
1190
1192 public float ShadowDy => _shadowDy;
1193
1195 public byte ShadowAlpha => _shadowAlpha;
1196
1198 public float ShadowBlurSigma => _shadowBlurSigma;
1199
1201 public SKTextAlign AlignHoriz => _hAlign;
1202
1204 public VerticalAlign AlignVert => _vAlign;
1205
1207 public bool PulseTextEnabled => _pulseTextEnabled;
1208
1210 public SKColor PulseFrom => _pulseFrom;
1211
1213 public SKColor PulseTo => _pulseTo;
1214
1216 public float PulsePeriodSec => _pulsePeriodSec;
1217
1219 public PulseWave PulseWaveValue => _pulseWave;
1220
1222 public TextRevealMode TextRevealModeValue => _textRevealMode;
1223
1225 public float TextRevealRate => _revealRate;
1226
1228 public bool PuctuationPauseEnabled => _pauseEnabled;
1229
1231 public float PunctuationPauseLongSec => _pauseLongSec;
1232
1234 public float PunctuationPauseShortSec => _pauseShortSec;
1235
1237 public SKColor ResolvedForeColor => _resolvedForeColor;
1238
1239 #endregion public readonly properties
1240
1244 public enum PulseWave
1245 {
1250 }
1251
1266
1267 private readonly object _eventLock = new();
1268
1273 protected override void Dispose(bool disposing)
1274 {
1275 if (disposing)
1276 {
1277 lock (_eventLock)
1278 {
1279 TextRevealed = null;
1280 TextRevealComplete = null;
1281 }
1282 }
1283
1284 base.Dispose(disposing);
1285 }
1286}
View? View
Gets the view to which this direct drawing is attached, or null if the drawing is in scene-layer mode...
long _lastTick
The last engine tick value used for delta-time calculations in Update.
SceneLayer? SceneLayer
Gets the scene layer to which this direct drawing is attached, or null if the drawing is in view mode...
Rectangle WorldBounds
Gets or sets the world-space bounds of this direct drawing in pixels.
DirectDrawingMode Mode
Gets the drawing mode that determines how this direct drawing is positioned and transformed.
Rectangle ScreenBounds
Gets or sets the screen-space bounds of this direct drawing in pixels.
DirectDrawingMovableBase(RenderSurfaceHostBase renderSurfaceHost, DirectDrawingMode mode, SceneLayer? sceneLayer, View? view, Rectangle? screenBounds, Rectangle? worldBounds, string? name=null)
Initializes a new instance of the DirectDrawingMovableBase class with integrated movement capabilitie...
TextBlock SetShadow(float dx, float dy, byte alpha=128, float blurSigma=1.5f)
Configures the drop shadow effect for text rendering.
Definition TextBlock.cs:476
TextBlock UseOutline(bool enable=true)
Enables or disables a 1-pixel outline stroke around the text glyphs.
Definition TextBlock.cs:491
float PunctuationPauseShortSec
Gets the short punctuation pause duration (seconds) used for ',', ';', ':'.
float ShadowDy
Gets the vertical shadow offset (in pixels).
PulseWave
Selects the waveform used by PulseColor(Color, Color, float, bool, bool).
@ Triangle
Triangle wave: linear fade-in/out between colors.
@ Sine
Sine wave: smooth continuous pulse between colors.
SKColor ResolvedForeColor
Gets the currently resolved (effective) foreground color used for drawing.
float PulsePeriodSec
Gets the pulse period in seconds.
TextBlock(RenderSurfaceHostBase renderSurfaceHost, SceneLayer sceneLayer, View? view, Rectangle worldBounds, string? nickname=null)
Creates a new TextBlock that draws in world space on the specified scene layer.
Definition TextBlock.cs:161
TextBlock SetColors(Color fg, Color bg)
Sets the foreground (text) and background colors using System.Drawing colors.
float ShadowBlurSigma
Gets the blur radius for the shadow (sigma, in pixels).
SKColor PulseFrom
Gets the starting color for the pulse effect.
VerticalAlign AlignVert
Gets the vertical alignment used when drawing lines.
float HorizontalPadding
Gets or sets the horizontal padding (in pixels) on both left and right sides of the text.
Definition TextBlock.cs:203
float PunctuationPauseLongSec
Gets the long punctuation pause duration (seconds) used for '.', '!', '?'.
TextBlock SetFont(SKTypeface typeface, float size, float? minSize=null)
Sets the font face and size (and optional minimum size for auto-shrink).
Definition TextBlock.cs:394
bool WrapText
Gets a value indicating whether word wrapping is enabled.
TextBlock(RenderSurfaceHostBase renderSurfaceHost, View view, Rectangle screenBounds, string? nickname=null)
Creates a new TextBlock that draws in screen space relative to a view.
Definition TextBlock.cs:182
SKColor ForeColor
Gets the configured foreground (text) color.
TextBlock EnableWrapping(bool enable=true)
Enables or disables word wrapping when laying out paragraphs.
Definition TextBlock.cs:503
TextBlock SetAlignment(SKTextAlign h, VerticalAlign v)
Sets the horizontal and vertical alignment used when drawing the laid-out lines within the bounds.
Definition TextBlock.cs:433
float? MinFontSize
Gets the optional minimum font size used by auto-shrink (or null if disabled).
TextBlock SetPadding(float horizontal, float vertical)
Sets symmetric horizontal and vertical padding and invalidates cached layout.
Definition TextBlock.cs:317
SKTypeface? TypeFace
Gets the configured typeface (or null to use SKTypeface.Default).
TextBlock StartWordReveal(float wordsPerSecond, bool enablePauses=true, float longPauseSec=0.25f, float shortPauseSec=0.10f)
Begins a word-by-word reveal. Words are detected by \S+\s* and include their trailing whitespace/punc...
Definition TextBlock.cs:599
VerticalAlign
Vertical alignment options for multi-line text within the control's bounds.
Definition TextBlock.cs:49
@ Bottom
Align text to the bottom of the inner content area.
Definition TextBlock.cs:55
@ Center
Center text vertically within the inner content area.
Definition TextBlock.cs:53
@ Top
Align text to the top of the inner content area.
Definition TextBlock.cs:51
SKColor BackColor
Gets the configured background color.
TextBlock UseShadow(bool enable=true)
Enables or disables drawing a drop shadow behind the text (parameters are configured via SetShadow(fl...
Definition TextBlock.cs:446
TextBlock SetColors(SKColor fg, SKColor bg)
Sets the foreground (text) and background colors using Skia colors.
Definition TextBlock.cs:410
float LineSpacingMultiplier
Gets or sets a scale factor applied to the computed line height (1.0 = natural spacing).
Definition TextBlock.cs:198
override void OnDraw(BackbufferBase backbuffer, RectangleF destRectScreen)
Draws the laid-out text block contents into the destination rectangle.
Definition TextBlock.cs:826
bool PulseTextEnabled
Gets a value indicating whether color pulsing is enabled.
float VerticalScrollEndPaddingPx
Gets or sets extra scrollable space after the final laid-out line.
Definition TextBlock.cs:241
Action< string >? TextRevealComplete
Raised once when the reveal completes. Argument is the full text content. Note: when the final chunk ...
Definition TextBlock.cs:71
override void Dispose(bool disposing)
Releases managed resources used by the text block.
TextBlock RevealSetCount(int charCount)
Sets the currently revealed character count directly (manual mode).
Definition TextBlock.cs:647
float FontSize
Gets the requested font size (before any auto-shrink adjustments).
PulseWave PulseWaveValue
Gets the pulse waveform currently in use.
byte ShadowAlpha
Gets the shadow opacity (0-255).
float MeasureMaximumVerticalScrollOffsetPx()
Measures the maximum vertical scroll offset required to expose all currently laid-out text.
Definition TextBlock.cs:263
bool OutlineEnabled
Gets a value indicating whether an outline stroke is enabled.
SKColor PulseTo
Gets the ending color for the pulse effect.
TextRevealMode TextRevealModeValue
Gets the active text reveal mode.
bool ShadowEnabled
Gets a value indicating whether a drop shadow is enabled.
int? MaxLines
Gets the optional maximum number of lines to draw (or null for no limit).
float ShadowDx
Gets the horizontal shadow offset (in pixels).
TextBlock SetPunctuationPauses(bool enabled, float longPauseSec=0.25f, float shortPauseSec=0.10f)
Configures punctuation pause behavior used by the current or next reveal animation.
Definition TextBlock.cs:634
float VerticalPadding
Gets or sets the vertical padding (in pixels) on both top and bottom sides of the text.
Definition TextBlock.cs:208
List< string > Lines
Gets the laid-out lines (after wrapping and paragraph processing).
TextBlock StopColorPulse()
Stops text color pulsing and restores the base foreground color.
Definition TextBlock.cs:550
float VerticalScrollOffsetPx
Gets or sets the vertical text-content scroll offset in native pixels.
Definition TextBlock.cs:218
TextRevealMode
Describes the mode used for revealing text over time.
@ CharactersPerSecond
Reveal advances by characters per second.
@ None
No reveal is active; all text is shown.
@ ManualCount
Reveal amount is controlled directly via RevealSetCount(int).
@ WordsPerSecond
Reveal advances by words per second.
TextBlock RevealStop()
Stops any active reveal animation and shows the full text immediately.
Definition TextBlock.cs:660
SKTextAlign AlignHoriz
Gets the horizontal alignment used when drawing lines.
Action< string >? TextRevealed
Raised whenever the revealed text portion advances. The argument is the cumulative text currently rev...
Definition TextBlock.cs:64
TextBlock SetText(string text)
Sets the current text content and rebuilds layout as needed. If no reveal animation is active,...
Definition TextBlock.cs:358
float LineHeight
Gets the measured line height (in pixels) used for drawing.
TextBlock PulseColor(Color from, Color to, float periodSec, bool enabled=true, bool triangle=false)
Animates the text color between from and to over periodSec seconds.
Definition TextBlock.cs:532
string Text
Gets the current raw text content.
TextBlock SetSize(Size size)
Sets the text-block size while preserving its current position and invalidates cached layout.
Definition TextBlock.cs:336
TextBlock SetMaxLines(int? maxLines)
Sets a maximum number of lines to draw. If null, all laid-out lines are drawn.
Definition TextBlock.cs:516
override void Update(long tick)
Advances internal animations (pulse, typewriter/word reveal) based on the current tick,...
Definition TextBlock.cs:690
float TextRevealRate
Gets the configured reveal rate (characters/sec or words/sec depending on mode).
bool PuctuationPauseEnabled
Gets a value indicating whether punctuation pauses are enabled.
TextBlock StartTypewriter(float charsPerSecond, int? maxChars=null, bool enablePauses=true, float longPauseSec=0.25f, float shortPauseSec=0.10f)
Begins a character-by-character reveal (typewriter effect).
Definition TextBlock.cs:567
Represents a base class for managing a graphical backbuffer, and is the in-memory surface where rende...
SKCanvas Canvas
Gets the SkiaSharp canvas used for drawing operations on this backbuffer.
Represents a base class for hosting a render surface, providing functionality for managing rendering ...
DirectDrawingMode
Specifies where direct drawing operations are applied within the rendering pipeline.
@ View
Performs direct drawing at the view level using screen-space coordinates.