From 08e69570e8e9e159e05a99e1647ddca08d7b8128 Mon Sep 17 00:00:00 2001 From: Kiko kiko Date: Sat, 4 Apr 2026 17:18:56 +0200 Subject: [PATCH 1/3] Add LinkLabel control Supports multiple links, hover/active/visited states and demo panel --- samples/ControlGallery/MainForm.cs | 3 + .../ControlGallery/Panels/LinkLabelPanel.cs | 140 ++++ src/Modern.Forms/LinkArea.cs | 56 ++ src/Modern.Forms/LinkBehavior.cs | 32 + src/Modern.Forms/LinkLabel.Link.cs | 191 ++++++ src/Modern.Forms/LinkLabel.LinkCollection.cs | 233 +++++++ src/Modern.Forms/LinkLabel.cs | 634 ++++++++++++++++++ .../LinkLabelLinkClickedEventArgs.cs | 38 ++ .../LinkLabelLinkClickedEventHandler.cs | 9 + src/Modern.Forms/LinkState.cs | 29 + .../Renderers/LinkLabelRenderer.cs | 228 +++++++ src/Modern.Forms/Renderers/RenderManager.cs | 1 + 12 files changed, 1594 insertions(+) create mode 100644 samples/ControlGallery/Panels/LinkLabelPanel.cs create mode 100644 src/Modern.Forms/LinkArea.cs create mode 100644 src/Modern.Forms/LinkBehavior.cs create mode 100644 src/Modern.Forms/LinkLabel.Link.cs create mode 100644 src/Modern.Forms/LinkLabel.LinkCollection.cs create mode 100644 src/Modern.Forms/LinkLabel.cs create mode 100644 src/Modern.Forms/LinkLabelLinkClickedEventArgs.cs create mode 100644 src/Modern.Forms/LinkLabelLinkClickedEventHandler.cs create mode 100644 src/Modern.Forms/LinkState.cs create mode 100644 src/Modern.Forms/Renderers/LinkLabelRenderer.cs diff --git a/samples/ControlGallery/MainForm.cs b/samples/ControlGallery/MainForm.cs index 97417e8..f6c7a31 100644 --- a/samples/ControlGallery/MainForm.cs +++ b/samples/ControlGallery/MainForm.cs @@ -29,6 +29,7 @@ public MainForm () tree.Items.Add ("FormShortcuts", ImageLoader.Get ("button.png")); tree.Items.Add ("ImageList", ImageLoader.Get ("button.png")); tree.Items.Add ("Label", ImageLoader.Get ("button.png")); + tree.Items.Add ("LinkLabel", ImageLoader.Get ("button.png")); tree.Items.Add ("ListBox", ImageLoader.Get ("button.png")); tree.Items.Add ("ListView", ImageLoader.Get ("button.png")); tree.Items.Add ("Menu", ImageLoader.Get ("button.png")); @@ -100,6 +101,8 @@ private void Tree_ItemSelected (object? sender, EventArgs e) return new ImageListPanel (); case "Label": return new LabelPanel (); + case "LinkLabel": + return new LinkLabelPanel (); case "ListBox": return new ListBoxPanel (); case "ListView": diff --git a/samples/ControlGallery/Panels/LinkLabelPanel.cs b/samples/ControlGallery/Panels/LinkLabelPanel.cs new file mode 100644 index 0000000..c82825c --- /dev/null +++ b/samples/ControlGallery/Panels/LinkLabelPanel.cs @@ -0,0 +1,140 @@ +using System.Drawing; +using Modern.Forms; +using SkiaSharp; + +namespace ControlGallery.Panels +{ + /// + /// Demonstrates the LinkLabel control with various configurations. + /// + public class LinkLabelPanel : Panel + { + private readonly Label output; + + public LinkLabelPanel () + { + Padding = new Padding (20); + + output = new Label { + Text = "Output: ", + Location = new Point (20, 400), + Width = 500, + Height = 30 + }; + + Controls.Add (output); + + // === Simple single link === + var simple = new LinkLabel { + Text = "Click here to open documentation", + Location = new Point (20, 20), + Width = 400, + Height = 30, + HoverLinkColor = SKColors.Red, + VisitedLinkColor = SKColors.Purple + }; + + simple.LinkClicked += (s, e) => { + output.Text = "Simple link clicked"; + }; + + Controls.Add (simple); + + // === Multiple links === + var multi = new LinkLabel { + Text = "Visit docs or support page", + Location = new Point (20, 70), + Width = 400, + Height = 30 + }; + + multi.Links.Clear (); + multi.Links.Add (0, 10, "docs"); + multi.Links.Add (14, 7, "support"); + + multi.LinkClicked += (s, e) => { + output.Text = $"Clicked: {e.Link?.LinkData}"; + }; + + Controls.Add (multi); + + // === Visited links === + var visited = new LinkLabel { + Text = "Visited link example", + Location = new Point (20, 120), + LinkVisited = true, + Width = 400, + Height = 30 + }; + + visited.LinkClicked += (s, e) => { + output.Text = "Visited link clicked"; + }; + + Controls.Add (visited); + + // === Disabled link === + var disabled = new LinkLabel { + Text = "Disabled link example", + Location = new Point (20, 170), + Width = 400, + Height = 30 + }; + + disabled.Links[0].Enabled = false; + + Controls.Add (disabled); + + // === Hover underline only === + var hover = new LinkLabel { + Text = "Hover underline example", + Location = new Point (20, 220), + Width = 400, + Height = 30, + LinkBehavior = LinkBehavior.HoverUnderline, + HoverLinkColor = SKColors.Red + }; + + hover.LinkClicked += (s, e) => { + output.Text = "Hover link clicked"; + }; + + Controls.Add (hover); + + // === Custom colors === + var custom = new LinkLabel { + Text = "Custom color link", + Location = new Point (20, 270), + Width = 400, + Height = 30, + LinkColor = SKColors.Green, + ActiveLinkColor = SKColors.Red, + VisitedLinkColor = SKColors.Purple + }; + + custom.LinkClicked += (s, e) => { + output.Text = "Custom color link clicked"; + }; + + Controls.Add (custom); + + // === Keyboard navigation demo === + var keyboard = new LinkLabel { + Text = "Use TAB / arrows to switch links", + Location = new Point (20, 320), + Width = 400, + Height = 30 + }; + + keyboard.Links.Clear (); + keyboard.Links.Add (4, 3, "tab"); + keyboard.Links.Add (10, 6, "arrows"); + + keyboard.LinkClicked += (s, e) => { + output.Text = $"Keyboard link: {e.Link?.LinkData}"; + }; + + Controls.Add (keyboard); + } + } +} diff --git a/src/Modern.Forms/LinkArea.cs b/src/Modern.Forms/LinkArea.cs new file mode 100644 index 0000000..8bf6c47 --- /dev/null +++ b/src/Modern.Forms/LinkArea.cs @@ -0,0 +1,56 @@ +namespace Modern.Forms +{ + /// + /// Represents a range of text treated as a link. + /// + public struct LinkArea : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + /// The zero-based starting character index. + /// The length of the link range. + public LinkArea (int start, int length) + { + Start = start; + Length = length; + } + + /// + /// Gets or sets the zero-based starting character index of the link range. + /// + public int Start { get; set; } + + /// + /// Gets or sets the length of the link range. + /// + public int Length { get; set; } + + /// + /// Gets a value indicating whether this link area is empty. + /// + public bool IsEmpty => Start == 0 && Length == 0; + + /// + public override bool Equals (object? obj) => obj is LinkArea other && Equals (other); + + /// + public bool Equals (LinkArea other) => Start == other.Start && Length == other.Length; + + /// + public override int GetHashCode () => HashCode.Combine (Start, Length); + + /// + /// Determines whether two values are equal. + /// + public static bool operator == (LinkArea left, LinkArea right) => left.Equals (right); + + /// + /// Determines whether two values are not equal. + /// + public static bool operator != (LinkArea left, LinkArea right) => !left.Equals (right); + + /// + public override string ToString () => $"{{Start={Start}, Length={Length}}}"; + } +} diff --git a/src/Modern.Forms/LinkBehavior.cs b/src/Modern.Forms/LinkBehavior.cs new file mode 100644 index 0000000..19de1c3 --- /dev/null +++ b/src/Modern.Forms/LinkBehavior.cs @@ -0,0 +1,32 @@ +namespace Modern.Forms +{ + /// + /// Specifies how link text should be underlined in a . + /// + public enum LinkBehavior + { + /// + /// Uses the framework default behavior. + /// + /// + /// In this implementation, behaves the same as + /// . + /// + SystemDefault, + + /// + /// Link text is always underlined. + /// + AlwaysUnderline, + + /// + /// Link text is underlined only while the pointer hovers over it. + /// + HoverUnderline, + + /// + /// Link text is never underlined. + /// + NeverUnderline + } +} diff --git a/src/Modern.Forms/LinkLabel.Link.cs b/src/Modern.Forms/LinkLabel.Link.cs new file mode 100644 index 0000000..8534152 --- /dev/null +++ b/src/Modern.Forms/LinkLabel.Link.cs @@ -0,0 +1,191 @@ +using System.Drawing; + +namespace Modern.Forms +{ + public partial class LinkLabel + { + /// + /// Represents a clickable link range within a . + /// + public class Link + { + private readonly List visual_bounds = []; + private int start; + private int length; + private bool enabled = true; + private string? name; + + /// + /// Initializes a new instance of the class. + /// + public Link () + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The zero-based starting character index. + /// + /// The length of the link. + /// A value of -1 means the link extends to the end of the text. + /// + public Link (int start, int length) + { + this.start = start; + this.length = length; + } + + /// + /// Initializes a new instance of the class. + /// + /// The zero-based starting character index. + /// + /// The length of the link. + /// A value of -1 means the link extends to the end of the text. + /// + /// An arbitrary object associated with the link. + public Link (int start, int length, object? linkData) + : this (start, length) + { + LinkData = linkData; + } + + /// + /// Gets or sets a value indicating whether the link can be activated. + /// + public bool Enabled { + get => enabled; + set { + if (enabled != value) { + enabled = value; + + if ((State & (LinkState.Hover | LinkState.Active)) != 0) + State &= ~(LinkState.Hover | LinkState.Active); + + Owner?.Invalidate (); + } + } + } + + /// + /// Gets or sets the effective length of the link. + /// + public int Length { + get { + if (length == -1) { + var text_length = Owner?.Text?.Length ?? 0; + return Math.Max (0, text_length - Start); + } + + return length; + } + set { + if (length != value) { + length = value; + Owner?.InvalidateLayout (); + Owner?.Invalidate (); + } + } + } + + /// + /// Gets or sets an optional object associated with the link. + /// + public object? LinkData { get; set; } + + /// + /// Gets or sets the name of the link. + /// + public string Name { + get => name ?? string.Empty; + set => name = value; + } + + /// + /// Gets or sets the link owner. + /// + internal LinkLabel? Owner { get; set; } + + /// + /// Gets or sets the raw stored length value. + /// + internal int RawLength { + get => length; + set => length = value; + } + + /// + /// Gets or sets the zero-based starting character index. + /// + public int Start { + get => start; + set { + if (start != value) { + start = value; + + if (Owner is not null) { + Owner.Links.SortByStart (); + Owner.InvalidateLayout (); + Owner.Invalidate (); + } + } + } + } + + /// + /// Gets or sets the current visual state of the link. + /// + internal LinkState State { get; set; } + + /// + /// Gets or sets an arbitrary user-defined object associated with the link. + /// + public object? Tag { get; set; } + + /// + /// Gets or sets a value indicating whether the link has been visited. + /// + public bool Visited { + get => (State & LinkState.Visited) == LinkState.Visited; + set { + if (value != Visited) { + if (value) + State |= LinkState.Visited; + else + State &= ~LinkState.Visited; + + Owner?.Invalidate (); + } + } + } + + /// + /// Gets the rectangles occupied by the rendered link. + /// + public IReadOnlyList VisualBounds => visual_bounds; + + /// + /// Clears cached visual bounds. + /// + internal void ClearVisualBounds () => visual_bounds.Clear (); + + /// + /// Adds a visual bounds rectangle for the link. + /// + /// The bounds to add. + internal void AddVisualBounds (Rectangle bounds) + { + if (bounds.Width > 0 && bounds.Height > 0) + visual_bounds.Add (bounds); + } + + /// + /// Determines whether the given point lies within the rendered link area. + /// + /// The point to test. + /// if the point is within the link; otherwise, . + internal bool Contains (Point location) => visual_bounds.Any (bounds => bounds.Contains (location)); + } + } +} diff --git a/src/Modern.Forms/LinkLabel.LinkCollection.cs b/src/Modern.Forms/LinkLabel.LinkCollection.cs new file mode 100644 index 0000000..ee34785 --- /dev/null +++ b/src/Modern.Forms/LinkLabel.LinkCollection.cs @@ -0,0 +1,233 @@ +using System.Collections; + +namespace Modern.Forms +{ + public partial class LinkLabel + { + /// + /// Represents a strongly typed collection of objects. + /// + public class LinkCollection : IList + { + private readonly LinkLabel owner; + private readonly List items = []; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + public LinkCollection (LinkLabel owner) + { + this.owner = owner ?? throw new ArgumentNullException (nameof (owner)); + } + + /// + /// Gets the number of links in the collection. + /// + public int Count => items.Count; + + /// + /// Gets a value indicating whether the collection is read-only. + /// + public bool IsReadOnly => false; + + /// + /// Gets or sets the link at the specified index. + /// + public Link this[int index] { + get => items[index]; + set { + ArgumentNullException.ThrowIfNull (value); + + value.Owner = owner; + items[index] = value; + + SortByStart (); + owner.ValidateNoOverlappingLinks (); + owner.UpdateSelectability (); + owner.InvalidateLayout (); + owner.Invalidate (); + } + } + + /// + /// Adds a link to the collection. + /// + /// The link to add. + public void Add (Link item) + { + ArgumentNullException.ThrowIfNull (item); + + item.Owner = owner; + items.Add (item); + + SortByStart (); + owner.ValidateNoOverlappingLinks (); + owner.UpdateSelectability (); + owner.InvalidateLayout (); + owner.Invalidate (); + } + + /// + /// Adds a link to the collection. + /// + /// The zero-based starting character index. + /// The length of the link. + /// The created . + public Link Add (int start, int length) + { + var link = new Link (start, length); + Add (link); + return link; + } + + /// + /// Adds a link to the collection. + /// + /// The zero-based starting character index. + /// The length of the link. + /// An arbitrary object associated with the link. + /// The created . + public Link Add (int start, int length, object? linkData) + { + var link = new Link (start, length, linkData); + Add (link); + return link; + } + + /// + /// Removes all links from the collection. + /// + public void Clear () + { + items.Clear (); + owner.UpdateSelectability (); + owner.InvalidateLayout (); + owner.Invalidate (); + } + + /// + /// Determines whether the specified link exists in the collection. + /// + /// The link to locate. + /// if the link exists; otherwise, . + public bool Contains (Link item) => items.Contains (item); + + /// + /// Copies the collection to an array. + /// + /// The destination array. + /// The zero-based starting index in the destination array. + public void CopyTo (Link[] array, int arrayIndex) => items.CopyTo (array, arrayIndex); + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator over the collection. + public IEnumerator GetEnumerator () => items.GetEnumerator (); + + /// + /// Returns the index of the specified link. + /// + /// The link to locate. + /// The zero-based index of the link, or -1 if not found. + public int IndexOf (Link item) => items.IndexOf (item); + + /// + /// Inserts a link at the specified index. + /// + /// The zero-based index. + /// The link to insert. + public void Insert (int index, Link item) + { + ArgumentNullException.ThrowIfNull (item); + + item.Owner = owner; + items.Insert (index, item); + + SortByStart (); + owner.ValidateNoOverlappingLinks (); + owner.UpdateSelectability (); + owner.InvalidateLayout (); + owner.Invalidate (); + } + + /// + /// Removes the first occurrence of the specified link. + /// + /// The link to remove. + /// if the link was removed; otherwise, . + public bool Remove (Link item) + { + var removed = items.Remove (item); + + if (removed) { + owner.UpdateSelectability (); + owner.InvalidateLayout (); + owner.Invalidate (); + + if (ReferenceEquals (owner.FocusLink, item)) + owner.FocusLink = items.Count > 0 ? items[0] : null; + } + + return removed; + } + + /// + /// Removes the link at the specified index. + /// + /// The zero-based index of the link to remove. + public void RemoveAt (int index) + { + var removed = items[index]; + items.RemoveAt (index); + + owner.UpdateSelectability (); + owner.InvalidateLayout (); + owner.Invalidate (); + + if (ReferenceEquals (owner.FocusLink, removed)) + owner.FocusLink = items.Count > 0 ? items[0] : null; + } + + IEnumerator IEnumerable.GetEnumerator () => GetEnumerator (); + + /// + /// Returns the first link with the specified name. + /// + /// The link name to locate. + /// The matching link, or if not found. + public Link? this[string key] => string.IsNullOrEmpty (key) + ? null + : items.FirstOrDefault (item => string.Equals (item.Name, key, StringComparison.OrdinalIgnoreCase)); + + /// + /// Removes the first link with the specified name. + /// + /// The link name to remove. + public void RemoveByKey (string key) + { + var item = this[key]; + if (item is not null) + Remove (item); + } + + /// + /// Sorts the collection by link start position. + /// + internal void SortByStart () + { + items.Sort ((left, right) => left.Start.CompareTo (right.Start)); + } + + /// + /// Clears cached visual bounds on all links. + /// + internal void ClearVisualBounds () + { + foreach (var item in items) + item.ClearVisualBounds (); + } + } + } +} diff --git a/src/Modern.Forms/LinkLabel.cs b/src/Modern.Forms/LinkLabel.cs new file mode 100644 index 0000000..607048c --- /dev/null +++ b/src/Modern.Forms/LinkLabel.cs @@ -0,0 +1,634 @@ +using System.Drawing; +using Modern.Forms.Renderers; +using SkiaSharp; + +namespace Modern.Forms +{ + /// + /// Represents a label control that can display one or more clickable links. + /// + /// + /// + /// extends by adding support for + /// clickable text ranges, link states, keyboard navigation, and custom link colors. + /// + /// + /// This control is rendered by . + /// + /// + /// + /// + /// var label = new LinkLabel + /// { + /// Text = "Visit documentation or support", + /// Width = 320, + /// Height = 30 + /// }; + /// + /// label.Links.Clear(); + /// label.Links.Add(new LinkLabel.Link(0, 13) { Tag = "docs" }); + /// label.Links.Add(new LinkLabel.Link(17, 7) { Tag = "support" }); + /// + /// label.LinkClicked += (sender, e) => + /// { + /// Console.WriteLine(e.Link?.Tag); + /// }; + /// + /// + public partial class LinkLabel : Label + { + private Link? focus_link; + private Link? hovered_link; + private Link? pressed_link; + + private SKColor link_color = SKColor.Empty; + private SKColor active_link_color = SKColor.Empty; + private SKColor visited_link_color = SKColor.Empty; + private SKColor disabled_link_color = SKColor.Empty; + + private LinkBehavior link_behavior = LinkBehavior.SystemDefault; + private LinkCollection? link_collection; + private bool layout_invalidated = true; + + /// + /// Initializes a new instance of the class. + /// + public LinkLabel () + { + SetControlBehavior (ControlBehaviors.Hoverable | ControlBehaviors.InvalidateOnTextChanged); + SetControlBehavior (ControlBehaviors.Selectable, true); + + TabStop = true; + ResetLinkArea (); + } + + /// + /// Gets the default for all instances. + /// + public new static readonly ControlStyle DefaultStyle = new ControlStyle (Label.DefaultStyle, + style => { + style.ForegroundColor = Theme.AccentColor; + }); + + /// + public override ControlStyle Style { get; } = new ControlStyle (DefaultStyle); + + /// + /// Gets or sets the color used to display active links. + /// + public SKColor ActiveLinkColor { + get => active_link_color == SKColor.Empty ? Theme.AccentColor2 : active_link_color; + set { + if (active_link_color != value) { + active_link_color = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets the color used to display disabled links. + /// + public SKColor DisabledLinkColor { + get => disabled_link_color == SKColor.Empty ? Theme.ForegroundDisabledColor : disabled_link_color; + set { + if (disabled_link_color != value) { + disabled_link_color = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets the range in the text treated as the primary link. + /// + /// + /// Setting this property clears the current collection + /// and replaces it with a single link. + /// + public LinkArea LinkArea { + get { + if (Links.Count == 0) + return new LinkArea (0, 0); + + return new LinkArea (Links[0].Start, Links[0].RawLength); + } + set { + if (value.Start < 0) + throw new ArgumentOutOfRangeException (nameof (LinkArea), "Start must be greater than or equal to zero."); + + if (value.Length < -1) + throw new ArgumentOutOfRangeException (nameof (LinkArea), "Length must be greater than or equal to -1."); + + Links.Clear (); + + if (!(value.Start == 0 && value.Length == 0)) { + Links.Add (new Link (value.Start, value.Length)); + } + + UpdateSelectability (); + InvalidateLayout (); + Invalidate (); + } + } + + /// + /// Gets or sets how link text should be underlined. + /// + public LinkBehavior LinkBehavior { + get => link_behavior; + set { + if (link_behavior != value) { + link_behavior = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets the color used to display normal links. + /// + public SKColor LinkColor { + get => link_color == SKColor.Empty ? Theme.AccentColor : link_color; + set { + if (link_color != value) { + link_color = value; + Invalidate (); + } + } + } + + /// + /// Gets the collection of links contained in this control. + /// + public LinkCollection Links => link_collection ??= new LinkCollection (this); + + /// + /// Gets or sets a value indicating whether the primary link has been visited. + /// + public bool LinkVisited { + get => Links.Count > 0 && Links[0].Visited; + set { + if (Links.Count == 0) + Links.Add (new Link (0, -1)); + + if (Links[0].Visited != value) { + Links[0].Visited = value; + Invalidate (); + } + } + } + + /// + /// Gets or sets the color used to display visited links. + /// + public SKColor VisitedLinkColor { + get => visited_link_color == SKColor.Empty ? SKColors.Purple : visited_link_color; + set { + if (visited_link_color != value) { + visited_link_color = value; + Invalidate (); + } + } + } + + /// + /// Occurs when a link is clicked. + /// + public event LinkLabelLinkClickedEventHandler? LinkClicked; + + /// + /// Gets the link currently focused for keyboard interaction. + /// + internal Link? FocusLink { + get => focus_link; + set { + if (!ReferenceEquals (focus_link, value)) { + focus_link = value; + Invalidate (); + } + } + } + + private SKColor hover_link_color = SKColor.Empty; + + /// + /// Gets or sets the color used to display hovered links. + /// + public SKColor HoverLinkColor { + get => hover_link_color == SKColor.Empty ? Theme.AccentColor2: hover_link_color; + set { + if (hover_link_color != value) { + hover_link_color = value; + Invalidate (); + } + } + } + + /// + /// Gets the link currently hovered by the pointer. + /// + internal Link? HoveredLink => hovered_link; + + /// + /// Gets the link currently pressed by the pointer. + /// + internal Link? PressedLink => pressed_link; + + /// + /// Gets a value indicating whether cached link layout must be recalculated. + /// + internal bool IsLayoutInvalidated => layout_invalidated; + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + Links.ClearVisualBounds (); + + base.Dispose (disposing); + } + + /// + protected override void OnEnabledChanged (EventArgs e) + { + base.OnEnabledChanged (e); + + foreach (var link in Links) + link.State &= ~(LinkState.Hover | LinkState.Active); + + hovered_link = null; + pressed_link = null; + + Invalidate (); + } + + /// + protected override void OnKeyDown (KeyEventArgs e) + { + base.OnKeyDown (e); + + switch (e.KeyCode) { + case Keys.Enter: + case Keys.Space: + if (FocusLink is not null && FocusLink.Enabled) { + ActivateLink (FocusLink, MouseButtons.Left); + e.Handled = true; + } + + break; + + case Keys.Left: + case Keys.Up: + if (FocusNextLink (false)) + e.Handled = true; + + break; + + case Keys.Right: + case Keys.Down: + if (FocusNextLink (true)) + e.Handled = true; + + break; + + case Keys.Tab: + if (FocusNextLink (!e.Shift)) + e.Handled = true; + + break; + } + } + + /// + protected override void OnMouseDown (MouseEventArgs e) + { + base.OnMouseDown (e); + + if (!Enabled || !e.Button.HasFlag (MouseButtons.Left)) + return; + + Select (); + + var link = PointInLink (e.Location); + if (link is null || !link.Enabled) + return; + + pressed_link = link; + link.State |= LinkState.Active; + FocusLink = link; + Invalidate (); + } + + /// + protected override void OnMouseLeave (EventArgs e) + { + base.OnMouseLeave (e); + + var invalidate = false; + + if (hovered_link is not null) { + hovered_link.State &= ~LinkState.Hover; + hovered_link = null; + invalidate = true; + } + + if (pressed_link is not null) { + pressed_link.State &= ~LinkState.Active; + pressed_link = null; + invalidate = true; + } + + if (invalidate) + Invalidate (); + } + + /// + protected override void OnMouseMove (MouseEventArgs e) + { + base.OnMouseMove (e); + + if (!Enabled) + return; + + var link = PointInLink (e.Location); + + if (!ReferenceEquals (hovered_link, link)) { + if (hovered_link is not null) + hovered_link.State &= ~LinkState.Hover; + + hovered_link = link; + + if (hovered_link is not null && hovered_link.Enabled) + hovered_link.State |= LinkState.Hover; + + Invalidate (); + } + } + + /// + protected override void OnMouseUp (MouseEventArgs e) + { + base.OnMouseUp (e); + + if (!Enabled || !e.Button.HasFlag (MouseButtons.Left)) + return; + + var released_link = PointInLink (e.Location); + var should_activate = pressed_link is not null && ReferenceEquals (pressed_link, released_link) && pressed_link.Enabled; + + if (pressed_link is not null) { + pressed_link.State &= ~LinkState.Active; + pressed_link = null; + } + + if (should_activate && released_link is not null) + ActivateLink (released_link, e.Button); + + Invalidate (); + } + + /// + protected override void OnPaddingChanged (EventArgs e) + { + base.OnPaddingChanged (e); + InvalidateLayout (); + Invalidate (); + } + + /// + protected override void OnPaint (PaintEventArgs e) + { + RenderManager.Render (this, e); + } + + /// + protected override void OnSizeChanged (EventArgs e) + { + base.OnSizeChanged (e); + InvalidateLayout (); + Invalidate (); + } + + /// + protected override void OnTextAlignChanged (EventArgs e) + { + base.OnTextAlignChanged (e); + InvalidateLayout (); + Invalidate (); + } + + /// + protected override void OnTextChanged (EventArgs e) + { + base.OnTextChanged (e); + + NormalizeLinks (); + UpdateSelectability (); + InvalidateLayout (); + Invalidate (); + } + + /// + protected override void SetBoundsCore (int x, int y, int width, int height, BoundsSpecified specified) + { + InvalidateLayout (); + base.SetBoundsCore (x, y, width, height, specified); + } + + /// + /// Raises the event. + /// + /// The event data. + protected virtual void OnLinkClicked (LinkLabelLinkClickedEventArgs e) + => LinkClicked?.Invoke (this, e); + + /// + /// Determines whether the specified link should be underlined. + /// + /// The link to evaluate. + /// if the link should be underlined; otherwise, . + internal bool ShouldUnderline (Link link) + { + var behavior = LinkBehavior == LinkBehavior.SystemDefault + ? LinkBehavior.AlwaysUnderline + : LinkBehavior; + + return behavior switch { + LinkBehavior.AlwaysUnderline => true, + LinkBehavior.HoverUnderline => (link.State & LinkState.Hover) == LinkState.Hover, + LinkBehavior.NeverUnderline => false, + _ => true + }; + } + + /// + /// Resolves the effective display color for the specified link. + /// + /// The link whose color should be resolved. + /// The color that should be used when drawing the link. + internal SKColor ResolveLinkColor (Link link) + { + if (!Enabled || !link.Enabled) + return DisabledLinkColor; + + if ((link.State & LinkState.Active) == LinkState.Active) + return ActiveLinkColor; + + if ((link.State & LinkState.Visited) == LinkState.Visited) + return VisitedLinkColor; + + if ((link.State & LinkState.Hover) == LinkState.Hover) + return HoverLinkColor; + + return LinkColor; + } + + /// + /// Marks the internal layout cache as invalid. + /// + internal void InvalidateLayout () + { + layout_invalidated = true; + Links.ClearVisualBounds (); + } + + /// + /// Marks the internal layout cache as valid. + /// + internal void ValidateLayout () + { + layout_invalidated = false; + } + + /// + /// Validates that no two links overlap. + /// + /// + /// Thrown when link ranges overlap. + /// + internal void ValidateNoOverlappingLinks () + { + for (var i = 0; i < Links.Count; i++) { + var left = Links[i]; + var left_end = left.Start + left.Length; + + for (var j = i + 1; j < Links.Count; j++) { + var right = Links[j]; + var right_end = right.Start + right.Length; + + var max_start = Math.Max (left.Start, right.Start); + var min_end = Math.Min (left_end, right_end); + + if (max_start < min_end) + throw new InvalidOperationException ("Link ranges must not overlap."); + } + } + } + + private void ActivateLink (Link link, MouseButtons button) + { + link.Visited = true; + FocusLink = link; + + OnLinkClicked (new LinkLabelLinkClickedEventArgs (link, button)); + Invalidate (); + } + + private bool FocusNextLink (bool forward) + { + if (Links.Count == 0) + return false; + + var current_index = FocusLink is null + ? (forward ? -1 : Links.Count) + : Links.IndexOf (FocusLink); + + var next_index = GetNextLinkIndex (current_index, forward); + + if (next_index < 0) { + next_index = forward + ? GetNextLinkIndex (-1, true) + : GetNextLinkIndex (Links.Count, false); + } + + if (next_index < 0) + return false; + + FocusLink = Links[next_index]; + return true; + } + + private int GetNextLinkIndex (int index, bool forward) + { + if (forward) { + for (var i = index + 1; i < Links.Count; i++) { + if (Links[i].Enabled && Links[i].Length > 0) + return i; + } + } else { + for (var i = index - 1; i >= 0; i--) { + if (Links[i].Enabled && Links[i].Length > 0) + return i; + } + } + + return -1; + } + + private void NormalizeLinks () + { + if (Links.Count == 0) + return; + + var text_length = Text?.Length ?? 0; + + foreach (var link in Links) { + if (link.Start < 0) + link.Start = 0; + + if (link.Start > text_length) + link.Start = text_length; + + if (link.RawLength != -1 && link.RawLength < 0) + link.RawLength = 0; + + if (link.RawLength != -1 && link.Start + link.RawLength > text_length) + link.RawLength = Math.Max (0, text_length - link.Start); + } + + ValidateNoOverlappingLinks (); + } + + private Link? PointInLink (Point location) + { + var renderer = RenderManager.GetRenderer () + ?? throw new InvalidOperationException ("No LinkLabelRenderer has been registered."); + + return renderer.HitTest (this, location); + } + + private void ResetLinkArea () + { + Links.Clear (); + Links.Add (new Link (0, -1)); + } + + private void UpdateSelectability () + { + var selectable = Links.Any (link => link.Enabled && link.Length > 0); + + TabStop = selectable; + SetControlBehavior (ControlBehaviors.Selectable, selectable); + + if (!selectable) + FocusLink = null; + } + } + + internal static class LinkLabelColorExtensions + { + internal static Color ToColor (this SkiaSharp.SKColor color) + => Color.FromArgb (color.Alpha, color.Red, color.Green, color.Blue); + } +} diff --git a/src/Modern.Forms/LinkLabelLinkClickedEventArgs.cs b/src/Modern.Forms/LinkLabelLinkClickedEventArgs.cs new file mode 100644 index 0000000..dba4661 --- /dev/null +++ b/src/Modern.Forms/LinkLabelLinkClickedEventArgs.cs @@ -0,0 +1,38 @@ +namespace Modern.Forms +{ + /// + /// Provides data for the event. + /// + public class LinkLabelLinkClickedEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The link that was clicked. + public LinkLabelLinkClickedEventArgs (LinkLabel.Link? link) + : this (link, MouseButtons.Left) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The link that was clicked. + /// The mouse button associated with the click. + public LinkLabelLinkClickedEventArgs (LinkLabel.Link? link, MouseButtons button) + { + Link = link; + Button = button; + } + + /// + /// Gets the link that was clicked. + /// + public LinkLabel.Link? Link { get; } + + /// + /// Gets the mouse button associated with the click. + /// + public MouseButtons Button { get; } + } +} diff --git a/src/Modern.Forms/LinkLabelLinkClickedEventHandler.cs b/src/Modern.Forms/LinkLabelLinkClickedEventHandler.cs new file mode 100644 index 0000000..c036d25 --- /dev/null +++ b/src/Modern.Forms/LinkLabelLinkClickedEventHandler.cs @@ -0,0 +1,9 @@ +namespace Modern.Forms +{ + /// + /// Represents the method that handles the event. + /// + /// The event source. + /// The event data. + public delegate void LinkLabelLinkClickedEventHandler (object? sender, LinkLabelLinkClickedEventArgs e); +} diff --git a/src/Modern.Forms/LinkState.cs b/src/Modern.Forms/LinkState.cs new file mode 100644 index 0000000..e7f8578 --- /dev/null +++ b/src/Modern.Forms/LinkState.cs @@ -0,0 +1,29 @@ +namespace Modern.Forms +{ + /// + /// Specifies the visual state of a link within a . + /// + [System.Flags] + public enum LinkState + { + /// + /// The link is in its normal state. + /// + Normal = 0x00, + + /// + /// The pointer is hovering over the link. + /// + Hover = 0x01, + + /// + /// The link is currently pressed. + /// + Active = 0x02, + + /// + /// The link has been marked as visited. + /// + Visited = 0x04 + } +} diff --git a/src/Modern.Forms/Renderers/LinkLabelRenderer.cs b/src/Modern.Forms/Renderers/LinkLabelRenderer.cs new file mode 100644 index 0000000..79b05c4 --- /dev/null +++ b/src/Modern.Forms/Renderers/LinkLabelRenderer.cs @@ -0,0 +1,228 @@ +using System.Drawing; +using Modern.Forms.Layout; +using SkiaSharp; + +namespace Modern.Forms.Renderers +{ + /// + /// Renders a control. + /// + /// + /// This renderer is responsible for drawing: + /// + /// The optional image inherited from . + /// Normal text segments and linked text segments separately. + /// Hover, active, visited, and disabled link colors. + /// Optional underlines. + /// Focus cues for the focused link. + /// + /// + public class LinkLabelRenderer : Renderer + { + /// + protected override void Render (LinkLabel control, PaintEventArgs e) + { + var layout = LayoutTextAndImage (control); + + DrawImage (control, e, layout); + + if (string.IsNullOrEmpty (control.Text)) + return; + + DrawTextRuns (control, e, layout); + + if (control.Selected && control.ShowFocusCues && control.FocusLink is not null) { + foreach (var bounds in control.FocusLink.VisualBounds) + e.Canvas.DrawRectangle (bounds, Theme.AccentColor2); + } + } + + /// + /// Performs hit testing and returns the link located at the given client point. + /// + /// The associated control. + /// The client location to test. + /// The matching link, or if no link was found. + public LinkLabel.Link? HitTest (LinkLabel control, Point location) + { + EnsureLayoutCache (control); + return control.Links.FirstOrDefault (link => link.Contains (location)); + } + + private static LinkLabelTextLayout LayoutTextAndImage (LinkLabel control) + { + var layout = TextImageLayoutEngine.Layout (control); + return new LinkLabelTextLayout (layout.TextBounds, layout.ImageBounds); + } + + private static void DrawImage (LinkLabel control, PaintEventArgs e, LinkLabelTextLayout layout) + { + if ((control as IHaveTextAndImageAlign).GetImage () is SKBitmap image) + e.Canvas.DrawBitmap (image, layout.ImageBounds, !control.Enabled); + } + + private void DrawTextRuns (LinkLabel control, PaintEventArgs e, LinkLabelTextLayout layout) + { + EnsureLayoutCache (control); + + using var paint = CreatePaint (control); + var metrics = paint.FontMetrics; + var line_height = (float)Math.Ceiling (metrics.Descent - metrics.Ascent + metrics.Leading); + var baseline_offset = -metrics.Ascent; + + var lines = BuildLines (control.Text ?? string.Empty); + var line_rectangles = MeasureLines (paint, lines, layout.TextBounds, line_height, control.TextAlign); + + var current_index = 0; + + for (var line_index = 0; line_index < lines.Count; line_index++) { + var line = lines[line_index]; + var line_rect = line_rectangles[line_index]; + + var x = line_rect.Left; + var baseline = line_rect.Top + baseline_offset; + + for (var i = 0; i < line.Length; i++) { + var character = line[i].ToString (); + var width = Math.Max (1f, paint.MeasureText (character)); + + var text_index = current_index + i; + var link = GetLinkAtIndex (control, text_index); + var bounds = Rectangle.Round (new RectangleF (x, line_rect.Top, width, line_height)); + + if (link is not null) { + var color = control.ResolveLinkColor (link); + paint.Color = color; + + e.Canvas.DrawText (character, x, baseline, paint); + + if (control.ShouldUnderline (link)) { + var underline_offset = Math.Max (1, control.LogicalToDeviceUnits (1)); + var underline_y = bounds.Bottom - underline_offset; + e.Canvas.DrawLine ((int)x, underline_y, (int)(x + width), underline_y, paint.Color); + } + } else { + paint.Color = control.Enabled + ? (control.Style.ForegroundColor ?? Theme.ForegroundColor) + : Theme.ForegroundDisabledColor; + + e.Canvas.DrawText (character, x, baseline, paint); + } + + x += width; + } + + current_index += line.Length; + + if (line_index < lines.Count - 1) + current_index += 1; + } + } + + private void EnsureLayoutCache (LinkLabel control) + { + if (!control.IsLayoutInvalidated) + return; + + control.Links.ClearVisualBounds (); + + using var paint = CreatePaint (control); + var metrics = paint.FontMetrics; + var line_height = (float)Math.Ceiling (metrics.Descent - metrics.Ascent + metrics.Leading); + + var layout = LayoutTextAndImage (control); + var lines = BuildLines (control.Text ?? string.Empty); + var line_rectangles = MeasureLines (paint, lines, layout.TextBounds, line_height, control.TextAlign); + + var current_index = 0; + + for (var line_index = 0; line_index < lines.Count; line_index++) { + var line = lines[line_index]; + var line_rect = line_rectangles[line_index]; + + var x = line_rect.Left; + + for (var i = 0; i < line.Length; i++) { + var character = line[i].ToString (); + var width = Math.Max (1f, paint.MeasureText (character)); + var text_index = current_index + i; + + var link = GetLinkAtIndex (control, text_index); + if (link is not null) { + var bounds = Rectangle.Round (new RectangleF (x, line_rect.Top, width, line_height)); + link.AddVisualBounds (bounds); + } + + x += width; + } + + current_index += line.Length; + + if (line_index < lines.Count - 1) + current_index += 1; + } + + control.ValidateLayout (); + } + + private static SKPaint CreatePaint (LinkLabel control) + { + return new SKPaint { + IsAntialias = true, + Typeface = control.CurrentStyle.GetFont (), + TextSize = control.LogicalToDeviceUnits (control.CurrentStyle.GetFontSize ()) + }; + } + + private static List BuildLines (string text) + { + if (string.IsNullOrEmpty (text)) + return [string.Empty]; + + return text.Replace ("\r\n", "\n").Split ('\n').ToList (); + } + + private static LinkLabel.Link? GetLinkAtIndex (LinkLabel control, int index) + { + foreach (var link in control.Links) { + var end = link.Start + link.Length; + if (index >= link.Start && index < end) + return link; + } + + return null; + } + + private static List MeasureLines ( + SKPaint paint, + List lines, + Rectangle available_bounds, + float line_height, + ContentAlignment alignment) + { + var result = new List (lines.Count); + + var total_height = line_height * lines.Count; + var start_y = alignment switch { + ContentAlignment.TopLeft or ContentAlignment.TopCenter or ContentAlignment.TopRight => available_bounds.Top, + ContentAlignment.MiddleLeft or ContentAlignment.MiddleCenter or ContentAlignment.MiddleRight => available_bounds.Top + ((available_bounds.Height - total_height) / 2f), + _ => available_bounds.Bottom - total_height + }; + + for (var i = 0; i < lines.Count; i++) { + var width = paint.MeasureText (lines[i]); + var x = alignment switch { + ContentAlignment.TopCenter or ContentAlignment.MiddleCenter or ContentAlignment.BottomCenter => available_bounds.Left + ((available_bounds.Width - width) / 2f), + ContentAlignment.TopRight or ContentAlignment.MiddleRight or ContentAlignment.BottomRight => available_bounds.Right - width, + _ => available_bounds.Left + }; + + result.Add (new RectangleF (x, start_y + (i * line_height), width, line_height)); + } + + return result; + } + + private readonly record struct LinkLabelTextLayout (Rectangle TextBounds, Rectangle ImageBounds); + } +} diff --git a/src/Modern.Forms/Renderers/RenderManager.cs b/src/Modern.Forms/Renderers/RenderManager.cs index 1652634..193ec5b 100644 --- a/src/Modern.Forms/Renderers/RenderManager.cs +++ b/src/Modern.Forms/Renderers/RenderManager.cs @@ -14,6 +14,7 @@ static RenderManager () SetRenderer (new ComboBoxRenderer ()); SetRenderer (new FormTitleBarRenderer ()); SetRenderer