Skip to content

Instantly share code, notes, and snippets.

@Raygunpewpew1
Last active February 14, 2026 15:31
Show Gist options
  • Select an option

  • Save Raygunpewpew1/d2647f86e0c40ff1bd247545f254fa3c to your computer and use it in GitHub Desktop.

Select an option

Save Raygunpewpew1/d2647f86e0c40ff1bd247545f254fa3c to your computer and use it in GitHub Desktop.
High-Performance Virtualized Grid & Rich Text in Delphi FMX (Skia)

60FPS Virtualized Grid & Rich Text Experiment (Delphi FMX + Skia)

I'm building a personal Magic: The Gathering collection app and hit a wall with standard FMX components. TListView and TGrid were struggling to handle scrolling thousands of high-res card images without stuttering on Android.

I decided to try writing a custom renderer using Skia4Delphi. I'm sharing the code in case anyone else is fighting scroll lag or trying to render complex text layouts in FMX.

View Screen Recording (60FPS Demo)
Screen_Recording_20260212_215531.2.1.mp4

Disclaimer: This isn't a drop-in component package. It's "hobbyist code" tightly coupled to my internal data types (MTGCore), but the logic for virtualization and rendering should be useful if you're building your own solution.

The Two Main Problems I Solved

1. The Scroll Lag (CustomCardGrid.pas)

Standard controls calculate layout for every item, which kills performance with big lists.

  • My Fix: I implemented manual virtualization. The grid calculates exactly which indices are visible based on FScrollOffset and only loops through those in the Paint method.
  • The Result: Consistent 60fps scrolling on my phone, even with network image loading.
  • Memory: It aggressively clears off-screen ISkImage references to keep RAM usage flat.

2. Mixed Text & Icons (MTGCardTextView.pas)

Rendering MTG card text is tricky because it mixes standard text with SVG icons (Mana Symbols) and bold keywords.

  • My Fix: I used Skia's TSkParagraph and TSkPlaceholderStyle.
  • How it works: I reserve exact pixel space for the mana symbols within the text stream using placeholders. Then, during the paint cycle, I draw the SVG directly into those empty spots.
Card Detail View (Keywords & Symbols) Search Results Grid

Key Code Logic

The Render Loop (Grid)

Instead of letting FMX decide what to draw, I force strict bounds:

// Only iterate through what the user can actually see
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
  // Direct Skia drawing for max performance
  LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LPaint);
  LSkCanvas.DrawImageRect(Card.ImageSkia, ImageRect, TSkSamplingOptions.Medium);
end;

The Text Rendering

Regex parses the text for symbols (like {U} or {T}) and inserts placeholders:

// Add symbol placeholder
ABuilder.AddPlaceholder(TSkPlaceholderStyle.Create(
  FSymbolSize * FSymbolScale,
  FSymbolSize * FSymbolScale,
  TSkPlaceholderAlignment.Middle,
  TSkTextBaseline.Alphabetic,
  0
));

Dependencies

  • Skia4Delphi (Required)
  • Standard FMX units
  • Note: You will need to replace MTGCore / TCard with your own data models.

2. Mixed Text & Icons (MTGCardTextView.pas)

Rendering MTG card text is tricky because it mixes standard text with SVG icons (Mana Symbols) and bold keywords.

  • My Fix: I used Skia's TSkParagraph and TSkPlaceholderStyle.
  • How it works: I reserve exact pixel space for the mana symbols within the text stream using placeholders. Then, during the paint cycle, I draw the SVG directly into those empty spots. | Card Detail View (Keywords & Symbols) | Search Results Grid | | :---: | :---: | | | |

Key Code Logic

The Render Loop (Grid): Instead of letting FMX decide what to draw, I force strict bounds:

// Only iterate through what the user can actually see
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
  // Direct Skia drawing for max performance
  LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LPaint);
  LSkCanvas.DrawImageRect(Card.ImageSkia, ImageRect, TSkSamplingOptions.Medium);
end;

The Text Rendering: Regex parses the text for {U} or {T} symbols and inserts placeholders: // Add symbol placeholder ABuilder.AddPlaceholder(TSkPlaceholderStyle.Create( FSymbolSize * FSymbolScale, FSymbolSize * FSymbolScale, TSkPlaceholderAlignment.Middle, TSkTextBaseline.Alphabetic, 0 ));

Dependencies ​Skia4Delphi (Required) ​Standard FMX units ​You will need to replace MTGCore / TCard with your own data

