From 0d67d9185122532704c2ccbc48431f43f52afe12 Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:32:18 -0300 Subject: [PATCH 1/3] Add user variable provider and testing --- Silkstring/Configuration.cs | 1 + Silkstring/Models/UserVariable.cs | 26 +++++++++ Silkstring/Plugin.cs | 3 +- .../Variables/UserVariableProvider.cs | 28 +++++++++ Tests/UserVariableProviderTests.cs | 58 +++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 Silkstring/Models/UserVariable.cs create mode 100644 Silkstring/Services/Variables/UserVariableProvider.cs create mode 100644 Tests/UserVariableProviderTests.cs diff --git a/Silkstring/Configuration.cs b/Silkstring/Configuration.cs index 2305fff..0fccfe8 100644 --- a/Silkstring/Configuration.cs +++ b/Silkstring/Configuration.cs @@ -26,6 +26,7 @@ public int CommandDelay public int Version { get; set; } = CurrentVersion; public List Folders = new(); public List Aliases = new(); + public List UserVariables = new(); public bool MultilineCommands { get; set; } public void Save() diff --git a/Silkstring/Models/UserVariable.cs b/Silkstring/Models/UserVariable.cs new file mode 100644 index 0000000..3bf0788 --- /dev/null +++ b/Silkstring/Models/UserVariable.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.Json.Serialization; +using System.Threading; + +namespace Silkstring.Models; + +public class UserVariable +{ + private static int _nextId = 0; + + public string Name = string.Empty; + public string Value = string.Empty; + + [NonSerialized] + [JsonIgnore] + public bool Delete; + + [NonSerialized] + [JsonIgnore] + public int UniqueId; + + public UserVariable() + { + UniqueId = Interlocked.Increment(ref _nextId); + } +} diff --git a/Silkstring/Plugin.cs b/Silkstring/Plugin.cs index 3817538..222a164 100644 --- a/Silkstring/Plugin.cs +++ b/Silkstring/Plugin.cs @@ -65,7 +65,8 @@ public Plugin() new PlayerVariableProvider(PlayerState), new VitalsVariablesProvider(ClientState), new CombatVariablesProvider(Condition, TargetManager), - new CurrencyVariablesProvider() + new CurrencyVariablesProvider(), + new UserVariableProvider(() => Configuration.UserVariables), ]; _commandResolver = new CommandResolver(providers); diff --git a/Silkstring/Services/Variables/UserVariableProvider.cs b/Silkstring/Services/Variables/UserVariableProvider.cs new file mode 100644 index 0000000..cc63551 --- /dev/null +++ b/Silkstring/Services/Variables/UserVariableProvider.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Silkstring.Models; + +namespace Silkstring.Services.Variables; + +public sealed class UserVariableProvider : IVariableProvider +{ + private readonly Func> _variables; + + public UserVariableProvider(Func> variables) + { + _variables = variables; + } + + public IEnumerable GetVariables() + { + foreach (var variable in _variables()) + { + var name = variable.Name; + yield return new(name, "User-defined variable", "User", () => Lookup(name)); + } + } + + private string? Lookup(string name) + => _variables().FirstOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase))?.Value; +} diff --git a/Tests/UserVariableProviderTests.cs b/Tests/UserVariableProviderTests.cs new file mode 100644 index 0000000..19e4b3b --- /dev/null +++ b/Tests/UserVariableProviderTests.cs @@ -0,0 +1,58 @@ +using Silkstring.Models; +using Silkstring.Services; +using Silkstring.Services.Variables; + +public class UserVariableProviderTests +{ + private static List Vars(params (string Name, string Value)[] vars) + => vars.Select(v => new UserVariable { Name = v.Name, Value = v.Value }).ToList(); + + [Fact] + public void ResolvesStoredValue() + { + var descriptor = new UserVariableProvider(() => Vars(("greeting", "hello"))).GetVariables().Single(); + Assert.Equal("greeting", descriptor.Name); + Assert.Equal("User", descriptor.Category); + Assert.Equal("hello", descriptor.Resolve()); + } + + [Fact] + public void YieldsOneDescriptorPerVariable() + => Assert.Equal(new[] { "a", "b" }, new UserVariableProvider(() => Vars(("a", "1"), ("b", "2"))).GetVariables().Select(v => v.Name)); + + [Fact] + public void EmptyWhenNoVariables() + => Assert.Empty(new UserVariableProvider(() => Vars()).GetVariables()); + + [Fact] + public void ReflectsValueChangedAfterResolveCaptured() + { + var vars = Vars(("mode", "raid")); + var resolve = new UserVariableProvider(() => vars).GetVariables().Single().Resolve; + vars[0].Value = "solo"; + Assert.Equal("solo", resolve()); + } + + [Fact] + public void ResolvesThroughCommandResolver() + { + var resolver = new CommandResolver(new[] { new UserVariableProvider(() => Vars(("name", "Oreo"))) }); + Assert.Equal("hi Oreo", resolver.Resolve("hi {name}")); + } + + [Fact] + public void ResolverIsCaseInsensitive() + { + var resolver = new CommandResolver(new[] { new UserVariableProvider(() => Vars(("name", "Oreo"))) }); + Assert.Equal("Oreo", resolver.Resolve("{NAME}")); + } + + [Fact] + public void ResolverReflectsLaterValueChange() + { + var vars = Vars(("name", "Oreo")); + var resolver = new CommandResolver(new[] { new UserVariableProvider(() => vars) }); + vars[0].Value = "Biscuit"; + Assert.Equal("Biscuit", resolver.Resolve("{name}")); + } +} From 9f45ebda14e7285755521cf0dcbba19be3f074a8 Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:09:44 -0300 Subject: [PATCH 2/3] add :set and variables window. Provide validation for :set --- Silkstring/Plugin.cs | 29 +++-- Silkstring/Services/AliasValidator.cs | 13 +++ Silkstring/Services/CommandHandler.cs | 19 ++- Silkstring/Services/CommandResolver.cs | 10 +- .../Services/Conditions/BlockInterpreter.cs | 11 +- .../Services/Variables/UserVariableStore.cs | 59 ++++++++++ Silkstring/UI/ImGuiUtil.cs | 7 ++ Silkstring/UI/Panels/AliasEditPanel.cs | 3 +- Silkstring/Windows/VariablesWindow.cs | 93 +++++++++++++++ Tests/BlockInterpreterTests.cs | 16 +++ Tests/UserVariableStoreTests.cs | 108 ++++++++++++++++++ 11 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 Silkstring/Services/Variables/UserVariableStore.cs create mode 100644 Silkstring/Windows/VariablesWindow.cs create mode 100644 Tests/UserVariableStoreTests.cs diff --git a/Silkstring/Plugin.cs b/Silkstring/Plugin.cs index 222a164..7abf7ac 100644 --- a/Silkstring/Plugin.cs +++ b/Silkstring/Plugin.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Reflection; using Dalamud.Game.Command; using Dalamud.Interface.ImGuiNotification; @@ -33,6 +34,7 @@ public sealed class Plugin : IDalamudPlugin public readonly WindowSystem WindowSystem = new("Silkstring"); private ConfigWindow ConfigWindow { get; init; } private MainWindow MainWindow { get; init; } + private VariablesWindow VariablesWindow { get; init; } private HelpWindow HelpWindow { get; init; } private ChangelogWindow ChangelogWindow { get; init; } @@ -60,25 +62,30 @@ public Plugin() ECommonsMain.Init(PluginInterface, this); - IVariableProvider[] providers = - [ - new PlayerVariableProvider(PlayerState), - new VitalsVariablesProvider(ClientState), - new CombatVariablesProvider(Condition, TargetManager), - new CurrencyVariablesProvider(), - new UserVariableProvider(() => Configuration.UserVariables), - ]; + IVariableProvider[] builtIn = + [ + new PlayerVariableProvider(PlayerState), + new VitalsVariablesProvider(ClientState), + new CombatVariablesProvider(Condition, TargetManager), + new CurrencyVariablesProvider(), + ]; + + var reserved = builtIn.SelectMany(p => p.GetVariables()).Select(v => v.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var store = new UserVariableStore(Configuration.UserVariables, reserved, Configuration.MarkDirty); + IVariableProvider[] providers = [..builtIn, new UserVariableProvider(() => store.Variables)]; _commandResolver = new CommandResolver(providers); - _commandHandler = new CommandHandler(_commandResolver, Framework); + _commandHandler = new CommandHandler(_commandResolver, Framework, store.TrySet); _chatInterceptor = new ChatInterceptor(GameInteropProvider, Framework, Configuration, _commandHandler); ConfigWindow = new ConfigWindow(this); MainWindow = new MainWindow(this, ToggleConfigUi, ToggleHelpUi, ToggleChangelogUi); + VariablesWindow = new VariablesWindow(store, _commandResolver); HelpWindow = new HelpWindow(_commandResolver); ChangelogWindow = new ChangelogWindow(); WindowSystem.AddWindow(MainWindow); + WindowSystem.AddWindow(VariablesWindow); WindowSystem.AddWindow(ConfigWindow); WindowSystem.AddWindow(HelpWindow); WindowSystem.AddWindow(ChangelogWindow); @@ -87,6 +94,7 @@ public Plugin() { HelpMessage = "/silkstring → Open the Silkstring alias manager.\n" + "/silkstring help → Open the Silkstring help window.\n" + + "/silkstring variables → Open the Silkstring variables window.\n" + "/silkstring changelog → Open the Silkstring changelog window." }); @@ -117,6 +125,7 @@ public void Dispose() WindowSystem.RemoveAllWindows(); MainWindow.Dispose(); + VariablesWindow.Dispose(); ConfigWindow.Dispose(); HelpWindow.Dispose(); ChangelogWindow.Dispose(); @@ -130,6 +139,7 @@ private void OnCommand(string command, string args) { case "help": ToggleHelpUi(); break; case "changelog": ToggleChangelogUi(); break; + case "variables": ToggleVariablesUi(); break; default: MainWindow.Toggle(); break; } } @@ -141,6 +151,7 @@ private void OnFrameworkUpdate(IFramework framework) public void ToggleConfigUi() => ConfigWindow.Toggle(); public void ToggleMainUi() => MainWindow.Toggle(); + public void ToggleVariablesUi() => VariablesWindow.Toggle(); public void ToggleHelpUi() => HelpWindow.Toggle(); public void ToggleChangelogUi() => ChangelogWindow.Toggle(); } diff --git a/Silkstring/Services/AliasValidator.cs b/Silkstring/Services/AliasValidator.cs index d9e016d..c51e84c 100644 --- a/Silkstring/Services/AliasValidator.cs +++ b/Silkstring/Services/AliasValidator.cs @@ -46,6 +46,19 @@ public static List FindCycle(AliasEntry target, IEnumerable return elseSeen.Count > 0 ? "Unclosed :if (missing :endif)" : null; } + public static string? ValidateSets(AliasEntry alias, ISet defined) + { + foreach (var command in alias.Output) + { + var (kind, expression) = BlockInterpreter.Classify(command.Command); + if (kind != BlockKind.Set) continue; + var (name, _) = BlockInterpreter.ParseSet(expression); + if (string.IsNullOrEmpty(name)) return ":set needs a variable name"; + if (!defined.Contains(name)) return $"Unknown variable in :set: {name}"; + } + return null; + } + private static Dictionary BuildTriggerLookup(IEnumerable allAliases) { var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/Silkstring/Services/CommandHandler.cs b/Silkstring/Services/CommandHandler.cs index 2f65763..8c51deb 100644 --- a/Silkstring/Services/CommandHandler.cs +++ b/Silkstring/Services/CommandHandler.cs @@ -14,12 +14,13 @@ public class CommandHandler private readonly IFramework _framework; private readonly CommandResolver _resolver; private readonly ConditionEvaluator _conditions; - - public CommandHandler(CommandResolver resolver, IFramework framework) + private readonly Func _setUserVariable; + public CommandHandler(CommandResolver resolver, IFramework framework, Func setUserVariable) { _resolver = resolver; _framework = framework; _conditions = new ConditionEvaluator(_resolver.Resolve); + _setUserVariable = setUserVariable; } public async Task ExecuteAsync(IReadOnlyList commands, IReadOnlyList args, int delayMs = 100, CancellationToken cancellationToken = default, Func? shouldSkip = null) @@ -41,6 +42,20 @@ public async Task ExecuteAsync(IReadOnlyList commands, IReadOnlyList + { + if (!_setUserVariable(name, _resolver.Resolve(rawValue, args))) + Log.Warning("Unknown user variable in :set: {Name}", name); + }); + } + continue; + } + if (!blocks.Active) continue; var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args)); diff --git a/Silkstring/Services/CommandResolver.cs b/Silkstring/Services/CommandResolver.cs index 5ca059d..05acf27 100644 --- a/Silkstring/Services/CommandResolver.cs +++ b/Silkstring/Services/CommandResolver.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using Serilog; @@ -9,13 +10,18 @@ namespace Silkstring.Services; public class CommandResolver { - private readonly Dictionary _variables; + private readonly IVariableProvider[] _providers; + private Dictionary _variables; public CommandResolver(IEnumerable providers) { - _variables = providers.SelectMany(p => p.GetVariables()).ToDictionary(v => v.Name, StringComparer.OrdinalIgnoreCase); + _providers = providers.ToArray(); + Refresh(); } + [MemberNotNull(nameof(_variables))] + public void Refresh() => _variables = _providers.SelectMany(p => p.GetVariables()).ToDictionary(v => v.Name, StringComparer.OrdinalIgnoreCase); + public IReadOnlyCollection Variables => _variables.Values; public string Resolve(string command) => Resolve(command, []); diff --git a/Silkstring/Services/Conditions/BlockInterpreter.cs b/Silkstring/Services/Conditions/BlockInterpreter.cs index c082867..1aeddfe 100644 --- a/Silkstring/Services/Conditions/BlockInterpreter.cs +++ b/Silkstring/Services/Conditions/BlockInterpreter.cs @@ -8,7 +8,8 @@ internal enum BlockKind Command, If, Else, - EndIf + EndIf, + Set } internal sealed class BlockInterpreter @@ -21,9 +22,17 @@ public static (BlockKind Kind, string Expression) Classify(string line) if (line.StartsWith(":if ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.If, line[4..]); if (line.Equals(":else", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Else, ""); if (line.Equals(":endif", StringComparison.OrdinalIgnoreCase)) return (BlockKind.EndIf, ""); + if (line.StartsWith(":set ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Set, line[5..]); return (BlockKind.Command, line); } + public static (string Name, string Value) ParseSet(string expression) + { + var trimmed = expression.Trim(); + var space = trimmed.IndexOf(' '); + return space < 0 ? (trimmed, "") : (trimmed[..space], trimmed[(space + 1)..].Trim()); + } + public void EnterIf(bool conditionMet) { var parentActive = Active; diff --git a/Silkstring/Services/Variables/UserVariableStore.cs b/Silkstring/Services/Variables/UserVariableStore.cs new file mode 100644 index 0000000..3900226 --- /dev/null +++ b/Silkstring/Services/Variables/UserVariableStore.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Silkstring.Models; + +namespace Silkstring.Services.Variables; + +public sealed class UserVariableStore +{ + private readonly List _vars; + private readonly ISet _reserved; + private readonly Action _onChanged; + + private static readonly Regex NamePattern = new(@"^\w+$", RegexOptions.Compiled); + + public IReadOnlyList Variables => _vars; + + public UserVariableStore(List vars, ISet reserved, Action onChanged) + { + _vars = vars; + _reserved = reserved; + _onChanged = onChanged; + } + + public string? ValidateName(string name, UserVariable? excluding = null) + { + if (string.IsNullOrWhiteSpace(name)) return "Enter a name"; + if (!NamePattern.IsMatch(name)) return "Use only letters, numbers, and underscores"; + if (_reserved.Contains(name)) return $"{name} is a built-in variable"; + if (_vars.Any(v => v != excluding && string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase))) return $"{name} is already defined"; + return null; + } + + public bool TryAdd(string name) + { + if (ValidateName(name) != null) return false; + _vars.Add(new UserVariable { Name = name }); + _onChanged(); + return true; + } + + public bool TrySet(string name, string value) + { + var variable = _vars.FirstOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); + if (variable == null) return false; + variable.Value = value; + _onChanged(); + return true; + } + + public void MarkChanged() => _onChanged(); + + public void Remove(UserVariable variable) + { + _vars.Remove(variable); + _onChanged(); + } +} diff --git a/Silkstring/UI/ImGuiUtil.cs b/Silkstring/UI/ImGuiUtil.cs index 515e179..90f7af1 100644 --- a/Silkstring/UI/ImGuiUtil.cs +++ b/Silkstring/UI/ImGuiUtil.cs @@ -9,4 +9,11 @@ public static void Tooltip(string text, bool allowWhenDisabled = false) var flags = allowWhenDisabled ? ImGuiHoveredFlags.AllowWhenDisabled : ImGuiHoveredFlags.None; if (ImGui.IsItemHovered(flags)) ImGui.SetTooltip(text); } + + public static void TextWrappedDisabled(string text) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled)); + ImGui.TextWrapped(text); + ImGui.PopStyleColor(); + } } diff --git a/Silkstring/UI/Panels/AliasEditPanel.cs b/Silkstring/UI/Panels/AliasEditPanel.cs index a692dc0..ad486ee 100644 --- a/Silkstring/UI/Panels/AliasEditPanel.cs +++ b/Silkstring/UI/Panels/AliasEditPanel.cs @@ -154,7 +154,8 @@ private void RefreshCycleCheck() { if (_selectedAlias == null) return; _detectedCycle = AliasValidator.FindCycle(_selectedAlias, _configuration.GetAliases()); - _blockError = AliasValidator.ValidateBlocks(_selectedAlias); + var defined = new HashSet(_configuration.UserVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); + _blockError = AliasValidator.ValidateBlocks(_selectedAlias) ?? AliasValidator.ValidateSets(_selectedAlias, defined); } private void SyncMultilineBuffer(AliasEntry alias) diff --git a/Silkstring/Windows/VariablesWindow.cs b/Silkstring/Windows/VariablesWindow.cs new file mode 100644 index 0000000..b8c18e3 --- /dev/null +++ b/Silkstring/Windows/VariablesWindow.cs @@ -0,0 +1,93 @@ +using System; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Components; +using Dalamud.Interface.Windowing; +using Silkstring.Models; +using Silkstring.Services; +using Silkstring.Services.Variables; +using Silkstring.UI; + +namespace Silkstring.Windows; + +public class VariablesWindow : Window, IDisposable +{ + private readonly UserVariableStore _store; + private readonly CommandResolver _resolver; + private string _newName = string.Empty; + private string? _addError; + + public VariablesWindow(UserVariableStore store, CommandResolver resolver) : base("Silkstring Variables###Variables") + { + _store = store; + _resolver = resolver; + } + + public void Dispose() { } + + public override void PreDraw() + { + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(300, 200), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + }; + } + + public override void Draw() + { + DrawAddRow(); + ImGui.Separator(); + DrawList(); + } + + private void DrawAddRow() + { + ImGui.SetNextItemWidth(200); + if(ImGui.InputTextWithHint("###newVar", "new variable name", ref _newName, 100)) _addError = null; + ImGui.SameLine(); + if (ImGuiComponents.IconButton((int)FontAwesomeIcon.Plus, FontAwesomeIcon.Plus)) + { + _addError = _store.ValidateName(_newName); + if (_addError == null) + { + _store.TryAdd(_newName); + _resolver.Refresh(); + _newName = string.Empty; + } + } + ImGuiUtil.Tooltip("Add Variable"); + if (_addError != null) ImGui.TextColored(new Vector4(1f, 0.4f, 0.4f, 1f), _addError); + } + + private void DrawList() + { + if (_store.Variables.Count == 0) + { + ImGuiUtil.TextWrappedDisabled("No variables yet. Add one above, then use it with {name} in any alias."); + return; + } + + UserVariable? toRemove = null; + foreach (var variable in _store.Variables) + { + ImGui.TextUnformatted(variable.Name); + ImGui.SameLine(150); + ImGui.SetNextItemWidth(-60); + if (ImGui.InputText($"###val{variable.UniqueId}", ref variable.Value, 200)) _store.MarkChanged(); + ImGui.SameLine(); + var canDelete = ImGui.GetIO().KeyShift && ImGui.GetIO().KeyCtrl; + ImGui.BeginDisabled(!canDelete); + if (ImGuiComponents.IconButton(variable.UniqueId, FontAwesomeIcon.Trash)) toRemove = variable; + ImGui.EndDisabled(); + ImGuiUtil.Tooltip("Hold Shift + Ctrl to delete", true); + } + + if (toRemove != null) + { + _store.Remove(toRemove); + _resolver.Refresh(); + } + } +} diff --git a/Tests/BlockInterpreterTests.cs b/Tests/BlockInterpreterTests.cs index 55ec60d..91b9f6a 100644 --- a/Tests/BlockInterpreterTests.cs +++ b/Tests/BlockInterpreterTests.cs @@ -9,6 +9,9 @@ public class BlockInterpreterTests [InlineData(":endif", "EndIf", "")] [InlineData("/say hi", "Command", "/say hi")] [InlineData(":ifx", "Command", ":ifx")] + [InlineData(":set foo bar", "Set", "foo bar")] + [InlineData(":SET foo bar", "Set", "foo bar")] + [InlineData(":set", "Command", ":set")] public void Classifies(string line, string kind, string expression) { var (k, e) = BlockInterpreter.Classify(line); @@ -16,6 +19,19 @@ public void Classifies(string line, string kind, string expression) Assert.Equal(expression, e); } + [Theory] + [InlineData("foo bar", "foo", "bar")] + [InlineData("foo", "foo", "")] + [InlineData("foo bar baz", "foo", "bar baz")] + [InlineData(" foo bar ", "foo", "bar")] + [InlineData("greet hi {0}", "greet", "hi {0}")] + public void ParsesSet(string expression, string name, string value) + { + var (n, v) = BlockInterpreter.ParseSet(expression); + Assert.Equal(name, n); + Assert.Equal(value, v); + } + [Fact] public void EmptyIsActive() => Assert.True(new BlockInterpreter().Active); diff --git a/Tests/UserVariableStoreTests.cs b/Tests/UserVariableStoreTests.cs new file mode 100644 index 0000000..d160bc8 --- /dev/null +++ b/Tests/UserVariableStoreTests.cs @@ -0,0 +1,108 @@ +using Silkstring.Models; +using Silkstring.Services.Variables; + +public class UserVariableStoreTests +{ + private static List Vars(params (string Name, string Value)[] vars) + => vars.Select(v => new UserVariable { Name = v.Name, Value = v.Value }).ToList(); + + private static UserVariableStore Store(List vars, Action onChanged, params string[] reserved) + => new(vars, new HashSet(reserved, StringComparer.OrdinalIgnoreCase), onChanged); + + [Fact] + public void SetsKnownVariable() + { + var vars = Vars(("mode", "raid")); + var changed = false; + var store = Store(vars, () => changed = true); + Assert.True(store.TrySet("mode", "solo")); + Assert.Equal("solo", vars[0].Value); + Assert.True(changed); + } + + [Fact] + public void MatchesNameCaseInsensitively() + { + var vars = Vars(("Mode", "raid")); + var store = Store(vars, () => { }); + Assert.True(store.TrySet("mode", "solo")); + Assert.Equal("solo", vars[0].Value); + } + + [Fact] + public void UnknownNameDoesNothing() + { + var vars = Vars(("mode", "raid")); + var changed = false; + var store = Store(vars, () => changed = true); + Assert.False(store.TrySet("other", "x")); + Assert.Equal("raid", vars[0].Value); + Assert.False(changed); + } + + [Fact] + public void BuiltInNameIsNotSettable() + { + var store = Store(Vars(("mode", "raid")), () => { }); + Assert.False(store.TrySet("job", "WHM")); + } + + [Fact] + public void AddsValidName() + { + var vars = Vars(); + var changed = false; + var store = Store(vars, () => changed = true); + Assert.True(store.TryAdd("greeting")); + Assert.Equal("greeting", Assert.Single(vars).Name); + Assert.True(changed); + } + + [Fact] + public void DoesNotAddInvalidName() + { + var vars = Vars(); + var changed = false; + var store = Store(vars, () => changed = true); + Assert.False(store.TryAdd("has space")); + Assert.Empty(vars); + Assert.False(changed); + } + + [Fact] + public void Removes() + { + var vars = Vars(("a", "1"), ("b", "2")); + var changed = false; + var store = Store(vars, () => changed = true); + store.Remove(vars[0]); + Assert.Equal("b", Assert.Single(vars).Name); + Assert.True(changed); + } + + [Theory] + [InlineData("greeting", null)] + [InlineData("greet_2", null)] + [InlineData("", "Enter a name")] + [InlineData(" ", "Enter a name")] + [InlineData("has space", "Use only letters, numbers, and underscores")] + [InlineData("no-dash", "Use only letters, numbers, and underscores")] + public void ValidatesNameFormat(string name, string? expected) + => Assert.Equal(expected, Store(Vars(), () => { }).ValidateName(name)); + + [Fact] + public void RejectsReservedName() + => Assert.Equal("JOB is a built-in variable", Store(Vars(), () => { }, "job").ValidateName("JOB")); + + [Fact] + public void RejectsDuplicateName() + => Assert.Equal("greeting is already defined", Store(Vars(("Greeting", "")), () => { }).ValidateName("greeting")); + + [Fact] + public void ExcludedRowIsNotADuplicate() + { + var vars = Vars(("greeting", "")); + var store = Store(vars, () => { }); + Assert.Null(store.ValidateName("greeting", vars[0])); + } +} From c421c5f038fe2800e2a394dfc2d5e217a239785d Mon Sep 17 00:00:00 2001 From: devoreofox <232652342+devoreofox@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:20:29 -0300 Subject: [PATCH 3/3] Update documentation, new visual for variables in help --- CHANGELOG.md | 6 +++++ README.md | 24 +++++++++++++++++++ Silkstring/UI/Palette.cs | 8 +++++++ Silkstring/Windows/HelpWindow.cs | 41 +++++++++++++++++++++++++------- Tests/AliasValidatorTests.cs | 10 ++++++++ 5 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 Silkstring/UI/Palette.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7735f..f8ce13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.3.0.0 - 2026-06-30 +### Added +- Your own variables: create variables in the new Variables window (open it with `/silkstring variables`), give each one a value, and use it in any alias with `{name}`, just like the built-in ones +- Change a variable from inside an alias with `:set`, for example `:set mode raid`. The new value is saved automatically and kept between sessions +- The alias editor warns you when a `:set` points at a variable you have not created yet + ## v1.2.0.0 - 2026-06-29 ### Added - Conditionals: run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks. Everything in a block runs only when its condition holds, with an optional `:else` for the false case, and blocks can be nested diff --git a/README.md b/README.md index 74a8f7a..9ad6690 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Dalamud plugin for FFXIV that lets you define custom command aliases. Each ali - Run multiple lines per alias, in order, with a configurable delay between them - Mix game commands and chat messages in a single alias - Insert live game state with variables such as `{character}` and `{job}` +- Define your own variables and change them on the fly from inside an alias - Give an alias several triggers using a `|` separator - Organize aliases into collapsible folders with drag and drop - Set a friendly display name for an alias, separate from its trigger @@ -134,6 +135,29 @@ A condition compares values with `==`, `!=`, `<`, `>`, `<=`, `>=`, and you can c Text comparisons are case-insensitive, and numbers compare as numbers. The `:if`, `:else`, and `:endif` lines are never sent to chat, and the alias editor warns you if a block is left open or a condition cannot be understood. +### User Variables + +Alongside the built-in variables, you can define your own. Open the Variables window with `/silkstring variables`, type a name, and give it a value. Names can use letters, numbers, and underscores, and cannot reuse a built-in variable name. + +Your variables work just like the built-in ones: insert them anywhere with `{name}`, and they appear in the Variables tab of the help window. + +You can change a variable from inside an alias with `:set`: + +``` +:set mode raid +:set greeting hi {0} +``` + +The value is resolved when the line runs, so it can include other variables and parameters, and the result is saved automatically and kept between sessions. A `:set` only affects a variable you have already created. If it names one that does not exist, nothing happens and the alias editor warns you. + +Because conditions treat `true` and `false` the same way as the built-in switches, a variable makes a handy on/off flag. Toggle it in one alias with `:set burst true` (or `false`), and check it in another: + +``` +:if {burst} +/say bursting +:endif +``` + ### Folders Create a folder with the **New Folder** button, then drag aliases into or out of it. Folders can be collapsed, renamed, and deleted from their right-click menu. diff --git a/Silkstring/UI/Palette.cs b/Silkstring/UI/Palette.cs new file mode 100644 index 0000000..8879bb4 --- /dev/null +++ b/Silkstring/UI/Palette.cs @@ -0,0 +1,8 @@ +using System.Numerics; + +namespace Silkstring.UI; + +public static class Palette +{ + public static readonly Vector4 VariableToken = new(0.4f, 0.8f, 1.0f, 1.0f); +} diff --git a/Silkstring/Windows/HelpWindow.cs b/Silkstring/Windows/HelpWindow.cs index e33504e..5a4c151 100644 --- a/Silkstring/Windows/HelpWindow.cs +++ b/Silkstring/Windows/HelpWindow.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Windowing; using Silkstring.Services; +using Silkstring.Services.Variables; +using Silkstring.UI; namespace Silkstring.Windows; @@ -164,6 +167,7 @@ private void DrawCommandsHelp() ImGui.Spacing(); ImGui.TextWrapped("Variables are resolved at the moment the alias fires, so they always reflect your current game state. " + "See the Variables tab for a full list of supported variables."); + ImGui.TextWrapped("You can also define your own variables, see the Variables tab."); ImGui.TextColored(HeadingColor, "Parameters"); ImGui.Separator(); @@ -219,31 +223,52 @@ private void DrawVariablesHelp() "it is left as-is in the command string rather than being replaced with an empty value."); ImGui.Spacing(); + ImGui.TextColored(HeadingColor, "Your Own Variables"); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextWrapped("Besides the built-in variables below, you can define your own. Open the Variables window with /silkstring variables, give it a name and a value, then use it anywhere with {name}."); + ImGui.Spacing(); + ImGui.TextWrapped("Change a variable from inside an alias with :set. The value is resolved when the line runs, saved automatically, and kept between sessions:"); + ImGui.Spacing(); + ImGui.Indent(); + ImGui.TextDisabled(":set mode raid"); + ImGui.TextDisabled(":set greeting hi {0}"); + ImGui.Unindent(); + ImGui.Spacing(); + ImGui.TextWrapped("A :set only works on a variable you have already created. The alias editor warns you if it names one that does not exist."); + ImGui.Spacing(); + ImGui.TextColored(HeadingColor, "Available Variables"); ImGui.Separator(); ImGui.Spacing(); - if (ImGui.BeginTable("###variablesTable", 3, ImGuiTableFlags.BordersInnerH | ImGuiTableFlags.RowBg)) + foreach (var group in _resolver.Variables.GroupBy(v => v.Category).OrderBy(g => g.Key == "User" ? 1 : 0).ThenBy(g => g.Key)) + { + DrawVariableCategory(group.Key, group.OrderBy(v => v.Name)); + } + } + + private void DrawVariableCategory(string category, IEnumerable variables) + { + ImGui.TextColored(HeadingColor, category); + if (ImGui.BeginTable($"###varTable_{category}", 3, ImGuiTableFlags.BordersInnerH | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersOuter)) { ImGui.TableSetupColumn("Variable", ImGuiTableColumnFlags.WidthFixed, 120); ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("Current Value", ImGuiTableColumnFlags.WidthFixed, 150); - ImGui.TableHeadersRow(); - - foreach (var variable in _resolver.Variables.OrderBy(v => v.Category).ThenBy(v => v.Name)) - { + foreach (var variable in variables) DrawVariableRow($"{{{variable.Name}}}", variable.Description, variable.Resolve() ?? "Not available"); - } - ImGui.EndTable(); } + ImGui.Spacing(); } private void DrawVariableRow(string variable, string description, string currentValue) { ImGui.TableNextRow(); ImGui.TableNextColumn(); - ImGui.TextDisabled(variable); + ImGui.TextColored(Palette.VariableToken, variable); + ImGui.TableNextColumn(); ImGui.TextWrapped(description); ImGui.TableNextColumn(); diff --git a/Tests/AliasValidatorTests.cs b/Tests/AliasValidatorTests.cs index 5f962bd..ca972d3 100644 --- a/Tests/AliasValidatorTests.cs +++ b/Tests/AliasValidatorTests.cs @@ -6,6 +6,9 @@ public class AliasValidatorTests private static AliasEntry Alias(string name, params string[] output) => new() { Name = name, Output = output.Select(o => new CommandEntry { Command = o }).ToList() }; + private static HashSet Defined(params string[] names) + => new(names, StringComparer.OrdinalIgnoreCase); + [Fact] public void DetectsDirectCycle() { @@ -52,4 +55,11 @@ public void NonSlashLineIsNotADependency() [Fact] public void OrphanEndIf() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":endif"))); [Fact] public void DuplicateElse() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":if {a} == 1", ":else", ":else", ":endif"))); [Fact] public void BadExpression() => Assert.NotNull(AliasValidator.ValidateBlocks(Alias("a", ":if {hp} <=", ":endif"))); + + [Fact] public void SetKnown() => Assert.Null(AliasValidator.ValidateSets(Alias("a", ":set foo bar"), Defined("foo"))); + [Fact] public void SetCaseInsensitive() => Assert.Null(AliasValidator.ValidateSets(Alias("a", ":set FOO bar"), Defined("foo"))); + [Fact] public void SetUnknown() => Assert.NotNull(AliasValidator.ValidateSets(Alias("a", ":set foo bar"), Defined())); + [Fact] public void SetNoName() => Assert.NotNull(AliasValidator.ValidateSets(Alias("a", ":set "), Defined("foo"))); + [Fact] public void SetOneBadAmongGood() => Assert.NotNull(AliasValidator.ValidateSets(Alias("a", ":set foo a", ":set bar b"), Defined("foo"))); + [Fact] public void SetNonSetLinesIgnored() => Assert.Null(AliasValidator.ValidateSets(Alias("a", "/say hi"), Defined())); }