diff --git a/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs b/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs index 9fc9f59..2b2cd42 100644 --- a/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs +++ b/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs @@ -36,6 +36,7 @@ private readonly (string name, Func factory)[] _pages = ("๐Ÿ”„ Refresh & Swipe", () => new RefreshSwipePage()), ("๐ŸŽ  Carousel & Indicators", () => new CarouselIndicatorPage()), ("๐ŸŽจ Graphics (Cairo)", () => new GraphicsPage()), + ("๐Ÿงช Graphics Features", () => new GraphicsFeaturePage()), ("๐Ÿ“ฑ Device & App Info", () => new DeviceInfoPage()), ("๐Ÿ”‹ Battery & Network", () => new BatteryNetworkPage()), ("๐Ÿ“‹ Clipboard & Storage", () => new ClipboardPrefsPage()), diff --git a/samples/Platform.Maui.Linux.Gtk4.Sample/Pages/GraphicsFeaturePage.cs b/samples/Platform.Maui.Linux.Gtk4.Sample/Pages/GraphicsFeaturePage.cs new file mode 100644 index 0000000..55db704 --- /dev/null +++ b/samples/Platform.Maui.Linux.Gtk4.Sample/Pages/GraphicsFeaturePage.cs @@ -0,0 +1,706 @@ +using System.Numerics; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; + +namespace Platform.Maui.Linux.Gtk4.Sample.Pages; + +/// +/// Exercises all CairoCanvas features implemented for issue #11. +/// Each GraphicsView section targets a specific feature set. +/// +public class GraphicsFeaturePage : ContentPage +{ + private Label _interactionLog; + + public GraphicsFeaturePage() + { + Title = "Graphics Features"; + + _interactionLog = new Label + { + Text = "Tap, drag, or hover the interactive GraphicsView below", + FontSize = 11, + TextColor = Colors.Gray, + }; + + var interactiveView = new GraphicsView + { + HeightRequest = 120, + Drawable = new InteractionDrawable(), + }; + + // Wire interaction events + interactiveView.StartInteraction += (s, e) => + _interactionLog.Text = $"StartInteraction at ({e.Touches[0].X:F0}, {e.Touches[0].Y:F0})"; + interactiveView.EndInteraction += (s, e) => + _interactionLog.Text = $"EndInteraction at ({e.Touches[0].X:F0}, {e.Touches[0].Y:F0})"; + interactiveView.DragInteraction += (s, e) => + _interactionLog.Text = $"DragInteraction at ({e.Touches[0].X:F0}, {e.Touches[0].Y:F0})"; + interactiveView.StartHoverInteraction += (s, e) => + _interactionLog.Text = $"Hover at ({e.Touches[0].X:F0}, {e.Touches[0].Y:F0})"; + interactiveView.MoveHoverInteraction += (s, e) => + _interactionLog.Text = $"MoveHover at ({e.Touches[0].X:F0}, {e.Touches[0].Y:F0})"; + interactiveView.EndHoverInteraction += (s, e) => + _interactionLog.Text = "EndHoverInteraction"; + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Spacing = 12, + Padding = new Thickness(24), + Children = + { + new Label { Text = "Graphics Feature Tests", FontSize = 22, FontAttributes = FontAttributes.Bold }, + new Label { Text = "Each section exercises a specific CairoCanvas capability.", FontSize = 12, TextColor = Colors.Gray }, + new BoxView { HeightRequest = 2, Color = Colors.DodgerBlue }, + + Section("1. Path Operations (Bezier, Quad, Arc, Close)"), + new GraphicsView { HeightRequest = 160, Drawable = new PathOpsDrawable() }, + + Section("2. Stroke Properties (Dash, Caps, Joins, Miter)"), + new GraphicsView { HeightRequest = 160, Drawable = new StrokePropsDrawable() }, + + Section("3. Gradient Paint (Linear & Radial)"), + new GraphicsView { HeightRequest = 160, Drawable = new GradientDrawable() }, + + Section("4. Text Alignment, Font Weight & Style"), + new GraphicsView { HeightRequest = 160, Drawable = new TextFeaturesDrawable() }, + + Section("5. ConcatenateTransform"), + new GraphicsView { HeightRequest = 160, Drawable = new TransformDrawable() }, + + Section("6. Shadow Rendering"), + new GraphicsView { HeightRequest = 160, Drawable = new ShadowDrawable() }, + + Section("7. Antialias & BlendMode"), + new GraphicsView { HeightRequest = 140, Drawable = new AntialiasBlendDrawable() }, + + Section("8. SubtractFromClip"), + new GraphicsView { HeightRequest = 140, Drawable = new ClipDrawable() }, + + Section("9. Interaction Events (click, drag, hover)"), + interactiveView, + _interactionLog, + + Section("10. Multi-line Text Wrapping (Pango)"), + new GraphicsView { HeightRequest = 200, Drawable = new TextWrapDrawable() }, + } + } + }; + } + + private static Label Section(string title) => new() + { + Text = title, + FontSize = 14, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(0, 8, 0, 0), + }; +} + +// โ”€โ”€ 1. Path Operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class PathOpsDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + // Cubic bezier curve + var cubic = new PathF(); + cubic.MoveTo(20, 120); + cubic.CurveTo(60, 20, 120, 20, 160, 120); + canvas.StrokeColor = Colors.DodgerBlue; + canvas.StrokeSize = 3; + canvas.DrawPath(cubic); + canvas.FontSize = 10; + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString("Cubic Bezier", 50, 130, HorizontalAlignment.Left); + + // Quadratic bezier curve + var quad = new PathF(); + quad.MoveTo(190, 120); + quad.QuadTo(250, 20, 310, 120); + canvas.StrokeColor = Colors.Coral; + canvas.DrawPath(quad); + canvas.FontColor = Colors.Coral; + canvas.DrawString("Quad Bezier", 210, 130, HorizontalAlignment.Left); + + // Arc + canvas.StrokeColor = Colors.MediumSeaGreen; + canvas.DrawArc(340, 20, 100, 100, 0, 270, true, false); + canvas.FontColor = Colors.MediumSeaGreen; + canvas.DrawString("Arc (270ยฐ)", 350, 130, HorizontalAlignment.Left); + + // Closed path (triangle) + var tri = new PathF(); + tri.MoveTo(500, 120); + tri.LineTo(540, 30); + tri.LineTo(580, 120); + tri.Close(); + canvas.FillColor = Color.FromRgba(155, 89, 182, 128); + canvas.FillPath(tri); + canvas.StrokeColor = Colors.Purple; + canvas.DrawPath(tri); + canvas.FontColor = Colors.Purple; + canvas.DrawString("Closed Path", 500, 130, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 2. Stroke Properties โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class StrokePropsDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + float y = 30; + + // Dash pattern + canvas.StrokeColor = Colors.DodgerBlue; + canvas.StrokeSize = 3; + canvas.StrokeDashPattern = [10, 5]; + canvas.DrawLine(20, y, 180, y); + canvas.FontSize = 10; + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString("Dash [10,5]", 20, y + 8, HorizontalAlignment.Left); + + // Dash-dot pattern + y += 30; + canvas.StrokeDashPattern = [15, 5, 3, 5]; + canvas.DrawLine(20, y, 180, y); + canvas.DrawString("Dash-dot [15,5,3,5]", 20, y + 8, HorizontalAlignment.Left); + + // Reset dash, show line caps + canvas.StrokeDashPattern = []; + canvas.StrokeSize = 8; + + // Butt cap + y += 40; + canvas.StrokeLineCap = LineCap.Butt; + canvas.StrokeColor = Colors.Crimson; + canvas.DrawLine(220, y, 320, y); + canvas.FontSize = 10; + canvas.FontColor = Colors.Crimson; + canvas.DrawString("Butt Cap", 220, y + 14, HorizontalAlignment.Left); + + // Round cap + canvas.StrokeLineCap = LineCap.Round; + canvas.DrawLine(340, y, 440, y); + canvas.DrawString("Round Cap", 340, y + 14, HorizontalAlignment.Left); + + // Square cap + canvas.StrokeLineCap = LineCap.Square; + canvas.DrawLine(460, y, 560, y); + canvas.DrawString("Square Cap", 460, y + 14, HorizontalAlignment.Left); + + // Line joins + canvas.StrokeLineCap = LineCap.Butt; + canvas.StrokeSize = 5; + canvas.StrokeColor = Colors.Teal; + + // Miter join + var miter = new PathF(); + miter.MoveTo(20, 140); + miter.LineTo(50, 110); + miter.LineTo(80, 140); + canvas.StrokeLineJoin = LineJoin.Miter; + canvas.DrawPath(miter); + canvas.FontColor = Colors.Teal; + canvas.DrawString("Miter", 30, 145, HorizontalAlignment.Left); + + // Round join + var round = new PathF(); + round.MoveTo(120, 140); + round.LineTo(150, 110); + round.LineTo(180, 140); + canvas.StrokeLineJoin = LineJoin.Round; + canvas.DrawPath(round); + canvas.DrawString("Round", 130, 145, HorizontalAlignment.Left); + + // Bevel join + var bevel = new PathF(); + bevel.MoveTo(220, 140); + bevel.LineTo(250, 110); + bevel.LineTo(280, 140); + canvas.StrokeLineJoin = LineJoin.Bevel; + canvas.DrawPath(bevel); + canvas.DrawString("Bevel", 230, 145, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 3. Gradient Paint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class GradientDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + // Linear gradient rectangle + var linearRect = new RectF(20, 20, 200, 100); + var linearPaint = new LinearGradientPaint + { + StartPoint = new Point(0, 0), + EndPoint = new Point(1, 1), + GradientStops = + [ + new PaintGradientStop(0, Colors.DodgerBlue), + new PaintGradientStop(0.5f, Colors.MediumPurple), + new PaintGradientStop(1, Colors.Coral), + ] + }; + canvas.SetFillPaint(linearPaint, linearRect); + canvas.FillRoundedRectangle(linearRect, 12); + + canvas.FontSize = 11; + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString("Linear Gradient", 60, 130, HorizontalAlignment.Left); + + // Radial gradient circle + var radialRect = new RectF(280, 15, 120, 120); + var radialPaint = new RadialGradientPaint + { + Center = new Point(0.5, 0.5), + Radius = 0.5, + GradientStops = + [ + new PaintGradientStop(0, Colors.White), + new PaintGradientStop(0.5f, Colors.Gold), + new PaintGradientStop(1, Colors.OrangeRed), + ] + }; + canvas.SetFillPaint(radialPaint, radialRect); + canvas.FillEllipse(radialRect); + canvas.DrawString("Radial Gradient", 290, 140, HorizontalAlignment.Left); + + // Gradient on path + var pathRect = new RectF(460, 20, 120, 100); + var pathPaint = new LinearGradientPaint + { + StartPoint = new Point(0, 0), + EndPoint = new Point(0, 1), + GradientStops = + [ + new PaintGradientStop(0, Colors.LimeGreen), + new PaintGradientStop(1, Colors.DarkGreen), + ] + }; + canvas.SetFillPaint(pathPaint, pathRect); + + var star = new PathF(); + float cx = 520, cy = 70, r = 45; + for (int i = 0; i < 5; i++) + { + float angle = (float)(i * 4 * Math.PI / 5 - Math.PI / 2); + float px = cx + r * (float)Math.Cos(angle); + float py = cy + r * (float)Math.Sin(angle); + if (i == 0) star.MoveTo(px, py); + else star.LineTo(px, py); + } + star.Close(); + canvas.FillPath(star); + + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString("Gradient Path", 470, 130, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 4. Text Features โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TextFeaturesDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + // Horizontal alignment + canvas.FontColor = Colors.DodgerBlue; + canvas.FontSize = 12; + + float midX = rect.Width / 4; + + // Guide line + canvas.StrokeColor = Color.FromRgba(100, 100, 100, 60); + canvas.StrokeSize = 1; + canvas.StrokeDashPattern = [3, 3]; + canvas.DrawLine(midX, 10, midX, 90); + canvas.StrokeDashPattern = []; + + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString("Left aligned", midX, 12, HorizontalAlignment.Left); + canvas.DrawString("Center aligned", midX, 32, HorizontalAlignment.Center); + canvas.DrawString("Right aligned", midX, 52, HorizontalAlignment.Right); + + // Vertical alignment in box + float boxX = rect.Width / 2 + 20; + float boxW = 140, boxH = 30; + + canvas.StrokeColor = Colors.Gray; + canvas.StrokeSize = 1; + canvas.DrawRectangle(boxX, 10, boxW, boxH); + canvas.FontColor = Colors.Crimson; + canvas.DrawString("VTop", boxX, 10, boxW, boxH, HorizontalAlignment.Center, VerticalAlignment.Top); + + canvas.DrawRectangle(boxX, 50, boxW, boxH); + canvas.DrawString("VCenter", boxX, 50, boxW, boxH, HorizontalAlignment.Center, VerticalAlignment.Center); + + canvas.DrawRectangle(boxX, 90, boxW, boxH); + canvas.DrawString("VBottom", boxX, 90, boxW, boxH, HorizontalAlignment.Center, VerticalAlignment.Bottom); + + // Font weight + canvas.FontSize = 14; + canvas.FontColor = Colors.DarkSlateGray; + canvas.Font = new Microsoft.Maui.Graphics.Font("Sans", 800); // Bold + canvas.DrawString("Bold (weight 800)", 20, 100, HorizontalAlignment.Left); + + // Font style + canvas.Font = new Microsoft.Maui.Graphics.Font("Sans", 400, FontStyleType.Italic); + canvas.DrawString("Italic (style)", 20, 120, HorizontalAlignment.Left); + + canvas.Font = new Microsoft.Maui.Graphics.Font("Sans", 800, FontStyleType.Italic); + canvas.DrawString("Bold + Italic", 20, 140, HorizontalAlignment.Left); + + // GetStringSize + var measureFont = Microsoft.Maui.Graphics.Font.Default; + float measureFontSize = 12; + canvas.Font = measureFont; + canvas.FontSize = measureFontSize; + string measured = "Measured Text"; + var size = canvas.GetStringSize(measured, measureFont, measureFontSize); + canvas.FillColor = Color.FromRgba(52, 152, 219, 40); + canvas.FillRectangle(boxX, 130, size.Width, size.Height); + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString(measured, boxX, 130, HorizontalAlignment.Left); + canvas.FontSize = 9; + canvas.FontColor = Colors.Gray; + canvas.DrawString($"({size.Width:F0}ร—{size.Height:F0}px)", boxX + size.Width + 4, 132, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 5. ConcatenateTransform โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TransformDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + // Original rectangle + canvas.StrokeColor = Colors.Gray; + canvas.StrokeSize = 1; + canvas.StrokeDashPattern = [4, 4]; + canvas.DrawRectangle(50, 30, 80, 50); + canvas.FontSize = 10; + canvas.FontColor = Colors.Gray; + canvas.DrawString("Original", 55, 85, HorizontalAlignment.Left); + canvas.StrokeDashPattern = []; + + // Rotate via ConcatenateTransform + canvas.SaveState(); + float cx = 250, cy = 55; + var rotation = Matrix3x2.CreateRotation((float)(15 * Math.PI / 180), new Vector2(cx, cy)); + canvas.ConcatenateTransform(rotation); + canvas.StrokeColor = Colors.DodgerBlue; + canvas.StrokeSize = 2; + canvas.DrawRectangle(210, 30, 80, 50); + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString("Rotated 15ยฐ", 215, 85, HorizontalAlignment.Left); + canvas.RestoreState(); + + // Scale via ConcatenateTransform + canvas.SaveState(); + cx = 410; cy = 55; + var scale = Matrix3x2.CreateScale(1.3f, 0.7f, new Vector2(cx, cy)); + canvas.ConcatenateTransform(scale); + canvas.StrokeColor = Colors.Coral; + canvas.StrokeSize = 2; + canvas.DrawRectangle(370, 30, 80, 50); + canvas.FontColor = Colors.Coral; + canvas.DrawString("Scaled 1.3ร—0.7", 370, 85, HorizontalAlignment.Left); + canvas.RestoreState(); + + // Skew via ConcatenateTransform + canvas.SaveState(); + var skew = Matrix3x2.CreateSkew(0.3f, 0, new Vector2(90, 130)); + canvas.ConcatenateTransform(skew); + canvas.FillColor = Color.FromRgba(46, 204, 113, 100); + canvas.FillRectangle(50, 105, 80, 40); + canvas.FontColor = Colors.MediumSeaGreen; + canvas.DrawString("Skewed", 55, 148, HorizontalAlignment.Left); + canvas.RestoreState(); + + // Composite: translate + rotate + canvas.SaveState(); + var composite = Matrix3x2.CreateRotation((float)(-10 * Math.PI / 180), new Vector2(300, 130)); + composite *= Matrix3x2.CreateTranslation(20, 0); + canvas.ConcatenateTransform(composite); + canvas.FillColor = Color.FromRgba(155, 89, 182, 120); + canvas.FillRoundedRectangle(260, 105, 100, 40, 8); + canvas.FontColor = Colors.Purple; + canvas.DrawString("Composite", 270, 148, HorizontalAlignment.Left); + canvas.RestoreState(); + } +} + +// โ”€โ”€ 6. Shadow Rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class ShadowDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f8f9fa"); + canvas.FillRectangle(rect); + + // Rectangle with shadow + canvas.SetShadow(new SizeF(4, 4), 8, Color.FromRgba(0, 0, 0, 80)); + canvas.FillColor = Colors.DodgerBlue; + canvas.FillRectangle(30, 30, 100, 70); + canvas.FontSize = 10; + canvas.FontColor = Colors.DarkSlateGray; + canvas.SetShadow(SizeF.Zero, 0, null); + canvas.DrawString("Rect + Shadow", 30, 110, HorizontalAlignment.Left); + + // Rounded rect with shadow + canvas.SetShadow(new SizeF(5, 5), 12, Color.FromRgba(0, 0, 0, 100)); + canvas.FillColor = Colors.Coral; + canvas.FillRoundedRectangle(180, 30, 100, 70, 14); + canvas.SetShadow(SizeF.Zero, 0, null); + canvas.DrawString("Rounded + Shadow", 180, 110, HorizontalAlignment.Left); + + // Ellipse with shadow + canvas.SetShadow(new SizeF(3, 6), 10, Color.FromRgba(100, 0, 150, 100)); + canvas.FillColor = Colors.MediumPurple; + canvas.FillEllipse(330, 25, 110, 80); + canvas.SetShadow(SizeF.Zero, 0, null); + canvas.DrawString("Ellipse + Shadow", 345, 110, HorizontalAlignment.Left); + + // Path (star) with shadow + canvas.SetShadow(new SizeF(4, 4), 6, Color.FromRgba(0, 0, 0, 80)); + canvas.FillColor = Colors.Gold; + var star = new PathF(); + float sx = 520, sy = 65, sr = 35; + for (int i = 0; i < 5; i++) + { + float angle = (float)(i * 4 * Math.PI / 5 - Math.PI / 2); + float px = sx + sr * (float)Math.Cos(angle); + float py = sy + sr * (float)Math.Sin(angle); + if (i == 0) star.MoveTo(px, py); + else star.LineTo(px, py); + } + star.Close(); + canvas.FillPath(star); + canvas.SetShadow(SizeF.Zero, 0, null); + canvas.DrawString("Path + Shadow", 490, 110, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 7. Antialias & BlendMode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class AntialiasBlendDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + // Antialiased circle + canvas.Antialias = true; + canvas.FillColor = Colors.DodgerBlue; + canvas.FillCircle(60, 60, 40); + canvas.FontSize = 10; + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString("Antialias ON", 20, 110, HorizontalAlignment.Left); + + // Non-antialiased circle + canvas.Antialias = false; + canvas.FillColor = Colors.DodgerBlue; + canvas.FillCircle(170, 60, 40); + canvas.Antialias = true; // restore for text + canvas.DrawString("Antialias OFF", 130, 110, HorizontalAlignment.Left); + + // BlendMode demo: overlapping shapes + canvas.FillColor = Colors.Red; + canvas.FillCircle(310, 50, 35); + + canvas.SaveState(); + canvas.BlendMode = BlendMode.Xor; + canvas.FillColor = Colors.Blue; + canvas.FillCircle(340, 50, 35); + canvas.RestoreState(); + + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString("Xor Blend", 290, 110, HorizontalAlignment.Left); + + // SourceAtop blend + canvas.FillColor = Colors.MediumSeaGreen; + canvas.FillRoundedRectangle(410, 20, 70, 70, 8); + + canvas.SaveState(); + canvas.BlendMode = BlendMode.DestinationOver; + canvas.FillColor = Colors.Orange; + canvas.FillCircle(470, 60, 35); + canvas.RestoreState(); + + canvas.DrawString("DestOver", 420, 110, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 8. SubtractFromClip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class ClipDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + // ClipRectangle demo + canvas.SaveState(); + canvas.ClipRectangle(20, 10, 150, 100); + canvas.FillColor = Colors.DodgerBlue; + canvas.FillCircle(95, 60, 80); // Clipped to rectangle bounds + canvas.RestoreState(); + canvas.FontSize = 10; + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString("ClipRectangle", 40, 115, HorizontalAlignment.Left); + + // SubtractFromClip demo: draw a filled rect with a hole cut out + canvas.SaveState(); + canvas.ClipRectangle(220, 10, 160, 100); + canvas.SubtractFromClip(260, 30, 80, 50); + + // Fill the entire clipped area โ€” the subtracted region will show through + canvas.FillColor = Colors.Coral; + canvas.FillRectangle(220, 10, 160, 100); + canvas.RestoreState(); + + canvas.DrawString("SubtractFromClip", 240, 115, HorizontalAlignment.Left); + + // ClipPath demo + canvas.SaveState(); + var clipPath = new PathF(); + float cx = 490, cy = 55; + for (int i = 0; i < 6; i++) + { + float angle = (float)(i * Math.PI / 3 - Math.PI / 2); + float px = cx + 50 * (float)Math.Cos(angle); + float py = cy + 50 * (float)Math.Sin(angle); + if (i == 0) clipPath.MoveTo(px, py); + else clipPath.LineTo(px, py); + } + clipPath.Close(); + canvas.ClipPath(clipPath); + + // Fill stripes inside the hexagonal clip + for (float x = 430; x < 560; x += 15) + { + canvas.FillColor = ((int)(x / 15) % 2 == 0) ? Colors.MediumPurple : Colors.Gold; + canvas.FillRectangle(x, 0, 15, 130); + } + canvas.RestoreState(); + + canvas.DrawString("ClipPath (hex)", 455, 115, HorizontalAlignment.Left); + } +} + +// โ”€โ”€ 9. Interaction Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class InteractionDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#e8f4fd"); + canvas.FillRectangle(rect); + + canvas.StrokeColor = Colors.DodgerBlue; + canvas.StrokeSize = 2; + canvas.StrokeDashPattern = [6, 4]; + canvas.DrawRoundedRectangle(10, 10, rect.Width - 20, rect.Height - 20, 10); + canvas.StrokeDashPattern = []; + + canvas.FontSize = 14; + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString("Interactive Area โ€” Click, Drag, or Hover here", + rect.Width / 2, rect.Height / 2 - 10, HorizontalAlignment.Center); + + canvas.FontSize = 11; + canvas.FontColor = Colors.Gray; + canvas.DrawString("Events are logged below", + rect.Width / 2, rect.Height / 2 + 10, HorizontalAlignment.Center); + } +} + +// โ”€โ”€ 10. Multi-line Text Wrapping (Pango) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TextWrapDrawable : IDrawable +{ + public void Draw(ICanvas canvas, RectF rect) + { + canvas.FillColor = Color.FromArgb("#f0f4f8"); + canvas.FillRectangle(rect); + + string longText = "This is a long paragraph that should automatically wrap " + + "to multiple lines when drawn inside a bounded rectangle. " + + "Pango handles word wrapping, line breaking, and text shaping."; + + // Left-aligned wrapped text + float boxW = (rect.Width - 60) / 3; + + canvas.StrokeColor = Colors.DodgerBlue; + canvas.StrokeSize = 1; + canvas.StrokeDashPattern = [3, 3]; + canvas.DrawRectangle(10, 10, boxW, 150); + canvas.StrokeDashPattern = []; + + canvas.FontSize = 11; + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString(longText, 10, 10, boxW, 150, + HorizontalAlignment.Left, VerticalAlignment.Top); + + canvas.FontSize = 9; + canvas.FontColor = Colors.DodgerBlue; + canvas.DrawString("Left / Top", 10, 165, HorizontalAlignment.Left); + + // Center-aligned wrapped text + float x2 = 20 + boxW; + canvas.StrokeColor = Colors.Coral; + canvas.StrokeSize = 1; + canvas.StrokeDashPattern = [3, 3]; + canvas.DrawRectangle(x2, 10, boxW, 150); + canvas.StrokeDashPattern = []; + + canvas.FontSize = 11; + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString(longText, x2, 10, boxW, 150, + HorizontalAlignment.Center, VerticalAlignment.Center); + + canvas.FontSize = 9; + canvas.FontColor = Colors.Coral; + canvas.DrawString("Center / Center", x2, 165, HorizontalAlignment.Left); + + // Right-aligned wrapped text + float x3 = 30 + boxW * 2; + canvas.StrokeColor = Colors.MediumPurple; + canvas.StrokeSize = 1; + canvas.StrokeDashPattern = [3, 3]; + canvas.DrawRectangle(x3, 10, boxW, 150); + canvas.StrokeDashPattern = []; + + canvas.FontSize = 11; + canvas.FontColor = Colors.DarkSlateGray; + canvas.DrawString(longText, x3, 10, boxW, 150, + HorizontalAlignment.Right, VerticalAlignment.Bottom); + + canvas.FontSize = 9; + canvas.FontColor = Colors.MediumPurple; + canvas.DrawString("Right / Bottom", x3, 165, HorizontalAlignment.Left); + } +} diff --git a/src/Platform.Maui.Linux.Gtk4/Graphics/CairoCanvas.cs b/src/Platform.Maui.Linux.Gtk4/Graphics/CairoCanvas.cs new file mode 100644 index 0000000..57a8a35 --- /dev/null +++ b/src/Platform.Maui.Linux.Gtk4/Graphics/CairoCanvas.cs @@ -0,0 +1,807 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Graphics.Text; +using IImage = Microsoft.Maui.Graphics.IImage; + +namespace Platform.Maui.Linux.Gtk4.Graphics; + +/// +/// ICanvas implementation backed by Cairo for GTK4. +/// Handles all MAUI.Graphics drawing operations including paths with bezier curves, +/// gradient paints, text measurement, stroke properties, and transforms. +/// +internal class CairoCanvas : ICanvas +{ + private readonly Cairo.Context _cr; + private float _strokeSize = 1; + private Color _strokeColor = Colors.Black; + private Color _fillColor = Colors.White; + private Color _fontColor = Colors.Black; + private float _fontSize = 14; + private float _alpha = 1; + private Paint? _currentPaint; + private RectF _currentPaintRect; + private SizeF _shadowOffset; + private float _shadowBlur; + private Color? _shadowColor; + + public CairoCanvas(Cairo.Context cr) + { + _cr = cr; + } + + // --- Properties --- + + public float StrokeSize { get => _strokeSize; set => _strokeSize = value; } + public float MiterLimit { get; set; } = 10; + public LineCap StrokeLineCap { get; set; } = LineCap.Butt; + public LineJoin StrokeLineJoin { get; set; } = LineJoin.Miter; + public Color StrokeColor { get => _strokeColor; set => _strokeColor = value ?? Colors.Black; } + public Color FillColor { get => _fillColor; set { _fillColor = value ?? Colors.Transparent; _currentPaint = null; } } + public Color FontColor { get => _fontColor; set => _fontColor = value ?? Colors.Black; } + public float FontSize { get => _fontSize; set => _fontSize = value; } + public IFont Font { get; set; } = Microsoft.Maui.Graphics.Font.Default; + public float Alpha { get => _alpha; set => _alpha = Math.Clamp(value, 0, 1); } + public bool Antialias { get; set; } = true; + public float DisplayScale { get; set; } = 1; + public BlendMode BlendMode { get; set; } = BlendMode.Normal; + public float[] StrokeDashPattern { get; set; } = []; + public float StrokeDashOffset { get; set; } + public bool RestrictToClipBounds { get; set; } + + // --- Basic shapes --- + + public void DrawLine(float x1, float y1, float x2, float y2) + { + ApplyStroke(); + _cr.MoveTo(x1, y1); + _cr.LineTo(x2, y2); + _cr.Stroke(); + } + + public void DrawRectangle(float x, float y, float width, float height) + { + ApplyStroke(); + _cr.Rectangle(x, y, width, height); + _cr.Stroke(); + } + + public void FillRectangle(float x, float y, float width, float height) + { + DrawShadowFill(() => { _cr.Rectangle(x, y, width, height); _cr.Fill(); }); + ApplyFill(); + _cr.Rectangle(x, y, width, height); + _cr.Fill(); + } + + public void DrawRoundedRectangle(float x, float y, float width, float height, float cornerRadius) + { + ApplyStroke(); + RoundedRectPath(x, y, width, height, cornerRadius); + _cr.Stroke(); + } + + public void FillRoundedRectangle(float x, float y, float width, float height, float cornerRadius) + { + DrawShadowFill(() => { RoundedRectPath(x, y, width, height, cornerRadius); _cr.Fill(); }); + ApplyFill(); + RoundedRectPath(x, y, width, height, cornerRadius); + _cr.Fill(); + } + + public void DrawEllipse(float x, float y, float width, float height) + { + ApplyStroke(); + EllipsePath(x, y, width, height); + _cr.Stroke(); + } + + public void FillEllipse(float x, float y, float width, float height) + { + DrawShadowFill(() => { EllipsePath(x, y, width, height); _cr.Fill(); }); + ApplyFill(); + EllipsePath(x, y, width, height); + _cr.Fill(); + } + + public void DrawCircle(float centerX, float centerY, float radius) + => DrawEllipse(centerX - radius, centerY - radius, radius * 2, radius * 2); + + public void FillCircle(float centerX, float centerY, float radius) + => FillEllipse(centerX - radius, centerY - radius, radius * 2, radius * 2); + + // --- Arcs --- + + public void DrawArc(float x, float y, float width, float height, float startAngle, float endAngle, bool clockwise, bool closed) + { + ApplyStroke(); + ArcPath(x, y, width, height, startAngle, endAngle, clockwise); + if (closed) _cr.ClosePath(); + _cr.Stroke(); + } + + public void FillArc(float x, float y, float width, float height, float startAngle, float endAngle, bool clockwise) + { + ApplyFill(); + ArcPath(x, y, width, height, startAngle, endAngle, clockwise); + _cr.ClosePath(); + _cr.Fill(); + } + + // --- Text --- + + public void DrawString(string value, float x, float y, HorizontalAlignment horizontalAlignment) + { + if (string.IsNullOrEmpty(value)) return; + + ApplyFontColor(); + var layout = CreatePangoLayout(value); + + layout.SetAlignment(horizontalAlignment switch + { + HorizontalAlignment.Center => Pango.Alignment.Center, + HorizontalAlignment.Right => Pango.Alignment.Right, + _ => Pango.Alignment.Left, + }); + + layout.GetPixelSize(out int textW, out int textH); + + double drawX = horizontalAlignment switch + { + HorizontalAlignment.Center => x - textW / 2.0, + HorizontalAlignment.Right => x - textW, + _ => x, + }; + + _cr.MoveTo(drawX, y); + PangoCairo.Functions.ShowLayout(_cr, layout); + } + + public void DrawString(string value, float x, float y, float width, float height, + HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment, + TextFlow textFlow = TextFlow.ClipBounds, float lineSpacingAdjustment = 0) + { + if (string.IsNullOrEmpty(value)) return; + + ApplyFontColor(); + var layout = CreatePangoLayout(value); + + // Enable word wrapping when we have a width constraint + layout.SetWidth((int)(width * Pango.Constants.SCALE)); + layout.SetWrap(Pango.WrapMode.Word); + + layout.SetAlignment(horizontalAlignment switch + { + HorizontalAlignment.Center => Pango.Alignment.Center, + HorizontalAlignment.Right => Pango.Alignment.Right, + _ => Pango.Alignment.Left, + }); + + if (lineSpacingAdjustment != 0) + layout.SetSpacing((int)(lineSpacingAdjustment * Pango.Constants.SCALE)); + + layout.GetPixelSize(out int textW, out int textH); + + double drawY = verticalAlignment switch + { + VerticalAlignment.Center => y + (height - textH) / 2.0, + VerticalAlignment.Bottom => y + height - textH, + _ => y, + }; + + if (textFlow == TextFlow.ClipBounds) + { + _cr.Save(); + _cr.Rectangle(x, y, width, height); + _cr.Clip(); + } + + _cr.MoveTo(x, drawY); + PangoCairo.Functions.ShowLayout(_cr, layout); + + if (textFlow == TextFlow.ClipBounds) + { + _cr.Restore(); + } + } + + public void DrawText(IAttributedText value, float x, float y, float width, float height) + { + if (value == null || string.IsNullOrEmpty(value.Text)) return; + + ApplyFontColor(); + var layout = CreatePangoLayout(value.Text); + layout.SetWidth((int)(width * Pango.Constants.SCALE)); + layout.SetWrap(Pango.WrapMode.Word); + + // Apply text attributes (bold, italic, color, etc.) + var attrList = BuildPangoAttrList(value); + if (attrList != IntPtr.Zero) + { + pango_layout_set_attributes(layout.Handle.DangerousGetHandle(), attrList); + // attrList ownership transfers to layout + } + + _cr.MoveTo(x, y); + PangoCairo.Functions.ShowLayout(_cr, layout); + } + + public SizeF GetStringSize(string value, IFont font, float fontSize) + { + if (string.IsNullOrEmpty(value)) + return SizeF.Zero; + + var layout = CreatePangoLayout(value, font, fontSize); + layout.GetPixelSize(out int textW, out int textH); + return new SizeF(textW, textH); + } + + public SizeF GetStringSize(string value, IFont font, float fontSize, HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) + { + return GetStringSize(value, font, fontSize); + } + + // --- Paths --- + + public void DrawPath(PathF path) + { + ApplyStroke(); + DrawPathInternal(path); + _cr.Stroke(); + } + + public void FillPath(PathF path, WindingMode windingMode = WindingMode.NonZero) + { + var rule = windingMode == WindingMode.NonZero ? Cairo.FillRule.Winding : Cairo.FillRule.EvenOdd; + DrawShadowFill(() => { DrawPathInternal(path); _cr.FillRule = rule; _cr.Fill(); }); + ApplyFill(); + DrawPathInternal(path); + _cr.FillRule = rule; + _cr.Fill(); + } + + public void ClipPath(PathF path, WindingMode windingMode = WindingMode.NonZero) + { + DrawPathInternal(path); + _cr.FillRule = windingMode == WindingMode.NonZero ? Cairo.FillRule.Winding : Cairo.FillRule.EvenOdd; + _cr.Clip(); + } + + public void ClipRectangle(float x, float y, float width, float height) + { + _cr.Rectangle(x, y, width, height); + _cr.Clip(); + } + + public void SubtractFromClip(float x, float y, float width, float height) + { + // Cairo doesn't support direct clip subtraction. We use even-odd fill rule + // with a large outer rect and the subtracted inner rect. + var savedRule = _cr.FillRule; + _cr.FillRule = Cairo.FillRule.EvenOdd; + + // Outer rect covering effectively infinite area + _cr.Rectangle(-1e6, -1e6, 2e6, 2e6); + // Inner rect to subtract + _cr.Rectangle(x, y, width, height); + _cr.Clip(); + + _cr.FillRule = savedRule; + } + + // --- Image --- + + public void DrawImage(IImage image, float x, float y, float width, float height) + { + if (image is not CairoPlatformImage cairoImage) + return; + + var surface = cairoImage.Surface; + if (surface == null) + return; + + _cr.Save(); + + double scaleX = width / cairo_image_surface_get_width(surface.Handle.DangerousGetHandle()); + double scaleY = height / cairo_image_surface_get_height(surface.Handle.DangerousGetHandle()); + _cr.Translate(x, y); + _cr.Scale(scaleX, scaleY); + + _cr.SetSourceSurface(surface, 0, 0); + _cr.PaintWithAlpha(_alpha); + + _cr.Restore(); + } + + // --- Transforms --- + + public void Rotate(float degrees, float x, float y) + { + _cr.Translate(x, y); + _cr.Rotate(degrees * Math.PI / 180); + _cr.Translate(-x, -y); + } + + public void Rotate(float degrees) => Rotate(degrees, 0, 0); + public void Scale(float sx, float sy) => _cr.Scale(sx, sy); + public void Translate(float tx, float ty) => _cr.Translate(tx, ty); + + public void ConcatenateTransform(Matrix3x2 transform) + { + // Matrix3x2 layout: M11 M12 / M21 M22 / M31 M32 (translation) + // Cairo matrix Init: xx, yx, xy, yy, x0, y0 + var matrix = new Cairo.Matrix(); + matrix.Init(transform.M11, transform.M12, transform.M21, transform.M22, transform.M31, transform.M32); + _cr.Transform(matrix); + } + + // --- State --- + + public void SaveState() => _cr.Save(); + + public bool RestoreState() + { + _cr.Restore(); + return true; + } + + public void ResetState() + { + _strokeSize = 1; + _strokeColor = Colors.Black; + _fillColor = Colors.White; + _fontColor = Colors.Black; + _fontSize = 14; + _alpha = 1; + _currentPaint = null; + _shadowColor = null; + StrokeLineCap = LineCap.Butt; + StrokeLineJoin = LineJoin.Miter; + MiterLimit = 10; + StrokeDashPattern = []; + StrokeDashOffset = 0; + BlendMode = BlendMode.Normal; + Antialias = true; + Font = Microsoft.Maui.Graphics.Font.Default; + } + + // --- Shadow --- + + public void SetShadow(SizeF offset, float blur, Color color) + { + _shadowOffset = offset; + _shadowBlur = blur; + _shadowColor = color; + } + + private bool HasShadow => _shadowColor != null && (_shadowOffset.Width != 0 || _shadowOffset.Height != 0 || _shadowBlur > 0); + + /// + /// Draws a shadow version of the current path/shape before the actual draw. + /// Uses offset copies with decreasing alpha to approximate blur. + /// + private void DrawShadowFill(Action drawShape) + { + if (!HasShadow || _shadowColor == null) + { + drawShape(); + return; + } + + _cr.Save(); + + // Approximate blur with multiple offset passes + int passes = _shadowBlur > 0 ? Math.Max(1, (int)(_shadowBlur / 2)) : 1; + passes = Math.Min(passes, 5); // Cap at 5 for performance + float baseAlpha = _shadowColor.Alpha * _alpha; + + for (int i = passes; i >= 1; i--) + { + float spread = _shadowBlur > 0 ? _shadowBlur * i / passes : 0; + float passAlpha = baseAlpha / (passes + 1); + + _cr.Save(); + _cr.Translate(_shadowOffset.Width, _shadowOffset.Height); + + if (spread > 0) + { + // Slight scale to simulate spread + _cr.Translate(spread / 2, spread / 2); + } + + _cr.SetSourceRgba(_shadowColor.Red, _shadowColor.Green, _shadowColor.Blue, passAlpha); + drawShape(); + _cr.Restore(); + } + + _cr.Restore(); + } + + // --- Paint --- + + public void SetFillPaint(Paint paint, RectF rectangle) + { + _currentPaint = paint; + _currentPaintRect = rectangle; + + if (paint is SolidPaint solidPaint && solidPaint.Color != null) + _fillColor = solidPaint.Color; + } + + // --- Private helpers --- + + private void ApplyStroke() + { + ApplyOperator(); + ApplyAntialias(); + + _cr.SetSourceRgba(_strokeColor.Red, _strokeColor.Green, _strokeColor.Blue, _strokeColor.Alpha * _alpha); + _cr.LineWidth = _strokeSize; + + _cr.LineCap = StrokeLineCap switch + { + LineCap.Round => Cairo.LineCap.Round, + LineCap.Square => Cairo.LineCap.Square, + _ => Cairo.LineCap.Butt + }; + + _cr.LineJoin = StrokeLineJoin switch + { + LineJoin.Round => Cairo.LineJoin.Round, + LineJoin.Bevel => Cairo.LineJoin.Bevel, + _ => Cairo.LineJoin.Miter + }; + + _cr.MiterLimit = MiterLimit; + + if (StrokeDashPattern is { Length: > 0 }) + { + var dashes = new double[StrokeDashPattern.Length]; + for (int i = 0; i < StrokeDashPattern.Length; i++) + dashes[i] = StrokeDashPattern[i] * _strokeSize; + _cr.SetDash(dashes, StrokeDashOffset * _strokeSize); + } + else + { + _cr.SetDash([], 0); + } + } + + private void ApplyFill() + { + ApplyOperator(); + ApplyAntialias(); + + if (_currentPaint is LinearGradientPaint linear) + { + ApplyLinearGradient(linear, _currentPaintRect); + return; + } + + if (_currentPaint is RadialGradientPaint radial) + { + ApplyRadialGradient(radial, _currentPaintRect); + return; + } + + _cr.SetSourceRgba(_fillColor.Red, _fillColor.Green, _fillColor.Blue, _fillColor.Alpha * _alpha); + } + + private void ApplyLinearGradient(LinearGradientPaint paint, RectF rect) + { + double x0 = rect.X + paint.StartPoint.X * rect.Width; + double y0 = rect.Y + paint.StartPoint.Y * rect.Height; + double x1 = rect.X + paint.EndPoint.X * rect.Width; + double y1 = rect.Y + paint.EndPoint.Y * rect.Height; + + var patternHandle = cairo_pattern_create_linear(x0, y0, x1, y1); + try + { + AddGradientStops(patternHandle, paint.GradientStops); + cairo_set_source(_cr.Handle.DangerousGetHandle(), patternHandle); + } + finally + { + cairo_pattern_destroy(patternHandle); + } + } + + private void ApplyRadialGradient(RadialGradientPaint paint, RectF rect) + { + double cx = rect.X + paint.Center.X * rect.Width; + double cy = rect.Y + paint.Center.Y * rect.Height; + double radius = Math.Max(rect.Width, rect.Height) * paint.Radius; + + var patternHandle = cairo_pattern_create_radial(cx, cy, 0, cx, cy, radius); + try + { + AddGradientStops(patternHandle, paint.GradientStops); + cairo_set_source(_cr.Handle.DangerousGetHandle(), patternHandle); + } + finally + { + cairo_pattern_destroy(patternHandle); + } + } + + private void AddGradientStops(nint pattern, PaintGradientStop[]? stops) + { + if (stops == null) return; + + foreach (var stop in stops) + { + var color = stop.Color; + cairo_pattern_add_color_stop_rgba(pattern, stop.Offset, + color.Red, color.Green, color.Blue, color.Alpha * _alpha); + } + } + + private void ApplyFontColor() + { + _cr.SetSourceRgba(_fontColor.Red, _fontColor.Green, _fontColor.Blue, _fontColor.Alpha * _alpha); + } + + /// + /// Creates a Pango layout configured with the current font settings. + /// + private Pango.Layout CreatePangoLayout(string text, IFont? font = null, float? fontSize = null) + { + var layout = PangoCairo.Functions.CreateLayout(_cr); + var fontDesc = Pango.FontDescription.New(); + + var f = font ?? Font; + var size = fontSize ?? _fontSize; + + fontDesc.SetFamily(f?.Name ?? "Sans"); + fontDesc.SetAbsoluteSize(size * Pango.Constants.SCALE); + + if (f != null) + { + fontDesc.SetWeight(f.Weight >= 600 ? Pango.Weight.Bold : Pango.Weight.Normal); + fontDesc.SetStyle(f.StyleType switch + { + FontStyleType.Italic => Pango.Style.Italic, + FontStyleType.Oblique => Pango.Style.Oblique, + _ => Pango.Style.Normal, + }); + } + + layout.SetFontDescription(fontDesc); + layout.SetText(text, -1); + return layout; + } + + /// + /// Builds a PangoAttrList from IAttributedText runs. + /// Returns IntPtr.Zero if no attributes to apply. + /// + private static IntPtr BuildPangoAttrList(IAttributedText attributedText) + { + if (attributedText.Runs == null || attributedText.Runs.Count == 0) + return IntPtr.Zero; + + var attrList = pango_attr_list_new(); + var text = attributedText.Text; + + foreach (var run in attributedText.Runs) + { + // Convert character indices to byte indices (Pango uses UTF-8 byte offsets) + int byteStart = System.Text.Encoding.UTF8.GetByteCount(text.AsSpan(0, Math.Min(run.Start, text.Length))); + int byteEnd = System.Text.Encoding.UTF8.GetByteCount(text.AsSpan(0, Math.Min(run.Start + run.Length, text.Length))); + + var attrs = run.Attributes; + if (attrs == null) continue; + + if (attrs.ContainsKey(TextAttribute.Bold)) + InsertPangoAttr(attrList, pango_attr_weight_new(700), byteStart, byteEnd); + + if (attrs.ContainsKey(TextAttribute.Italic)) + InsertPangoAttr(attrList, pango_attr_style_new(2), byteStart, byteEnd); + + if (attrs.ContainsKey(TextAttribute.Underline)) + InsertPangoAttr(attrList, pango_attr_underline_new(1), byteStart, byteEnd); + + if (attrs.ContainsKey(TextAttribute.Strikethrough)) + InsertPangoAttr(attrList, pango_attr_strikethrough_new(true), byteStart, byteEnd); + + if (attrs.TryGetValue(TextAttribute.FontSize, out var fontSizeStr) + && float.TryParse(fontSizeStr, out float attrFontSize)) + { + InsertPangoAttr(attrList, pango_attr_size_new((int)(attrFontSize * Pango.Constants.SCALE)), byteStart, byteEnd); + } + + if (attrs.TryGetValue(TextAttribute.Color, out var colorStr)) + { + if (Color.TryParse(colorStr, out var color)) + { + ushort r = (ushort)(color.Red * 65535); + ushort g = (ushort)(color.Green * 65535); + ushort b = (ushort)(color.Blue * 65535); + InsertPangoAttr(attrList, pango_attr_foreground_new(r, g, b), byteStart, byteEnd); + } + } + } + + return attrList; + } + + /// + /// Sets start/end byte indices on a PangoAttribute and inserts it into the list. + /// PangoAttribute struct layout: klass (ptr), start_index (uint), end_index (uint) + /// + private static void InsertPangoAttr(IntPtr attrList, IntPtr attr, int byteStart, int byteEnd) + { + if (attr == IntPtr.Zero) return; + Marshal.WriteInt32(attr, IntPtr.Size, byteStart); + Marshal.WriteInt32(attr, IntPtr.Size + 4, byteEnd); + pango_attr_list_insert(attrList, attr); + } + + private void ApplyOperator() + { + _cr.Operator = BlendMode switch + { + BlendMode.Clear => Cairo.Operator.Clear, + BlendMode.Copy => Cairo.Operator.Source, + BlendMode.SourceIn => Cairo.Operator.In, + BlendMode.SourceOut => Cairo.Operator.Out, + BlendMode.SourceAtop => Cairo.Operator.Atop, + BlendMode.DestinationOver => Cairo.Operator.DestOver, + BlendMode.DestinationIn => Cairo.Operator.DestIn, + BlendMode.DestinationOut => Cairo.Operator.DestOut, + BlendMode.DestinationAtop => Cairo.Operator.DestAtop, + BlendMode.Xor => Cairo.Operator.Xor, + _ => Cairo.Operator.Over, + }; + } + + private void ApplyAntialias() + { + _cr.Antialias = Antialias ? Cairo.Antialias.Default : Cairo.Antialias.None; + } + + private void ArcPath(float x, float y, float width, float height, float startAngle, float endAngle, bool clockwise) + { + double cx = x + width / 2, cy = y + height / 2; + double rx = width / 2, ry = height / 2; + _cr.Save(); + _cr.Translate(cx, cy); + _cr.Scale(rx, ry); + if (clockwise) + _cr.Arc(0, 0, 1, startAngle * Math.PI / 180, endAngle * Math.PI / 180); + else + _cr.ArcNegative(0, 0, 1, startAngle * Math.PI / 180, endAngle * Math.PI / 180); + _cr.Restore(); + } + + private void EllipsePath(float x, float y, float width, float height) + { + _cr.Save(); + _cr.Translate(x + width / 2, y + height / 2); + _cr.Scale(width / 2, height / 2); + _cr.Arc(0, 0, 1, 0, 2 * Math.PI); + _cr.Restore(); + } + + private void RoundedRectPath(float x, float y, float w, float h, float r) + { + r = Math.Min(r, Math.Min(w / 2, h / 2)); + _cr.NewPath(); + _cr.Arc(x + w - r, y + r, r, -Math.PI / 2, 0); + _cr.Arc(x + w - r, y + h - r, r, 0, Math.PI / 2); + _cr.Arc(x + r, y + h - r, r, Math.PI / 2, Math.PI); + _cr.Arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2); + _cr.ClosePath(); + } + + /// + /// Renders a PathF by iterating segment types (Move, Line, Cubic, Quad, Arc, Close). + /// + private void DrawPathInternal(PathF path) + { + _cr.NewPath(); + if (path.OperationCount == 0) + return; + + for (int i = 0; i < path.OperationCount; i++) + { + var type = path.GetSegmentType(i); + var points = path.GetPointsForSegment(i); + + switch (type) + { + case PathOperation.Move: + if (points.Length > 0) + _cr.MoveTo(points[0].X, points[0].Y); + break; + + case PathOperation.Line: + if (points.Length > 0) + _cr.LineTo(points[0].X, points[0].Y); + break; + + case PathOperation.Cubic: + if (points.Length >= 3) + _cr.CurveTo( + points[0].X, points[0].Y, + points[1].X, points[1].Y, + points[2].X, points[2].Y); + break; + + case PathOperation.Quad: + if (points.Length >= 2) + { + _cr.GetCurrentPoint(out var cx, out var cy); + var c1x = cx + 2.0 / 3.0 * (points[0].X - cx); + var c1y = cy + 2.0 / 3.0 * (points[0].Y - cy); + var c2x = points[1].X + 2.0 / 3.0 * (points[0].X - points[1].X); + var c2y = points[1].Y + 2.0 / 3.0 * (points[0].Y - points[1].Y); + _cr.CurveTo(c1x, c1y, c2x, c2y, points[1].X, points[1].Y); + } + break; + + case PathOperation.Arc: + if (points.Length > 0) + _cr.LineTo(points[0].X, points[0].Y); + break; + + case PathOperation.Close: + _cr.ClosePath(); + break; + } + } + } + + // --- Cairo P/Invoke for gradient patterns --- + + [DllImport("libcairo.so.2")] + private static extern nint cairo_pattern_create_linear(double x0, double y0, double x1, double y1); + + [DllImport("libcairo.so.2")] + private static extern nint cairo_pattern_create_radial(double cx0, double cy0, double radius0, double cx1, double cy1, double radius1); + + [DllImport("libcairo.so.2")] + private static extern void cairo_pattern_add_color_stop_rgba(nint pattern, double offset, double red, double green, double blue, double alpha); + + [DllImport("libcairo.so.2")] + private static extern void cairo_pattern_destroy(nint pattern); + + [DllImport("libcairo.so.2")] + private static extern void cairo_set_source(nint cr, nint pattern); + + [DllImport("libcairo.so.2")] + private static extern int cairo_image_surface_get_width(nint surface); + + [DllImport("libcairo.so.2")] + private static extern int cairo_image_surface_get_height(nint surface); + + // --- Pango P/Invoke for attributed text --- + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_list_new(); + + [DllImport("libpango-1.0.so.0")] + private static extern void pango_attr_list_insert(IntPtr list, IntPtr attr); + + [DllImport("libpango-1.0.so.0")] + private static extern void pango_layout_set_attributes(IntPtr layout, IntPtr attrs); + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_weight_new(int weight); + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_style_new(int style); + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_underline_new(int underline); + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_strikethrough_new(bool strikethrough); + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_size_new(int size); + + [DllImport("libpango-1.0.so.0")] + private static extern IntPtr pango_attr_foreground_new(ushort red, ushort green, ushort blue); +} diff --git a/src/Platform.Maui.Linux.Gtk4/Graphics/CairoGraphicsServices.cs b/src/Platform.Maui.Linux.Gtk4/Graphics/CairoGraphicsServices.cs new file mode 100644 index 0000000..44dfc78 --- /dev/null +++ b/src/Platform.Maui.Linux.Gtk4/Graphics/CairoGraphicsServices.cs @@ -0,0 +1,142 @@ +using System.Runtime.InteropServices; +using Microsoft.Maui.Graphics; +using Platform.Maui.Linux.Gtk4.Graphics; +using IImage = Microsoft.Maui.Graphics.IImage; + +namespace Platform.Maui.Linux.Gtk4.Graphics; + +/// +/// IStringSizeService implementation using Pango text layout. +/// Measures text without requiring an active drawing context. +/// +internal class CairoStringSizeService : IStringSizeService +{ + public SizeF GetStringSize(string value, IFont font, float fontSize) + { + if (string.IsNullOrEmpty(value)) + return SizeF.Zero; + + // Create a temporary Cairo surface + context for Pango measurement + var surface = new Cairo.ImageSurface(Cairo.Format.Argb32, 1, 1); + var cr = new Cairo.Context(surface); + + var layout = PangoCairo.Functions.CreateLayout(cr); + var fontDesc = Pango.FontDescription.New(); + fontDesc.SetFamily(font?.Name ?? "Sans"); + fontDesc.SetAbsoluteSize(fontSize * Pango.Constants.SCALE); + fontDesc.SetWeight((font?.Weight ?? 400) >= 600 ? Pango.Weight.Bold : Pango.Weight.Normal); + fontDesc.SetStyle(font?.StyleType switch + { + FontStyleType.Italic => Pango.Style.Italic, + FontStyleType.Oblique => Pango.Style.Oblique, + _ => Pango.Style.Normal, + }); + layout.SetFontDescription(fontDesc); + layout.SetText(value, -1); + + layout.GetPixelSize(out int textW, out int textH); + + cr.Dispose(); + surface.Dispose(); + + return new SizeF(textW, textH); + } + + public SizeF GetStringSize(string value, IFont font, float fontSize, + HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) + { + return GetStringSize(value, font, fontSize); + } +} + +/// +/// IBitmapExportService implementation using Cairo ImageSurface. +/// Creates export contexts that render MAUI.Graphics drawing commands to bitmaps. +/// +internal class CairoBitmapExportService : IBitmapExportService +{ + public BitmapExportContext CreateContext(int width, int height, float displayScale = 1) + { + return new CairoBitmapExportContext(width, height, displayScale); + } +} + +/// +/// BitmapExportContext backed by a Cairo ImageSurface. +/// Provides an ICanvas for drawing and produces an IImage result. +/// +internal class CairoBitmapExportContext : BitmapExportContext +{ + private readonly Cairo.ImageSurface _surface; + private readonly Cairo.Context _cr; + private readonly CairoCanvas _canvas; + + public CairoBitmapExportContext(int width, int height, float displayScale) + : base(width, height, displayScale) + { + int scaledWidth = (int)(width * displayScale); + int scaledHeight = (int)(height * displayScale); + _surface = new Cairo.ImageSurface(Cairo.Format.Argb32, scaledWidth, scaledHeight); + _cr = new Cairo.Context(_surface); + + if (displayScale != 1) + _cr.Scale(displayScale, displayScale); + + _canvas = new CairoCanvas(_cr); + } + + public override ICanvas Canvas => _canvas; + + public override IImage Image + { + get + { + Cairo.Internal.Surface.Flush(_surface.Handle); + return new CairoPlatformImage(_surface); + } + } + + public override void WriteToStream(Stream stream) + { + Cairo.Internal.Surface.Flush(_surface.Handle); + + var tmpPath = Path.Combine(Path.GetTempPath(), $"maui_export_{Guid.NewGuid():N}.png"); + try + { + cairo_surface_write_to_png(_surface.Handle.DangerousGetHandle(), tmpPath); + using var fs = File.OpenRead(tmpPath); + fs.CopyTo(stream); + } + finally + { + try { File.Delete(tmpPath); } catch { } + } + } + + public override void Dispose() + { + _cr?.Dispose(); + // Don't dispose _surface here โ€” it may still be referenced via Image + } + + [DllImport("libcairo.so.2")] + private static extern int cairo_surface_write_to_png(nint surface, + [MarshalAs(UnmanagedType.LPUTF8Str)] string filename); +} + +/// +/// IImageLoadingService implementation using Cairo PNG loading. +/// +internal class CairoImageLoadingService : IImageLoadingService +{ + public IImage FromStream(Stream stream, ImageFormat format = ImageFormat.Png) + { + ArgumentNullException.ThrowIfNull(stream); + + var image = CairoPlatformImage.FromStream(stream); + if (image == null) + throw new ArgumentException("Could not decode image from stream."); + + return image; + } +} diff --git a/src/Platform.Maui.Linux.Gtk4/Graphics/CairoPlatformImage.cs b/src/Platform.Maui.Linux.Gtk4/Graphics/CairoPlatformImage.cs new file mode 100644 index 0000000..0774036 --- /dev/null +++ b/src/Platform.Maui.Linux.Gtk4/Graphics/CairoPlatformImage.cs @@ -0,0 +1,181 @@ +using System.Runtime.InteropServices; +using Microsoft.Maui.Graphics; +using IImage = Microsoft.Maui.Graphics.IImage; + +namespace Platform.Maui.Linux.Gtk4.Graphics; + +/// +/// IImage implementation backed by a Cairo.ImageSurface. +/// Supports loading from streams and rendering via CairoCanvas.DrawImage. +/// +internal class CairoPlatformImage : IImage +{ + public CairoPlatformImage(Cairo.ImageSurface surface) + { + Surface = surface ?? throw new ArgumentNullException(nameof(surface)); + } + + internal Cairo.ImageSurface Surface { get; } + + public float Width => cairo_image_surface_get_width(Surface.Handle.DangerousGetHandle()); + public float Height => cairo_image_surface_get_height(Surface.Handle.DangerousGetHandle()); + + public IImage Downsize(float maxWidthOrHeight, bool disposeOriginal = false) + { + return Downsize(maxWidthOrHeight, maxWidthOrHeight, disposeOriginal); + } + + public IImage Downsize(float maxWidth, float maxHeight, bool disposeOriginal = false) + { + var ratioX = maxWidth / Width; + var ratioY = maxHeight / Height; + var ratio = Math.Min(ratioX, ratioY); + if (ratio >= 1) + return this; + + int newWidth = (int)(Width * ratio); + int newHeight = (int)(Height * ratio); + var newSurface = new Cairo.ImageSurface(Cairo.Format.Argb32, newWidth, newHeight); + var cr = new Cairo.Context(newSurface); + + cr.Scale(ratio, ratio); + cr.SetSourceSurface(Surface, 0, 0); + cr.Paint(); + cr.Dispose(); + + if (disposeOriginal) + Dispose(); + + return new CairoPlatformImage(newSurface); + } + + public IImage ToPlatformImage() + { + return this; + } + + public void Draw(ICanvas canvas, RectF dirtyRect) + { + canvas.DrawImage(this, dirtyRect.X, dirtyRect.Y, dirtyRect.Width, dirtyRect.Height); + } + + public IImage Resize(float width, float height, ResizeMode resizeMode = ResizeMode.Fit, bool disposeOriginal = false) + { + int newWidth = (int)width; + int newHeight = (int)height; + var newSurface = new Cairo.ImageSurface(Cairo.Format.Argb32, newWidth, newHeight); + var cr = new Cairo.Context(newSurface); + + double scaleX = width / Width; + double scaleY = height / Height; + + switch (resizeMode) + { + case ResizeMode.Fit: + double fitScale = Math.Min(scaleX, scaleY); + double offsetX = (width - Width * fitScale) / 2; + double offsetY = (height - Height * fitScale) / 2; + cr.Translate(offsetX, offsetY); + cr.Scale(fitScale, fitScale); + break; + case ResizeMode.Bleed: + double bleedScale = Math.Max(scaleX, scaleY); + double bleedOffsetX = (width - Width * bleedScale) / 2; + double bleedOffsetY = (height - Height * bleedScale) / 2; + cr.Translate(bleedOffsetX, bleedOffsetY); + cr.Scale(bleedScale, bleedScale); + break; + default: + cr.Scale(scaleX, scaleY); + break; + } + + cr.SetSourceSurface(Surface, 0, 0); + cr.Paint(); + cr.Dispose(); + + if (disposeOriginal) + Dispose(); + + return new CairoPlatformImage(newSurface); + } + + public void Save(Stream stream, ImageFormat format = ImageFormat.Png, float quality = 1) + { + var tmpPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"maui_img_{Guid.NewGuid():N}.png"); + try + { + cairo_surface_write_to_png(Surface.Handle.DangerousGetHandle(), tmpPath); + using var fs = File.OpenRead(tmpPath); + fs.CopyTo(stream); + } + finally + { + try { File.Delete(tmpPath); } catch { } + } + } + + public Task SaveAsync(Stream stream, ImageFormat format = ImageFormat.Png, float quality = 1) + { + Save(stream, format, quality); + return Task.CompletedTask; + } + + /// + /// Creates a CairoPlatformImage from a PNG stream. + /// + public static CairoPlatformImage? FromStream(Stream stream) + { + var tmpPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"maui_img_{Guid.NewGuid():N}.png"); + try + { + using (var fs = File.Create(tmpPath)) + stream.CopyTo(fs); + + var surfaceHandle = cairo_image_surface_create_from_png(tmpPath); + if (surfaceHandle == nint.Zero) + return null; + + // Wrap the unmanaged surface handle. We use an ImageSurface that manages its own lifetime. + var surface = new Cairo.ImageSurface(Cairo.Format.Argb32, + cairo_image_surface_get_width(surfaceHandle), + cairo_image_surface_get_height(surfaceHandle)); + + // Copy the PNG data onto our managed surface + var cr = new Cairo.Context(surface); + cairo_set_source_surface(cr.Handle.DangerousGetHandle(), surfaceHandle, 0, 0); + Cairo.Internal.Context.Paint(cr.Handle); + cr.Dispose(); + cairo_surface_destroy(surfaceHandle); + + return new CairoPlatformImage(surface); + } + finally + { + try { File.Delete(tmpPath); } catch { } + } + } + + public void Dispose() + { + Surface?.Dispose(); + } + + [DllImport("libcairo.so.2")] + private static extern int cairo_surface_write_to_png(nint surface, [MarshalAs(UnmanagedType.LPUTF8Str)] string filename); + + [DllImport("libcairo.so.2")] + private static extern nint cairo_image_surface_create_from_png([MarshalAs(UnmanagedType.LPUTF8Str)] string filename); + + [DllImport("libcairo.so.2")] + private static extern int cairo_image_surface_get_width(nint surface); + + [DllImport("libcairo.so.2")] + private static extern int cairo_image_surface_get_height(nint surface); + + [DllImport("libcairo.so.2")] + private static extern void cairo_set_source_surface(nint cr, nint surface, double x, double y); + + [DllImport("libcairo.so.2")] + private static extern void cairo_surface_destroy(nint surface); +} diff --git a/src/Platform.Maui.Linux.Gtk4/Handlers/GraphicsViewHandler.cs b/src/Platform.Maui.Linux.Gtk4/Handlers/GraphicsViewHandler.cs index 0fcb74e..45ec143 100644 --- a/src/Platform.Maui.Linux.Gtk4/Handlers/GraphicsViewHandler.cs +++ b/src/Platform.Maui.Linux.Gtk4/Handlers/GraphicsViewHandler.cs @@ -1,15 +1,13 @@ -using System.Numerics; using Microsoft.Maui; using Microsoft.Maui.Graphics; -using Microsoft.Maui.Graphics.Text; using Microsoft.Maui.Handlers; -using IImage = Microsoft.Maui.Graphics.IImage; +using Platform.Maui.Linux.Gtk4.Graphics; namespace Platform.Maui.Linux.Gtk4.Handlers; /// /// Handler for GraphicsView using Gtk.DrawingArea with Cairo-backed ICanvas. -/// Renders MAUI.Graphics drawing commands via Cairo. +/// Renders MAUI.Graphics drawing commands via Cairo and forwards interaction events. /// public class GraphicsViewHandler : GtkViewHandler { @@ -30,6 +28,7 @@ protected override Gtk.DrawingArea CreatePlatformView() { var area = Gtk.DrawingArea.New(); area.SetDrawFunc(OnDraw); + ConnectInteractionEvents(area); return area; } @@ -43,292 +42,58 @@ private void OnDraw(Gtk.DrawingArea area, Cairo.Context cr, int width, int heigh VirtualView.Drawable.Draw(canvas, dirtyRect); } - public static void MapInvalidate(GraphicsViewHandler handler, IGraphicsView view, object? arg) - { - handler.PlatformView?.QueueDraw(); - } - - public static void MapDrawable(GraphicsViewHandler handler, IGraphicsView view) - { - handler.PlatformView?.QueueDraw(); - } -} - -/// -/// Minimal ICanvas implementation backed by Cairo. -/// Supports the core drawing operations needed for MAUI.Graphics. -/// -internal class CairoCanvas : ICanvas -{ - private readonly Cairo.Context _cr; - private float _strokeSize = 1; - private Color _strokeColor = Colors.Black; - private Color _fillColor = Colors.White; - private Color _fontColor = Colors.Black; - private float _fontSize = 14; - private float _alpha = 1; - - public CairoCanvas(Cairo.Context cr) - { - _cr = cr; - } - - public float StrokeSize { get => _strokeSize; set => _strokeSize = value; } - public float MiterLimit { get; set; } = 10; - public LineCap StrokeLineCap { get; set; } = LineCap.Butt; - public LineJoin StrokeLineJoin { get; set; } = LineJoin.Miter; - public Color StrokeColor { get => _strokeColor; set => _strokeColor = value ?? Colors.Black; } - public Color FillColor { get => _fillColor; set => _fillColor = value ?? Colors.Transparent; } - public Color FontColor { get => _fontColor; set => _fontColor = value ?? Colors.Black; } - public float FontSize { get => _fontSize; set => _fontSize = value; } - public IFont Font { get; set; } = Microsoft.Maui.Graphics.Font.Default; - public float Alpha { get => _alpha; set => _alpha = Math.Clamp(value, 0, 1); } - public bool Antialias { get; set; } = true; - public float DisplayScale { get; set; } = 1; - public BlendMode BlendMode { get; set; } = BlendMode.Normal; - public float[] StrokeDashPattern { get; set; } = []; - public float StrokeDashOffset { get; set; } - - public void DrawLine(float x1, float y1, float x2, float y2) - { - ApplyStroke(); - _cr.MoveTo(x1, y1); - _cr.LineTo(x2, y2); - _cr.Stroke(); - } - - public void DrawRectangle(float x, float y, float width, float height) - { - ApplyStroke(); - _cr.Rectangle(x, y, width, height); - _cr.Stroke(); - } - - public void FillRectangle(float x, float y, float width, float height) - { - ApplyFill(); - _cr.Rectangle(x, y, width, height); - _cr.Fill(); - } - - public void DrawRoundedRectangle(float x, float y, float width, float height, float cornerRadius) - { - ApplyStroke(); - RoundedRectPath(x, y, width, height, cornerRadius); - _cr.Stroke(); - } - - public void FillRoundedRectangle(float x, float y, float width, float height, float cornerRadius) - { - ApplyFill(); - RoundedRectPath(x, y, width, height, cornerRadius); - _cr.Fill(); - } - - public void DrawEllipse(float x, float y, float width, float height) - { - ApplyStroke(); - EllipsePath(x, y, width, height); - _cr.Stroke(); - } - - public void FillEllipse(float x, float y, float width, float height) - { - ApplyFill(); - EllipsePath(x, y, width, height); - _cr.Fill(); - } - - public void DrawCircle(float centerX, float centerY, float radius) - => DrawEllipse(centerX - radius, centerY - radius, radius * 2, radius * 2); - - public void FillCircle(float centerX, float centerY, float radius) - => FillEllipse(centerX - radius, centerY - radius, radius * 2, radius * 2); - - public void DrawArc(float x, float y, float width, float height, float startAngle, float endAngle, bool clockwise, bool closed) - { - ApplyStroke(); - double cx = x + width / 2, cy = y + height / 2; - double rx = width / 2, ry = height / 2; - _cr.Save(); - _cr.Translate(cx, cy); - _cr.Scale(rx, ry); - if (clockwise) - _cr.Arc(0, 0, 1, startAngle * Math.PI / 180, endAngle * Math.PI / 180); - else - _cr.ArcNegative(0, 0, 1, startAngle * Math.PI / 180, endAngle * Math.PI / 180); - if (closed) _cr.ClosePath(); - _cr.Restore(); - _cr.Stroke(); - } - - public void FillArc(float x, float y, float width, float height, float startAngle, float endAngle, bool clockwise) - { - ApplyFill(); - double cx = x + width / 2, cy = y + height / 2; - double rx = width / 2, ry = height / 2; - _cr.Save(); - _cr.Translate(cx, cy); - _cr.Scale(rx, ry); - if (clockwise) - _cr.Arc(0, 0, 1, startAngle * Math.PI / 180, endAngle * Math.PI / 180); - else - _cr.ArcNegative(0, 0, 1, startAngle * Math.PI / 180, endAngle * Math.PI / 180); - _cr.ClosePath(); - _cr.Restore(); - _cr.Fill(); - } - - public void DrawString(string value, float x, float y, HorizontalAlignment horizontalAlignment) - { - ApplyFontColor(); - var fontName = Font?.Name ?? "Sans"; - _cr.SelectFontFace(fontName, Cairo.FontSlant.Normal, Cairo.FontWeight.Normal); - _cr.SetFontSize(_fontSize); - _cr.MoveTo(x, y + _fontSize); - _cr.ShowText(value); - } - - public void DrawString(string value, float x, float y, float width, float height, - HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment, - TextFlow textFlow = TextFlow.ClipBounds, float lineSpacingAdjustment = 0) - { - DrawString(value, x, y + height / 2, horizontalAlignment); - } - - public void DrawText(IAttributedText value, float x, float y, float width, float height) - { - DrawString(value.Text, x, y, width, height, HorizontalAlignment.Left, VerticalAlignment.Top); - } - - public SizeF GetStringSize(string value, IFont font, float fontSize) - { - return new SizeF(value.Length * fontSize * 0.6f, fontSize * 1.2f); - } - - public SizeF GetStringSize(string value, IFont font, float fontSize, HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) - { - return GetStringSize(value, font, fontSize); - } - - public void DrawPath(PathF path) - { - ApplyStroke(); - DrawPathInternal(path); - _cr.Stroke(); - } - - public void FillPath(PathF path, WindingMode windingMode = WindingMode.NonZero) - { - ApplyFill(); - DrawPathInternal(path); - _cr.FillRule = (windingMode == WindingMode.NonZero ? Cairo.FillRule.Winding : Cairo.FillRule.EvenOdd); - _cr.Fill(); - } - - public void ClipPath(PathF path, WindingMode windingMode = WindingMode.NonZero) - { - DrawPathInternal(path); - _cr.FillRule = (windingMode == WindingMode.NonZero ? Cairo.FillRule.Winding : Cairo.FillRule.EvenOdd); - _cr.Clip(); - } - - public void ClipRectangle(float x, float y, float width, float height) - { - _cr.Rectangle(x, y, width, height); - _cr.Clip(); - } - - public void SubtractFromClip(float x, float y, float width, float height) { } - - public void DrawImage(IImage image, float x, float y, float width, float height) { } - - public void Rotate(float degrees, float x, float y) - { - _cr.Translate(x, y); - _cr.Rotate(degrees * Math.PI / 180); - _cr.Translate(-x, -y); - } - - public void Rotate(float degrees) => Rotate(degrees, 0, 0); - public void Scale(float sx, float sy) => _cr.Scale(sx, sy); - public void Translate(float tx, float ty) => _cr.Translate(tx, ty); - public void ConcatenateTransform(Matrix3x2 transform) { } - - public void SaveState() => _cr.Save(); - public bool RestoreState() + private void ConnectInteractionEvents(Gtk.DrawingArea area) { - _cr.Restore(); - return true; - } - public void ResetState() - { - _strokeSize = 1; - _strokeColor = Colors.Black; - _fillColor = Colors.White; - _fontColor = Colors.Black; - _fontSize = 14; - _alpha = 1; - } - - public bool RestrictToClipBounds { get; set; } - - public void SetShadow(SizeF offset, float blur, Color color) { } - public void SetFillPaint(Paint paint, RectF rectangle) - { - if (paint is SolidPaint solidPaint && solidPaint.Color != null) - FillColor = solidPaint.Color; - } - - private void ApplyStroke() - { - _cr.SetSourceRgba(_strokeColor.Red, _strokeColor.Green, _strokeColor.Blue, _strokeColor.Alpha * _alpha); - _cr.LineWidth = _strokeSize; - } - - private void ApplyFill() - { - _cr.SetSourceRgba(_fillColor.Red, _fillColor.Green, _fillColor.Blue, _fillColor.Alpha * _alpha); - } + // Click (press/release) for StartInteraction / EndInteraction + var click = Gtk.GestureClick.New(); + click.SetButton(0); // all buttons + click.OnPressed += (sender, args) => + { + VirtualView?.StartInteraction(new[] { new PointF((float)args.X, (float)args.Y) }); + }; + click.OnReleased += (sender, args) => + { + VirtualView?.EndInteraction(new[] { new PointF((float)args.X, (float)args.Y) }, true); + }; + click.OnCancel += (sender, args) => + { + VirtualView?.CancelInteraction(); + }; + area.AddController(click); - private void ApplyFontColor() - { - _cr.SetSourceRgba(_fontColor.Red, _fontColor.Green, _fontColor.Blue, _fontColor.Alpha * _alpha); - } + // Drag for DragInteraction + var drag = Gtk.GestureDrag.New(); + drag.OnDragUpdate += (sender, args) => + { + drag.GetStartPoint(out double startX, out double startY); + VirtualView?.DragInteraction(new[] { new PointF((float)(startX + args.OffsetX), (float)(startY + args.OffsetY)) }); + }; + area.AddController(drag); - private void EllipsePath(float x, float y, float width, float height) - { - _cr.Save(); - _cr.Translate(x + width / 2, y + height / 2); - _cr.Scale(width / 2, height / 2); - _cr.Arc(0, 0, 1, 0, 2 * Math.PI); - _cr.Restore(); + // Motion for hover events + var motion = Gtk.EventControllerMotion.New(); + motion.OnEnter += (sender, args) => + { + VirtualView?.StartHoverInteraction(new[] { new PointF((float)args.X, (float)args.Y) }); + }; + motion.OnMotion += (sender, args) => + { + VirtualView?.MoveHoverInteraction(new[] { new PointF((float)args.X, (float)args.Y) }); + }; + motion.OnLeave += (sender, args) => + { + VirtualView?.EndHoverInteraction(); + }; + area.AddController(motion); } - private void RoundedRectPath(float x, float y, float w, float h, float r) + public static void MapInvalidate(GraphicsViewHandler handler, IGraphicsView view, object? arg) { - r = Math.Min(r, Math.Min(w / 2, h / 2)); - _cr.NewPath(); - _cr.Arc(x + w - r, y + r, r, -Math.PI / 2, 0); - _cr.Arc(x + w - r, y + h - r, r, 0, Math.PI / 2); - _cr.Arc(x + r, y + h - r, r, Math.PI / 2, Math.PI); - _cr.Arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2); - _cr.ClosePath(); + handler.PlatformView?.QueueDraw(); } - private void DrawPathInternal(PathF path) + public static void MapDrawable(GraphicsViewHandler handler, IGraphicsView view) { - _cr.NewPath(); - var points = path.Points?.ToArray(); - if (points == null || points.Length == 0) - return; - - _cr.MoveTo(points[0].X, points[0].Y); - for (int i = 1; i < points.Length; i++) - { - _cr.LineTo(points[i].X, points[i].Y); - } - if (path.Closed) - _cr.ClosePath(); + handler.PlatformView?.QueueDraw(); } } diff --git a/src/Platform.Maui.Linux.Gtk4/Hosting/AppHostBuilderExtensions.cs b/src/Platform.Maui.Linux.Gtk4/Hosting/AppHostBuilderExtensions.cs index 115901c..431aeef 100644 --- a/src/Platform.Maui.Linux.Gtk4/Hosting/AppHostBuilderExtensions.cs +++ b/src/Platform.Maui.Linux.Gtk4/Hosting/AppHostBuilderExtensions.cs @@ -7,7 +7,9 @@ using Microsoft.Maui.Animations; using Microsoft.Maui.Controls; using Microsoft.Maui.Dispatching; +using Microsoft.Maui.Graphics; using Microsoft.Maui.Hosting; +using Platform.Maui.Linux.Gtk4.Graphics; using Platform.Maui.Linux.Gtk4.Handlers; using Platform.Maui.Linux.Gtk4.Platform; @@ -127,6 +129,11 @@ static MauiAppBuilder SetupDefaults(this MauiAppBuilder builder) // Named font sizes (FontSize="Title", etc.) Microsoft.Maui.Controls.DependencyService.Register(); + // Graphics platform services + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.ConfigureMauiHandlers(handlers => { handlers.AddMauiControlsHandlers();