All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
# Patchnotes 2.1.0 The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update. We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which: # Location Sharing (Big shout out to @tsubasahane for bringing this feature) - Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>) [#49](<Lightless-Sync/LightlessServer#49>) - To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>) [#49](<Lightless-Sync/LightlessServer#49>) - To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>) [#49](<Lightless-Sync/LightlessServer#49>) [1] # Model Optimization (Mesh Decimating) - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>) - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>) - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking. - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>) + ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE ❗ ** [2] # Animation (PAP) Validation (Safer animations) - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>) - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>) - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>) # UI Changes (Thanks to @kyuwu for UI Changes) - The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>) [3] - Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>) - The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>) - Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>) # LightFinder / ShellFinder - UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does. [#127](<#127>) [4] Co-authored-by: defnotken <itsdefnotken@gmail.com> Co-authored-by: azyges <aaaaaa@aaa.aaa> Co-authored-by: cake <admin@cakeandbanana.nl> Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org> Co-authored-by: choco <choco@patat.nl> Co-authored-by: celine <aaa@aaa.aaa> Co-authored-by: celine <celine@noreply.git.lightless-sync.org> Co-authored-by: Tsubasahane <wozaiha@gmail.com> Co-authored-by: cake <cake@noreply.git.lightless-sync.org> Reviewed-on: #123
499 lines
17 KiB
C#
499 lines
17 KiB
C#
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface;
|
|
using Dalamud.Interface.ManagedFontAtlas;
|
|
using Dalamud.Interface.Utility;
|
|
using Dalamud.Interface.Utility.Raii;
|
|
using System.Numerics;
|
|
|
|
namespace LightlessSync.UI.Style;
|
|
|
|
/// <summary>
|
|
/// A reusable animated header component with a gradient background, some funny stars, and shooting star effects to match the lightless void theme a bit.
|
|
/// </summary>
|
|
public class AnimatedHeader
|
|
{
|
|
private struct Particle
|
|
{
|
|
public Vector2 Position;
|
|
public Vector2 Velocity;
|
|
public float Life;
|
|
public float MaxLife;
|
|
public float Size;
|
|
public ParticleType Type;
|
|
public List<Vector2>? Trail;
|
|
public float Twinkle;
|
|
public float Depth;
|
|
public float Hue;
|
|
}
|
|
|
|
private enum ParticleType
|
|
{
|
|
TwinklingStar,
|
|
ShootingStar
|
|
}
|
|
|
|
private readonly List<Particle> _particles = [];
|
|
private float _particleSpawnTimer;
|
|
private readonly Random _random = new();
|
|
|
|
private const float _particleSpawnInterval = 0.2f;
|
|
private const int _maxParticles = 50;
|
|
private const int _maxTrailLength = 50;
|
|
private const float _edgeFadeDistance = 30f;
|
|
private const float _extendedParticleHeight = 40f;
|
|
|
|
public float Height { get; set; } = 150f;
|
|
|
|
// Color keys for theming
|
|
public string? TopColorKey { get; set; } = "HeaderGradientTop";
|
|
public string? BottomColorKey { get; set; } = "HeaderGradientBottom";
|
|
public string? StaticStarColorKey { get; set; } = "HeaderStaticStar";
|
|
public string? ShootingStarColorKey { get; set; } = "HeaderShootingStar";
|
|
|
|
// Fallbacks if the color keys are not found
|
|
public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f);
|
|
public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f);
|
|
public Vector4 StaticStarColor { get; set; } = new(1f, 1f, 1f, 1f);
|
|
public Vector4 ShootingStarColor { get; set; } = new(0.4f, 0.8f, 1.0f, 1.0f);
|
|
|
|
public bool EnableParticles { get; set; } = true;
|
|
public bool EnableBottomGradient { get; set; } = true;
|
|
|
|
public float GradientHeight { get; set; } = 60f;
|
|
|
|
/// <summary>
|
|
/// Draws the animated header with some customizable content
|
|
/// </summary>
|
|
/// <param name="width">Width of the header</param>
|
|
/// <param name="drawContent">Action to draw custom content inside the header</param>
|
|
public void Draw(float width, Action<Vector2, Vector2> drawContent)
|
|
{
|
|
var windowPos = ImGui.GetWindowPos();
|
|
var windowPadding = ImGui.GetStyle().WindowPadding;
|
|
|
|
var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y);
|
|
var headerEnd = headerStart + new Vector2(width, Height);
|
|
var extendedParticleSize = new Vector2(width, Height + _extendedParticleHeight);
|
|
|
|
DrawGradientBackground(headerStart, headerEnd);
|
|
|
|
if (EnableParticles)
|
|
{
|
|
DrawParticleEffects(headerStart, extendedParticleSize);
|
|
}
|
|
|
|
drawContent(headerStart, headerEnd);
|
|
|
|
if (EnableBottomGradient)
|
|
{
|
|
DrawBottomGradient(headerStart, headerEnd, width);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws a simple animated header with title and subtitle.
|
|
/// </summary>
|
|
public void DrawSimple(float width, string title, string subtitle, IFontHandle? titleFont = null, Vector4? titleColor = null, Vector4? subtitleColor = null)
|
|
{
|
|
Draw(width, (headerStart, headerEnd) =>
|
|
{
|
|
var textX = 20f;
|
|
var textY = 30f;
|
|
|
|
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY));
|
|
|
|
if (titleFont != null)
|
|
{
|
|
using (titleFont.Push())
|
|
{
|
|
ImGui.TextColored(titleColor ?? new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ImGui.TextColored(titleColor ?? new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title);
|
|
}
|
|
|
|
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f));
|
|
ImGui.TextColored(subtitleColor ?? UIColors.Get("LightlessBlue"), subtitle);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws a header with title, subtitle, and action buttons in the top-right corner.
|
|
/// </summary>
|
|
public void DrawWithButtons(float width, string title, string subtitle, List<HeaderButton> buttons, IFontHandle? titleFont = null)
|
|
{
|
|
Draw(width, (headerStart, headerEnd) =>
|
|
{
|
|
// Draw title and subtitle
|
|
var textX = 20f;
|
|
var textY = 30f;
|
|
|
|
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY));
|
|
|
|
if (titleFont != null)
|
|
{
|
|
using (titleFont.Push())
|
|
{
|
|
ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title);
|
|
}
|
|
|
|
ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f));
|
|
ImGui.TextColored(UIColors.Get("LightlessBlue"), subtitle);
|
|
|
|
// Draw buttons
|
|
if (buttons.Count > 0)
|
|
{
|
|
DrawHeaderButtons(headerStart, width, buttons);
|
|
}
|
|
});
|
|
}
|
|
|
|
private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd)
|
|
{
|
|
var drawList = ImGui.GetWindowDrawList();
|
|
|
|
var top = ResolveColor(TopColorKey, TopColor);
|
|
var bottom = ResolveColor(BottomColorKey, BottomColor);
|
|
|
|
drawList.AddRectFilledMultiColor(
|
|
headerStart,
|
|
headerEnd,
|
|
ImGui.GetColorU32(top),
|
|
ImGui.GetColorU32(top),
|
|
ImGui.GetColorU32(bottom),
|
|
ImGui.GetColorU32(bottom)
|
|
);
|
|
|
|
// Draw static background stars
|
|
var starBase = ResolveColor(StaticStarColorKey, StaticStarColor);
|
|
|
|
var random = new Random(42);
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
var starPos = headerStart + new Vector2(
|
|
(float)random.NextDouble() * (headerEnd.X - headerStart.X),
|
|
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
|
|
);
|
|
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
|
|
var starColor = starBase with { W = starBase.W * brightness };
|
|
|
|
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor));
|
|
}
|
|
}
|
|
|
|
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
|
|
{
|
|
var drawList = ImGui.GetWindowDrawList();
|
|
var gradientHeight = GradientHeight;
|
|
var bottom = ResolveColor(BottomColorKey, BottomColor);
|
|
|
|
for (int i = 0; i < gradientHeight; i++)
|
|
{
|
|
var progress = i / gradientHeight;
|
|
var smoothProgress = progress * progress;
|
|
|
|
var r = bottom.X + (0.0f - bottom.X) * smoothProgress;
|
|
var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress;
|
|
var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress;
|
|
var alpha = 1f - smoothProgress;
|
|
|
|
var gradientColor = new Vector4(r, g, b, alpha);
|
|
drawList.AddLine(
|
|
new Vector2(headerStart.X, headerEnd.Y + i),
|
|
new Vector2(headerStart.X + width, headerEnd.Y + i),
|
|
ImGui.GetColorU32(gradientColor),
|
|
1f
|
|
);
|
|
}
|
|
}
|
|
|
|
private void DrawHeaderButtons(Vector2 headerStart, float headerWidth, List<HeaderButton> buttons)
|
|
{
|
|
var spacing = 8f * ImGuiHelpers.GlobalScale;
|
|
var rightPadding = 15f * ImGuiHelpers.GlobalScale;
|
|
var topPadding = 15f * ImGuiHelpers.GlobalScale;
|
|
var buttonY = headerStart.Y + topPadding;
|
|
|
|
using (ImRaii.PushFont(UiBuilder.IconFont))
|
|
{
|
|
// Calculate button size (assuming all buttons are the same size)
|
|
var buttonSize = ImGui.CalcTextSize(FontAwesomeIcon.Globe.ToIconString());
|
|
buttonSize += ImGui.GetStyle().FramePadding * 2;
|
|
|
|
float currentX = headerStart.X + headerWidth - rightPadding - buttonSize.X;
|
|
|
|
using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0)))
|
|
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f }))
|
|
using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f }))
|
|
{
|
|
for (int i = buttons.Count - 1; i >= 0; i--)
|
|
{
|
|
var button = buttons[i];
|
|
ImGui.SetCursorScreenPos(new Vector2(currentX, buttonY));
|
|
|
|
if (ImGui.Button(button.Icon.ToIconString()))
|
|
{
|
|
button.OnClick?.Invoke();
|
|
}
|
|
|
|
if (ImGui.IsItemHovered() && !string.IsNullOrEmpty(button.Tooltip))
|
|
{
|
|
ImGui.PushFont(UiBuilder.DefaultFont);
|
|
ImGui.SetTooltip(button.Tooltip);
|
|
ImGui.PopFont();
|
|
}
|
|
|
|
currentX -= buttonSize.X + spacing;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize)
|
|
{
|
|
var deltaTime = ImGui.GetIO().DeltaTime;
|
|
_particleSpawnTimer += deltaTime;
|
|
|
|
if (_particleSpawnTimer > _particleSpawnInterval && _particles.Count < _maxParticles)
|
|
{
|
|
SpawnParticle(bannerSize);
|
|
_particleSpawnTimer = 0f;
|
|
}
|
|
|
|
if (_random.NextDouble() < 0.003)
|
|
{
|
|
SpawnShootingStar(bannerSize);
|
|
}
|
|
|
|
var drawList = ImGui.GetWindowDrawList();
|
|
|
|
for (int i = _particles.Count - 1; i >= 0; i--)
|
|
{
|
|
var particle = _particles[i];
|
|
|
|
var screenPos = bannerStart + particle.Position;
|
|
|
|
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null)
|
|
{
|
|
particle.Trail.Insert(0, particle.Position);
|
|
if (particle.Trail.Count > _maxTrailLength)
|
|
particle.Trail.RemoveAt(particle.Trail.Count - 1);
|
|
}
|
|
|
|
if (particle.Type == ParticleType.TwinklingStar)
|
|
{
|
|
particle.Twinkle += 0.005f * particle.Depth;
|
|
}
|
|
|
|
particle.Position += particle.Velocity * deltaTime;
|
|
particle.Life -= deltaTime;
|
|
|
|
var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 ||
|
|
particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50;
|
|
|
|
if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds))
|
|
{
|
|
_particles.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
if (particle.Type == ParticleType.TwinklingStar)
|
|
{
|
|
if (particle.Position.X < 0 || particle.Position.X > bannerSize.X)
|
|
particle.Velocity = particle.Velocity with { X = -particle.Velocity.X };
|
|
if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y)
|
|
particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y };
|
|
}
|
|
|
|
var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f);
|
|
var fadeOut = Math.Min(1f, particle.Life / 20f);
|
|
var lifeFade = Math.Min(fadeIn, fadeOut);
|
|
|
|
var edgeFadeX = Math.Min(
|
|
Math.Min(1f, (particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance),
|
|
Math.Min(1f, (bannerSize.X - particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance)
|
|
);
|
|
var edgeFadeY = Math.Min(
|
|
Math.Min(1f, (particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance),
|
|
Math.Min(1f, (bannerSize.Y - particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance)
|
|
);
|
|
var edgeFade = Math.Min(edgeFadeX, edgeFadeY);
|
|
|
|
var baseAlpha = lifeFade * edgeFade;
|
|
var finalAlpha = particle.Type == ParticleType.TwinklingStar
|
|
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
|
|
: baseAlpha;
|
|
|
|
var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor);
|
|
|
|
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
|
|
{
|
|
var baseColor = shootingBase;
|
|
|
|
for (int t = 1; t < particle.Trail.Count; t++)
|
|
{
|
|
var trailProgress = (float)t / particle.Trail.Count;
|
|
var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f);
|
|
var trailWidth = (1f - trailProgress) * 3f + 1f;
|
|
|
|
var glowAlpha = trailAlpha * 0.4f;
|
|
|
|
drawList.AddLine(
|
|
bannerStart + particle.Trail[t - 1],
|
|
bannerStart + particle.Trail[t],
|
|
ImGui.GetColorU32(baseColor with { W = glowAlpha }),
|
|
trailWidth + 4f
|
|
);
|
|
|
|
drawList.AddLine(
|
|
bannerStart + particle.Trail[t - 1],
|
|
bannerStart + particle.Trail[t],
|
|
ImGui.GetColorU32(baseColor with { W = trailAlpha }),
|
|
trailWidth
|
|
);
|
|
}
|
|
}
|
|
else if (particle.Type == ParticleType.TwinklingStar)
|
|
{
|
|
DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth);
|
|
}
|
|
|
|
_particles[i] = particle;
|
|
}
|
|
}
|
|
|
|
private static void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha, float depth)
|
|
{
|
|
var color = HslToRgb(hue, 1.0f, 0.85f);
|
|
color.W = alpha;
|
|
|
|
drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color));
|
|
|
|
var glowColor = color with { W = alpha * 0.3f };
|
|
drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor));
|
|
}
|
|
|
|
private static Vector4 HslToRgb(float h, float s, float l)
|
|
{
|
|
h = h / 360f;
|
|
float c = (1 - MathF.Abs(2 * l - 1)) * s;
|
|
float x = c * (1 - MathF.Abs((h * 6) % 2 - 1));
|
|
float m = l - c / 2;
|
|
|
|
float r, g, b;
|
|
if (h < 1f / 6f)
|
|
{
|
|
r = c; g = x; b = 0;
|
|
}
|
|
else if (h < 2f / 6f)
|
|
{
|
|
r = x; g = c; b = 0;
|
|
}
|
|
else if (h < 3f / 6f)
|
|
{
|
|
r = 0; g = c; b = x;
|
|
}
|
|
else if (h < 4f / 6f)
|
|
{
|
|
r = 0; g = x; b = c;
|
|
}
|
|
else if (h < 5f / 6f)
|
|
{
|
|
r = x; g = 0; b = c;
|
|
}
|
|
else
|
|
{
|
|
r = c; g = 0; b = x;
|
|
}
|
|
|
|
return new Vector4(r + m, g + m, b + m, 1.0f);
|
|
}
|
|
|
|
private void SpawnParticle(Vector2 bannerSize)
|
|
{
|
|
var position = new Vector2(
|
|
(float)_random.NextDouble() * bannerSize.X,
|
|
(float)_random.NextDouble() * bannerSize.Y
|
|
);
|
|
|
|
var depthLayers = new[] { 0.5f, 1.0f, 1.5f };
|
|
var depth = depthLayers[_random.Next(depthLayers.Length)];
|
|
|
|
var velocity = new Vector2(
|
|
((float)_random.NextDouble() - 0.5f) * 0.05f * depth,
|
|
((float)_random.NextDouble() - 0.5f) * 0.05f * depth
|
|
);
|
|
|
|
var isBlue = _random.NextDouble() < 0.5;
|
|
var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f;
|
|
var size = (0.5f + (float)_random.NextDouble() * 2f) * depth;
|
|
var maxLife = 120f + (float)_random.NextDouble() * 60f;
|
|
|
|
_particles.Add(new Particle
|
|
{
|
|
Position = position,
|
|
Velocity = velocity,
|
|
Life = maxLife,
|
|
MaxLife = maxLife,
|
|
Size = size,
|
|
Type = ParticleType.TwinklingStar,
|
|
Trail = null,
|
|
Twinkle = (float)_random.NextDouble() * MathF.PI * 2,
|
|
Depth = depth,
|
|
Hue = hue
|
|
});
|
|
}
|
|
|
|
private void SpawnShootingStar(Vector2 bannerSize)
|
|
{
|
|
var maxLife = 80f + (float)_random.NextDouble() * 40f;
|
|
var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f);
|
|
var startY = -10f;
|
|
|
|
_particles.Add(new Particle
|
|
{
|
|
Position = new Vector2(startX, startY),
|
|
Velocity = new Vector2(
|
|
-50f - (float)_random.NextDouble() * 40f,
|
|
30f + (float)_random.NextDouble() * 40f
|
|
),
|
|
Life = maxLife,
|
|
MaxLife = maxLife,
|
|
Size = 2.5f,
|
|
Type = ParticleType.ShootingStar,
|
|
Trail = new List<Vector2>(),
|
|
Twinkle = 0,
|
|
Depth = 1.0f,
|
|
Hue = 270f
|
|
});
|
|
}
|
|
private static Vector4 ResolveColor(string? key, Vector4 fallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
return fallback;
|
|
|
|
return UIColors.Get(key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all active particles. Useful when closing or hiding a window with an animated header.
|
|
/// </summary>
|
|
public void ClearParticles()
|
|
{
|
|
_particles.Clear();
|
|
_particleSpawnTimer = 0f;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a button in the animated header.
|
|
/// </summary>
|
|
public record HeaderButton(FontAwesomeIcon Icon, string Tooltip, Action? OnClick = null);
|