unit CustomCardGrid;
interface
uses
System.SysUtils, System.Types, System.UITypes, System.Classes,
System.Generics.Collections, System.Math, FMX.Types, FMX.Controls,
FMX.Graphics, FMX.Objects, FMX.Layouts, FMX.Ani, FMX.Forms, FMX.Platform,
System.Skia, FMX.Skia.Canvas, MTGCore, CardPriceTypes;
type
TGridCardData = class
private
FImageSkia: ISkImage;
FPriceData: TCardPriceData;
FHasPriceData: Boolean;
CachedDisplayPrice: string;
public
UUID: string;
Name: string;
SetCode: string;
Number: string;
SetAndNum:string;
ScryfallId: string;
ImageLoading: Boolean;
AnimationProgress: Single;
FirstVisible: Boolean;
IsOnlineOnly: Boolean;
constructor Create(const ACard: TCard);
destructor Destroy; override;
procedure UpdatePriceData(const APriceData: TCardPriceData);
function GetDisplayPrice: string;
property ImageSkia: ISkImage read FImageSkia write FImageSkia;
property PriceData: TCardPriceData read FPriceData;
property HasPriceData: Boolean read FHasPriceData;
end;
TCustomCardGrid = class(TControl)
private
FCards: TObjectList<TGridCardData>;
FCardWidth: Single;
FCardHeight: Single;
FMinCardWidth: Single;
FColumnCount: Integer;
FSpacing: Single;
FHoveredCard: Integer;
FScrollOffset: Single;
FOnCardClick: TNotifyEvent;
FOnCardMouseDown: TNotifyEvent;
FClickedCardUUID: string;
FPlaceholderBitmap: TBitmap;
FVisibleIndexStart: Integer;
FVisibleIndexEnd: Integer;
FAnimProgress: Single;
FMouseDownPos: TPointF;
FMouseMoved: Boolean;
FScreenScale: Single;
FLastCleanupTime: UInt64;
FRepaintScheduled: Boolean;
FOnCardLongPress: TNotifyEvent;
FLongPressCard: Integer;
FPriceLoadInProgress: Boolean;
FPaint: ISkPaint;
FBorderPaint: ISkPaint;
// Animation properties
FHoverScaleProgress: Single;
FShimmerProgress: Single;
FRippleProgress: Single;
FRippleCardIndex: Integer;
procedure CalculateLayout;
function GetCardRect(Index: Integer): TRectF;
function CardAtPoint(const APoint: TPointF): Integer;
procedure CreatePlaceholder;
procedure CalculateVisibleIndices;
procedure CheckForNewAnimations;
procedure SetAnimProgress(const Value: Single);
procedure SetHoverScaleProgress(const Value: Single);
procedure SetShimmerProgress(const Value: Single);
procedure SetRippleProgress(const Value: Single);
procedure UpdateScreenScale;
procedure CleanupOffScreenBitmaps;
procedure StartShimmerAnimation;
procedure LoadVisibleCardPrices;
protected
procedure Paint; override;
procedure DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean); override;
procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override;
procedure MouseMove(Shift: TShiftState; X, Y: Single); override;
procedure MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override;
procedure Resize; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure SetCards(const ACards: TArray<TCard>);
procedure AddCards(const ACards: TArray<TCard>);
procedure ClearCards;
function IsCardInRecentBuffer(const ACardUUID: string): Boolean;
procedure SetScrollOffset(AOffset: Single);
procedure UpdateCardImage(const ACardUUID: string; const AImage: ISkImage);
function GetCardUUIDAt(Index: Integer): string;
function GetCardCount: Integer;
function HasCardImage(const ACardUUID: string): Boolean;
procedure RefreshPrices;
property OnCardLongPress: TNotifyEvent read FOnCardLongPress write FOnCardLongPress;
property OnCardClick: TNotifyEvent read FOnCardClick write FOnCardClick;
property OnCardMouseDown: TNotifyEvent read FOnCardMouseDown write FOnCardMouseDown;
property ClickedCardUUID: string read FClickedCardUUID;
property AnimProgress: Single read FAnimProgress write SetAnimProgress;
property HoverScaleProgress: Single read FHoverScaleProgress write SetHoverScaleProgress;
property ShimmerProgress: Single read FShimmerProgress write SetShimmerProgress;
property RippleProgress: Single read FRippleProgress write SetRippleProgress;
property MinCardWidth: Single read FMinCardWidth write FMinCardWidth;
property ColumnCount: Integer read FColumnCount;
property CardHeight: Single read FCardHeight;
property Spacing: Single read FSpacing;
end;
implementation
uses
System.Threading, CardPriceManager;
const
CARD_IMAGE_RATIO = 1.3968;
LABEL_HEIGHT = 42;
ANIMATION_DURATION = 0.3;
CLEANUP_INTERVAL_SECONDS = 2.0;
OFFSCREEN_BUFFER_CARDS = 15;
function MakeSkiaColor(const AColor: TAlphaColor; const AOpacity: Single): TAlphaColor;
var
ColorRec: TAlphaColorRec;
begin
ColorRec := TAlphaColorRec(AColor);
ColorRec.A := Round(ColorRec.A * EnsureRange(AOpacity, 0.0, 1.0));
Result := ColorRec.Color;
end;
{ TGridCardData }
constructor TGridCardData.Create(const ACard: TCard);
begin
inherited Create;
UUID := ACard.UUID;
Name := ACard.Name;
IsOnlineOnly := ACard.IsOnlineOnly;
SetCode := ACard.SetCode;
Number := ACard.Number;
SetAndNum:= ACard.GetSetAndNumber;
ScryfallId := ACard.ScryfallId;
ImageLoading := False;
AnimationProgress := 0.0;
FirstVisible := False;
FHasPriceData := False;
FPriceData := TCardPriceData.Empty;
end;
destructor TGridCardData.Destroy;
begin
FImageSkia := nil;
inherited;
end;
procedure TGridCardData.UpdatePriceData(const APriceData: TCardPriceData);
begin
FPriceData := APriceData;
FHasPriceData := True;
// Calculate the string ONCE here
CachedDisplayPrice := GetDisplayPrice;
end;
function TGridCardData.GetDisplayPrice: string;
var
Paper: TPaperPlatform;
// Small helper function to handle the EUR/USD formatting cleanly
function FormatPrice(Price: Double; Cur: TCurrency): string;
begin
if Cur = curEUR then
Result := Format('€%.2f', [Price])
else
Result := Format('$%.2f', [Price]);
end;
begin
if not FHasPriceData then
Exit('');
Paper := FPriceData.Paper;
// 1. Try TCGPlayer (Normal, then Foil)
if Paper.TCGPlayer.RetailNormal.Price > 0 then
Exit(FormatPrice(Paper.TCGPlayer.RetailNormal.Price, Paper.TCGPlayer.Currency));
if Paper.TCGPlayer.RetailFoil.Price > 0 then
Exit(FormatPrice(Paper.TCGPlayer.RetailFoil.Price, Paper.TCGPlayer.Currency));
// 2. Try Cardmarket (Normal, then Foil)
if Paper.Cardmarket.RetailNormal.Price > 0 then
Exit(FormatPrice(Paper.Cardmarket.RetailNormal.Price, Paper.Cardmarket.Currency));
if Paper.Cardmarket.RetailFoil.Price > 0 then
Exit(FormatPrice(Paper.Cardmarket.RetailFoil.Price, Paper.Cardmarket.Currency));
// 3. Try Card Kingdom (Normal, then Foil)
if Paper.CardKingdom.RetailNormal.Price > 0 then
Exit(FormatPrice(Paper.CardKingdom.RetailNormal.Price, Paper.CardKingdom.Currency));
if Paper.CardKingdom.RetailFoil.Price > 0 then
Exit(FormatPrice(Paper.CardKingdom.RetailFoil.Price, Paper.CardKingdom.Currency));
// 4. Try ManaPool (Normal, then Foil)
if Paper.ManaPool.RetailNormal.Price > 0 then
Exit(FormatPrice(Paper.ManaPool.RetailNormal.Price, Paper.ManaPool.Currency));
if Paper.ManaPool.RetailFoil.Price > 0 then
Exit(FormatPrice(Paper.ManaPool.RetailFoil.Price, Paper.ManaPool.Currency));
// If absolutely no prices are found across any vendor
Result := '';
end;
{ TCustomCardGrid }
constructor TCustomCardGrid.Create(AOwner: TComponent);
begin
inherited;
FCards := TObjectList<TGridCardData>.Create(True); // OwnsObjects = True
Touch.InteractiveGestures := [TInteractiveGesture.LongTap];
FMinCardWidth := 100;
FCardWidth := 150;
FCardHeight := 210;
FSpacing := 8;
FHoveredCard := -1;
FScrollOffset := 0;
FColumnCount := 3;
FVisibleIndexStart := 0;
FVisibleIndexEnd := 0;
FAnimProgress := 0;
FMouseDownPos := TPointF.Zero;
FMouseMoved := False;
FLastCleanupTime := 0;
HitTest := True;
FRepaintScheduled := False;
FLongPressCard := -1;
FPriceLoadInProgress := False;
// paint once
FPaint := TSkPaint.Create;
FPaint.AntiAlias := True;
FBorderPaint := TSkPaint.Create;
FBorderPaint.Style := TSkPaintStyle.Stroke;
FBorderPaint.StrokeWidth := 2;
FBorderPaint.AntiAlias := True;
// Initialize animation properties
FHoverScaleProgress := 0;
FShimmerProgress := 0;
FRippleProgress := 0;
FRippleCardIndex := -1;
UpdateScreenScale;
CreatePlaceholder;
StartShimmerAnimation;
end;
destructor TCustomCardGrid.Destroy;
begin
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress');
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress');
TAnimator.StopPropertyAnimation(Self, 'RippleProgress');
TAnimator.StopPropertyAnimation(Self, 'AnimProgress');
FCards.Free; // TObjectList handles all TGridCardData destruction
if Assigned(FPlaceholderBitmap) then
FPlaceholderBitmap.Free;
FPaint := nil;
FBorderPaint := nil;
inherited;
end;
procedure TCustomCardGrid.UpdateScreenScale;
var
ScreenService: IFMXScreenService;
begin
FScreenScale := 1.0;
if TPlatformServices.Current.SupportsPlatformService(IFMXScreenService, ScreenService) then
FScreenScale := ScreenService.GetScreenScale;
{$IFDEF ANDROID}
if FScreenScale > 2.0 then
FScreenScale := 2.0;
{$ENDIF}
{$IFDEF IOS}
if FScreenScale > 2.0 then
FScreenScale := 2.0;
{$ENDIF}
end;
procedure TCustomCardGrid.CreatePlaceholder;
begin
FPlaceholderBitmap := TBitmap.Create(Round(150 * FScreenScale), Round((210 - LABEL_HEIGHT) * FScreenScale));
FPlaceholderBitmap.Clear(TAlphaColors.Dimgray);
if FPlaceholderBitmap.Canvas.BeginScene then
try
FPlaceholderBitmap.Canvas.Fill.Color := TAlphaColors.White;
FPlaceholderBitmap.Canvas.Font.Size := 14 * FScreenScale;
FPlaceholderBitmap.Canvas.FillText(RectF(0, 0, FPlaceholderBitmap.Width, FPlaceholderBitmap.Height), 'Loading...', False, 1.0, [], TTextAlign.Center, TTextAlign.Center);
finally
FPlaceholderBitmap.Canvas.EndScene;
end;
end;
procedure TCustomCardGrid.CleanupOffScreenBitmaps;
var
I: Integer;
// CurrentTime: TDateTime;
BufferStart, BufferEnd: Integer;
TotalCards: Integer;
CurrentTick: UInt64;
begin
CurrentTick := TThread.GetTickCount64;
// CLEANUP_INTERVAL_SECONDS is 2.0, so we compare against 2000ms
if (CurrentTick - FLastCleanupTime) < (CLEANUP_INTERVAL_SECONDS * 1000) then
Exit;
FLastCleanupTime := CurrentTick;
TotalCards := FCards.Count;
if TotalCards = 0 then
Exit;
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS);
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS);
for I := 0 to FCards.Count - 1 do
begin
if (I < BufferStart) or (I > BufferEnd) then
begin
if Assigned(FCards[I].ImageSkia) then
FCards[I].ImageSkia := nil; // Direct mutation - no copy needed
end;
end;
end;
procedure TCustomCardGrid.LoadVisibleCardPrices;
var
SnapUUIDs: TArray<string>;
SnapIndices: TArray<Integer>;
I, ReqCount: Integer;
begin
if FPriceLoadInProgress then Exit;
if not PriceManager.HasPriceData then Exit;
// Capture snapshot of cards needing prices on UI thread (safe FCards access).
// This avoids accessing FCards or TGridCardData objects from the background
// thread, eliminating the race condition that caused crashes when gesture
// events modified card state between TThread.Synchronize calls.
ReqCount := 0;
SetLength(SnapUUIDs, Max(0, FVisibleIndexEnd - FVisibleIndexStart + 1));
SetLength(SnapIndices, Length(SnapUUIDs));
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I >= FCards.Count then Break;
if not FCards[I].HasPriceData then
begin
SnapUUIDs[ReqCount] := FCards[I].UUID;
SnapIndices[ReqCount] := I;
Inc(ReqCount);
end;
end;
if ReqCount = 0 then Exit;
SetLength(SnapUUIDs, ReqCount);
SetLength(SnapIndices, ReqCount);
FPriceLoadInProgress := True;
TTask.Run(procedure
var
PriceResults: TArray<TCardPriceData>;
ResultUUIDs: TArray<string>;
ResultIndices: TArray<Integer>;
ResCount, J: Integer;
Prices: TCardPriceData;
begin
try
SetLength(PriceResults, ReqCount);
SetLength(ResultUUIDs, ReqCount);
SetLength(ResultIndices, ReqCount);
ResCount := 0;
// Background thread: only works with captured UUIDs, never touches FCards
for J := 0 to ReqCount - 1 do
begin
if PriceManager.GetCardPrices(SnapUUIDs[J], Prices) then
begin
ResultUUIDs[ResCount] := SnapUUIDs[J];
ResultIndices[ResCount] := SnapIndices[J];
PriceResults[ResCount] := Prices;
Inc(ResCount);
end;
end;
// Apply all results in a single Synchronize call on UI thread
TThread.Synchronize(nil, procedure
var
K: Integer;
NeedsRepaint: Boolean;
begin
NeedsRepaint := False;
for K := 0 to ResCount - 1 do
begin
if (ResultIndices[K] >= 0) and (ResultIndices[K] < FCards.Count) then
begin
// Verify card at this index hasn't changed since snapshot
if FCards[ResultIndices[K]].UUID = ResultUUIDs[K] then
begin
FCards[ResultIndices[K]].UpdatePriceData(PriceResults[K]);
NeedsRepaint := True;
end;
end;
end;
FPriceLoadInProgress := False;
if NeedsRepaint then
InvalidateRect(LocalRect);
end);
except
// Ensure flag is reset even if an error occurs
TThread.Synchronize(nil, procedure
begin
FPriceLoadInProgress := False;
end);
end;
end);
end;
procedure TCustomCardGrid.RefreshPrices;
var
I: Integer;
begin
// Clear cached prices and reload
for I := 0 to FCards.Count - 1 do
FCards[I].FHasPriceData := False;
LoadVisibleCardPrices;
end;
procedure TCustomCardGrid.CalculateLayout;
var
RowCount: Integer;
NewHeight: Single;
ParentWidth: Single;
ImageAreaHeight: Single;
begin
if FCards.Count = 0 then
begin
Width := 300;
Height := 100;
Exit;
end;
if Assigned(Parent) and (Parent is TControl) then
ParentWidth := (Parent as TControl).Width
else
ParentWidth := Width;
ParentWidth := ParentWidth - 20;
if ParentWidth <= 0 then
ParentWidth := 300;
FColumnCount := Floor((ParentWidth - FSpacing) / (FMinCardWidth + FSpacing));
if FColumnCount < 1 then
FColumnCount := 1;
FCardWidth := (ParentWidth - (FSpacing * (FColumnCount + 1))) / FColumnCount;
ImageAreaHeight := FCardWidth * CARD_IMAGE_RATIO;
FCardHeight := ImageAreaHeight + LABEL_HEIGHT;
RowCount := Ceil(FCards.Count / FColumnCount);
NewHeight := (RowCount * (FCardHeight + FSpacing)) + FSpacing;
if (Width <> ParentWidth + 20) or (Height <> NewHeight) then
begin
Width := ParentWidth + 20;
Height := NewHeight;
if Assigned(Parent) and (Parent is TScrollBox) then
TScrollBox(Parent).RealignContent;
end;
end;
procedure TCustomCardGrid.CalculateVisibleIndices;
var
ViewportTop, ViewportBottom: Single;
FirstRow, LastRow: Integer;
CalcEnd: Integer;
begin
if Assigned(Parent) and (Parent is TControl) then
ViewportBottom := FScrollOffset + (Parent as TControl).Height
else
ViewportBottom := FScrollOffset + 600;
ViewportTop := FScrollOffset;
FirstRow := Max(0, Floor((ViewportTop - FSpacing) / (FCardHeight + FSpacing)) - 1);
LastRow := Min(Ceil(FCards.Count / FColumnCount) - 1, Ceil((ViewportBottom + FSpacing) / (FCardHeight + FSpacing)) + 1);
FVisibleIndexStart := Max(0, FirstRow * FColumnCount);
CalcEnd := (LastRow + 1) * FColumnCount - 1;
FVisibleIndexEnd := Min(FCards.Count - 1, CalcEnd);
if LastRow >= (Ceil(FCards.Count / FColumnCount) - 2) then
FVisibleIndexEnd := FCards.Count - 1;
end;
procedure TCustomCardGrid.CheckForNewAnimations;
var
I: Integer;
HasNewCards: Boolean;
begin
HasNewCards := False;
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I < FCards.Count then
begin
if not FCards[I].FirstVisible then
begin
FCards[I].FirstVisible := True;
FCards[I].AnimationProgress := 0.0;
HasNewCards := True;
end;
end;
end;
if HasNewCards then
begin
FAnimProgress := 0;
TAnimator.StopPropertyAnimation(Self, 'AnimProgress');
TAnimator.AnimateFloat(Self, 'AnimProgress', 1.0, ANIMATION_DURATION, TAnimationType.in, TInterpolationType.Back);
end;
end;
procedure TCustomCardGrid.SetAnimProgress(const Value: Single);
var
I: Integer;
HasChanges: Boolean;
begin
if Abs(FAnimProgress - Value) < 0.02 then
Exit;
FAnimProgress := Value;
HasChanges := False;
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I < FCards.Count then
begin
if FCards[I].FirstVisible and (FCards[I].AnimationProgress < 0.99) then
begin
FCards[I].AnimationProgress := Value;
HasChanges := True;
end;
end;
end;
if HasChanges then
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.SetHoverScaleProgress(const Value: Single);
begin
if Abs(FHoverScaleProgress - Value) < 0.01 then
Exit;
FHoverScaleProgress := Value;
if FHoveredCard >= 0 then
InvalidateRect(GetCardRect(FHoveredCard));
end;
procedure TCustomCardGrid.SetShimmerProgress(const Value: Single);
begin
if FShimmerProgress = Value then
Exit;
FShimmerProgress := Value;
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.SetRippleProgress(const Value: Single);
begin
FRippleProgress := Value;
if (FRippleCardIndex >= 0) and (FRippleCardIndex < FCards.Count) then
InvalidateRect(GetCardRect(FRippleCardIndex));
end;
procedure TCustomCardGrid.StartShimmerAnimation;
begin
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress');
TAnimator.AnimateFloat(Self, 'ShimmerProgress', 1.0, 1.5, TAnimationType.in, TInterpolationType.Linear);
end;
function TCustomCardGrid.GetCardRect(Index: Integer): TRectF;
var
Col, Row: Integer;
X, Y: Single;
ScaleFactor: Single;
begin
Col := Index mod FColumnCount;
Row := Index div FColumnCount;
X := FSpacing + Col * (FCardWidth + FSpacing);
Y := FSpacing + Row * (FCardHeight + FSpacing);
Result := RectF(X, Y, X + FCardWidth, Y + FCardHeight);
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))}
if Index = FHoveredCard then
begin
ScaleFactor := 1.0 + (FHoverScaleProgress * 0.05);
Result.Inflate((FCardWidth * (ScaleFactor - 1)) / 2, (FCardHeight * (ScaleFactor - 1)) / 2);
end;
{$ENDIF}
end;
function TCustomCardGrid.CardAtPoint(const APoint: TPointF): Integer;
var
Col, Row: Integer;
begin
Result := -1;
if (APoint.X < FSpacing) or (APoint.X > Width - FSpacing) then
Exit;
Row := Floor((APoint.Y - FSpacing) / (FCardHeight + FSpacing));
Col := Floor((APoint.X - FSpacing) / (FCardWidth + FSpacing));
if (Row >= 0) and (Col >= 0) and (Col < FColumnCount) then
begin
Result := Row * FColumnCount + Col;
if Result >= FCards.Count then
Result := -1;
end;
end;
procedure TCustomCardGrid.Paint;
var
I: Integer;
CardRect, ImageRect, TextRect, LabelRect, ChipRect: TRectF;
Card: TGridCardData;
CardOpacity: Single;
LSkCanvas: ISkCanvas;
// FPaint, LBorderPaint: ISkPaint;
PriceText: string;
begin
inherited;
if FCards.Count = 0 then
Exit;
CleanupOffScreenBitmaps;
try
LSkCanvas := TSkCanvasCustom(Canvas).Canvas;
LSkCanvas.Clear(TAlphaColors.Black);
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I >= FCards.Count then
Break;
Card := FCards[I];
CardRect := GetCardRect(I);
CardOpacity := Card.AnimationProgress;
if Card.AnimationProgress >= 0.99 then
CardOpacity := 1.0;
// 1. CARD BACKGROUND
FPaint.Color := MakeSkiaColor($FF1A1A1A, CardOpacity);
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), FPaint);
// 2. CARD IMAGE AREA
ImageRect := RectF(CardRect.Left, CardRect.Top, CardRect.Right, CardRect.Bottom - LABEL_HEIGHT);
if Assigned(Card.ImageSkia) then
begin
FPaint.AlphaF := CardOpacity;
LSkCanvas.DrawImageRect(Card.ImageSkia, ImageRect, TSkSamplingOptions.Medium);
FPaint.AlphaF := 1.0;
// Draw price chip if available
if Card.HasPriceData and Card.IsOnlineOnly = False then
begin
PriceText := Card.CachedDisplayPrice;
if PriceText <> '' then
begin
ChipRect := TRectF.Create(
ImageRect.CenterPoint.X - 30,
ImageRect.Bottom - 20,
ImageRect.CenterPoint.X + 30,
ImageRect.Bottom - 0
);
// Chip background (semi-transparent dark)
FPaint.Shader := nil;
FPaint.Color := MakeSkiaColor($DD000000, CardOpacity); // 87% opacity black
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(ChipRect, 10, 10), FPaint);
// Price text
ChipRect.Inflate(-4, 0);
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity);
Canvas.Font.Size := 9;
Canvas.Font.Style := [TFontStyle.fsBold];
Canvas.FillText(ChipRect, PriceText, False, CardOpacity,
[], TTextAlign.Center, TTextAlign.Center);
end;
end;
end
else
begin
// ANIMATED SHIMMER for loading placeholder
FPaint.Shader := TSkShader.MakeGradientLinear(
TPointF.Create(ImageRect.Left, ImageRect.Top),
TPointF.Create(ImageRect.Right, ImageRect.Top),
TArray<TAlphaColor>.Create(
MakeSkiaColor($FF2A2A2A, CardOpacity),
MakeSkiaColor($FF3A3A3A, CardOpacity),
MakeSkiaColor($FF2A2A2A, CardOpacity)
),
TArray<Single>.Create(
Max(0.0, FShimmerProgress - 0.3),
FShimmerProgress,
Min(1.0, FShimmerProgress + 0.3)
),
TSkTileMode.Clamp
);
LSkCanvas.DrawRect(ImageRect, FPaint);
FPaint.Shader := nil;
end;
// 3. LABEL BACKGROUND
LabelRect := RectF(CardRect.Left, CardRect.Bottom - LABEL_HEIGHT, CardRect.Right, CardRect.Bottom);
FPaint.Color := MakeSkiaColor($FF0D0D0D, CardOpacity);
LSkCanvas.DrawRect(LabelRect, FPaint);
// 4. CARD NAME TEXT
TextRect := LabelRect;
TextRect.Inflate(-6, 0);
Canvas.Font.Style := [];
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity);
Canvas.Font.Size := 11;
Canvas.FillText(TextRect, Card.Name + sLineBreak + Card.SetAndNum, True, CardOpacity, [], TTextAlign.Leading, TTextAlign.Center);
// 5. HOVER/SELECTION EFFECTS
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))}
if I = FHoveredCard then
begin
var GlowIntensity := 0.6 + (FHoverScaleProgress * 0.3);
FBorderPaint.Color := MakeSkiaColor($FF4A90E2, GlowIntensity * CardOpacity);
FBorderPaint.StrokeWidth := 2 + FHoverScaleProgress;
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), FBorderPaint);
end;
{$ELSE}
// Mobile: Ripple effect
if (I = FRippleCardIndex) and (FRippleProgress < 1.0) then
begin
var RippleRadius := Min(FCardWidth, FCardHeight) * 0.7 * FRippleProgress;
FPaint.Color := MakeSkiaColor($40FFFFFF, (1.0 - FRippleProgress) * CardOpacity);
var RippleCenter := TPointF.Create(CardRect.CenterPoint.X, CardRect.CenterPoint.Y);
LSkCanvas.DrawCircle(RippleCenter, RippleRadius, FPaint);
end;
// Subtle overlay on tap
if I = FHoveredCard then
begin
FPaint.Color := MakeSkiaColor($20FFFFFF, CardOpacity);
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), FPaint);
end;
{$ENDIF}
end;
except
on E: Exception do
inherited Paint;
end;
end;
procedure TCustomCardGrid.DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean);
var
CardIndex: Integer;
begin
inherited; // Call inherited first
if EventInfo.GestureID = igiLongTap then
begin
CardIndex := CardAtPoint(EventInfo.Location);
if CardIndex >= 0 then
begin
FLongPressCard := CardIndex;
FClickedCardUUID := FCards[CardIndex].UUID;
// Visual feedback
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
FHoveredCard := CardIndex;
InvalidateRect(GetCardRect(CardIndex));
{$ENDIF}
if Assigned(FOnCardLongPress) then
FOnCardLongPress(Self);
Handled := True;
end;
end;
end;
procedure TCustomCardGrid.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single);
var
CardIndex: Integer;
begin
inherited;
if Button = TMouseButton.mbLeft then
begin
FMouseDownPos := PointF(X, Y);
FMouseMoved := False;
CardIndex := CardAtPoint(PointF(X, Y));
if CardIndex >= 0 then
begin
FClickedCardUUID := FCards[CardIndex].UUID;
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
FHoveredCard := CardIndex;
FRippleCardIndex := CardIndex;
FRippleProgress := 0;
TAnimator.AnimateFloat(Self, 'RippleProgress', 1.0, 0.4);
InvalidateRect(GetCardRect(CardIndex));
{$ENDIF}
if Assigned(FOnCardMouseDown) then
FOnCardMouseDown(Self);
end;
end;
end;
procedure TCustomCardGrid.MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single);
var
CardIndex: Integer;
Dist: Single;
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
OldHovered: Integer;
{$ENDIF}
begin
inherited;
if Button = TMouseButton.mbLeft then
begin
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
if FHoveredCard >= 0 then
begin
OldHovered := FHoveredCard;
FHoveredCard := -1;
InvalidateRect(GetCardRect(OldHovered));
end;
{$ENDIF}
Dist := FMouseDownPos.Distance(PointF(X, Y));
if Dist <= 15 then
begin
CardIndex := CardAtPoint(PointF(X, Y));
if (CardIndex >= 0) and (FCards[CardIndex].UUID = FClickedCardUUID) then
if Assigned(FOnCardClick) then
FOnCardClick(Self);
end;
end;
end;
procedure TCustomCardGrid.MouseMove(Shift: TShiftState; X, Y: Single);
var
CardIndex: Integer;
begin
inherited;
if not FMouseMoved and (FMouseDownPos.Distance(PointF(X, Y)) > 10) then
FMouseMoved := True;
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))}
CardIndex := CardAtPoint(PointF(X, Y));
if CardIndex <> FHoveredCard then
begin
FHoveredCard := CardIndex;
if CardIndex >= 0 then
begin
FHoverScaleProgress := 0;
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress');
TAnimator.AnimateFloat(Self, 'HoverScaleProgress', 1.0, 0.2, TAnimationType.in, TInterpolationType.Circular);
end;
InvalidateRect(LocalRect);
end;
{$ENDIF}
end;
procedure TCustomCardGrid.Resize;
begin
inherited;
CalculateLayout;
CalculateVisibleIndices;
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.UpdateCardImage(const ACardUUID: string; const AImage: ISkImage);
var
I: Integer;
begin
if AImage = nil then
Exit;
for I := 0 to FCards.Count - 1 do
begin
if FCards[I].UUID = ACardUUID then
begin
FCards[I].ImageSkia := AImage; // Old image released automatically via interface ref counting
FCards[I].ImageLoading := False;
if (I >= FVisibleIndexStart) and (I <= FVisibleIndexEnd) then
begin
if not FRepaintScheduled then
begin
FRepaintScheduled := True;
TThread.ForceQueue(nil,
procedure
begin
FRepaintScheduled := False;
InvalidateRect(LocalRect);
end);
end;
end;
Break;
end;
end;
end;
procedure TCustomCardGrid.SetCards(const ACards: TArray<TCard>);
var
I: Integer;
begin
ClearCards;
for I := 0 to Length(ACards) - 1 do
FCards.Add(TGridCardData.Create(ACards[I]));
CalculateLayout;
if Assigned(Parent) and (Parent is TScrollBox) then
TScrollBox(Parent).RealignContent;
CalculateVisibleIndices;
CheckForNewAnimations;
LoadVisibleCardPrices; // Load prices for initially visible cards
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.AddCards(const ACards: TArray<TCard>);
var
I: Integer;
begin
for I := 0 to Length(ACards) - 1 do
FCards.Add(TGridCardData.Create(ACards[I]));
CalculateLayout;
if Assigned(Parent) and (Parent is TScrollBox) then
TScrollBox(Parent).RealignContent;
CalculateVisibleIndices;
CheckForNewAnimations;
LoadVisibleCardPrices; // Load prices for newly visible cards
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.ClearCards;
begin
FCards.Clear; // TObjectList handles all TGridCardData destruction
FHoveredCard := -1;
FLastCleanupTime := 0;
CalculateLayout;
InvalidateRect(LocalRect);
end;
function TCustomCardGrid.IsCardInRecentBuffer(const ACardUUID: string): Boolean;
var
I: Integer;
BufferStart, BufferEnd: Integer;
TotalCards: Integer;
begin
Result := False;
TotalCards := FCards.Count;
if TotalCards = 0 then
Exit;
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS);
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS);
for I := BufferStart to BufferEnd do
begin
if FCards[I].UUID = ACardUUID then
begin
Result := True;
Break;
end;
end;
end;
procedure TCustomCardGrid.SetScrollOffset(AOffset: Single);
begin
if Abs(FScrollOffset - AOffset) < 1.0 then
Exit;
if FScrollOffset <> AOffset then
begin
FScrollOffset := AOffset;
CalculateVisibleIndices;
CheckForNewAnimations;
LoadVisibleCardPrices; // Load prices for newly visible cards
InvalidateRect(LocalRect);
end;
end;
function TCustomCardGrid.GetCardUUIDAt(Index: Integer): string;
begin
Result := '';
if (Index >= 0) and (Index < FCards.Count) then
Result := FCards[Index].UUID;
end;
function TCustomCardGrid.GetCardCount: Integer;
begin
Result := FCards.Count;
end;
function TCustomCardGrid.HasCardImage(const ACardUUID: string): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to FCards.Count - 1 do
begin
if FCards[I].UUID = ACardUUID then
begin
Result := Assigned(FCards[I].ImageSkia);
Exit;
end;
end;
end;
end.
private
FCards: TObjectList<TGridCardData>;
FCardWidth: Single;
FCardHeight: Single;
FMinCardWidth: Single;
FColumnCount: Integer;
FSpacing: Single;
FHoveredCard: Integer;
FScrollOffset: Single;
FOnCardClick: TNotifyEvent;
FOnCardMouseDown: TNotifyEvent;
FClickedCardUUID: string;
FPlaceholderBitmap: TBitmap;
FVisibleIndexStart: Integer;
FVisibleIndexEnd: Integer;
FAnimProgress: Single;
FMouseDownPos: TPointF;
FMouseMoved: Boolean;
FScreenScale: Single;
FLastCleanupTime: TDateTime;
FRepaintScheduled: Boolean;
FOnCardLongPress: TNotifyEvent;
FLongPressCard: Integer;
FPriceLoadInProgress: Boolean;
// Animation properties
FHoverScaleProgress: Single;
FShimmerProgress: Single;
FRippleProgress: Single;
FRippleCardIndex: Integer;
procedure CalculateLayout;
function GetCardRect(Index: Integer): TRectF;
function CardAtPoint(const APoint: TPointF): Integer;
procedure CreatePlaceholder;
procedure CalculateVisibleIndices;
procedure CheckForNewAnimations;
procedure SetAnimProgress(const Value: Single);
procedure SetHoverScaleProgress(const Value: Single);
procedure SetShimmerProgress(const Value: Single);
procedure SetRippleProgress(const Value: Single);
procedure UpdateScreenScale;
procedure CleanupOffScreenBitmaps;
procedure StartShimmerAnimation;
procedure LoadVisibleCardPrices;
protected
procedure Paint; override;
procedure DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean); override;
procedure MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override;
procedure MouseMove(Shift: TShiftState; X, Y: Single); override;
procedure MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single); override;
procedure Resize; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure SetCards(const ACards: TArray<TCard>);
procedure AddCards(const ACards: TArray<TCard>);
procedure ClearCards;
function IsCardInRecentBuffer(const ACardUUID: string): Boolean;
procedure SetScrollOffset(AOffset: Single);
procedure UpdateCardImage(const ACardUUID: string; const AImage: ISkImage);
function GetCardUUIDAt(Index: Integer): string;
function GetCardCount: Integer;
function HasCardImage(const ACardUUID: string): Boolean;
procedure RefreshPrices;
property OnCardLongPress: TNotifyEvent read FOnCardLongPress write FOnCardLongPress;
property OnCardClick: TNotifyEvent read FOnCardClick write FOnCardClick;
property OnCardMouseDown: TNotifyEvent read FOnCardMouseDown write FOnCardMouseDown;
property ClickedCardUUID: string read FClickedCardUUID;
property AnimProgress: Single read FAnimProgress write SetAnimProgress;
property HoverScaleProgress: Single read FHoverScaleProgress write SetHoverScaleProgress;
property ShimmerProgress: Single read FShimmerProgress write SetShimmerProgress;
property RippleProgress: Single read FRippleProgress write SetRippleProgress;
property MinCardWidth: Single read FMinCardWidth write FMinCardWidth;
property ColumnCount: Integer read FColumnCount;
property CardHeight: Single read FCardHeight;
property Spacing: Single read FSpacing;
end;
implementation
uses
System.Threading, CardPriceManager;
const
CARD_IMAGE_RATIO = 1.3968;
LABEL_HEIGHT = 42;
ANIMATION_DURATION = 0.3;
CLEANUP_INTERVAL_SECONDS = 2.0;
OFFSCREEN_BUFFER_CARDS = 15;
function MakeSkiaColor(const AColor: TAlphaColor; const AOpacity: Single): TAlphaColor;
var
ColorRec: TAlphaColorRec;
begin
ColorRec := TAlphaColorRec(AColor);
ColorRec.A := Round(ColorRec.A * EnsureRange(AOpacity, 0.0, 1.0));
Result := ColorRec.Color;
end;
{ TGridCardData }
constructor TGridCardData.Create(const ACard: TCard);
begin
inherited Create;
UUID := ACard.UUID;
Name := ACard.Name;
IsOnlineOnly := ACard.IsOnlineOnly;
SetCode := ACard.SetCode;
ScryfallId := ACard.ScryfallId;
ImageLoading := False;
AnimationProgress := 0.0;
FirstVisible := False;
FHasPriceData := False;
FPriceData := TCardPriceData.Empty;
end;
destructor TGridCardData.Destroy;
begin
FImageSkia := nil;
inherited;
end;
procedure TGridCardData.UpdatePriceData(const APriceData: TCardPriceData);
begin
FPriceData := APriceData;
FHasPriceData := True;
end;
function TGridCardData.GetDisplayPrice: string;
begin
if not FHasPriceData then
Exit('');
// Try TCGPlayer first (most common for US users)
if FPriceData.Paper.TCGPlayer.RetailNormal.Price > 0 then
Result := Format('$%.2f', [FPriceData.Paper.TCGPlayer.RetailNormal.Price])
// Then Cardmarket (Europe)
else if FPriceData.Paper.Cardmarket.RetailNormal.Price > 0 then
begin
if FPriceData.Paper.Cardmarket.Currency = curEUR then
Result := Format('€%.2f', [FPriceData.Paper.Cardmarket.RetailNormal.Price])
else
Result := Format('$%.2f', [FPriceData.Paper.Cardmarket.RetailNormal.Price]);
end
// Finally Card Kingdom
else if FPriceData.Paper.CardKingdom.RetailNormal.Price > 0 then
Result := Format('$%.2f', [FPriceData.Paper.CardKingdom.RetailNormal.Price])
else
Result := '';
end;
{ TCustomCardGrid }
constructor TCustomCardGrid.Create(AOwner: TComponent);
begin
inherited;
FCards := TObjectList<TGridCardData>.Create(True); // OwnsObjects = True
Touch.InteractiveGestures := [TInteractiveGesture.LongTap];
FMinCardWidth := 100;
FCardWidth := 150;
FCardHeight := 210;
FSpacing := 8;
FHoveredCard := -1;
FScrollOffset := 0;
FColumnCount := 3;
FVisibleIndexStart := 0;
FVisibleIndexEnd := 0;
FAnimProgress := 0;
FMouseDownPos := TPointF.Zero;
FMouseMoved := False;
FLastCleanupTime := 0;
HitTest := True;
FRepaintScheduled := False;
FLongPressCard := -1;
FPriceLoadInProgress := False;
// Initialize animation properties
FHoverScaleProgress := 0;
FShimmerProgress := 0;
FRippleProgress := 0;
FRippleCardIndex := -1;
UpdateScreenScale;
CreatePlaceholder;
StartShimmerAnimation;
end;
destructor TCustomCardGrid.Destroy;
begin
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress');
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress');
TAnimator.StopPropertyAnimation(Self, 'RippleProgress');
TAnimator.StopPropertyAnimation(Self, 'AnimProgress');
FCards.Free; // TObjectList handles all TGridCardData destruction
if Assigned(FPlaceholderBitmap) then
FPlaceholderBitmap.Free;
inherited;
end;
procedure TCustomCardGrid.UpdateScreenScale;
var
ScreenService: IFMXScreenService;
begin
FScreenScale := 1.0;
if TPlatformServices.Current.SupportsPlatformService(IFMXScreenService, ScreenService) then
FScreenScale := ScreenService.GetScreenScale;
{$IFDEF ANDROID}
if FScreenScale > 2.0 then
FScreenScale := 2.0;
{$ENDIF}
{$IFDEF IOS}
if FScreenScale > 2.0 then
FScreenScale := 2.0;
{$ENDIF}
end;
procedure TCustomCardGrid.CreatePlaceholder;
begin
FPlaceholderBitmap := TBitmap.Create(Round(150 * FScreenScale), Round((210 - LABEL_HEIGHT) * FScreenScale));
FPlaceholderBitmap.Clear(TAlphaColors.Dimgray);
if FPlaceholderBitmap.Canvas.BeginScene then
try
FPlaceholderBitmap.Canvas.Fill.Color := TAlphaColors.White;
FPlaceholderBitmap.Canvas.Font.Size := 14 * FScreenScale;
FPlaceholderBitmap.Canvas.FillText(RectF(0, 0, FPlaceholderBitmap.Width, FPlaceholderBitmap.Height), 'Loading...', False, 1.0, [], TTextAlign.Center, TTextAlign.Center);
finally
FPlaceholderBitmap.Canvas.EndScene;
end;
end;
procedure TCustomCardGrid.CleanupOffScreenBitmaps;
var
I: Integer;
CurrentTime: TDateTime;
BufferStart, BufferEnd: Integer;
TotalCards: Integer;
begin
CurrentTime := Now;
if (CurrentTime - FLastCleanupTime) < (CLEANUP_INTERVAL_SECONDS / 86400.0) then
Exit;
FLastCleanupTime := CurrentTime;
TotalCards := FCards.Count;
if TotalCards = 0 then
Exit;
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS);
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS);
for I := 0 to FCards.Count - 1 do
begin
if (I < BufferStart) or (I > BufferEnd) then
begin
if Assigned(FCards[I].ImageSkia) then
FCards[I].ImageSkia := nil; // Direct mutation - no copy needed
end;
end;
end;
procedure TCustomCardGrid.LoadVisibleCardPrices;
var
I: Integer;
Card: TGridCardData;
begin
if FPriceLoadInProgress then Exit;
if not PriceManager.HasPriceData then Exit;
FPriceLoadInProgress := True;
TTask.Run(procedure
var
Prices: TCardPriceData;
NeedsRepaint: Boolean;
CardIndex: Integer;
CardUUID: string;
begin
NeedsRepaint := False;
// Capture the range we're working with
for CardIndex := FVisibleIndexStart to FVisibleIndexEnd do
begin
if CardIndex >= FCards.Count then Break;
// Thread-safe read of card data
TThread.Synchronize(nil, procedure
begin
if CardIndex < FCards.Count then
begin
Card := FCards[CardIndex];
CardUUID := Card.UUID;
end
else
CardUUID := '';
end);
if CardUUID = '' then Continue;
// Only fetch if we don't have it cached
TThread.Synchronize(nil, procedure
begin
if CardIndex < FCards.Count then
Card := FCards[CardIndex]
else
Card := nil;
end);
if Assigned(Card) and not Card.HasPriceData then
begin
if PriceManager.GetCardPrices(CardUUID, Prices) then
begin
TThread.Synchronize(nil, procedure
begin
if CardIndex < FCards.Count then
begin
FCards[CardIndex].UpdatePriceData(Prices);
NeedsRepaint := True;
end;
end);
end;
end;
end;
TThread.Synchronize(nil, procedure
begin
FPriceLoadInProgress := False;
if NeedsRepaint then
InvalidateRect(LocalRect);
end);
end);
end;
procedure TCustomCardGrid.RefreshPrices;
var
I: Integer;
begin
// Clear cached prices and reload
for I := 0 to FCards.Count - 1 do
FCards[I].FHasPriceData := False;
LoadVisibleCardPrices;
end;
procedure TCustomCardGrid.CalculateLayout;
var
RowCount: Integer;
NewHeight: Single;
ParentWidth: Single;
ImageAreaHeight: Single;
begin
if FCards.Count = 0 then
begin
Width := 300;
Height := 100;
Exit;
end;
if Assigned(Parent) and (Parent is TControl) then
ParentWidth := (Parent as TControl).Width
else
ParentWidth := Width;
ParentWidth := ParentWidth - 20;
if ParentWidth <= 0 then
ParentWidth := 300;
FColumnCount := Floor((ParentWidth - FSpacing) / (FMinCardWidth + FSpacing));
if FColumnCount < 1 then
FColumnCount := 1;
FCardWidth := (ParentWidth - (FSpacing * (FColumnCount + 1))) / FColumnCount;
ImageAreaHeight := FCardWidth * CARD_IMAGE_RATIO;
FCardHeight := ImageAreaHeight + LABEL_HEIGHT;
RowCount := Ceil(FCards.Count / FColumnCount);
NewHeight := (RowCount * (FCardHeight + FSpacing)) + FSpacing;
if (Width <> ParentWidth + 20) or (Height <> NewHeight) then
begin
Width := ParentWidth + 20;
Height := NewHeight;
if Assigned(Parent) and (Parent is TScrollBox) then
TScrollBox(Parent).RealignContent;
end;
end;
procedure TCustomCardGrid.CalculateVisibleIndices;
var
ViewportTop, ViewportBottom: Single;
FirstRow, LastRow: Integer;
CalcEnd: Integer;
begin
if Assigned(Parent) and (Parent is TControl) then
ViewportBottom := FScrollOffset + (Parent as TControl).Height
else
ViewportBottom := FScrollOffset + 600;
ViewportTop := FScrollOffset;
FirstRow := Max(0, Floor((ViewportTop - FSpacing) / (FCardHeight + FSpacing)) - 1);
LastRow := Min(Ceil(FCards.Count / FColumnCount) - 1, Ceil((ViewportBottom + FSpacing) / (FCardHeight + FSpacing)) + 1);
FVisibleIndexStart := Max(0, FirstRow * FColumnCount);
CalcEnd := (LastRow + 1) * FColumnCount - 1;
FVisibleIndexEnd := Min(FCards.Count - 1, CalcEnd);
if LastRow >= (Ceil(FCards.Count / FColumnCount) - 2) then
FVisibleIndexEnd := FCards.Count - 1;
end;
procedure TCustomCardGrid.CheckForNewAnimations;
var
I: Integer;
HasNewCards: Boolean;
begin
HasNewCards := False;
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I < FCards.Count then
begin
if not FCards[I].FirstVisible then
begin
FCards[I].FirstVisible := True;
FCards[I].AnimationProgress := 0.0;
HasNewCards := True;
end;
end;
end;
if HasNewCards then
begin
FAnimProgress := 0;
TAnimator.StopPropertyAnimation(Self, 'AnimProgress');
TAnimator.AnimateFloat(Self, 'AnimProgress', 1.0, ANIMATION_DURATION, TAnimationType.in, TInterpolationType.Back);
end;
end;
procedure TCustomCardGrid.SetAnimProgress(const Value: Single);
var
I: Integer;
HasChanges: Boolean;
begin
if Abs(FAnimProgress - Value) < 0.02 then
Exit;
FAnimProgress := Value;
HasChanges := False;
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I < FCards.Count then
begin
if FCards[I].FirstVisible and (FCards[I].AnimationProgress < 0.99) then
begin
FCards[I].AnimationProgress := Value;
HasChanges := True;
end;
end;
end;
if HasChanges then
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.SetHoverScaleProgress(const Value: Single);
begin
if Abs(FHoverScaleProgress - Value) < 0.01 then
Exit;
FHoverScaleProgress := Value;
if FHoveredCard >= 0 then
InvalidateRect(GetCardRect(FHoveredCard));
end;
procedure TCustomCardGrid.SetShimmerProgress(const Value: Single);
begin
if FShimmerProgress = Value then
Exit;
FShimmerProgress := Value;
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.SetRippleProgress(const Value: Single);
begin
FRippleProgress := Value;
if (FRippleCardIndex >= 0) and (FRippleCardIndex < FCards.Count) then
InvalidateRect(GetCardRect(FRippleCardIndex));
end;
procedure TCustomCardGrid.StartShimmerAnimation;
begin
TAnimator.StopPropertyAnimation(Self, 'ShimmerProgress');
TAnimator.AnimateFloat(Self, 'ShimmerProgress', 1.0, 1.5, TAnimationType.in, TInterpolationType.Linear);
end;
function TCustomCardGrid.GetCardRect(Index: Integer): TRectF;
var
Col, Row: Integer;
X, Y: Single;
ScaleFactor: Single;
begin
Col := Index mod FColumnCount;
Row := Index div FColumnCount;
X := FSpacing + Col * (FCardWidth + FSpacing);
Y := FSpacing + Row * (FCardHeight + FSpacing);
Result := RectF(X, Y, X + FCardWidth, Y + FCardHeight);
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))}
if Index = FHoveredCard then
begin
ScaleFactor := 1.0 + (FHoverScaleProgress * 0.05);
Result.Inflate((FCardWidth * (ScaleFactor - 1)) / 2, (FCardHeight * (ScaleFactor - 1)) / 2);
end;
{$ENDIF}
end;
function TCustomCardGrid.CardAtPoint(const APoint: TPointF): Integer;
var
Col, Row: Integer;
begin
Result := -1;
if (APoint.X < FSpacing) or (APoint.X > Width - FSpacing) then
Exit;
Row := Floor((APoint.Y - FSpacing) / (FCardHeight + FSpacing));
Col := Floor((APoint.X - FSpacing) / (FCardWidth + FSpacing));
if (Row >= 0) and (Col >= 0) and (Col < FColumnCount) then
begin
Result := Row * FColumnCount + Col;
if Result >= FCards.Count then
Result := -1;
end;
end;
procedure TCustomCardGrid.Paint;
var
I: Integer;
CardRect, ImageRect, TextRect, LabelRect, ChipRect: TRectF;
Card: TGridCardData;
CardOpacity: Single;
LSkCanvas: ISkCanvas;
LPaint, LBorderPaint: ISkPaint;
PriceText: string;
begin
inherited;
if FCards.Count = 0 then
Exit;
CleanupOffScreenBitmaps;
try
LSkCanvas := TSkCanvasCustom(Canvas).Canvas;
LSkCanvas.Clear(TAlphaColors.Black);
LPaint := TSkPaint.Create;
LPaint.AntiAlias := True;
LBorderPaint := TSkPaint.Create;
LBorderPaint.Style := TSkPaintStyle.Stroke;
LBorderPaint.StrokeWidth := 2;
LBorderPaint.AntiAlias := True;
for I := FVisibleIndexStart to FVisibleIndexEnd do
begin
if I >= FCards.Count then
Break;
Card := FCards[I];
CardRect := GetCardRect(I);
CardOpacity := Card.AnimationProgress;
if Card.AnimationProgress >= 0.99 then
CardOpacity := 1.0;
// 1. CARD BACKGROUND
LPaint.Color := MakeSkiaColor($FF1A1A1A, CardOpacity);
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LPaint);
// 2. CARD IMAGE AREA
ImageRect := RectF(CardRect.Left, CardRect.Top, CardRect.Right, CardRect.Bottom - LABEL_HEIGHT);
if Assigned(Card.ImageSkia) then
begin
LPaint.AlphaF := CardOpacity;
LSkCanvas.DrawImageRect(Card.ImageSkia, ImageRect, TSkSamplingOptions.Medium);
LPaint.AlphaF := 1.0;
// Draw price chip if available
if Card.HasPriceData and Card.IsOnlineOnly = False then
begin
PriceText := Card.GetDisplayPrice;
if PriceText <> '' then
begin
ChipRect := TRectF.Create(
ImageRect.Right - 60,
ImageRect.Bottom - 28,
ImageRect.Right - 8,
ImageRect.Bottom - 8
);
// Chip background (semi-transparent dark)
LPaint.Shader := nil;
LPaint.Color := MakeSkiaColor($DD000000, CardOpacity); // 87% opacity black
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(ChipRect, 10, 10), LPaint);
// Price text
ChipRect.Inflate(-4, 0);
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity);
Canvas.Font.Size := 9;
Canvas.Font.Style := [TFontStyle.fsBold];
Canvas.FillText(ChipRect, PriceText, False, CardOpacity,
[], TTextAlign.Center, TTextAlign.Center);
end;
end;
end
else
begin
// ANIMATED SHIMMER for loading placeholder
LPaint.Shader := TSkShader.MakeGradientLinear(
TPointF.Create(ImageRect.Left, ImageRect.Top),
TPointF.Create(ImageRect.Right, ImageRect.Top),
TArray<TAlphaColor>.Create(
MakeSkiaColor($FF2A2A2A, CardOpacity),
MakeSkiaColor($FF3A3A3A, CardOpacity),
MakeSkiaColor($FF2A2A2A, CardOpacity)
),
TArray<Single>.Create(
Max(0.0, FShimmerProgress - 0.3),
FShimmerProgress,
Min(1.0, FShimmerProgress + 0.3)
),
TSkTileMode.Clamp
);
LSkCanvas.DrawRect(ImageRect, LPaint);
LPaint.Shader := nil;
end;
// 3. LABEL BACKGROUND
LabelRect := RectF(CardRect.Left, CardRect.Bottom - LABEL_HEIGHT, CardRect.Right, CardRect.Bottom);
LPaint.Color := MakeSkiaColor($FF0D0D0D, CardOpacity);
LSkCanvas.DrawRect(LabelRect, LPaint);
// 4. CARD NAME TEXT
TextRect := LabelRect;
TextRect.Inflate(-6, 0);
Canvas.Font.Style := [];
Canvas.Fill.Color := MakeSkiaColor(TAlphaColors.White, CardOpacity);
Canvas.Font.Size := 11;
Canvas.FillText(TextRect, Card.Name, True, CardOpacity, [], TTextAlign.Leading, TTextAlign.Center);
// 5. HOVER/SELECTION EFFECTS
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))}
if I = FHoveredCard then
begin
var GlowIntensity := 0.6 + (FHoverScaleProgress * 0.3);
LBorderPaint.Color := MakeSkiaColor($FF4A90E2, GlowIntensity * CardOpacity);
LBorderPaint.StrokeWidth := 2 + FHoverScaleProgress;
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LBorderPaint);
end;
{$ELSE}
// Mobile: Ripple effect
if (I = FRippleCardIndex) and (FRippleProgress < 1.0) then
begin
var RippleRadius := Min(FCardWidth, FCardHeight) * 0.7 * FRippleProgress;
LPaint.Color := MakeSkiaColor($40FFFFFF, (1.0 - FRippleProgress) * CardOpacity);
var RippleCenter := TPointF.Create(CardRect.CenterPoint.X, CardRect.CenterPoint.Y);
LSkCanvas.DrawCircle(RippleCenter, RippleRadius, LPaint);
end;
// Subtle overlay on tap
if I = FHoveredCard then
begin
LPaint.Color := MakeSkiaColor($20FFFFFF, CardOpacity);
LSkCanvas.DrawRoundRect(TSkRoundRect.Create(CardRect, 6, 6), LPaint);
end;
{$ENDIF}
end;
except
on E: Exception do
inherited Paint;
end;
end;
procedure TCustomCardGrid.DoGesture(const EventInfo: TGestureEventInfo; var Handled: Boolean);
var
CardIndex: Integer;
begin
inherited; // Call inherited first
if EventInfo.GestureID = igiLongTap then
begin
CardIndex := CardAtPoint(EventInfo.Location);
if CardIndex >= 0 then
begin
FLongPressCard := CardIndex;
FClickedCardUUID := FCards[CardIndex].UUID;
// Visual feedback
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
FHoveredCard := CardIndex;
InvalidateRect(GetCardRect(CardIndex));
{$ENDIF}
if Assigned(FOnCardLongPress) then
FOnCardLongPress(Self);
Handled := True;
end;
end;
end;
procedure TCustomCardGrid.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Single);
var
CardIndex: Integer;
begin
inherited;
if Button = TMouseButton.mbLeft then
begin
FMouseDownPos := PointF(X, Y);
FMouseMoved := False;
CardIndex := CardAtPoint(PointF(X, Y));
if CardIndex >= 0 then
begin
FClickedCardUUID := FCards[CardIndex].UUID;
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
FHoveredCard := CardIndex;
FRippleCardIndex := CardIndex;
FRippleProgress := 0;
TAnimator.AnimateFloat(Self, 'RippleProgress', 1.0, 0.4);
InvalidateRect(GetCardRect(CardIndex));
{$ENDIF}
if Assigned(FOnCardMouseDown) then
FOnCardMouseDown(Self);
end;
end;
end;
procedure TCustomCardGrid.MouseUp(Button: TMouseButton; Shift: TShiftState; X, Y: Single);
var
CardIndex: Integer;
Dist: Single;
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
OldHovered: Integer;
{$ENDIF}
begin
inherited;
if Button = TMouseButton.mbLeft then
begin
{$IF DEFINED(ANDROID) OR DEFINED(IOS)}
if FHoveredCard >= 0 then
begin
OldHovered := FHoveredCard;
FHoveredCard := -1;
InvalidateRect(GetCardRect(OldHovered));
end;
{$ENDIF}
Dist := FMouseDownPos.Distance(PointF(X, Y));
if Dist <= 15 then
begin
CardIndex := CardAtPoint(PointF(X, Y));
if (CardIndex >= 0) and (FCards[CardIndex].UUID = FClickedCardUUID) then
if Assigned(FOnCardClick) then
FOnCardClick(Self);
end;
end;
end;
procedure TCustomCardGrid.MouseMove(Shift: TShiftState; X, Y: Single);
var
CardIndex: Integer;
begin
inherited;
if not FMouseMoved and (FMouseDownPos.Distance(PointF(X, Y)) > 10) then
FMouseMoved := True;
{$IF NOT (DEFINED(ANDROID) OR DEFINED(IOS))}
CardIndex := CardAtPoint(PointF(X, Y));
if CardIndex <> FHoveredCard then
begin
FHoveredCard := CardIndex;
if CardIndex >= 0 then
begin
FHoverScaleProgress := 0;
TAnimator.StopPropertyAnimation(Self, 'HoverScaleProgress');
TAnimator.AnimateFloat(Self, 'HoverScaleProgress', 1.0, 0.2, TAnimationType.in, TInterpolationType.Circular);
end;
InvalidateRect(LocalRect);
end;
{$ENDIF}
end;
procedure TCustomCardGrid.Resize;
begin
inherited;
CalculateLayout;
CalculateVisibleIndices;
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.UpdateCardImage(const ACardUUID: string; const AImage: ISkImage);
var
I: Integer;
begin
if AImage = nil then
Exit;
for I := 0 to FCards.Count - 1 do
begin
if FCards[I].UUID = ACardUUID then
begin
FCards[I].ImageSkia := AImage; // Old image released automatically via interface ref counting
FCards[I].ImageLoading := False;
if (I >= FVisibleIndexStart) and (I <= FVisibleIndexEnd) then
begin
if not FRepaintScheduled then
begin
FRepaintScheduled := True;
TThread.ForceQueue(nil,
procedure
begin
FRepaintScheduled := False;
InvalidateRect(LocalRect);
end);
end;
end;
Break;
end;
end;
end;
procedure TCustomCardGrid.SetCards(const ACards: TArray<TCard>);
var
I: Integer;
begin
ClearCards;
for I := 0 to Length(ACards) - 1 do
FCards.Add(TGridCardData.Create(ACards[I]));
CalculateLayout;
if Assigned(Parent) and (Parent is TScrollBox) then
TScrollBox(Parent).RealignContent;
CalculateVisibleIndices;
CheckForNewAnimations;
LoadVisibleCardPrices; // Load prices for initially visible cards
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.AddCards(const ACards: TArray<TCard>);
var
I: Integer;
begin
for I := 0 to Length(ACards) - 1 do
FCards.Add(TGridCardData.Create(ACards[I]));
CalculateLayout;
if Assigned(Parent) and (Parent is TScrollBox) then
TScrollBox(Parent).RealignContent;
CalculateVisibleIndices;
CheckForNewAnimations;
LoadVisibleCardPrices; // Load prices for newly visible cards
InvalidateRect(LocalRect);
end;
procedure TCustomCardGrid.ClearCards;
begin
FCards.Clear; // TObjectList handles all TGridCardData destruction
FHoveredCard := -1;
FLastCleanupTime := 0;
CalculateLayout;
InvalidateRect(LocalRect);
end;
function TCustomCardGrid.IsCardInRecentBuffer(const ACardUUID: string): Boolean;
var
I: Integer;
BufferStart, BufferEnd: Integer;
TotalCards: Integer;
begin
Result := False;
TotalCards := FCards.Count;
if TotalCards = 0 then
Exit;
BufferStart := Max(0, FVisibleIndexStart - OFFSCREEN_BUFFER_CARDS);
BufferEnd := Min(TotalCards - 1, FVisibleIndexEnd + OFFSCREEN_BUFFER_CARDS);
for I := BufferStart to BufferEnd do
begin
if FCards[I].UUID = ACardUUID then
begin
Result := True;
Break;
end;
end;
end;
procedure TCustomCardGrid.SetScrollOffset(AOffset: Single);
begin
if Abs(FScrollOffset - AOffset) < 1.0 then
Exit;
if FScrollOffset <> AOffset then
begin
FScrollOffset := AOffset;
CalculateVisibleIndices;
CheckForNewAnimations;
LoadVisibleCardPrices; // Load prices for newly visible cards
InvalidateRect(LocalRect);
end;
end;
function TCustomCardGrid.GetCardUUIDAt(Index: Integer): string;
begin
Result := '';
if (Index >= 0) and (Index < FCards.Count) then
Result := FCards[Index].UUID;
end;
function TCustomCardGrid.GetCardCount: Integer;
begin
Result := FCards.Count;
end;
function TCustomCardGrid.HasCardImage(const ACardUUID: string): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to FCards.Count - 1 do
begin
if FCards[I].UUID = ACardUUID then
begin
Result := Assigned(FCards[I].ImageSkia);
Exit;
end;
end;
end;
end.
unit MTGCardTextView;
interface
uses
System.SysUtils, System.Classes, System.Types, System.UITypes,
System.Generics.Collections,System.Generics.Defaults, System.RegularExpressions, System.IOUtils,
System.Math, FMX.Types, FMX.Controls, FMX.Graphics, System.Skia, FMX.Skia;
type
TMTGTextAlign = (mtaLeft, mtaCenter, mtaRight, mtaJustify);
/// <summary>
/// Event triggered when an SVG symbol cannot be loaded
/// </summary>
TSymbolNotFoundEvent = procedure(Sender: TObject; const SymbolName: string) of object;
/// <summary>
/// Custom text rendering control for Magic: The Gathering card text.
/// Supports keyword highlighting, SVG symbol rendering, and rich text formatting.
/// </summary>
/// <remarks>
/// Usage Example:
/// <code>
/// CardText.CardText := 'Flying, First strike\nWhen this creature enters, draw a card {T}: Add {W}';
/// CardText.SVGPath := 'C:\Symbols\';
/// CardText.EnableKeywordHighlighting := True;
/// </code>
/// </remarks>
TMTGCardTextView = class(TSkPaintBox)
private
// Text properties
FCardText: string;
// Keyword highlighting
FEnableKeywordHighlighting: Boolean;
// Internal state
FIsInternalResizing: Boolean;
FKeywordBold: Boolean;
FKeywordColor: TAlphaColor;
FKeywords: TStringList;
FLineSpacing: Single;
FParagraph: ISkParagraph;
// Shadow properties
FShadowBlur: Single;
FShadowColor: TAlphaColor;
FSVGCache: TDictionary<string, ISkSVGDOM>;
// Symbol properties
FSVGPath: string;
FSymbolPositions: TList<string>;
FSymbolScale: Single;
FSymbolSize: Single;
FSymbolVerticalOffset: Single;
FTextAlign: TMTGTextAlign;
FTextColor: TAlphaColor;
FTextSize: Single;
// Update batching
FUpdateCount: Integer;
FNeedsRebuild: Boolean;
// Events
FOnSymbolNotFound: TSymbolNotFoundEvent;
function BuildKeywordPattern: string;
function CreateParagraphStyle: ISkParagraphStyle;
function CreateTextStyle(ABold: Boolean): ISkTextStyle;
procedure HandleDraw(ASender: TObject; const ACanvas: ISkCanvas; const ADest: TRectF; const AOpacity: Single);
procedure InitializeDefaultKeywords;
// Internal helpers
procedure InvalidateParagraph;
function IsKeyword(const AWord: string): Boolean;
function LoadSVG(const SymbolName: string): ISkSVGDOM;
function NormalizeSymbol(const Symbol: string): string;
procedure ProcessTextWithKeywords(const ABuilder: ISkParagraphBuilder; const AText: string);
procedure RebuildParagraph;
procedure RenderSymbols(const ACanvas: ISkCanvas);
// Setters
procedure SetCardText(const Value: string);
procedure SetEnableKeywordHighlighting(const Value: Boolean);
procedure SetKeywordBold(const Value: Boolean);
procedure SetKeywordColor(const Value: TAlphaColor);
procedure SetLineSpacing(const Value: Single);
procedure SetShadowBlur(const Value: Single);
procedure SetShadowColor(const Value: TAlphaColor);
procedure SetSVGPath(const Value: string);
procedure SetSymbolScale(const Value: Single);
procedure SetSymbolSize(const Value: Single);
procedure SetSymbolVerticalOffset(const Value: Single);
procedure SetTextAlign(const Value: TMTGTextAlign);
procedure SetTextColor(const Value: TAlphaColor);
procedure SetTextSize(const Value: Single);
protected
procedure Resize; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
/// <summary>
/// Add a keyword to the highlighting list
/// </summary>
procedure AddKeyword(const AKeyword: string);
/// <summary>
/// Remove all keywords from the highlighting list
/// </summary>
procedure ClearKeywords;
/// <summary>
/// Begin batch update. Call EndUpdate when done to rebuild once.
/// Overrides TControl.BeginUpdate to also defer paragraph rebuilding.
/// </summary>
procedure BeginUpdate; override;
/// <summary>
/// End batch update and rebuild paragraph if needed.
/// Overrides TControl.EndUpdate to trigger deferred paragraph rebuild.
/// </summary>
procedure EndUpdate; override;
/// <summary>
/// Export the rendered text to a PNG file
/// </summary>
/// <param name="AFileName">Full path to output file</param>
/// <param name="AQuality">PNG quality (0-100, default 100)</param>
/// <returns>True if export succeeded</returns>
function ExportToPNG(const AFileName: string; AQuality: Byte = 100): Boolean;
/// <summary>
/// Export the rendered text to a stream
/// </summary>
function ExportToStream(const AStream: TStream; AQuality: Byte = 100): Boolean;
/// <summary>
/// Create a bitmap screenshot of the current rendering
/// </summary>
function MakeScreenshot: TBitmap;
/// <summary>
/// Remove a keyword from the highlighting list
/// </summary>
procedure RemoveKeyword(const AKeyword: string);
// Properties
/// <summary>
/// The card text to render. Use \n for line breaks and {X} for symbols.
/// </summary>
property CardText: string read FCardText write SetCardText;
/// <summary>
/// Enable/disable keyword highlighting
/// </summary>
property EnableKeywordHighlighting: Boolean read FEnableKeywordHighlighting write SetEnableKeywordHighlighting;
/// <summary>
/// Make highlighted keywords bold
/// </summary>
property KeywordBold: Boolean read FKeywordBold write SetKeywordBold;
/// <summary>
/// Color for highlighted keywords
/// </summary>
property KeywordColor: TAlphaColor read FKeywordColor write SetKeywordColor;
/// <summary>
/// Line spacing multiplier (default 1.3)
/// </summary>
property LineSpacing: Single read FLineSpacing write SetLineSpacing;
/// <summary>
/// Text shadow blur radius
/// </summary>
property ShadowBlur: Single read FShadowBlur write SetShadowBlur;
/// <summary>
/// Text shadow color
/// </summary>
property ShadowColor: TAlphaColor read FShadowColor write SetShadowColor;
/// <summary>
/// Path to directory containing SVG symbol files
/// </summary>
property SVGPath: string read FSVGPath write SetSVGPath;
/// <summary>
/// Scale factor for symbols (default 1.0)
/// </summary>
property SymbolScale: Single read FSymbolScale write SetSymbolScale;
/// <summary>
/// Base size for symbols in pixels (default 15)
/// </summary>
property SymbolSize: Single read FSymbolSize write SetSymbolSize;
/// <summary>
/// Vertical offset for symbol positioning (deprecated - now uses Middle alignment)
/// </summary>
property SymbolVerticalOffset: Single read FSymbolVerticalOffset write SetSymbolVerticalOffset;
/// <summary>
/// Text alignment
/// </summary>
property TextAlign: TMTGTextAlign read FTextAlign write SetTextAlign;
/// <summary>
/// Main text color
/// </summary>
property TextColor: TAlphaColor read FTextColor write SetTextColor;
/// <summary>
/// Font size in pixels (default 15)
/// </summary>
property TextSize: Single read FTextSize write SetTextSize;
/// <summary>
/// Event fired when an SVG symbol cannot be loaded
/// </summary>
property OnSymbolNotFound: TSymbolNotFoundEvent read FOnSymbolNotFound write FOnSymbolNotFound;
end;
implementation
const
// START resource string wizard section
SFlying = 'Flying';
SFirstStrike = 'First strike';
SDoubleStrike = 'Double strike';
SDeathtouch = 'Deathtouch';
SHaste = 'Haste';
SHexproof = 'Hexproof';
SIndestructible = 'Indestructible';
SLifelink = 'Lifelink';
SMenace = 'Menace';
SReach = 'Reach';
STrample = 'Trample';
SVigilance = 'Vigilance';
SFlash = 'Flash';
SDefender = 'Defender';
SProwess = 'Prowess';
SWard = 'Ward';
SScry = 'Scry';
SSurveil = 'Surveil';
SConvoke = 'Convoke';
SDelve = 'Delve';
SFlashback = 'Flashback';
SKicker = 'Kicker';
SEquip = 'Equip';
SEnchant = 'Enchant';
SProtection = 'Protection';
SShroud = 'Shroud';
SCycling = 'Cycling';
SCascade = 'Cascade';
SInfect = 'Infect';
SToxic = 'Toxic';
SSvg = '.svg';
SMPlantin = 'MPlantin';
SBeleren = 'Beleren';
SRoboto = 'Roboto';
SSegoeUI = 'Segoe UI';
SArial = 'Arial';
// END resource string wizard section
MIN_WIDTH_FOR_LAYOUT = 5;
SYMBOL_PATTERN = '\{([^}]+)\}';
WORD_PATTERN = '[a-zA-Z][\w\s-]*[a-zA-Z]|[a-zA-Z]';
MIN_TEXT_SIZE = 1.0;
MAX_TEXT_SIZE = 200.0;
MIN_SYMBOL_SIZE = 1.0;
MAX_SYMBOL_SIZE = 200.0;
MIN_LINE_SPACING = 0.5;
MAX_LINE_SPACING = 5.0;
{ TMTGCardTextView }
constructor TMTGCardTextView.Create(AOwner: TComponent);
begin
inherited;
// Initialize collections
FSVGCache := TDictionary<string, ISkSVGDOM>.Create;
FSymbolPositions := TList<string>.Create;
FKeywords := TStringList.Create;
FKeywords.CaseSensitive := False;
FKeywords.Sorted := True;
FKeywords.Duplicates := dupIgnore;
// Set defaults
FTextSize := 15;
FSymbolSize := 15;
FSymbolScale := 1.0;
FSymbolVerticalOffset := 0; // Now using Middle alignment, so offset not needed
FLineSpacing := 1.3;
FTextColor := TAlphaColors.White;
FShadowBlur := 3;
FShadowColor := TAlphaColors.Black;
FTextAlign := mtaLeft;
FIsInternalResizing := False;
FUpdateCount := 0;
FNeedsRebuild := False;
// Keyword defaults
FEnableKeywordHighlighting := True;
FKeywordColor := TAlphaColorRec.Yellow;
FKeywordBold := True;
InitializeDefaultKeywords;
OnDraw := HandleDraw;
end;
destructor TMTGCardTextView.Destroy;
begin
FSVGCache.Free;
FSymbolPositions.Free;
FKeywords.Free;
inherited;
end;
procedure TMTGCardTextView.AddKeyword(const AKeyword: string);
begin
if AKeyword.Trim.IsEmpty then
Exit;
FKeywords.Add(AKeyword);
InvalidateParagraph;
end;
procedure TMTGCardTextView.BeginUpdate;
begin
inherited BeginUpdate; // Call base class implementation
Inc(FUpdateCount);
end;
function TMTGCardTextView.BuildKeywordPattern: string;
var
I: Integer;
EscapedKeywords: TArray<string>;
begin
if FKeywords.Count = 0 then
Exit('(?!)'); // Pattern that never matches
SetLength(EscapedKeywords, FKeywords.Count);
for I := 0 to FKeywords.Count - 1 do
EscapedKeywords[I] := TRegEx.Escape(FKeywords[I]);
// Sort by length descending to match longer keywords first
// This prevents "First" from matching before "First strike"
TArray.Sort<string>(EscapedKeywords, TComparer<string>.Construct(
function(const A, B: string): Integer
begin
Result := Length(B) - Length(A); // Descending order
end
));
Result := '\b(' + string.Join('|', EscapedKeywords) + ')\b';
end;
procedure TMTGCardTextView.ClearKeywords;
begin
FKeywords.Clear;
InvalidateParagraph;
end;
procedure TMTGCardTextView.EndUpdate;
begin
inherited EndUpdate; // Call base class implementation
if FUpdateCount > 0 then
Dec(FUpdateCount);
if (FUpdateCount = 0) and FNeedsRebuild then
begin
FNeedsRebuild := False;
RebuildParagraph;
Redraw;
end;
end;
{ Paragraph Building }
function TMTGCardTextView.CreateParagraphStyle: ISkParagraphStyle;
const
ALIGN_MAP: array[TMTGTextAlign] of TSkTextAlign = (
TSkTextAlign.Left,
TSkTextAlign.Center,
TSkTextAlign.Right,
TSkTextAlign.Justify
);
begin
Result := TSkParagraphStyle.Create;
Result.Height := FLineSpacing;
Result.TextAlign := ALIGN_MAP[FTextAlign];
end;
function TMTGCardTextView.CreateTextStyle(ABold: Boolean): ISkTextStyle;
begin
Result := TSkTextStyle.Create;
Result.FontSize := FTextSize;
Result.FontFamilies := TArray<string>.Create(SMPlantin, SBeleren, SRoboto, SSegoeUI, SArial);
if ABold and FKeywordBold then
begin
Result.Color := FKeywordColor;
Result.FontStyle := TSkFontStyle.Bold;
end
else
Result.Color := FTextColor;
if FShadowBlur > 0 then
Result.AddShadow(TSkTextShadow.Create(FShadowColor, TPointF.Create(1, 1), FShadowBlur));
end;
{ Export Features }
function TMTGCardTextView.ExportToPNG(const AFileName: string; AQuality: Byte): Boolean;
var
LStream: TFileStream;
begin
Result := False;
if (FParagraph = nil) or (Width < 1) or (Height < 1) then
Exit;
try
LStream := TFileStream.Create(AFileName, fmCreate);
try
Result := ExportToStream(LStream, AQuality);
finally
LStream.Free;
end;
except
on E: Exception do
begin
// Log error if needed
Result := False;
end;
end;
end;
function TMTGCardTextView.ExportToStream(const AStream: TStream; AQuality: Byte): Boolean;
var
LSurface: ISkSurface;
LImage: ISkImage;
begin
Result := False;
if (FParagraph = nil) or (Width < 1) or (Height < 1) then
Exit;
try
LSurface := TSkSurface.MakeRaster(Round(Width), Round(Height));
if LSurface = nil then
Exit;
LSurface.Canvas.Clear(TAlphaColors.Null);
FParagraph.Paint(LSurface.Canvas, 0, 0);
RenderSymbols(LSurface.Canvas);
LImage := LSurface.MakeImageSnapshot;
if LImage <> nil then
Result := LImage.EncodeToStream(AStream, TSkEncodedImageFormat.PNG, AQuality);
except
on E: Exception do
begin
// Log error if needed
Result := False;
end;
end;
end;
procedure TMTGCardTextView.HandleDraw(ASender: TObject; const ACanvas: ISkCanvas; const ADest: TRectF; const AOpacity: Single);
begin
if (FParagraph = nil) and (FCardText <> '') then
RebuildParagraph;
if FParagraph = nil then
Exit;
FParagraph.Paint(ACanvas, 0, 0);
RenderSymbols(ACanvas);
end;
procedure TMTGCardTextView.InitializeDefaultKeywords;
begin
// Add common MTG keywords
FKeywords.Add(SFlying);
FKeywords.Add(SFirstStrike);
FKeywords.Add(SDoubleStrike);
FKeywords.Add(SDeathtouch);
FKeywords.Add(SHaste);
FKeywords.Add(SHexproof);
FKeywords.Add(SIndestructible);
FKeywords.Add(SLifelink);
FKeywords.Add(SMenace);
FKeywords.Add(SReach);
FKeywords.Add(STrample);
FKeywords.Add(SVigilance);
FKeywords.Add(SFlash);
FKeywords.Add(SDefender);
FKeywords.Add(SProwess);
FKeywords.Add(SWard);
FKeywords.Add(SScry);
FKeywords.Add(SSurveil);
FKeywords.Add(SConvoke);
FKeywords.Add(SDelve);
FKeywords.Add(SFlashback);
FKeywords.Add(SKicker);
FKeywords.Add(SEquip);
FKeywords.Add(SEnchant);
FKeywords.Add(SProtection);
FKeywords.Add(SShroud);
FKeywords.Add(SCycling);
FKeywords.Add(SCascade);
FKeywords.Add(SInfect);
FKeywords.Add(SToxic);
end;
{ Property Setters }
procedure TMTGCardTextView.InvalidateParagraph;
begin
if FUpdateCount > 0 then
begin
FNeedsRebuild := True;
Exit;
end;
FParagraph := nil;
RebuildParagraph;
Redraw;
end;
function TMTGCardTextView.IsKeyword(const AWord: string): Boolean;
begin
Result := FEnableKeywordHighlighting and (FKeywords.IndexOf(AWord) >= 0);
end;
function TMTGCardTextView.LoadSVG(const SymbolName: string): ISkSVGDOM;
var
LPath: string;
begin
if FSVGCache.TryGetValue(SymbolName, Result) then
Exit;
if FSVGPath.IsEmpty then
begin
if Assigned(FOnSymbolNotFound) then
FOnSymbolNotFound(Self, SymbolName);
Exit(nil);
end;
LPath := TPath.Combine(FSVGPath, SymbolName + SSvg);
if not TFile.Exists(LPath) then
begin
if Assigned(FOnSymbolNotFound) then
FOnSymbolNotFound(Self, SymbolName);
Exit(nil);
end;
try
Result := TSkSVGDOM.MakeFromFile(LPath);
if Result <> nil then
FSVGCache.Add(SymbolName, Result)
else if Assigned(FOnSymbolNotFound) then
FOnSymbolNotFound(Self, SymbolName);
except
on E: Exception do
begin
// Log error if needed
if Assigned(FOnSymbolNotFound) then
FOnSymbolNotFound(Self, SymbolName);
Result := nil;
end;
end;
end;
function TMTGCardTextView.MakeScreenshot: TBitmap;
var
LSurface: ISkSurface;
LImage: ISkImage;
LBitData: TBitmapData;
begin
Result := nil;
if (FParagraph = nil) or (Width < 1) or (Height < 1) then
Exit;
try
LSurface := TSkSurface.MakeRaster(Round(Width), Round(Height));
if LSurface = nil then
Exit;
LSurface.Canvas.Clear(TAlphaColors.Null);
FParagraph.Paint(LSurface.Canvas, 0, 0);
RenderSymbols(LSurface.Canvas);
LImage := LSurface.MakeImageSnapshot;
if LImage = nil then
Exit;
Result := TBitmap.Create(LImage.Width, LImage.Height);
if Result.Map(TMapAccess.Write, LBitData) then
try
LImage.ReadPixels(LImage.ImageInfo, LBitData.Data, LBitData.Pitch);
finally
Result.Unmap(LBitData);
end;
except
on E: Exception do
begin
FreeAndNil(Result);
end;
end;
end;
{ SVG Handling }
function TMTGCardTextView.NormalizeSymbol(const Symbol: string): string;
begin
Result := TRegEx.Replace(Symbol, '/', '_', [roIgnoreCase]);
Result := Result.ToUpper;
end;
procedure TMTGCardTextView.ProcessTextWithKeywords(const ABuilder: ISkParagraphBuilder; const AText: string);
var
LSymbolMatch: TMatch;
LLastPos: Integer;
LTextSegment: string;
LNormalStyle, LKeywordStyle: ISkTextStyle;
LKeywordPattern: string;
LKeywordMatch: TMatch;
LSegmentPos: Integer;
begin
FSymbolPositions.Clear;
LLastPos := 1;
LNormalStyle := CreateTextStyle(False);
LKeywordStyle := CreateTextStyle(True);
// Build keyword pattern once
LKeywordPattern := BuildKeywordPattern;
// Process text looking for symbols
LSymbolMatch := TRegEx.Match(AText, SYMBOL_PATTERN, [roNotEmpty]);
while LSymbolMatch.Success do
begin
// Get text segment before this symbol
LTextSegment := AText.Substring(LLastPos - 1, LSymbolMatch.Index - LLastPos);
// Process text segment for keywords using regex
if FEnableKeywordHighlighting and (LTextSegment <> '') and (FKeywords.Count > 0) then
begin
LSegmentPos := 1;
LKeywordMatch := TRegEx.Match(LTextSegment, LKeywordPattern, [roIgnoreCase]);
while LKeywordMatch.Success do
begin
// Add text before keyword
if LKeywordMatch.Index > LSegmentPos then
begin
ABuilder.AddText(LTextSegment.Substring(LSegmentPos - 1, LKeywordMatch.Index - LSegmentPos));
end;
// Add keyword with highlighting
ABuilder.PushStyle(LKeywordStyle);
ABuilder.AddText(LKeywordMatch.Value);
ABuilder.Pop;
ABuilder.PushStyle(LNormalStyle);
LSegmentPos := LKeywordMatch.Index + LKeywordMatch.Length;
LKeywordMatch := LKeywordMatch.NextMatch;
end;
// Add remaining text after last keyword
if LSegmentPos <= Length(LTextSegment) then
ABuilder.AddText(LTextSegment.Substring(LSegmentPos - 1));
end
else
ABuilder.AddText(LTextSegment);
// Add symbol placeholder
FSymbolPositions.Add(NormalizeSymbol(LSymbolMatch.Groups[1].Value));
// Use Middle alignment for better vertical centering with text baseline
ABuilder.AddPlaceholder(TSkPlaceholderStyle.Create(
FSymbolSize * FSymbolScale,
FSymbolSize * FSymbolScale,
TSkPlaceholderAlignment.Middle,
TSkTextBaseline.Alphabetic,
0
));
LLastPos := LSymbolMatch.Index + LSymbolMatch.Length;
LSymbolMatch := LSymbolMatch.NextMatch;
end;
// Process remaining text after last symbol
LTextSegment := AText.Substring(LLastPos - 1);
if FEnableKeywordHighlighting and (LTextSegment <> '') and (FKeywords.Count > 0) then
begin
LSegmentPos := 1;
LKeywordMatch := TRegEx.Match(LTextSegment, LKeywordPattern, [roIgnoreCase]);
while LKeywordMatch.Success do
begin
// Add text before keyword
if LKeywordMatch.Index > LSegmentPos then
begin
ABuilder.AddText(LTextSegment.Substring(LSegmentPos - 1, LKeywordMatch.Index - LSegmentPos));
end;
// Add keyword with highlighting
ABuilder.PushStyle(LKeywordStyle);
ABuilder.AddText(LKeywordMatch.Value);
ABuilder.Pop;
ABuilder.PushStyle(LNormalStyle);
LSegmentPos := LKeywordMatch.Index + LKeywordMatch.Length;
LKeywordMatch := LKeywordMatch.NextMatch;
end;
// Add remaining text after last keyword
if LSegmentPos <= Length(LTextSegment) then
ABuilder.AddText(LTextSegment.Substring(LSegmentPos - 1));
end
else
ABuilder.AddText(LTextSegment);
end;
procedure TMTGCardTextView.RebuildParagraph;
var
LBuilder: ISkParagraphBuilder;
LStyle: ISkTextStyle;
LParaStyle: ISkParagraphStyle;
LFontProvider: ISkTypefaceFontProvider;
LWorkingText: string;
LNewHeight: Single;
begin
// Guard against invalid width or recursive resizing
if (Width < MIN_WIDTH_FOR_LAYOUT) or FIsInternalResizing then
Exit;
// Convert \n to actual line breaks, then double them for extra spacing
LWorkingText := TRegEx.Replace(FCardText, '\n', sLineBreak + sLineBreak, [roIgnoreCase]);
// Create paragraph builder
LParaStyle := CreateParagraphStyle;
LFontProvider := TSkTypefaceFontProvider.Create;
LBuilder := TSkParagraphBuilder.Create(LParaStyle, LFontProvider, True);
// Apply text style
LStyle := CreateTextStyle(False);
LBuilder.PushStyle(LStyle);
// Process text with keywords and symbols
ProcessTextWithKeywords(LBuilder, LWorkingText);
// Build and layout paragraph
FParagraph := LBuilder.Build;
FParagraph.Layout(Width);
// Update height if needed (with guard against recursion)
LNewHeight := FParagraph.Height;
if not SameValue(Self.Height, LNewHeight, 0.1) then
begin
FIsInternalResizing := True;
try
Self.Height := LNewHeight;
finally
FIsInternalResizing := False;
end;
end;
end;
procedure TMTGCardTextView.RemoveKeyword(const AKeyword: string);
var
LIdx: Integer;
begin
LIdx := FKeywords.IndexOf(AKeyword);
if LIdx >= 0 then
begin
FKeywords.Delete(LIdx);
InvalidateParagraph;
end;
end;
{ Rendering }
procedure TMTGCardTextView.RenderSymbols(const ACanvas: ISkCanvas);
var
LRects: TArray<TSkTextBox>;
I: Integer;
LSVG: ISkSVGDOM;
LRect: TRectF;
begin
if FParagraph = nil then
Exit;
LRects := FParagraph.GetRectsForPlaceholders;
for I := 0 to Min(High(LRects), FSymbolPositions.Count - 1) do
begin
LSVG := LoadSVG(FSymbolPositions[I]);
if LSVG = nil then
Continue;
LRect := LRects[I].Rect;
ACanvas.Save;
try
ACanvas.Translate(LRect.Left, LRect.Top);
LSVG.SetContainerSize(TSizeF.Create(LRect.Width, LRect.Height));
LSVG.Render(ACanvas);
finally
ACanvas.Restore;
end;
end;
end;
procedure TMTGCardTextView.Resize;
begin
inherited;
if not FIsInternalResizing then
InvalidateParagraph;
end;
procedure TMTGCardTextView.SetCardText(const Value: string);
begin
if FCardText <> Value then
begin
FCardText := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetEnableKeywordHighlighting(const Value: Boolean);
begin
if FEnableKeywordHighlighting <> Value then
begin
FEnableKeywordHighlighting := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetKeywordBold(const Value: Boolean);
begin
if FKeywordBold <> Value then
begin
FKeywordBold := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetKeywordColor(const Value: TAlphaColor);
begin
if FKeywordColor <> Value then
begin
FKeywordColor := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetLineSpacing(const Value: Single);
var
LClampedValue: Single;
begin
LClampedValue := EnsureRange(Value, MIN_LINE_SPACING, MAX_LINE_SPACING);
if not SameValue(FLineSpacing, LClampedValue) then
begin
FLineSpacing := LClampedValue;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetShadowBlur(const Value: Single);
var
LClampedValue: Single;
begin
LClampedValue := Max(0, Value); // No negative blur
if not SameValue(FShadowBlur, LClampedValue) then
begin
FShadowBlur := LClampedValue;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetShadowColor(const Value: TAlphaColor);
begin
if FShadowColor <> Value then
begin
FShadowColor := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetSVGPath(const Value: string);
begin
if FSVGPath <> Value then
begin
FSVGPath := Value;
FSVGCache.Clear;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetSymbolScale(const Value: Single);
var
LClampedValue: Single;
begin
LClampedValue := Max(0.1, Value); // Minimum 10% scale
if not SameValue(FSymbolScale, LClampedValue) then
begin
FSymbolScale := LClampedValue;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetSymbolSize(const Value: Single);
var
LClampedValue: Single;
begin
LClampedValue := EnsureRange(Value, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE);
if not SameValue(FSymbolSize, LClampedValue) then
begin
FSymbolSize := LClampedValue;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetSymbolVerticalOffset(const Value: Single);
begin
// Note: This property is now deprecated as we use Middle alignment
// Keeping for backwards compatibility
if not SameValue(FSymbolVerticalOffset, Value) then
begin
FSymbolVerticalOffset := Value;
// Don't invalidate - offset is no longer used
end;
end;
procedure TMTGCardTextView.SetTextAlign(const Value: TMTGTextAlign);
begin
if FTextAlign <> Value then
begin
FTextAlign := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetTextColor(const Value: TAlphaColor);
begin
if FTextColor <> Value then
begin
FTextColor := Value;
InvalidateParagraph;
end;
end;
procedure TMTGCardTextView.SetTextSize(const Value: Single);
var
LClampedValue: Single;
begin
LClampedValue := EnsureRange(Value, MIN_TEXT_SIZE, MAX_TEXT_SIZE);
if not SameValue(FTextSize, LClampedValue) then
begin
FTextSize := LClampedValue;
InvalidateParagraph;
end;
end;
end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment