// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using SkiaSharp;
namespace Microsoft.Maui.Platform;
///
/// A modal alert dialog rendered with Skia.
/// Supports title, message, and up to two buttons (cancel/accept).
///
public class SkiaAlertDialog : SkiaView
{
private readonly string _title;
private readonly string _message;
private readonly string? _cancel;
private readonly string? _accept;
private readonly TaskCompletionSource _tcs;
private SKRect _cancelButtonBounds;
private SKRect _acceptButtonBounds;
private bool _cancelHovered;
private bool _acceptHovered;
// Dialog styling
private static readonly SKColor OverlayColor = new SKColor(0, 0, 0, 128);
private static readonly SKColor DialogBackground = SKColors.White;
private static readonly SKColor TitleColor = new SKColor(0x21, 0x21, 0x21);
private static readonly SKColor MessageColor = new SKColor(0x61, 0x61, 0x61);
private static readonly SKColor ButtonColor = new SKColor(0x21, 0x96, 0xF3);
private static readonly SKColor ButtonHoverColor = new SKColor(0x19, 0x76, 0xD2);
private static readonly SKColor ButtonTextColor = SKColors.White;
private static readonly SKColor CancelButtonColor = new SKColor(0x9E, 0x9E, 0x9E);
private static readonly SKColor CancelButtonHoverColor = new SKColor(0x75, 0x75, 0x75);
private static readonly SKColor BorderColor = new SKColor(0xE0, 0xE0, 0xE0);
private const float DialogWidth = 400;
private const float DialogPadding = 24;
private const float ButtonHeight = 44;
private const float ButtonSpacing = 12;
private const float CornerRadius = 12;
///
/// Creates a new alert dialog.
///
public SkiaAlertDialog(string title, string message, string? accept, string? cancel)
{
_title = title;
_message = message;
_accept = accept;
_cancel = cancel;
_tcs = new TaskCompletionSource();
IsFocusable = true;
}
///
/// Gets the task that completes when the dialog is dismissed.
/// Returns true if accept was clicked, false if cancel was clicked.
///
public Task Result => _tcs.Task;
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
// Draw semi-transparent overlay covering entire screen
using var overlayPaint = new SKPaint
{
Color = OverlayColor,
Style = SKPaintStyle.Fill
};
canvas.DrawRect(bounds, overlayPaint);
// Calculate dialog dimensions
var messageLines = WrapText(_message, DialogWidth - DialogPadding * 2, 16);
var dialogHeight = CalculateDialogHeight(messageLines.Count);
var dialogLeft = bounds.MidX - DialogWidth / 2;
var dialogTop = bounds.MidY - dialogHeight / 2;
var dialogBounds = new SKRect(dialogLeft, dialogTop, dialogLeft + DialogWidth, dialogTop + dialogHeight);
// Draw dialog shadow
using var shadowPaint = new SKPaint
{
Color = new SKColor(0, 0, 0, 60),
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 8),
Style = SKPaintStyle.Fill
};
var shadowRect = new SKRect(dialogBounds.Left + 4, dialogBounds.Top + 4,
dialogBounds.Right + 4, dialogBounds.Bottom + 4);
canvas.DrawRoundRect(shadowRect, CornerRadius, CornerRadius, shadowPaint);
// Draw dialog background
using var bgPaint = new SKPaint
{
Color = DialogBackground,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(dialogBounds, CornerRadius, CornerRadius, bgPaint);
// Draw title
var yOffset = dialogBounds.Top + DialogPadding;
if (!string.IsNullOrEmpty(_title))
{
using var titleFont = new SKFont(SKTypeface.Default, 20) { Embolden = true };
using var titlePaint = new SKPaint(titleFont)
{
Color = TitleColor,
IsAntialias = true
};
canvas.DrawText(_title, dialogBounds.Left + DialogPadding, yOffset + 20, titlePaint);
yOffset += 36;
}
// Draw message
if (!string.IsNullOrEmpty(_message))
{
using var messageFont = new SKFont(SKTypeface.Default, 16);
using var messagePaint = new SKPaint(messageFont)
{
Color = MessageColor,
IsAntialias = true
};
foreach (var line in messageLines)
{
canvas.DrawText(line, dialogBounds.Left + DialogPadding, yOffset + 16, messagePaint);
yOffset += 22;
}
yOffset += 8;
}
// Draw buttons
yOffset = dialogBounds.Bottom - DialogPadding - ButtonHeight;
var buttonY = yOffset;
var buttonCount = (_accept != null ? 1 : 0) + (_cancel != null ? 1 : 0);
var totalButtonWidth = DialogWidth - DialogPadding * 2;
if (buttonCount == 2)
{
var singleButtonWidth = (totalButtonWidth - ButtonSpacing) / 2;
// Cancel button (left)
_cancelButtonBounds = new SKRect(
dialogBounds.Left + DialogPadding,
buttonY,
dialogBounds.Left + DialogPadding + singleButtonWidth,
buttonY + ButtonHeight);
DrawButton(canvas, _cancelButtonBounds, _cancel!,
_cancelHovered ? CancelButtonHoverColor : CancelButtonColor);
// Accept button (right)
_acceptButtonBounds = new SKRect(
dialogBounds.Right - DialogPadding - singleButtonWidth,
buttonY,
dialogBounds.Right - DialogPadding,
buttonY + ButtonHeight);
DrawButton(canvas, _acceptButtonBounds, _accept!,
_acceptHovered ? ButtonHoverColor : ButtonColor);
}
else if (_accept != null)
{
_acceptButtonBounds = new SKRect(
dialogBounds.Left + DialogPadding,
buttonY,
dialogBounds.Right - DialogPadding,
buttonY + ButtonHeight);
DrawButton(canvas, _acceptButtonBounds, _accept,
_acceptHovered ? ButtonHoverColor : ButtonColor);
}
else if (_cancel != null)
{
_cancelButtonBounds = new SKRect(
dialogBounds.Left + DialogPadding,
buttonY,
dialogBounds.Right - DialogPadding,
buttonY + ButtonHeight);
DrawButton(canvas, _cancelButtonBounds, _cancel,
_cancelHovered ? CancelButtonHoverColor : CancelButtonColor);
}
}
private void DrawButton(SKCanvas canvas, SKRect bounds, string text, SKColor bgColor)
{
// Button background
using var bgPaint = new SKPaint
{
Color = bgColor,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(bounds, 8, 8, bgPaint);
// Button text
using var font = new SKFont(SKTypeface.Default, 16) { Embolden = true };
using var textPaint = new SKPaint(font)
{
Color = ButtonTextColor,
IsAntialias = true
};
var textBounds = new SKRect();
textPaint.MeasureText(text, ref textBounds);
var x = bounds.MidX - textBounds.MidX;
var y = bounds.MidY - textBounds.MidY;
canvas.DrawText(text, x, y, textPaint);
}
private float CalculateDialogHeight(int messageLineCount)
{
var height = DialogPadding * 2; // Top and bottom padding
if (!string.IsNullOrEmpty(_title))
height += 36; // Title height
if (!string.IsNullOrEmpty(_message))
height += messageLineCount * 22 + 8; // Message lines + spacing
height += ButtonHeight; // Buttons
return Math.Max(height, 180); // Minimum height
}
private List WrapText(string text, float maxWidth, float fontSize)
{
var lines = new List();
if (string.IsNullOrEmpty(text))
return lines;
using var font = new SKFont(SKTypeface.Default, fontSize);
using var paint = new SKPaint(font);
var words = text.Split(' ');
var currentLine = "";
foreach (var word in words)
{
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
var width = paint.MeasureText(testLine);
if (width > maxWidth && !string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
currentLine = word;
}
else
{
currentLine = testLine;
}
}
if (!string.IsNullOrEmpty(currentLine))
lines.Add(currentLine);
return lines;
}
public override void OnPointerMoved(PointerEventArgs e)
{
var wasHovered = _cancelHovered || _acceptHovered;
_cancelHovered = _cancel != null && _cancelButtonBounds.Contains(e.X, e.Y);
_acceptHovered = _accept != null && _acceptButtonBounds.Contains(e.X, e.Y);
if (wasHovered != (_cancelHovered || _acceptHovered))
Invalidate();
}
public override void OnPointerPressed(PointerEventArgs e)
{
// Check if clicking on buttons
if (_cancel != null && _cancelButtonBounds.Contains(e.X, e.Y))
{
Dismiss(false);
return;
}
if (_accept != null && _acceptButtonBounds.Contains(e.X, e.Y))
{
Dismiss(true);
return;
}
// Clicking outside dialog doesn't dismiss it (it's modal)
}
public override void OnKeyDown(KeyEventArgs e)
{
// Handle Escape to cancel
if (e.Key == Key.Escape && _cancel != null)
{
Dismiss(false);
e.Handled = true;
return;
}
// Handle Enter to accept
if (e.Key == Key.Enter && _accept != null)
{
Dismiss(true);
e.Handled = true;
return;
}
}
private void Dismiss(bool result)
{
// Remove from dialog system
LinuxDialogService.HideDialog(this);
_tcs.TrySetResult(result);
}
protected override SKSize MeasureOverride(SKSize availableSize)
{
// Dialog takes full screen for the overlay
return availableSize;
}
public override SkiaView? HitTest(float x, float y)
{
// Modal dialogs capture all input
return this;
}
}
///
/// Service for showing modal dialogs in OpenMaui Linux.
///
public static class LinuxDialogService
{
private static readonly List _activeDialogs = new();
private static Action? _invalidateCallback;
///
/// Registers the invalidation callback (called by LinuxApplication).
///
public static void SetInvalidateCallback(Action callback)
{
_invalidateCallback = callback;
}
///
/// Shows an alert dialog and returns when dismissed.
///
public static Task ShowAlertAsync(string title, string message, string? accept, string? cancel)
{
var dialog = new SkiaAlertDialog(title, message, accept, cancel);
_activeDialogs.Add(dialog);
_invalidateCallback?.Invoke();
return dialog.Result;
}
///
/// Hides a dialog.
///
internal static void HideDialog(SkiaAlertDialog dialog)
{
_activeDialogs.Remove(dialog);
_invalidateCallback?.Invoke();
}
///
/// Gets whether there are active dialogs.
///
public static bool HasActiveDialog => _activeDialogs.Count > 0;
///
/// Gets the topmost dialog.
///
public static SkiaAlertDialog? TopDialog => _activeDialogs.Count > 0 ? _activeDialogs[^1] : null;
///
/// Draws all active dialogs.
///
public static void DrawDialogs(SKCanvas canvas, SKRect bounds)
{
foreach (var dialog in _activeDialogs)
{
dialog.Measure(new SKSize(bounds.Width, bounds.Height));
dialog.Arrange(bounds);
dialog.Draw(canvas);
}
}
}