From d6625f9c6acfd0a17051ab8d87203945d4e940be Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Thu, 14 Feb 2019 23:24:35 +0100 Subject: [PATCH] Implement CSS-like stylesheets into GUI. Label now reads properties from the CSS system. --- .../UserInterface/IUserInterfaceManager.cs | 3 +- .../ResourceTypes/FontResource.cs | 2 +- SS14.Client/SS14.Client.csproj | 1 + SS14.Client/UserInterface/Control.Styling.cs | 111 ++++++++++++++++++ SS14.Client/UserInterface/Control.cs | 30 +---- SS14.Client/UserInterface/Controls/Button.cs | 6 +- SS14.Client/UserInterface/Controls/Label.cs | 46 +++++++- .../UserInterface/Controls/LineEdit.cs | 8 +- SS14.Client/UserInterface/Controls/Panel.cs | 2 +- .../CustomControls/SS14Window.cs | 4 + SS14.Client/UserInterface/Stylesheet.cs | 88 +++++++++++++- SS14.Client/UserInterface/UITheme.cs | 3 + .../UserInterface/UserInterfaceManager.cs | 6 +- .../Client/DummyUserInterfaceManager.cs | 3 +- .../Client/UserInterface/StylesheetTest.cs | 39 +++++- 15 files changed, 304 insertions(+), 48 deletions(-) create mode 100644 SS14.Client/UserInterface/Control.Styling.cs diff --git a/SS14.Client/Interfaces/UserInterface/IUserInterfaceManager.cs b/SS14.Client/Interfaces/UserInterface/IUserInterfaceManager.cs index cc27e2a70..66c85a473 100644 --- a/SS14.Client/Interfaces/UserInterface/IUserInterfaceManager.cs +++ b/SS14.Client/Interfaces/UserInterface/IUserInterfaceManager.cs @@ -13,7 +13,8 @@ namespace SS14.Client.Interfaces.UserInterface { public interface IUserInterfaceManager { - UITheme Theme { get; } + UITheme ThemeDefaults { get; } + Stylesheet Stylesheet { get; set; } Control Focused { get; } diff --git a/SS14.Client/ResourceManagement/ResourceTypes/FontResource.cs b/SS14.Client/ResourceManagement/ResourceTypes/FontResource.cs index 228317a01..94e09c1b7 100644 --- a/SS14.Client/ResourceManagement/ResourceTypes/FontResource.cs +++ b/SS14.Client/ResourceManagement/ResourceTypes/FontResource.cs @@ -21,7 +21,7 @@ namespace SS14.Client.ResourceManagement { if (!cache.ContentFileExists(path)) { - throw new FileNotFoundException("Content file does not exist for texture"); + throw new FileNotFoundException("Content file does not exist for font"); } switch (GameController.Mode) diff --git a/SS14.Client/SS14.Client.csproj b/SS14.Client/SS14.Client.csproj index 84a751edf..9f92671cc 100644 --- a/SS14.Client/SS14.Client.csproj +++ b/SS14.Client/SS14.Client.csproj @@ -197,6 +197,7 @@ + diff --git a/SS14.Client/UserInterface/Control.Styling.cs b/SS14.Client/UserInterface/Control.Styling.cs new file mode 100644 index 000000000..a08c87137 --- /dev/null +++ b/SS14.Client/UserInterface/Control.Styling.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; + +namespace SS14.Client.UserInterface +{ + // ReSharper disable once RequiredBaseTypesIsNotInherited + public partial class Control + { + private readonly Dictionary _styleProperties = new Dictionary(); + private readonly HashSet _styleClasses = new HashSet(); + public IReadOnlyCollection StyleClasses => _styleClasses; + + private string _styleIdentifier; + public string StyleIdentifier + { + get => _styleIdentifier; + set + { + _styleIdentifier = value; + Restyle(); + } + } + + public bool HasStyleClass(string className) + { + return _styleClasses.Contains(className); + } + + public void AddStyleClass(string className) + { + _styleClasses.Add(className); + Restyle(); + } + + public void RemoveStyleClass(string className) + { + _styleClasses.Remove(className); + Restyle(); + } + + private void Restyle() + { + _styleProperties.Clear(); + + // TODO: probably gonna need support for multiple stylesheets. + var stylesheet = UserInterfaceManager.Stylesheet; + if (stylesheet == null) + { + return; + } + + // Get all rules that apply to us, sort them and apply they params again. + var ruleList = new List<(StyleRule rule, int index)>(); + var count = 0; + foreach (var rule in stylesheet.Rules) + { + if (!rule.Selector.Matches(this)) + { + continue; + } + + ruleList.Add((rule, count)); + count += 1; + } + + // Sort by specificity. + // The index is there to sort by if specificity is the same, in which case the last takes precedence. + ruleList.Sort((a, b) => + { + var cmp = a.rule.Specificity.CompareTo(b.rule.Specificity); + // Reverse this sort so that high specificity is at the TOP. + return -(cmp != 0 ? cmp : a.index.CompareTo(b.index)); + }); + + // Go over each rule. + foreach (var (rule, _) in ruleList) + { + foreach (var property in rule.Properties) + { + if (_styleProperties.ContainsKey(property.Name)) + { + // Since we've sorted by priority in reverse, + // the first ones to get applied have highest priority. + // So if we have a duplicate it's always lower priority and we can discard it. + continue; + } + + _styleProperties[property.Name] = property.Value; + } + } + + StylePropertiesChanged(); + } + + protected virtual void StylePropertiesChanged() + { + MinimumSizeChanged(); + } + + public bool TryGetStyleProperty(string param, out T value) + { + if (_styleProperties.TryGetValue(param, out var val)) + { + value = (T) val; + return true; + } + + value = default; + return false; + } + } +} diff --git a/SS14.Client/UserInterface/Control.cs b/SS14.Client/UserInterface/Control.cs index 07787cf6c..afad0feed 100644 --- a/SS14.Client/UserInterface/Control.cs +++ b/SS14.Client/UserInterface/Control.cs @@ -466,31 +466,6 @@ namespace SS14.Client.UserInterface } } - private readonly HashSet _styleClasses = new HashSet(); - public IReadOnlyCollection StyleClasses => _styleClasses; - - private string _styleIdentifier; - public string StyleIdentifier - { - get => _styleIdentifier; - set => _styleIdentifier = value; - } - - public bool HasStyleClass(string className) - { - return _styleClasses.Contains(className); - } - - public void AddStyleClass(string className) - { - _styleClasses.Add(className); - } - - public void RemoveStyleClass(string className) - { - _styleClasses.Remove(className); - } - public Color Modulate { get => SceneControl.Modulate.Convert(); @@ -586,6 +561,7 @@ namespace SS14.Client.UserInterface Name = GetType().Name; Initialize(); _applyPropertyMap(); + Restyle(); } /// The name the component will have. @@ -610,6 +586,7 @@ namespace SS14.Client.UserInterface Name = name; Initialize(); _applyPropertyMap(); + Restyle(); } @@ -625,6 +602,7 @@ namespace SS14.Client.UserInterface SetupSignalHooks(); //Logger.Debug($"Wrapping control {Name} ({GetType()} -> {control.GetType()})"); Initialize(); + Restyle(); } /// @@ -1184,6 +1162,7 @@ namespace SS14.Client.UserInterface protected virtual void Parented(Control newParent) { MinimumSizeChanged(); + Restyle(); } /// @@ -1230,6 +1209,7 @@ namespace SS14.Client.UserInterface /// protected virtual void Deparented() { + Restyle(); } /// diff --git a/SS14.Client/UserInterface/Controls/Button.cs b/SS14.Client/UserInterface/Controls/Button.cs index 624f7e3e5..3bc511e87 100644 --- a/SS14.Client/UserInterface/Controls/Button.cs +++ b/SS14.Client/UserInterface/Controls/Button.cs @@ -134,7 +134,7 @@ namespace SS14.Client.UserInterface.Controls return; } - var uiTheme = UserInterfaceManager.Theme; + var uiTheme = UserInterfaceManager.ThemeDefaults; StyleBox style; switch (DrawMode) { @@ -197,7 +197,7 @@ namespace SS14.Client.UserInterface.Controls return base.CalculateMinimumSize(); } - var uiTheme = UserInterfaceManager.Theme; + var uiTheme = UserInterfaceManager.ThemeDefaults; var style = uiTheme.ButtonStyleNormal; var font = uiTheme.DefaultFont; @@ -221,7 +221,7 @@ namespace SS14.Client.UserInterface.Controls return 0; } - var uiTheme = UserInterfaceManager.Theme; + var uiTheme = UserInterfaceManager.ThemeDefaults; var font = uiTheme.DefaultFont; var textWidth = 0; diff --git a/SS14.Client/UserInterface/Controls/Label.cs b/SS14.Client/UserInterface/Controls/Label.cs index 2ec89514c..e9c723c9d 100644 --- a/SS14.Client/UserInterface/Controls/Label.cs +++ b/SS14.Client/UserInterface/Controls/Label.cs @@ -13,6 +13,9 @@ namespace SS14.Client.UserInterface.Controls [ControlWrap(typeof(Godot.Label))] public class Label : Control { + public const string StylePropertyFontColor = "font-color"; + public const string StylePropertyFont = "font"; + private Vector2i? _textDimensionCache; public Label(string name) : base(name) @@ -105,6 +108,24 @@ namespace SS14.Client.UserInterface.Controls set => SetFontOverride("font", _fontOverride = value); } + private Font ActualFont + { + get + { + if (_fontOverride != null) + { + return _fontOverride; + } + + if (TryGetStyleProperty(StylePropertyFont, out var font)) + { + return font; + } + + return UserInterfaceManager.ThemeDefaults.LabelFont; + } + } + private Color? _fontColorShadowOverride; public Color? FontColorShadowOverride @@ -113,6 +134,24 @@ namespace SS14.Client.UserInterface.Controls set => SetColorOverride("font_color_shadow", _fontColorShadowOverride = value); } + private Color ActualFontColor + { + get + { + if (_fontColorOverride.HasValue) + { + return _fontColorOverride.Value; + } + + if (TryGetStyleProperty(StylePropertyFontColor, out var color)) + { + return color; + } + + return Color.White; + } + } + private Color? _fontColorOverride; public Color? FontColorOverride @@ -195,8 +234,9 @@ namespace SS14.Client.UserInterface.Controls } var newlines = 0; - var font = _fontOverride ?? UserInterfaceManager.Theme.LabelFont; + var font = ActualFont; var baseLine = new Vector2(hOffset, font.Ascent + vOffset); + var actualFontColor = ActualFontColor; foreach (var chr in _text) { if (chr == '\n') @@ -205,7 +245,7 @@ namespace SS14.Client.UserInterface.Controls baseLine = new Vector2(hOffset, font.Ascent + font.Height * newlines); } - var advance = font.DrawChar(handle, chr, baseLine, FontColorOverride ?? Color.White); + var advance = font.DrawChar(handle, chr, baseLine, actualFontColor); baseLine += new Vector2(advance, 0); } } @@ -250,7 +290,7 @@ namespace SS14.Client.UserInterface.Controls return; } - var font = _fontOverride ?? UserInterfaceManager.Theme.LabelFont; + var font = ActualFont; var height = font.Height; var maxLineSize = 0; var currentLineSize = 0; diff --git a/SS14.Client/UserInterface/Controls/LineEdit.cs b/SS14.Client/UserInterface/Controls/LineEdit.cs index 2f4927aa9..0f018c7cb 100644 --- a/SS14.Client/UserInterface/Controls/LineEdit.cs +++ b/SS14.Client/UserInterface/Controls/LineEdit.cs @@ -161,11 +161,11 @@ namespace SS14.Client.UserInterface.Controls return; } - var styleBox = UserInterfaceManager.Theme.LineEditBox; + var styleBox = UserInterfaceManager.ThemeDefaults.LineEditBox; var drawBox = new UIBox2(Vector2.Zero, Size); var contentBox = styleBox.GetContentBox(drawBox); styleBox.Draw(handle, drawBox); - var font = UserInterfaceManager.Theme.DefaultFont; + var font = UserInterfaceManager.ThemeDefaults.DefaultFont; var baseLine = new Vector2i(0, (int)(contentBox.Height + font.Ascent)/2) + contentBox.TopLeft; @@ -200,8 +200,8 @@ namespace SS14.Client.UserInterface.Controls return Vector2.Zero; } - var font = UserInterfaceManager.Theme.DefaultFont; - return new Vector2(0, font.Height) + UserInterfaceManager.Theme.LineEditBox.MinimumSize; + var font = UserInterfaceManager.ThemeDefaults.DefaultFont; + return new Vector2(0, font.Height) + UserInterfaceManager.ThemeDefaults.LineEditBox.MinimumSize; } public enum AlignMode diff --git a/SS14.Client/UserInterface/Controls/Panel.cs b/SS14.Client/UserInterface/Controls/Panel.cs index 651433fa6..7d58441bd 100644 --- a/SS14.Client/UserInterface/Controls/Panel.cs +++ b/SS14.Client/UserInterface/Controls/Panel.cs @@ -38,7 +38,7 @@ namespace SS14.Client.UserInterface.Controls if (!GameController.OnGodot) { - var panel = _panelOverride ?? UserInterfaceManager.Theme.PanelPanel; + var panel = _panelOverride ?? UserInterfaceManager.ThemeDefaults.PanelPanel; panel.Draw(handle, UIBox2.FromDimensions(Vector2.Zero, Size)); } } diff --git a/SS14.Client/UserInterface/CustomControls/SS14Window.cs b/SS14.Client/UserInterface/CustomControls/SS14Window.cs index 59e357013..fc0244804 100644 --- a/SS14.Client/UserInterface/CustomControls/SS14Window.cs +++ b/SS14.Client/UserInterface/CustomControls/SS14Window.cs @@ -10,6 +10,8 @@ namespace SS14.Client.UserInterface.CustomControls [ControlWrap("res://Engine/Scenes/SS14Window/SS14Window.tscn")] public class SS14Window : Panel { + public const string StyleClassWindowTitle = "windowTitle"; + [Dependency] private readonly IDisplayManager _displayManager; public SS14Window() : base() @@ -87,6 +89,8 @@ namespace SS14.Client.UserInterface.CustomControls CloseButton.OnPressed += CloseButtonPressed; Contents = GetChild("Contents"); + + TitleLabel.AddStyleClass(StyleClassWindowTitle); } protected override void Dispose(bool disposing) diff --git a/SS14.Client/UserInterface/Stylesheet.cs b/SS14.Client/UserInterface/Stylesheet.cs index 308f5e20e..65e2e4c69 100644 --- a/SS14.Client/UserInterface/Stylesheet.cs +++ b/SS14.Client/UserInterface/Stylesheet.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using JetBrains.Annotations; +using SS14.Shared.Utility; namespace SS14.Client.UserInterface { @@ -9,7 +11,7 @@ namespace SS14.Client.UserInterface /// public sealed class Stylesheet { - private IReadOnlyList Rules { get; } + public IReadOnlyList Rules { get; } public Stylesheet(IReadOnlyList rules) { @@ -26,12 +28,67 @@ namespace SS14.Client.UserInterface { Selector = selector; Properties = properties; + Specificity = selector.CalculateSpecificity(); } + public StyleSpecificity Specificity { get; } public Selector Selector { get; } public IReadOnlyList Properties { get; } } + // https://specifishity.com/ + public struct StyleSpecificity : IComparable, IComparable + { + public readonly int IdSelectors; + public readonly int ClassSelectors; + public readonly int TypeSelectors; + + public StyleSpecificity(int idSelectors, int classSelectors, int typeSelectors) + { + IdSelectors = idSelectors; + ClassSelectors = classSelectors; + TypeSelectors = typeSelectors; + } + + public static StyleSpecificity operator +(StyleSpecificity a, StyleSpecificity b) + { + return new StyleSpecificity( + a.IdSelectors + b.IdSelectors, + a.ClassSelectors + b.ClassSelectors, + a.TypeSelectors + b.TypeSelectors); + } + + public int CompareTo(StyleSpecificity other) + { + var idSelectorsComparison = IdSelectors.CompareTo(other.IdSelectors); + if (idSelectorsComparison != 0) + { + return idSelectorsComparison; + } + + var classSelectorsComparison = ClassSelectors.CompareTo(other.ClassSelectors); + if (classSelectorsComparison != 0) + { + return classSelectorsComparison; + } + + return TypeSelectors.CompareTo(other.TypeSelectors); + } + + public int CompareTo(object obj) + { + if (ReferenceEquals(null, obj)) return 1; + return obj is StyleSpecificity other + ? CompareTo(other) + : throw new ArgumentException($"Object must be of type {nameof(StyleSpecificity)}"); + } + + public override string ToString() + { + return $"({IdSelectors}-{ClassSelectors}-{TypeSelectors})"; + } + } + /// /// A single property in a rule, with a name and an object value. /// @@ -50,6 +107,7 @@ namespace SS14.Client.UserInterface public abstract class Selector { public abstract bool Matches(Control control); + public abstract StyleSpecificity CalculateSpecificity(); } public sealed class SelectorElement : Selector @@ -57,16 +115,19 @@ namespace SS14.Client.UserInterface public SelectorElement( string elementType, IReadOnlyCollection elementClasses, - string elementId) + string elementId, + string pseudoClass) { ElementType = elementType; ElementClasses = elementClasses; ElementId = elementId; + PseudoClass = pseudoClass; } public string ElementType { get; } public IReadOnlyCollection ElementClasses { get; } public string ElementId { get; } + public string PseudoClass { get; } public override bool Matches(Control control) { @@ -93,9 +154,19 @@ namespace SS14.Client.UserInterface return true; } + + public override StyleSpecificity CalculateSpecificity() + { + var countId = ElementId == null ? 0 : 1; + var countClasses = (ElementClasses?.Count ?? 0) + (PseudoClass == null ? 0 : 1); + var countTypes = ElementType == null ? 0 : 1; + return new StyleSpecificity(countId, countClasses, countTypes); + } } - public sealed class SelectorDescendant : Selector + // Temporarily hidden due to performance concerns. + // Like seriously this thing is O(n!) + internal sealed class SelectorDescendant : Selector { public SelectorDescendant([NotNull] Selector ascendant, [NotNull] Selector descendant) { @@ -128,8 +199,14 @@ namespace SS14.Client.UserInterface return found; } + + public override StyleSpecificity CalculateSpecificity() + { + return Ascendant.CalculateSpecificity() + Descendant.CalculateSpecificity(); + } } + // Temporarily hidden due to performance concerns. public sealed class SelectorChild : Selector { public SelectorChild(Selector parent, Selector child) @@ -150,5 +227,10 @@ namespace SS14.Client.UserInterface return Parent.Matches(control.Parent) && Child.Matches(control); } + + public override StyleSpecificity CalculateSpecificity() + { + return Parent.CalculateSpecificity() + Child.CalculateSpecificity(); + } } } diff --git a/SS14.Client/UserInterface/UITheme.cs b/SS14.Client/UserInterface/UITheme.cs index 19cd23290..28358b15d 100644 --- a/SS14.Client/UserInterface/UITheme.cs +++ b/SS14.Client/UserInterface/UITheme.cs @@ -7,6 +7,9 @@ using SS14.Shared.Maths; namespace SS14.Client.UserInterface { + /// + /// Fallback theme system for GUI. + /// public abstract class UITheme { public abstract Font DefaultFont { get; } diff --git a/SS14.Client/UserInterface/UserInterfaceManager.cs b/SS14.Client/UserInterface/UserInterfaceManager.cs index 3cfe3e98c..8c35919e8 100644 --- a/SS14.Client/UserInterface/UserInterfaceManager.cs +++ b/SS14.Client/UserInterface/UserInterfaceManager.cs @@ -31,8 +31,8 @@ namespace SS14.Client.UserInterface [Dependency] private readonly IDisplayManager _displayManager; [Dependency] private readonly IResourceCache _resourceCache; - public UITheme Theme { get; private set; } - + public UITheme ThemeDefaults { get; private set; } + public Stylesheet Stylesheet { get; set; } public Control Focused { get; private set; } // When a control receives a mouse down it must also receive a mouse up and mouse moves, always. @@ -59,7 +59,7 @@ namespace SS14.Client.UserInterface public void Initialize() { - Theme = new UIThemeDefault(); + ThemeDefaults = new UIThemeDefault(); if (GameController.OnGodot) { diff --git a/SS14.UnitTesting/Client/DummyUserInterfaceManager.cs b/SS14.UnitTesting/Client/DummyUserInterfaceManager.cs index 2a8c14e14..0d93698aa 100644 --- a/SS14.UnitTesting/Client/DummyUserInterfaceManager.cs +++ b/SS14.UnitTesting/Client/DummyUserInterfaceManager.cs @@ -12,7 +12,8 @@ namespace SS14.UnitTesting.Client { internal class DummyUserInterfaceManager : IUserInterfaceManagerInternal { - public UITheme Theme { get; } = new UIThemeDummy(); + public UITheme ThemeDefaults { get; } = new UIThemeDummy(); + public Stylesheet Stylesheet { get; set; } public Control Focused => throw new System.NotImplementedException(); diff --git a/SS14.UnitTesting/Client/UserInterface/StylesheetTest.cs b/SS14.UnitTesting/Client/UserInterface/StylesheetTest.cs index eb7293877..6f5af9ae9 100644 --- a/SS14.UnitTesting/Client/UserInterface/StylesheetTest.cs +++ b/SS14.UnitTesting/Client/UserInterface/StylesheetTest.cs @@ -1,6 +1,8 @@ using NUnit.Framework; +using SS14.Client.Interfaces.UserInterface; using SS14.Client.UserInterface; using SS14.Client.UserInterface.Controls; +using SS14.Shared.IoC; using SS14.Shared.Utility; namespace SS14.UnitTesting.Client.UserInterface @@ -13,14 +15,14 @@ namespace SS14.UnitTesting.Client.UserInterface [Test] public void TestSelectors() { - var selectorElementLabel = new SelectorElement("Label", null, null); + var selectorElementLabel = new SelectorElement("Label", null, null, null); var label = new Label(); var panel = new Panel {StyleIdentifier = "bar"}; Assert.That(selectorElementLabel.Matches(label), Is.True); Assert.That(selectorElementLabel.Matches(panel), Is.False); - selectorElementLabel = new SelectorElement("Label", new []{"foo"}, null); + selectorElementLabel = new SelectorElement("Label", new []{"foo"}, null, null); Assert.That(selectorElementLabel.Matches(label), Is.False); Assert.That(selectorElementLabel.Matches(panel), Is.False); @@ -36,9 +38,40 @@ namespace SS14.UnitTesting.Client.UserInterface // Make sure it doesn't throw. label.RemoveStyleClass("foo"); - selectorElementLabel = new SelectorElement(null, null, "bar"); + selectorElementLabel = new SelectorElement(null, null, "bar", null); Assert.That(selectorElementLabel.Matches(label), Is.False); Assert.That(selectorElementLabel.Matches(panel), Is.True); } + + [Test] + public void TestStyleProperties() + { + var sheet = new Stylesheet(new [] + { + new StyleRule(new SelectorElement("Label", null, "baz", null), new [] + { + new StyleProperty("foo", "honk"), + }), + new StyleRule(new SelectorElement("Label", null, null, null), new [] + { + new StyleProperty("foo", "heh"), + }), + new StyleRule(new SelectorElement("Label", null, null, null), new [] + { + new StyleProperty("foo", "bar"), + }), + }); + + var uiMgr = IoCManager.Resolve(); + uiMgr.Stylesheet = sheet; + + var control = new Label(); + control.TryGetStyleProperty("foo", out string value); + Assert.That(value, Is.EqualTo("bar")); + + control.StyleIdentifier = "baz"; + control.TryGetStyleProperty("foo", out value); + Assert.That(value, Is.EqualTo("honk")); + } } }