diff --git a/Handlers/LabelHandler.cs b/Handlers/LabelHandler.cs index 3300a6b..4424083 100644 --- a/Handlers/LabelHandler.cs +++ b/Handlers/LabelHandler.cs @@ -68,6 +68,13 @@ public partial class LabelHandler : ViewHandler } } + // Explicitly map LineBreakMode on connect - MAUI may not trigger property change for defaults + if (VirtualView is Microsoft.Maui.Controls.Label mauiLabel) + { + Console.WriteLine($"[LabelHandler] ConnectHandler Text='{mauiLabel.Text?.Substring(0, Math.Min(20, mauiLabel.Text?.Length ?? 0))}...' LineBreakMode={mauiLabel.LineBreakMode} ({(int)mauiLabel.LineBreakMode})"); + platformView.LineBreakMode = mauiLabel.LineBreakMode; + } + platformView.Tapped += OnPlatformViewTapped; } diff --git a/Services/Browser.cs b/Services/Browser.cs new file mode 100644 index 0000000..bb42da1 --- /dev/null +++ b/Services/Browser.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.ApplicationModel; + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Static Browser class providing MAUI-compatible API for opening URLs. +/// +public static class Browser +{ + private static IBrowser? _browser; + + /// + /// Gets or sets the browser implementation. Set during app initialization. + /// + public static IBrowser Default + { + get => _browser ??= new BrowserService(); + set => _browser = value; + } + + /// + /// Opens the specified URI in the default browser. + /// + public static Task OpenAsync(string uri) + { + return Default.OpenAsync(uri); + } + + /// + /// Opens the specified URI in the default browser with the specified launch mode. + /// + public static Task OpenAsync(string uri, BrowserLaunchMode launchMode) + { + return Default.OpenAsync(uri, launchMode); + } + + /// + /// Opens the specified URI in the default browser. + /// + public static Task OpenAsync(Uri uri) + { + return Default.OpenAsync(uri); + } + + /// + /// Opens the specified URI in the default browser with the specified launch mode. + /// + public static Task OpenAsync(Uri uri, BrowserLaunchMode launchMode) + { + return Default.OpenAsync(uri, launchMode); + } + + /// + /// Opens the specified URI in the default browser with the specified options. + /// + public static Task OpenAsync(Uri uri, BrowserLaunchOptions options) + { + return Default.OpenAsync(uri, options); + } +} diff --git a/Views/SkiaButton.cs b/Views/SkiaButton.cs index d0a8296..8bdbfdb 100644 --- a/Views/SkiaButton.cs +++ b/Views/SkiaButton.cs @@ -536,11 +536,32 @@ public class SkiaButton : SkiaView, IButtonController #region Drawing + /// + /// Override to prevent base class from drawing rectangular background. + /// Button draws its own rounded background in OnDraw. + /// + protected override void DrawBackground(SKCanvas canvas, SKRect bounds) + { + // Don't draw anything - OnDraw handles the rounded background + } + protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // BackgroundColor is inherited from SkiaView as MAUI Color - convert to SKColor for rendering var bgColor = GetEffectiveBackgroundColor(); - bool hasBackground = bgColor.Alpha > 0; + + // Check if BackgroundColor was explicitly set (even if set to transparent) + // This distinguishes "no background specified" from "explicitly transparent" + bool hasExplicitBackground = BackgroundColor != null; + + // If no background color is set, use a default button background (like other MAUI platforms) + // This ensures buttons are visible even without explicit styling + if (!hasExplicitBackground) + { + bgColor = SkiaTheme.Gray200SK; // Default button background + } + + bool hasBackground = hasExplicitBackground ? bgColor.Alpha > 0 : true; // Determine current state color SKColor currentBgColor; @@ -571,6 +592,10 @@ public class SkiaButton : SkiaView, IButtonController var roundRect = new SKRoundRect(bounds, cornerRadius); + // Clip to rounded rectangle to prevent background bleeding in corners + canvas.Save(); + canvas.ClipRoundRect(roundRect, antialias: true); + // Draw background if (currentBgColor.Alpha > 0) { @@ -612,17 +637,26 @@ public class SkiaButton : SkiaView, IButtonController } // Draw content (text and/or image) - DrawContent(canvas, bounds); + DrawContent(canvas, bounds, hasExplicitBackground); + + // Restore canvas state (undo clipping) + canvas.Restore(); } - private void DrawContent(SKCanvas canvas, SKRect bounds) + private void DrawContent(SKCanvas canvas, SKRect bounds, bool hasExplicitBackground) { var padding = Padding; + // Handle NaN padding (default to 14, 10) + float padLeft = float.IsNaN((float)padding.Left) ? 14f : (float)padding.Left; + float padTop = float.IsNaN((float)padding.Top) ? 10f : (float)padding.Top; + float padRight = float.IsNaN((float)padding.Right) ? 14f : (float)padding.Right; + float padBottom = float.IsNaN((float)padding.Bottom) ? 10f : (float)padding.Bottom; + var contentBounds = new SKRect( - bounds.Left + (float)padding.Left, - bounds.Top + (float)padding.Top, - bounds.Right - (float)padding.Right, - bounds.Bottom - (float)padding.Bottom); + bounds.Left + padLeft, + bounds.Top + padTop, + bounds.Right - padRight, + bounds.Bottom - padBottom); // Prepare font bool isBold = FontAttributes.HasFlag(FontAttributes.Bold); @@ -640,8 +674,24 @@ public class SkiaButton : SkiaView, IButtonController SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(fontFamily, fontStyle) ?? SKTypeface.Default, fontSize); - // Prepare text color (null means use platform default: white for buttons) - var textColor = TextColor != null ? ToSKColor(TextColor) : SkiaTheme.BackgroundWhiteSK; + // Prepare text color + // If TextColor is set, use it; otherwise use a sensible default based on background + SKColor textColor; + if (TextColor != null) + { + textColor = ToSKColor(TextColor); + } + else if (hasExplicitBackground) + { + // Explicit background but no text color - use white (common for colored buttons) + textColor = SkiaTheme.BackgroundWhiteSK; + } + else + { + // Default button (gray background) - use dark text for contrast + textColor = SkiaTheme.Gray800SK; + } + if (!IsEnabled) { textColor = textColor.WithAlpha(128); @@ -945,6 +995,11 @@ public class SkiaButton : SkiaView, IButtonController var padding = Padding; float paddingH = (float)(padding.Left + padding.Right); float paddingV = (float)(padding.Top + padding.Bottom); + + // Handle NaN padding (can happen with style resolution issues) + if (float.IsNaN(paddingH)) paddingH = 28f; // Default: 14 + 14 + if (float.IsNaN(paddingV)) paddingV = 20f; // Default: 10 + 10 + float fontSize = FontSize > 0 ? (float)FontSize : 14f; // Prepare font for measurement @@ -978,7 +1033,9 @@ public class SkiaButton : SkiaView, IButtonController { textWidth += (float)(CharacterSpacing * (displayText.Length - 1)); } - textHeight = textBounds.Height; + // Use font metrics for proper line height (ascent is negative) + var metrics = font.Metrics; + textHeight = metrics.Descent - metrics.Ascent; } float imageWidth = 0, imageHeight = 0; @@ -1037,7 +1094,10 @@ public class SkiaButton : SkiaView, IButtonController height = (float)HeightRequest; } - return new Size(Math.Max(width, 44f), Math.Max(height, 30f)); + var result = new Size(Math.Max(width, 44f), Math.Max(height, 36f)); + if (Text == "Round") + Console.WriteLine($"[SkiaButton.Measure] Text='Round' WReq={WidthRequest} HReq={HeightRequest} width={width:F1} height={height:F1} result={result.Width:F0}x{result.Height:F0}"); + return result; } #endregion diff --git a/Views/SkiaEditor.cs b/Views/SkiaEditor.cs index b239bea..c8af51d 100644 --- a/Views/SkiaEditor.cs +++ b/Views/SkiaEditor.cs @@ -921,7 +921,14 @@ public class SkiaEditor : SkiaView, IInputContext Color = GetEffectivePlaceholderColor(), IsAntialias = true }; - canvas.DrawText(Placeholder, contentRect.Left, contentRect.Top + fontSize, placeholderPaint); + // Handle multiline placeholder text by splitting on newlines + var placeholderLines = Placeholder.Split('\n'); + var y = contentRect.Top + fontSize; + foreach (var line in placeholderLines) + { + canvas.DrawText(line, contentRect.Left, y, placeholderPaint); + y += lineSpacing; + } } else { diff --git a/Views/SkiaLayoutView.cs b/Views/SkiaLayoutView.cs index 2b1ce69..6634eb0 100644 --- a/Views/SkiaLayoutView.cs +++ b/Views/SkiaLayoutView.cs @@ -361,7 +361,11 @@ public class SkiaStackLayout : SkiaLayoutView float maxWidth = 0; float maxHeight = 0; - var childAvailable = new Size(contentWidth, contentHeight); + // For stack layouts, give children infinite size in the stacking direction + // so they can measure to their natural size + var childAvailable = Orientation == StackOrientation.Horizontal + ? new Size(double.PositiveInfinity, contentHeight) // Horizontal: infinite width, constrained height + : new Size(contentWidth, double.PositiveInfinity); // Vertical: constrained width, infinite height foreach (var child in Children) { @@ -447,11 +451,9 @@ public class SkiaStackLayout : SkiaLayoutView } else { - // For ScrollView children, give them the remaining viewport width - var remainingWidth = Math.Max(0, contentWidth - offset); - var useWidth = child is SkiaScrollView - ? remainingWidth - : Math.Min(childWidth, remainingWidth > 0 ? remainingWidth : childWidth); + // Horizontal stack: give each child its measured width + // Don't constrain - let content overflow if needed (parent clips) + var useWidth = childWidth; // Respect child's VerticalOptions for horizontal layouts var useHeight = Math.Min(childHeight, contentHeight); diff --git a/Views/SkiaScrollView.cs b/Views/SkiaScrollView.cs index 7ee8944..e86948e 100644 --- a/Views/SkiaScrollView.cs +++ b/Views/SkiaScrollView.cs @@ -295,12 +295,36 @@ public class SkiaScrollView : SkiaView var contentDesired = _content.Measure(availableSize); ContentSize = new SKSize((float)contentDesired.Width, (float)contentDesired.Height); - // Apply content's margin + // Apply content's margin and arrange based on scroll orientation var margin = _content.Margin; var contentLeft = bounds.Left + (float)margin.Left; var contentTop = bounds.Top + (float)margin.Top; - var contentWidth = Math.Max(bounds.Width, (float)_content.DesiredSize.Width) - (float)margin.Left - (float)margin.Right; - var contentHeight = Math.Max(bounds.Height, (float)_content.DesiredSize.Height) - (float)margin.Top - (float)margin.Bottom; + + // Content dimensions depend on scroll orientation + float contentWidth, contentHeight; + switch (Orientation) + { + case ScrollOrientation.Horizontal: + contentWidth = Math.Max(bounds.Width, (float)_content.DesiredSize.Width); + contentHeight = bounds.Height; + break; + case ScrollOrientation.Neither: + contentWidth = bounds.Width; + contentHeight = bounds.Height; + break; + case ScrollOrientation.Both: + contentWidth = Math.Max(bounds.Width, (float)_content.DesiredSize.Width); + contentHeight = Math.Max(bounds.Height, (float)_content.DesiredSize.Height); + break; + case ScrollOrientation.Vertical: + default: + contentWidth = bounds.Width; + contentHeight = Math.Max(bounds.Height, (float)_content.DesiredSize.Height); + break; + } + + contentWidth -= (float)margin.Left + (float)margin.Right; + contentHeight -= (float)margin.Top + (float)margin.Bottom; var contentBounds = new Rect(contentLeft, contentTop, contentWidth, contentHeight); _content.Arrange(contentBounds); @@ -858,12 +882,41 @@ public class SkiaScrollView : SkiaView if (_content != null) { - // Apply content's margin and arrange content at its full size + // Apply content's margin and arrange content based on scroll orientation var margin = _content.Margin; var contentLeft = (float)actualBounds.Left + (float)margin.Left; var contentTop = (float)actualBounds.Top + (float)margin.Top; - var contentWidth = Math.Max((float)actualBounds.Width, ContentSize.Width) - (float)margin.Left - (float)margin.Right; - var contentHeight = Math.Max((float)actualBounds.Height, ContentSize.Height) - (float)margin.Top - (float)margin.Bottom; + + // Content dimensions depend on scroll orientation: + // - Vertical: width constrained to viewport, height can expand + // - Horizontal: width can expand, height constrained to viewport + // - Both: both can expand + // - Neither: both constrained to viewport + float contentWidth, contentHeight; + switch (Orientation) + { + case ScrollOrientation.Horizontal: + contentWidth = Math.Max((float)actualBounds.Width, ContentSize.Width); + contentHeight = (float)actualBounds.Height; + break; + case ScrollOrientation.Neither: + contentWidth = (float)actualBounds.Width; + contentHeight = (float)actualBounds.Height; + break; + case ScrollOrientation.Both: + contentWidth = Math.Max((float)actualBounds.Width, ContentSize.Width); + contentHeight = Math.Max((float)actualBounds.Height, ContentSize.Height); + break; + case ScrollOrientation.Vertical: + default: + // Vertical scroll: constrain width to viewport, allow height to expand + contentWidth = (float)actualBounds.Width; + contentHeight = Math.Max((float)actualBounds.Height, ContentSize.Height); + break; + } + + contentWidth -= (float)margin.Left + (float)margin.Right; + contentHeight -= (float)margin.Top + (float)margin.Bottom; var contentBounds = new Rect(contentLeft, contentTop, contentWidth, contentHeight); _content.Arrange(contentBounds); diff --git a/Views/SkiaSearchBar.cs b/Views/SkiaSearchBar.cs index 4c018b5..8655b2e 100644 --- a/Views/SkiaSearchBar.cs +++ b/Views/SkiaSearchBar.cs @@ -109,7 +109,8 @@ public class SkiaSearchBar : SkiaView BackgroundColor = Colors.Transparent, BorderColor = Colors.Transparent, FocusedBorderColor = Colors.Transparent, - BorderWidth = 0 + BorderWidth = 0, + VerticalTextAlignment = TextAlignment.Center }; _entry.TextChanged += (s, e) =>