diff --git a/MinecraftClient/Commands/Dialog.cs b/MinecraftClient/Commands/Dialog.cs new file mode 100644 index 00000000..682b468a --- /dev/null +++ b/MinecraftClient/Commands/Dialog.cs @@ -0,0 +1,106 @@ +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Dialogs; +using MinecraftClient.Tui; + +namespace MinecraftClient.Commands; + +public class Dialog : Command +{ + public override string CmdName => "dialog"; + public override string CmdUsage => Translations.cmd_dialog_usage; + public override string CmdDesc => Translations.cmd_dialog_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source)))); + + dispatcher.Register(l => l.Literal(CmdName) + .Executes(r => Show(r.Source)) + .Then(l => l.Literal("show") + .Executes(r => Show(r.Source))) + .Then(l => l.Literal("open") + .Executes(r => Open(r.Source))) + .Then(l => l.Literal("set") + .Then(l => l.Argument("Input", Arguments.String()) + .Then(l => l.Argument("Value", Arguments.GreedyString()) + .Executes(r => SetInput(r.Source, Arguments.GetString(r, "Input"), Arguments.GetString(r, "Value")))))) + .Then(l => l.Literal("input") + .Then(l => l.Argument("Input", Arguments.String()) + .Then(l => l.Argument("Value", Arguments.GreedyString()) + .Executes(r => SetInput(r.Source, Arguments.GetString(r, "Input"), Arguments.GetString(r, "Value")))))) + .Then(l => l.Literal("click") + .Then(l => l.Argument("Index", Arguments.Integer(min: 1)) + .Executes(r => Click(r.Source, Arguments.GetInteger(r, "Index"))))) + .Then(l => l.Literal("click-label") + .Then(l => l.Argument("Label", Arguments.GreedyString()) + .Executes(r => ClickLabel(r.Source, Arguments.GetString(r, "Label"))))) + .Then(l => l.Literal("cancel") + .Executes(r => Cancel(r.Source))) + .Then(l => l.Literal("dismiss") + .Executes(r => Dismiss(r.Source))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))); + } + + private int GetUsage(CmdResult r) => r.SetAndReturn(GetCmdDescTranslated()); + + private static int Show(CmdResult r) + { + var handler = CmdResult.currentHandler!; + var current = handler.Dialogs.Current; + return current is null + ? r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_none) + : r.SetAndReturn(CmdResult.Status.Done, DialogFormatter.Render(current)); + } + + private static int Open(CmdResult r) + { + var handler = CmdResult.currentHandler!; + var current = handler.Dialogs.Current; + if (current is null) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_none); + + if (ConsoleIO.Backend is not TuiConsoleBackend) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_tui_unavailable); + + return DialogTuiHost.TryOpen(handler, current, force: true) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.dialog_tui_opened) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_tui_unavailable); + } + + private static int SetInput(CmdResult r, string key, string value) + { + var result = CmdResult.currentHandler!.Dialogs.SetInput(key, value); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int Click(CmdResult r, int index) + { + var result = CmdResult.currentHandler!.Dialogs.Click(index); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int ClickLabel(CmdResult r, string label) + { + var result = CmdResult.currentHandler!.Dialogs.ClickLabel(label); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int Cancel(CmdResult r) + { + var result = CmdResult.currentHandler!.Dialogs.Cancel(); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int Dismiss(CmdResult r) + { + var result = CmdResult.currentHandler!.Dialogs.Dismiss(); + DialogTuiHost.CloseCurrent(); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } +} diff --git a/MinecraftClient/Dialogs/DialogFormatter.cs b/MinecraftClient/Dialogs/DialogFormatter.cs new file mode 100644 index 00000000..f3001aaa --- /dev/null +++ b/MinecraftClient/Dialogs/DialogFormatter.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using System.Text; + +namespace MinecraftClient.Dialogs; + +public static class DialogFormatter +{ + public static string DisplayTitle(this DialogDefinition definition) + { + if (!string.IsNullOrWhiteSpace(definition.ExternalTitle)) + return definition.ExternalTitle!; + + return string.IsNullOrWhiteSpace(definition.Title) ? definition.Type : definition.Title; + } + + public static string Render(DialogInstance instance) + { + StringBuilder builder = new(); + builder.AppendLine(string.Format(Translations.dialog_render_header, instance.Revision, instance.Phase, instance.Definition.DisplayTitle())); + builder.AppendLine(string.Format(Translations.dialog_render_type, instance.Definition.Type)); + + foreach (var body in instance.Definition.Body.Where(static body => !string.IsNullOrWhiteSpace(body.Text))) + builder.AppendLine(string.Format(Translations.dialog_render_body, body.Text)); + + if (instance.Definition.Inputs.Count > 0) + { + builder.AppendLine(Translations.dialog_render_inputs); + foreach (var input in instance.Definition.Inputs) + { + instance.Values.TryGetValue(input.Key, out var value); + value ??= input.InitialValue; + builder.AppendLine(string.Format(Translations.dialog_render_input, input.Key, input.Kind, input.Label, value, DescribeInput(input))); + } + } + + if (instance.Definition.Actions.Count > 0) + { + builder.AppendLine(Translations.dialog_render_actions); + foreach (var action in instance.Definition.Actions) + builder.AppendLine(string.Format(Translations.dialog_render_action, action.Index, action.Label, DescribeAction(action.Action))); + } + + if (instance.Definition.CancelAction is not null || instance.Definition.CanCloseWithEscape) + builder.AppendLine(Translations.dialog_render_cancel_hint); + + return builder.ToString(); + } + + private static string DescribeInput(DialogInput input) + { + return input.Kind switch + { + DialogInputKind.Text => string.Format(Translations.dialog_input_desc_text, input.MaxLength), + DialogInputKind.Boolean => string.Format(Translations.dialog_input_desc_boolean, input.OnTrue, input.OnFalse), + DialogInputKind.SingleOption => string.Format(Translations.dialog_input_desc_options, + string.Join(", ", input.Options?.Select(static option => option.Id) ?? [])), + DialogInputKind.NumberRange => string.Format(Translations.dialog_input_desc_number, input.Start, input.End), + _ => input.Type ?? Translations.dialog_input_desc_unknown + }; + } + + private static string DescribeAction(DialogActionDefinition? action) + { + if (action is null) + return Translations.dialog_action_desc_close; + + return action.Kind switch + { + DialogActionKind.RunCommand => Translations.dialog_action_desc_command, + DialogActionKind.CustomClick => Translations.dialog_action_desc_custom, + DialogActionKind.ShowDialog => Translations.dialog_action_desc_show_dialog, + DialogActionKind.OpenUrl => Translations.dialog_action_desc_open_url, + DialogActionKind.SuggestCommand => Translations.dialog_action_desc_suggest, + DialogActionKind.CopyToClipboard => Translations.dialog_action_desc_copy, + _ => action.Type ?? Translations.dialog_action_desc_unknown + }; + } +} diff --git a/MinecraftClient/Dialogs/DialogManager.cs b/MinecraftClient/Dialogs/DialogManager.cs new file mode 100644 index 00000000..ca34b281 --- /dev/null +++ b/MinecraftClient/Dialogs/DialogManager.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; + +namespace MinecraftClient.Dialogs; + +public sealed class DialogManager +{ + private readonly McClient _client; + private readonly Lock _lock = new(); + private readonly Dictionary _registryById = new(); + private readonly Dictionary _registryByName = new(StringComparer.Ordinal); + private readonly List _serverLinks = []; + private DialogInstance? _current; + private int _revision; + + public DialogManager(McClient client) + { + ArgumentNullException.ThrowIfNull(client); + _client = client; + } + + public event Action? DialogShown; + public event Action? DialogCleared; + + public DialogInstance? Current + { + get + { + lock (_lock) + return _current; + } + } + + public void StoreRegistryDialog(int protocolId, string resourceId, DialogDefinition definition) + { + lock (_lock) + { + _registryById[protocolId] = definition; + _registryByName[resourceId] = definition; + } + } + + public void ClearRegistry() + { + lock (_lock) + { + _registryById.Clear(); + _registryByName.Clear(); + } + } + + public void SetServerLinks(IEnumerable links) + { + lock (_lock) + { + _serverLinks.Clear(); + _serverLinks.AddRange(links); + } + } + + public DialogInstance Show(DialogDefinition definition, DialogPhase phase) + { + DialogInstance instance; + lock (_lock) + { + var expanded = ExpandServerLinks(definition); + var values = expanded.Inputs.ToDictionary(static input => input.Key, static input => input.InitialValue, StringComparer.Ordinal); + instance = new DialogInstance(++_revision, phase, expanded, values, DateTimeOffset.UtcNow); + _current = instance; + } + + _client.Log.Info(string.Format(Translations.dialog_received, instance.Definition.DisplayTitle())); + DialogShown?.Invoke(instance); + return instance; + } + + public DialogInstance ShowRegistryReference(int protocolId, DialogPhase phase) + { + DialogDefinition? definition; + lock (_lock) + _registryById.TryGetValue(protocolId, out definition); + + if (definition is not null) + return Show(definition, phase); + + var unresolved = new DialogDefinition( + "minecraft:unresolved", + string.Format(CultureInfo.InvariantCulture, Translations.dialog_unresolved_title, protocolId), + null, + CanCloseWithEscape: true, + Pause: false, + DialogAfterAction.Close, + [new DialogBody(DialogBodyKind.Unknown, string.Format(CultureInfo.InvariantCulture, Translations.dialog_unresolved_body, protocolId))], + [], + [], + null, + IsResolved: false, + UnresolvedReference: protocolId.ToString(CultureInfo.InvariantCulture)); + return Show(unresolved, phase); + } + + public void Clear() + { + int revision; + lock (_lock) + { + revision = _current?.Revision ?? _revision; + _current = null; + } + + _client.Log.Info(Translations.dialog_cleared); + DialogCleared?.Invoke(revision); + } + + public DialogActionResult Dismiss() + { + int revision; + lock (_lock) + { + if (_current is null) + return new DialogActionResult(false, Translations.dialog_none); + + revision = _current.Revision; + _current = null; + } + + DialogCleared?.Invoke(revision); + return new DialogActionResult(true, Translations.dialog_dismissed); + } + + public DialogActionResult SetInput(string key, string value) + { + lock (_lock) + { + if (_current is null) + return new DialogActionResult(false, Translations.dialog_none); + + var input = _current.Definition.Inputs.FirstOrDefault(input => input.Key.Equals(key, StringComparison.Ordinal)); + if (input is null) + return new DialogActionResult(false, string.Format(Translations.dialog_input_unknown, key)); + + var normalized = NormalizeInputValue(input, value, out var error); + if (error is not null) + return new DialogActionResult(false, error); + + var values = _current.Values.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + values[key] = normalized; + _current = _current with { Values = values }; + return new DialogActionResult(true, string.Format(Translations.dialog_input_set, key, normalized)); + } + } + + public DialogActionResult Click(int index) + { + DialogButton? button; + DialogInstance? instance; + lock (_lock) + { + instance = _current; + button = instance?.Definition.Actions.FirstOrDefault(action => action.Index == index); + } + + if (instance is null) + return new DialogActionResult(false, Translations.dialog_none); + + if (button is null) + return new DialogActionResult(false, string.Format(Translations.dialog_action_unknown, index)); + + return Execute(instance, button.Action, ShouldCloseAfterAction(instance.Definition.AfterAction)); + } + + public DialogActionResult ClickLabel(string label) + { + DialogButton[] matches; + DialogInstance? instance; + lock (_lock) + { + instance = _current; + matches = instance?.Definition.Actions + .Where(action => action.Label.Equals(label, StringComparison.OrdinalIgnoreCase)) + .ToArray() ?? []; + } + + if (instance is null) + return new DialogActionResult(false, Translations.dialog_none); + + return matches.Length switch + { + 0 => new DialogActionResult(false, string.Format(Translations.dialog_action_label_unknown, label)), + > 1 => new DialogActionResult(false, string.Format(Translations.dialog_action_label_ambiguous, label)), + _ => Execute(instance, matches[0].Action, ShouldCloseAfterAction(instance.Definition.AfterAction)) + }; + } + + public DialogActionResult Cancel() + { + DialogInstance? instance; + lock (_lock) + instance = _current; + + if (instance is null) + return new DialogActionResult(false, Translations.dialog_none); + + if (!instance.Definition.CanCloseWithEscape && instance.Definition.CancelAction is null) + return new DialogActionResult(false, Translations.dialog_cannot_cancel); + + return Execute(instance, instance.Definition.CancelAction, closeWhenDone: true); + } + + private DialogActionResult Execute(DialogInstance instance, DialogActionDefinition? action, bool closeWhenDone) + { + if (!instance.Definition.IsResolved) + return new DialogActionResult(false, Translations.dialog_unresolved_action_disabled); + + if (action is null || action.Kind == DialogActionKind.None) + { + if (closeWhenDone) + _ = Dismiss(); + return new DialogActionResult(true, Translations.dialog_action_closed); + } + + var values = BuildActionValues(instance); + switch (action.Kind) + { + case DialogActionKind.RunCommand: + if (instance.Phase != DialogPhase.Play) + return new DialogActionResult(false, Translations.dialog_action_command_not_in_play); + + var command = ApplyTemplate(action.Value ?? string.Empty, values.TemplateValues); + _client.SendText(command); + if (closeWhenDone) + _ = Dismiss(); + return new DialogActionResult(true, string.Format(Translations.dialog_action_command_sent, command)); + + case DialogActionKind.CustomClick: + if (action.Id is null) + return new DialogActionResult(false, Translations.dialog_action_invalid); + + var payload = action.Type == "minecraft:custom" && action.Payload is null && values.TagValues.Count == 0 + ? null + : MergePayload(action.Payload, values.TagValues); + if (!_client.SendCustomClickAction(action.Id, payload)) + return new DialogActionResult(false, Translations.dialog_action_custom_failed); + + if (closeWhenDone) + _ = Dismiss(); + return new DialogActionResult(true, string.Format(Translations.dialog_action_custom_sent, action.Id)); + + case DialogActionKind.ShowDialog: + if (action.NestedDialog is not null) + { + Show(action.NestedDialog, instance.Phase); + return new DialogActionResult(true, Translations.dialog_action_nested_opened); + } + + if (action.DialogReferenceId is int referenceId) + { + ShowRegistryReference(referenceId, instance.Phase); + return new DialogActionResult(true, Translations.dialog_action_nested_opened); + } + + if (action.Value is not null) + { + DialogDefinition? referencedDialog; + lock (_lock) + _registryByName.TryGetValue(action.Value, out referencedDialog); + + if (referencedDialog is not null) + { + Show(referencedDialog, instance.Phase); + return new DialogActionResult(true, Translations.dialog_action_nested_opened); + } + } + + return new DialogActionResult(false, Translations.dialog_action_invalid); + + case DialogActionKind.OpenUrl: + return new DialogActionResult(true, string.Format(Translations.dialog_action_open_url, action.Value ?? string.Empty)); + + case DialogActionKind.SuggestCommand: + return new DialogActionResult(true, string.Format(Translations.dialog_action_suggest_command, action.Value ?? string.Empty)); + + case DialogActionKind.CopyToClipboard: + return new DialogActionResult(true, string.Format(Translations.dialog_action_copy, action.Value ?? string.Empty)); + + default: + return new DialogActionResult(false, string.Format(Translations.dialog_action_unsupported, action.Type ?? action.Kind.ToString())); + } + } + + private static bool ShouldCloseAfterAction(DialogAfterAction afterAction) + { + return afterAction == DialogAfterAction.Close; + } + + private DialogDefinition ExpandServerLinks(DialogDefinition definition) + { + if (!definition.Type.Equals("minecraft:server_links", StringComparison.Ordinal)) + return definition; + + var linkActions = _serverLinks + .Select((link, index) => new DialogButton( + index + 1, + link.Label, + new DialogActionDefinition(DialogActionKind.OpenUrl, Value: link.Url))) + .ToList(); + + if (definition.Actions.Count > 0) + linkActions.AddRange(definition.Actions.Select((button, i) => button with { Index = linkActions.Count + i + 1 })); + + return definition with { Actions = linkActions }; + } + + private static DialogActionValues BuildActionValues(DialogInstance instance) + { + Dictionary templateValues = new(StringComparer.Ordinal); + Dictionary tagValues = new(StringComparer.Ordinal); + + foreach (var input in instance.Definition.Inputs) + { + instance.Values.TryGetValue(input.Key, out var value); + value ??= input.InitialValue; + templateValues[input.Key] = ToTemplateValue(input, value); + tagValues[input.Key] = ToNbtValue(input, value); + } + + return new DialogActionValues(templateValues, tagValues); + } + + private static Dictionary MergePayload(Dictionary? basePayload, Dictionary inputTags) + { + Dictionary payload = basePayload is null + ? new(StringComparer.Ordinal) + : new(basePayload, StringComparer.Ordinal); + + foreach (var (key, value) in inputTags) + payload[key] = value; + + return payload; + } + + private static string NormalizeInputValue(DialogInput input, string value, out string? error) + { + error = null; + switch (input.Kind) + { + case DialogInputKind.Text: + if (value.Length > input.MaxLength) + { + error = string.Format(Translations.dialog_input_too_long, input.Key, input.MaxLength); + return input.InitialValue; + } + return value; + + case DialogInputKind.Boolean: + if (bool.TryParse(value, out var boolValue)) + return boolValue ? "true" : "false"; + + if (value.Equals(input.OnTrue, StringComparison.OrdinalIgnoreCase)) + return "true"; + + if (value.Equals(input.OnFalse, StringComparison.OrdinalIgnoreCase)) + return "false"; + + error = string.Format(Translations.dialog_input_boolean_invalid, input.Key); + return input.InitialValue; + + case DialogInputKind.SingleOption: + if (input.Options?.Any(option => option.Id.Equals(value, StringComparison.Ordinal)) == true) + return value; + + error = string.Format(Translations.dialog_input_option_invalid, input.Key); + return input.InitialValue; + + case DialogInputKind.NumberRange: + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + { + error = string.Format(Translations.dialog_input_number_invalid, input.Key); + return input.InitialValue; + } + + var min = Math.Min(input.Start, input.End); + var max = Math.Max(input.Start, input.End); + if (number < min || number > max) + { + error = string.Format(CultureInfo.InvariantCulture, Translations.dialog_input_number_range_invalid, input.Key, min, max); + return input.InitialValue; + } + + return NumberToString(number); + + default: + return value; + } + } + + private static string ToTemplateValue(DialogInput input, string value) + { + return input.Kind switch + { + DialogInputKind.Boolean => value.Equals("true", StringComparison.OrdinalIgnoreCase) ? input.OnTrue : input.OnFalse, + DialogInputKind.Text => EscapeStringTagWithoutQuotes(value), + _ => value + }; + } + + private static object ToNbtValue(DialogInput input, string value) + { + return input.Kind switch + { + DialogInputKind.Boolean => (byte)(value.Equals("true", StringComparison.OrdinalIgnoreCase) ? 1 : 0), + DialogInputKind.NumberRange when float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) => number, + _ => value + }; + } + + private static string ApplyTemplate(string template, IReadOnlyDictionary values) + { + var result = template; + foreach (var (key, value) in values) + result = result.Replace("$(" + key + ")", value, StringComparison.Ordinal); + + return result; + } + + private static string EscapeStringTagWithoutQuotes(string value) + { + return value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + } + + private static string NumberToString(float value) + { + var integer = (int)value; + return integer == value + ? integer.ToString(CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + } + + private sealed record DialogActionValues( + IReadOnlyDictionary TemplateValues, + Dictionary TagValues); +} diff --git a/MinecraftClient/Dialogs/DialogModels.cs b/MinecraftClient/Dialogs/DialogModels.cs new file mode 100644 index 00000000..fc40b480 --- /dev/null +++ b/MinecraftClient/Dialogs/DialogModels.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; + +namespace MinecraftClient.Dialogs; + +public enum DialogPhase +{ + Configuration, + Play +} + +public enum DialogAfterAction +{ + Close, + None, + WaitForResponse +} + +public enum DialogBodyKind +{ + PlainMessage, + Item, + Unknown +} + +public enum DialogInputKind +{ + Text, + Boolean, + SingleOption, + NumberRange, + Unknown +} + +public enum DialogActionKind +{ + None, + RunCommand, + CustomClick, + ShowDialog, + OpenUrl, + SuggestCommand, + CopyToClipboard, + Unknown +} + +public sealed record DialogBody(DialogBodyKind Kind, string Text, string? Type = null); + +public sealed record DialogOption(string Id, string Display, bool Initial) +{ + public override string ToString() + { + return string.IsNullOrWhiteSpace(Display) ? Id : Display; + } +} + +public sealed record DialogInput( + string Key, + DialogInputKind Kind, + string Label, + string InitialValue, + int MaxLength = 32, + bool LabelVisible = true, + bool Multiline = false, + IReadOnlyList? Options = null, + string OnTrue = "true", + string OnFalse = "false", + float Start = 0, + float End = 1, + float? InitialNumber = null, + float? Step = null, + string? Type = null); + +public sealed record DialogActionDefinition( + DialogActionKind Kind, + string? Value = null, + string? Id = null, + Dictionary? Payload = null, + DialogDefinition? NestedDialog = null, + int? DialogReferenceId = null, + string? Type = null); + +public sealed record DialogButton(int Index, string Label, DialogActionDefinition? Action, bool IsCancel = false); + +public sealed record DialogServerLink(string Label, string Url); + +public sealed record DialogDefinition( + string Type, + string Title, + string? ExternalTitle, + bool CanCloseWithEscape, + bool Pause, + DialogAfterAction AfterAction, + IReadOnlyList Body, + IReadOnlyList Inputs, + IReadOnlyList Actions, + DialogActionDefinition? CancelAction, + int Columns = 1, + int ButtonWidth = 150, + bool IsResolved = true, + string? UnresolvedReference = null); + +public sealed record DialogInstance( + int Revision, + DialogPhase Phase, + DialogDefinition Definition, + IReadOnlyDictionary Values, + DateTimeOffset ReceivedAt); + +public sealed record DialogActionResult(bool Success, string Message); diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 0c7665fd..58184b0a 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -11,6 +11,7 @@ using MinecraftClient.ChatBots; using MinecraftClient.CommandHandler; using MinecraftClient.CommandHandler.Patch; using MinecraftClient.Commands; +using MinecraftClient.Dialogs; using MinecraftClient.Inventory; using MinecraftClient.Logger; using MinecraftClient.Mapping; @@ -235,6 +236,7 @@ namespace MinecraftClient private bool consoleHandlersAttached = false; public ILogger Log; + public DialogManager Dialogs { get; } private static IMinecraftComHandler? instance; public static IMinecraftComHandler? Instance => instance; @@ -276,6 +278,7 @@ namespace MinecraftClient Log.ChatEnabled = Config.Logging.ChatMessages; Log.WarnEnabled = Config.Logging.WarningMessages; Log.ErrorEnabled = Config.Logging.ErrorMessages; + Dialogs = new DialogManager(this); // SENTRY: Send our client version and server version to Sentry SentrySdk.ConfigureScope(scope => @@ -1819,6 +1822,14 @@ namespace MinecraftClient } } + public bool SendCustomClickAction(string id, Dictionary? payload) + { + if (InvokeRequired) + return InvokeOnMainThread(() => SendCustomClickAction(id, payload)); + + return handler.SendCustomClickAction(id, payload); + } + /// /// Allow to respawn after death /// @@ -3421,6 +3432,34 @@ namespace MinecraftClient DispatchBotEvent(bot => bot.OnNetworkPacket(packetID, packetData, isLogin, isInbound)); } + public void OnDialogRegistryData(int protocolId, string resourceId, DialogDefinition dialog) + { + Dialogs.StoreRegistryDialog(protocolId, resourceId, dialog); + } + + public void OnDialogShown(DialogDefinition dialog, DialogPhase phase) + { + var instance = Dialogs.Show(dialog, phase); + Tui.DialogTuiHost.TryOpen(this, instance, force: phase == DialogPhase.Configuration); + } + + public void OnDialogRegistryReferenceShown(int protocolId, DialogPhase phase) + { + var instance = Dialogs.ShowRegistryReference(protocolId, phase); + Tui.DialogTuiHost.TryOpen(this, instance, force: phase == DialogPhase.Configuration); + } + + public void OnDialogCleared() + { + Dialogs.Clear(); + Tui.DialogTuiHost.CloseCurrent(); + } + + public void OnServerLinksUpdated(IReadOnlyList links) + { + Dialogs.SetServerLinks(links); + } + /// /// Called when a server was successfully joined /// diff --git a/MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs b/MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs new file mode 100644 index 00000000..ff3bcf56 --- /dev/null +++ b/MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MinecraftClient.Dialogs; +using MinecraftClient.Protocol.Message; + +namespace MinecraftClient.Protocol.Dialogs; + +public sealed class DialogNbtParser +{ + public DialogDefinition Parse(Dictionary nbt) + { + var type = NormalizeType(GetString(nbt, "type") ?? "minecraft:notice"); + var common = ParseCommon(nbt, type); + var actions = new List(); + DialogActionDefinition? cancelAction = null; + var columns = GetInt(nbt, "columns", 1); + var buttonWidth = GetInt(nbt, "button_width", 150); + + switch (type) + { + case "minecraft:notice": + var noticeAction = ParseButton(nbt, "action", 1); + actions.Add(noticeAction ?? new DialogButton(1, Translations.dialog_action_ok, null)); + cancelAction = actions[0].Action; + break; + + case "minecraft:confirmation": + AddIfNotNull(actions, ParseButton(nbt, "yes", 1)); + AddIfNotNull(actions, ParseButton(nbt, "no", 2)); + cancelAction = actions.Count >= 2 ? actions[1].Action : null; + break; + + case "minecraft:multi_action": + actions.AddRange(ParseButtonList(GetValue(nbt, "actions"))); + cancelAction = ParseButton(nbt, "exit_action", 0)?.Action; + break; + + case "minecraft:dialog_list": + actions.AddRange(ParseDialogListActions(GetValue(nbt, "dialogs"))); + cancelAction = ParseButton(nbt, "exit_action", 0)?.Action; + break; + + case "minecraft:server_links": + cancelAction = ParseButton(nbt, "exit_action", 0)?.Action; + break; + } + + return new DialogDefinition( + type, + common.Title, + common.ExternalTitle, + common.CanCloseWithEscape, + common.Pause, + common.AfterAction, + common.Body, + common.Inputs, + actions, + cancelAction, + columns, + buttonWidth); + } + + public DialogDefinition? TryParse(Dictionary? nbt) + { + return nbt is null ? null : Parse(nbt); + } + + private static DialogCommon ParseCommon(Dictionary nbt, string type) + { + var title = ParseComponent(GetValue(nbt, "title")); + var externalTitle = nbt.TryGetValue("external_title", out var externalTitleValue) + ? ParseComponent(externalTitleValue) + : null; + var canCloseWithEscape = GetBool(nbt, "can_close_with_escape", true); + var pause = GetBool(nbt, "pause", true); + var afterAction = ParseAfterAction(GetString(nbt, "after_action") ?? "close"); + var body = ParseBody(GetValue(nbt, "body")); + var inputs = ParseInputs(GetValue(nbt, "inputs")); + + if (string.IsNullOrWhiteSpace(title)) + title = type; + + return new DialogCommon(title, externalTitle, canCloseWithEscape, pause, afterAction, body, inputs); + } + + private static IReadOnlyList ParseBody(object? value) + { + if (value is null) + return []; + + List body = []; + foreach (var item in Enumerate(value)) + { + if (item is Dictionary compound) + { + var type = NormalizeType(GetString(compound, "type") ?? "minecraft:plain_message"); + if (type == "minecraft:item") + { + var description = compound.TryGetValue("description", out var desc) + ? ParsePlainMessage(desc) + : string.Empty; + body.Add(new DialogBody(DialogBodyKind.Item, string.IsNullOrWhiteSpace(description) ? Translations.dialog_item_body : description, type)); + continue; + } + + body.Add(new DialogBody(DialogBodyKind.PlainMessage, ParsePlainMessage(compound), type)); + continue; + } + + body.Add(new DialogBody(DialogBodyKind.PlainMessage, ParseComponent(item), "minecraft:plain_message")); + } + + return body; + } + + private static string ParsePlainMessage(object? value) + { + if (value is Dictionary compound && compound.TryGetValue("contents", out var contents)) + return ParseComponent(contents); + + return ParseComponent(value); + } + + private static IReadOnlyList ParseInputs(object? value) + { + if (value is null) + return []; + + List inputs = []; + foreach (var item in Enumerate(value)) + { + if (item is not Dictionary inputData) + continue; + + var key = GetString(inputData, "key"); + if (string.IsNullOrWhiteSpace(key)) + continue; + + var control = inputData.TryGetValue("control", out var controlValue) && controlValue is Dictionary controlData + ? controlData + : inputData; + + var type = NormalizeType(GetString(control, "type") ?? "minecraft:text"); + inputs.Add(type switch + { + "minecraft:boolean" => ParseBooleanInput(key, type, control), + "minecraft:number_range" => ParseNumberInput(key, type, control), + "minecraft:single_option" => ParseOptionInput(key, type, control), + "minecraft:text" => ParseTextInput(key, type, control), + _ => new DialogInput(key, DialogInputKind.Unknown, ParseComponent(GetValue(control, "label")), string.Empty, Type: type) + }); + } + + return inputs; + } + + private static DialogInput ParseTextInput(string key, string type, Dictionary control) + { + return new DialogInput( + key, + DialogInputKind.Text, + ParseComponent(GetValue(control, "label")), + GetString(control, "initial") ?? string.Empty, + MaxLength: GetInt(control, "max_length", 32), + LabelVisible: GetBool(control, "label_visible", true), + Multiline: control.ContainsKey("multiline"), + Type: type); + } + + private static DialogInput ParseBooleanInput(string key, string type, Dictionary control) + { + var initial = GetBool(control, "initial", false); + return new DialogInput( + key, + DialogInputKind.Boolean, + ParseComponent(GetValue(control, "label")), + initial ? "true" : "false", + OnTrue: GetString(control, "on_true") ?? "true", + OnFalse: GetString(control, "on_false") ?? "false", + Type: type); + } + + private static DialogInput ParseOptionInput(string key, string type, Dictionary control) + { + var options = ParseOptions(GetValue(control, "options")); + var initial = options.FirstOrDefault(static option => option.Initial)?.Id + ?? options.FirstOrDefault()?.Id + ?? string.Empty; + return new DialogInput( + key, + DialogInputKind.SingleOption, + ParseComponent(GetValue(control, "label")), + initial, + LabelVisible: GetBool(control, "label_visible", true), + Options: options, + Type: type); + } + + private static DialogInput ParseNumberInput(string key, string type, Dictionary control) + { + var range = control.TryGetValue("range_info", out var rangeValue) && rangeValue is Dictionary rangeData + ? rangeData + : control; + var start = GetFloat(range, "start", 0); + var end = GetFloat(range, "end", 1); + var initial = TryGetFloat(range, "initial") ?? ((start + end) / 2F); + return new DialogInput( + key, + DialogInputKind.NumberRange, + ParseComponent(GetValue(control, "label")), + NumberToString(initial), + Start: start, + End: end, + InitialNumber: initial, + Step: TryGetFloat(range, "step"), + Type: type); + } + + private static IReadOnlyList ParseOptions(object? value) + { + if (value is null) + return []; + + List options = []; + foreach (var item in Enumerate(value)) + { + if (item is string id) + { + options.Add(new DialogOption(id, id, false)); + continue; + } + + if (item is Dictionary option) + { + var optionId = GetString(option, "id"); + if (optionId is null) + continue; + + var display = option.TryGetValue("display", out var displayValue) + ? ParseComponent(displayValue) + : optionId; + options.Add(new DialogOption(optionId, display, GetBool(option, "initial", false))); + } + } + + return options; + } + + private static List ParseButtonList(object? value) + { + List buttons = []; + var index = 1; + foreach (var item in Enumerate(value)) + { + if (item is Dictionary buttonData) + buttons.Add(ParseButton(buttonData, index++) ?? new DialogButton(index - 1, Translations.dialog_action_unnamed, null)); + } + + return buttons; + } + + private static IEnumerable ParseDialogListActions(object? value) + { + List buttons = []; + var index = 1; + foreach (var item in Enumerate(value)) + { + switch (item) + { + case string tag when tag.StartsWith('#'): + buttons.Add(new DialogButton(index++, tag, new DialogActionDefinition(DialogActionKind.Unknown, Type: "dialog_tag"))); + break; + case string resource: + buttons.Add(new DialogButton(index++, resource, new DialogActionDefinition(DialogActionKind.ShowDialog, Value: resource, Type: "dialog_reference_name"))); + break; + case Dictionary dialog: + var nested = new DialogNbtParser().Parse(dialog); + buttons.Add(new DialogButton(index++, nested.DisplayTitle(), new DialogActionDefinition(DialogActionKind.ShowDialog, NestedDialog: nested))); + break; + } + } + + return buttons; + } + + private static DialogButton? ParseButton(Dictionary owner, string key, int index) + { + return owner.TryGetValue(key, out var value) && value is Dictionary data + ? ParseButton(data, index) + : null; + } + + private static DialogButton? ParseButton(Dictionary data, int index) + { + var label = data.TryGetValue("label", out var labelValue) + ? ParseComponent(labelValue) + : Translations.dialog_action_unnamed; + var action = data.TryGetValue("action", out var actionValue) && actionValue is Dictionary actionData + ? ParseAction(actionData) + : null; + return new DialogButton(index, label, action); + } + + private static DialogActionDefinition ParseAction(Dictionary action) + { + var type = NormalizeType(GetString(action, "type") ?? GetString(action, "action") ?? "minecraft:none"); + return type switch + { + "minecraft:run_command" => new DialogActionDefinition(DialogActionKind.RunCommand, Value: GetString(action, "command"), Type: type), + "minecraft:dynamic/run_command" => new DialogActionDefinition(DialogActionKind.RunCommand, Value: GetString(action, "template"), Type: type), + "minecraft:custom" => new DialogActionDefinition(DialogActionKind.CustomClick, Id: GetString(action, "id"), Payload: GetCompound(action, "payload"), Type: type), + "minecraft:dynamic/custom" => new DialogActionDefinition(DialogActionKind.CustomClick, Id: GetString(action, "id"), Payload: GetCompound(action, "additions"), Type: type), + "minecraft:open_url" => new DialogActionDefinition(DialogActionKind.OpenUrl, Value: GetString(action, "url"), Type: type), + "minecraft:suggest_command" => new DialogActionDefinition(DialogActionKind.SuggestCommand, Value: GetString(action, "command"), Type: type), + "minecraft:copy_to_clipboard" => new DialogActionDefinition(DialogActionKind.CopyToClipboard, Value: GetString(action, "value"), Type: type), + "minecraft:show_dialog" => ParseShowDialogAction(action, type), + _ => new DialogActionDefinition(DialogActionKind.Unknown, Type: type) + }; + } + + private static DialogActionDefinition ParseShowDialogAction(Dictionary action, string type) + { + if (!action.TryGetValue("dialog", out var value)) + return new DialogActionDefinition(DialogActionKind.ShowDialog, Type: type); + + if (value is Dictionary dialogData) + return new DialogActionDefinition(DialogActionKind.ShowDialog, NestedDialog: new DialogNbtParser().Parse(dialogData), Type: type); + + return new DialogActionDefinition(DialogActionKind.ShowDialog, Value: value.ToString(), Type: type); + } + + private static DialogAfterAction ParseAfterAction(string value) + { + return value switch + { + "none" => DialogAfterAction.None, + "wait_for_response" => DialogAfterAction.WaitForResponse, + _ => DialogAfterAction.Close + }; + } + + private static string ParseComponent(object? value) + { + if (value is null) + return string.Empty; + + try + { + return value switch + { + Dictionary compound => ChatParser.ParseText(compound), + string text when text.StartsWith('{') || text.StartsWith('[') => ChatParser.ParseText(text), + string text => text, + _ => value.ToString() ?? string.Empty + }; + } + catch + { + return value.ToString() ?? string.Empty; + } + } + + private static IEnumerable Enumerate(object? value) + { + if (value is null) + yield break; + + if (value is object[] array) + { + foreach (var item in array) + yield return item; + yield break; + } + + yield return value; + } + + private static object? GetValue(Dictionary data, string key) + { + return data.TryGetValue(key, out var value) ? value : null; + } + + private static string? GetString(Dictionary data, string key) + { + return data.TryGetValue(key, out var value) ? value as string ?? value.ToString() : null; + } + + private static Dictionary? GetCompound(Dictionary data, string key) + { + return data.TryGetValue(key, out var value) && value is Dictionary compound ? compound : null; + } + + private static bool GetBool(Dictionary data, string key, bool fallback) + { + if (!data.TryGetValue(key, out var value)) + return fallback; + + return value switch + { + bool boolean => boolean, + byte number => number != 0, + sbyte number => number != 0, + int number => number != 0, + string text when bool.TryParse(text, out var parsed) => parsed, + _ => fallback + }; + } + + private static int GetInt(Dictionary data, string key, int fallback) + { + if (!data.TryGetValue(key, out var value)) + return fallback; + + return value switch + { + byte number => number, + short number => number, + int number => number, + long number => (int)number, + string text when int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => fallback + }; + } + + private static float GetFloat(Dictionary data, string key, float fallback) + { + return TryGetFloat(data, key) ?? fallback; + } + + private static float? TryGetFloat(Dictionary data, string key) + { + if (!data.TryGetValue(key, out var value)) + return null; + + return value switch + { + float number => number, + double number => (float)number, + int number => number, + long number => number, + string text when float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => null + }; + } + + private static string NormalizeType(string type) + { + return type.Contains(':', StringComparison.Ordinal) ? type : "minecraft:" + type; + } + + private static void AddIfNotNull(List buttons, DialogButton? button) + { + if (button is not null) + buttons.Add(button); + } + + private static string NumberToString(float value) + { + var integer = (int)value; + return integer == value + ? integer.ToString(CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + } + + private sealed record DialogCommon( + string Title, + string? ExternalTitle, + bool CanCloseWithEscape, + bool Pause, + DialogAfterAction AfterAction, + IReadOnlyList Body, + IReadOnlyList Inputs); +} diff --git a/MinecraftClient/Protocol/Handlers/Protocol16.cs b/MinecraftClient/Protocol/Handlers/Protocol16.cs index cedbd412..f7bd3d4a 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol16.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol16.cs @@ -266,6 +266,11 @@ namespace MinecraftClient.Protocol.Handlers throw new NotImplementedException(); } + public bool SendCustomClickAction(string id, Dictionary? payload) + { + return false; + } + public void Dispose() { try diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 3e6dccc9..90786f5b 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using MinecraftClient.Crypto; +using MinecraftClient.Dialogs; using MinecraftClient.Inventory; using MinecraftClient.Inventory.ItemPalettes; using MinecraftClient.Logger; @@ -19,6 +20,7 @@ using MinecraftClient.Mapping.EntityPalettes; using MinecraftClient.Protocol.Handlers.Forge; using MinecraftClient.Protocol.Handlers.packet.s2c; using MinecraftClient.Protocol.Handlers.PacketPalettes; +using MinecraftClient.Protocol.Dialogs; using MinecraftClient.Protocol.Message; using MinecraftClient.Protocol.ProfileKey; using MinecraftClient.Protocol.Session; @@ -118,6 +120,7 @@ namespace MinecraftClient.Protocol.Handlers readonly PacketTypePalette packetPalette; readonly SocketWrapper socketWrapper; readonly DataTypes dataTypes; + readonly DialogNbtParser dialogNbtParser = new(); Tuple? netMain = null; // main thread Tuple? netReader = null; // reader thread readonly ILogger log; @@ -564,6 +567,7 @@ namespace MinecraftClient.Protocol.Handlers var isDimension = registryId == "minecraft:dimension_type"; var isAttribute = registryId == "minecraft:attribute"; var isEnchantment = registryId == "minecraft:enchantment"; + var isDialog = registryId == "minecraft:dialog"; var availableChats = isChat ? new Dictionary() : null; var dimensionIdMap = isDimension ? new Dictionary() : null; @@ -596,6 +600,8 @@ namespace MinecraftClient.Protocol.Handlers } else if (isEnchantment) enchantmentIdMap!.Add(i, entryId); + else if (isDialog && nbtData is not null) + handler.OnDialogRegistryData(i, entryId, dialogNbtParser.Parse(nbtData)); } if (isChat) @@ -671,16 +677,15 @@ namespace MinecraftClient.Protocol.Handlers break; case ConfigurationPacketTypesIn.ServerLinks: - var cfgLinksCount = dataTypes.ReadNextVarInt(packetData); - for (var i = 0; i < cfgLinksCount; i++) - { - var cfgIsBuiltIn = dataTypes.ReadNextBool(packetData); - if (cfgIsBuiltIn) - dataTypes.ReadNextVarInt(packetData); // Known type ID - else - dataTypes.ReadNextChat(packetData); // Component label - dataTypes.ReadNextString(packetData); // URL - } + handler.OnServerLinksUpdated(ReadServerLinks(packetData)); + break; + + case ConfigurationPacketTypesIn.ClearDialog: + handler.OnDialogCleared(); + break; + + case ConfigurationPacketTypesIn.ShowDialog: + HandleShowDialog(packetData, DialogPhase.Configuration); break; // Ignore other packets at this stage @@ -3373,16 +3378,15 @@ namespace MinecraftClient.Protocol.Handlers break; case PacketTypesIn.ServerLinks: - var linksCount = dataTypes.ReadNextVarInt(packetData); - for (var i = 0; i < linksCount; i++) - { - var isBuiltIn = dataTypes.ReadNextBool(packetData); - if (isBuiltIn) - dataTypes.ReadNextVarInt(packetData); // Known type ID - else - dataTypes.ReadNextChat(packetData); // Component label - dataTypes.ReadNextString(packetData); // URL - } + handler.OnServerLinksUpdated(ReadServerLinks(packetData)); + break; + + case PacketTypesIn.ClearDialog: + handler.OnDialogCleared(); + break; + + case PacketTypesIn.ShowDialog: + HandleShowDialog(packetData, DialogPhase.Play); break; // 1.21.2+ new packets @@ -4105,6 +4109,61 @@ namespace MinecraftClient.Protocol.Handlers SendPacket(packetPalette.GetOutgoingIdByTypeConfiguration(packet), packetData, packet.ToString()); } + private void HandleShowDialog(Queue packetData, DialogPhase phase) + { + if (phase == DialogPhase.Play) + { + var holderId = dataTypes.ReadNextVarInt(packetData); + if (holderId != 0) + { + handler.OnDialogRegistryReferenceShown(holderId - 1, phase); + return; + } + } + + var dialog = dialogNbtParser.Parse(dataTypes.ReadNextNbt(packetData)); + handler.OnDialogShown(dialog, phase); + } + + private IReadOnlyList ReadServerLinks(Queue packetData) + { + var linksCount = dataTypes.ReadNextVarInt(packetData); + List links = new(linksCount); + + for (var i = 0; i < linksCount; i++) + { + string label; + var isBuiltIn = dataTypes.ReadNextBool(packetData); + if (isBuiltIn) + label = GetKnownServerLinkLabel(dataTypes.ReadNextVarInt(packetData)); + else + label = dataTypes.ReadNextChat(packetData); + + var url = dataTypes.ReadNextString(packetData); + links.Add(new DialogServerLink(label, url)); + } + + return links; + } + + private static string GetKnownServerLinkLabel(int id) + { + return id switch + { + 0 => Translations.dialog_server_link_report_bug, + 1 => Translations.dialog_server_link_community_guidelines, + 2 => Translations.dialog_server_link_support, + 3 => Translations.dialog_server_link_status, + 4 => Translations.dialog_server_link_feedback, + 5 => Translations.dialog_server_link_community, + 6 => Translations.dialog_server_link_website, + 7 => Translations.dialog_server_link_forums, + 8 => Translations.dialog_server_link_news, + 9 => Translations.dialog_server_link_announcements, + _ => id.ToString(CultureInfo.InvariantCulture) + }; + } + /// /// Send a packet to the server. Compression and encryption will be handled automatically. /// @@ -5025,6 +5084,49 @@ namespace MinecraftClient.Protocol.Handlers } } + public bool SendCustomClickAction(string id, Dictionary? payload) + { + if (protocolVersion < MC_1_21_6_Version) + return false; + + try + { + List fields = new(); + fields.AddRange(dataTypes.GetString(id.Contains(':', StringComparison.Ordinal) ? id : "minecraft:" + id)); + + var tagBytes = dataTypes.GetNbtTag(payload); + if (tagBytes.Length > 65536) + return false; + + fields.AddRange(DataTypes.GetVarInt(tagBytes.Length)); + fields.AddRange(tagBytes); + + switch (currentState) + { + case CurrentState.Configuration: + SendPacket(ConfigurationPacketTypesOut.CustomClickAction, fields); + return true; + case CurrentState.Play: + SendPacket(PacketTypesOut.CustomClickAction, fields); + return true; + default: + return false; + } + } + catch (SocketException) + { + return false; + } + catch (System.IO.IOException) + { + return false; + } + catch (ObjectDisposedException) + { + return false; + } + } + /// /// Send a chat message to the server /// diff --git a/MinecraftClient/Protocol/IMinecraftCom.cs b/MinecraftClient/Protocol/IMinecraftCom.cs index d8f87a2a..ee604ceb 100644 --- a/MinecraftClient/Protocol/IMinecraftCom.cs +++ b/MinecraftClient/Protocol/IMinecraftCom.cs @@ -48,6 +48,14 @@ namespace MinecraftClient.Protocol /// True if successfully sent bool SendChatMessage(string message, PlayerKeyPair? playerKeyPair = null); + /// + /// Send a custom click action packet introduced for dialogs in Minecraft 1.21.6. + /// + /// Custom action resource location + /// Optional NBT payload + /// True if successfully sent + bool SendCustomClickAction(string id, Dictionary? payload); + /// /// Allow to respawn after death /// diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index ec3881eb..447c7287 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using MinecraftClient.Dialogs; using MinecraftClient.Inventory; using MinecraftClient.Logger; using MinecraftClient.Mapping; @@ -93,6 +94,31 @@ namespace MinecraftClient.Protocol /// Message received public void OnTextReceived(ChatMessage message); + /// + /// Called when the server synchronizes a dialog registry entry. + /// + void OnDialogRegistryData(int protocolId, string resourceId, DialogDefinition dialog); + + /// + /// Called when the server shows a custom dialog. + /// + void OnDialogShown(DialogDefinition dialog, DialogPhase phase); + + /// + /// Called when the server shows a custom dialog by registry protocol ID. + /// + void OnDialogRegistryReferenceShown(int protocolId, DialogPhase phase); + + /// + /// Called when the server clears the current custom dialog. + /// + void OnDialogCleared(); + + /// + /// Called when the server sends updated server links. + /// + void OnServerLinksUpdated(IReadOnlyList links); + /// /// Will be called every animations of the hit and place block /// diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index ab775990..01936daf 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -7801,5 +7801,281 @@ namespace MinecraftClient { get { return ResourceManager.GetString("debug.packet.loop_reason.cancelled", resourceCulture); } } + internal static string cmd_dialog_usage { + get { return ResourceManager.GetString("cmd.dialog.usage", resourceCulture); } + } + + internal static string cmd_dialog_desc { + get { return ResourceManager.GetString("cmd.dialog.desc", resourceCulture); } + } + + internal static string dialog_received { + get { return ResourceManager.GetString("dialog.received", resourceCulture); } + } + + internal static string dialog_cleared { + get { return ResourceManager.GetString("dialog.cleared", resourceCulture); } + } + + internal static string dialog_none { + get { return ResourceManager.GetString("dialog.none", resourceCulture); } + } + + internal static string dialog_dismissed { + get { return ResourceManager.GetString("dialog.dismissed", resourceCulture); } + } + + internal static string dialog_unresolved_title { + get { return ResourceManager.GetString("dialog.unresolved_title", resourceCulture); } + } + + internal static string dialog_unresolved_body { + get { return ResourceManager.GetString("dialog.unresolved_body", resourceCulture); } + } + + internal static string dialog_unresolved_action_disabled { + get { return ResourceManager.GetString("dialog.unresolved_action_disabled", resourceCulture); } + } + + internal static string dialog_input_unknown { + get { return ResourceManager.GetString("dialog.input_unknown", resourceCulture); } + } + + internal static string dialog_input_set { + get { return ResourceManager.GetString("dialog.input_set", resourceCulture); } + } + + internal static string dialog_input_too_long { + get { return ResourceManager.GetString("dialog.input_too_long", resourceCulture); } + } + + internal static string dialog_input_boolean_invalid { + get { return ResourceManager.GetString("dialog.input_boolean_invalid", resourceCulture); } + } + + internal static string dialog_input_option_invalid { + get { return ResourceManager.GetString("dialog.input_option_invalid", resourceCulture); } + } + + internal static string dialog_input_number_invalid { + get { return ResourceManager.GetString("dialog.input_number_invalid", resourceCulture); } + } + + internal static string dialog_input_number_range_invalid { + get { return ResourceManager.GetString("dialog.input_number_range_invalid", resourceCulture); } + } + + internal static string dialog_action_unknown { + get { return ResourceManager.GetString("dialog.action_unknown", resourceCulture); } + } + + internal static string dialog_action_label_unknown { + get { return ResourceManager.GetString("dialog.action_label_unknown", resourceCulture); } + } + + internal static string dialog_action_label_ambiguous { + get { return ResourceManager.GetString("dialog.action_label_ambiguous", resourceCulture); } + } + + internal static string dialog_cannot_cancel { + get { return ResourceManager.GetString("dialog.cannot_cancel", resourceCulture); } + } + + internal static string dialog_action_closed { + get { return ResourceManager.GetString("dialog.action_closed", resourceCulture); } + } + + internal static string dialog_action_command_not_in_play { + get { return ResourceManager.GetString("dialog.action_command_not_in_play", resourceCulture); } + } + + internal static string dialog_action_invalid { + get { return ResourceManager.GetString("dialog.action_invalid", resourceCulture); } + } + + internal static string dialog_action_custom_failed { + get { return ResourceManager.GetString("dialog.action_custom_failed", resourceCulture); } + } + + internal static string dialog_action_command_sent { + get { return ResourceManager.GetString("dialog.action_command_sent", resourceCulture); } + } + + internal static string dialog_action_custom_sent { + get { return ResourceManager.GetString("dialog.action_custom_sent", resourceCulture); } + } + + internal static string dialog_action_nested_opened { + get { return ResourceManager.GetString("dialog.action_nested_opened", resourceCulture); } + } + + internal static string dialog_action_open_url { + get { return ResourceManager.GetString("dialog.action_open_url", resourceCulture); } + } + + internal static string dialog_action_suggest_command { + get { return ResourceManager.GetString("dialog.action_suggest_command", resourceCulture); } + } + + internal static string dialog_action_copy { + get { return ResourceManager.GetString("dialog.action_copy", resourceCulture); } + } + + internal static string dialog_action_unsupported { + get { return ResourceManager.GetString("dialog.action_unsupported", resourceCulture); } + } + + internal static string dialog_tui_pending { + get { return ResourceManager.GetString("dialog.tui_pending", resourceCulture); } + } + + internal static string dialog_tui_unavailable { + get { return ResourceManager.GetString("dialog.tui_unavailable", resourceCulture); } + } + + internal static string dialog_tui_opened { + get { return ResourceManager.GetString("dialog.tui_opened", resourceCulture); } + } + + internal static string dialog_action_unnamed { + get { return ResourceManager.GetString("dialog.action_unnamed", resourceCulture); } + } + + internal static string dialog_action_ok { + get { return ResourceManager.GetString("dialog.action_ok", resourceCulture); } + } + + internal static string dialog_item_body { + get { return ResourceManager.GetString("dialog.item_body", resourceCulture); } + } + + internal static string dialog_server_link_report_bug { + get { return ResourceManager.GetString("dialog.server_link.report_bug", resourceCulture); } + } + + internal static string dialog_server_link_community_guidelines { + get { return ResourceManager.GetString("dialog.server_link.community_guidelines", resourceCulture); } + } + + internal static string dialog_server_link_support { + get { return ResourceManager.GetString("dialog.server_link.support", resourceCulture); } + } + + internal static string dialog_server_link_status { + get { return ResourceManager.GetString("dialog.server_link.status", resourceCulture); } + } + + internal static string dialog_server_link_feedback { + get { return ResourceManager.GetString("dialog.server_link.feedback", resourceCulture); } + } + + internal static string dialog_server_link_community { + get { return ResourceManager.GetString("dialog.server_link.community", resourceCulture); } + } + + internal static string dialog_server_link_website { + get { return ResourceManager.GetString("dialog.server_link.website", resourceCulture); } + } + + internal static string dialog_server_link_forums { + get { return ResourceManager.GetString("dialog.server_link.forums", resourceCulture); } + } + + internal static string dialog_server_link_news { + get { return ResourceManager.GetString("dialog.server_link.news", resourceCulture); } + } + + internal static string dialog_server_link_announcements { + get { return ResourceManager.GetString("dialog.server_link.announcements", resourceCulture); } + } + + internal static string tui_dialog_cancel { + get { return ResourceManager.GetString("tui.dialog.cancel", resourceCulture); } + } + + internal static string dialog_render_header { + get { return ResourceManager.GetString("dialog.render.header", resourceCulture); } + } + + internal static string dialog_render_type { + get { return ResourceManager.GetString("dialog.render.type", resourceCulture); } + } + + internal static string dialog_render_body { + get { return ResourceManager.GetString("dialog.render.body", resourceCulture); } + } + + internal static string dialog_render_inputs { + get { return ResourceManager.GetString("dialog.render.inputs", resourceCulture); } + } + + internal static string dialog_render_input { + get { return ResourceManager.GetString("dialog.render.input", resourceCulture); } + } + + internal static string dialog_render_actions { + get { return ResourceManager.GetString("dialog.render.actions", resourceCulture); } + } + + internal static string dialog_render_action { + get { return ResourceManager.GetString("dialog.render.action", resourceCulture); } + } + + internal static string dialog_render_cancel_hint { + get { return ResourceManager.GetString("dialog.render.cancel_hint", resourceCulture); } + } + + internal static string dialog_input_desc_text { + get { return ResourceManager.GetString("dialog.input_desc_text", resourceCulture); } + } + + internal static string dialog_input_desc_boolean { + get { return ResourceManager.GetString("dialog.input_desc_boolean", resourceCulture); } + } + + internal static string dialog_input_desc_options { + get { return ResourceManager.GetString("dialog.input_desc_options", resourceCulture); } + } + + internal static string dialog_input_desc_number { + get { return ResourceManager.GetString("dialog.input_desc_number", resourceCulture); } + } + + internal static string dialog_input_desc_unknown { + get { return ResourceManager.GetString("dialog.input_desc_unknown", resourceCulture); } + } + + internal static string dialog_action_desc_close { + get { return ResourceManager.GetString("dialog.action_desc_close", resourceCulture); } + } + + internal static string dialog_action_desc_command { + get { return ResourceManager.GetString("dialog.action_desc_command", resourceCulture); } + } + + internal static string dialog_action_desc_custom { + get { return ResourceManager.GetString("dialog.action_desc_custom", resourceCulture); } + } + + internal static string dialog_action_desc_show_dialog { + get { return ResourceManager.GetString("dialog.action_desc_show_dialog", resourceCulture); } + } + + internal static string dialog_action_desc_open_url { + get { return ResourceManager.GetString("dialog.action_desc_open_url", resourceCulture); } + } + + internal static string dialog_action_desc_suggest { + get { return ResourceManager.GetString("dialog.action_desc_suggest", resourceCulture); } + } + + internal static string dialog_action_desc_copy { + get { return ResourceManager.GetString("dialog.action_desc_copy", resourceCulture); } + } + + internal static string dialog_action_desc_unknown { + get { return ResourceManager.GetString("dialog.action_desc_unknown", resourceCulture); } + } + } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 5494dafc..a0fa7014 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2827,4 +2827,211 @@ see item details. cancelled + + dialog [show|open|set|click|click-label|cancel|dismiss] + + + View and interact with the current server custom dialog. + + + Server showed custom dialog: {0}. Use dialog show. + + + Server cleared the custom dialog. + + + No custom dialog is active. + + + Dialog dismissed locally. + + + Unresolved dialog {0} + + + The server referenced dialog registry entry {0}, but MCC has no data for it. + + + This dialog could not be resolved, so actions are disabled. + + + Unknown dialog input: {0} + + + Dialog input {0} set to {1}. + + + Input {0} is longer than {1} characters. + + + Input {0} expects true or false. + + + Input {0} expects one of the listed option IDs. + + + Input {0} expects a number. + + + Input {0} must be between {1} and {2}. + + + Unknown dialog action index: {0} + + + Unknown dialog action label: {0} + + + Dialog action label is ambiguous: {0} + + + This dialog cannot be closed with cancel. + + + Dialog action closed locally. + + + This dialog command action is only available in play state. + + + Dialog action is invalid. + + + Custom dialog action packet could not be sent. + + + Dialog command sent: {0} + + + Dialog custom action sent: {0} + + + Nested dialog opened. + + + Dialog URL action: {0} + + + Dialog suggested command: {0} + + + Dialog copy action: {0} + + + Unsupported dialog action: {0} + + + Custom dialog is pending. Use dialog open after closing the current overlay. + + + Dialog TUI is unavailable. + + + Dialog TUI opened. + + + Action + + + OK + + + Item preview + + + Report bug + + + Community guidelines + + + Support + + + Status + + + Feedback + + + Community + + + Website + + + Forums + + + News + + + Announcements + + + Cancel + + + Dialog #{0} [{1}]: {2} + + + Type: {0} + + + Body: {0} + + + Inputs: + + + {0} ({1}) {2} = {3} [{4}] + + + Actions: + + + [{0}] {1} ({2}) + + + Use dialog cancel to close or run the cancel action. + + + max {0} chars + + + true={0}, false={1} + + + options: {0} + + + range {0}..{1} + + + unknown input + + + close + + + command + + + custom + + + show dialog + + + open URL + + + suggest command + + + copy + + + unknown + diff --git a/MinecraftClient/Tui/DialogTuiHost.cs b/MinecraftClient/Tui/DialogTuiHost.cs new file mode 100644 index 00000000..f926b35e --- /dev/null +++ b/MinecraftClient/Tui/DialogTuiHost.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using MinecraftClient.Dialogs; + +namespace MinecraftClient.Tui; + +internal interface IOverlayCloseHandler +{ + bool TryCloseByUser(); +} + +public static class DialogTuiHost +{ + public static bool TryOpen(McClient handler, DialogInstance instance, bool force) + { + if (ConsoleIO.Backend is not TuiConsoleBackend) + return false; + + Dispatcher.UIThread.Post(() => + { + var view = TuiConsoleBackend.Instance?.GetView(); + if (view is null) + return; + + if (view.HasOverlay && view.OverlayContent is not DialogView && !force) + { + handler.Log.Info(Translations.dialog_tui_pending); + return; + } + + view.ShowOverlay(new DialogView(handler, instance)); + }); + + return true; + } + + public static void CloseCurrent() + { + if (ConsoleIO.Backend is not TuiConsoleBackend) + return; + + Dispatcher.UIThread.Post(() => + { + var view = TuiConsoleBackend.Instance?.GetView(); + if (view?.OverlayContent is DialogView) + view.HideOverlay(); + }); + } +} + +internal sealed class DialogView : Border, IOverlayCloseHandler +{ + private readonly McClient _handler; + private readonly DialogInstance _instance; + private readonly TextBlock _status; + private readonly Dictionary _inputControls = new(StringComparer.Ordinal); + + public DialogView(McClient handler, DialogInstance instance) + { + _handler = handler; + _instance = instance; + + BorderBrush = Brushes.White; + BorderThickness = new Thickness(1); + Background = Brushes.Black; + Padding = new Thickness(1); + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + Focusable = true; + + _status = new TextBlock + { + Foreground = Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(1, 0) + }; + + Child = BuildContent(); + + AttachedToVisualTree += (_, _) => + { + AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true); + Focus(); + }; + DetachedFromVisualTree += (_, _) => RemoveHandler(KeyDownEvent, OnTunnelKeyDown); + } + + public bool TryCloseByUser() + { + if (!_instance.Definition.CanCloseWithEscape && _instance.Definition.CancelAction is null) + { + SetStatus(Translations.dialog_cannot_cancel); + return false; + } + + var result = _handler.Dialogs.Cancel(); + SetStatus(result.Message); + if (result.Success) + CloseIfInactive(); + + return false; + } + + private Control BuildContent() + { + var main = new StackPanel + { + Spacing = 1, + Margin = new Thickness(1) + }; + + main.Children.Add(new TextBlock + { + Text = _instance.Definition.DisplayTitle(), + Foreground = Brushes.Yellow, + FontWeight = FontWeight.Bold, + TextWrapping = TextWrapping.Wrap + }); + + foreach (var body in _instance.Definition.Body) + { + if (string.IsNullOrWhiteSpace(body.Text)) + continue; + + main.Children.Add(new TextBlock + { + Text = body.Text, + Foreground = Brushes.White, + TextWrapping = TextWrapping.Wrap + }); + } + + foreach (var input in _instance.Definition.Inputs) + main.Children.Add(BuildInput(input)); + + var buttons = new WrapPanel + { + Orientation = Orientation.Horizontal + }; + + foreach (var action in _instance.Definition.Actions) + { + var button = new Button + { + Content = action.Label, + Margin = new Thickness(0, 0, 1, 1), + MinWidth = Math.Max(8, Math.Min(action.Label.Length + 4, 32)) + }; + button.Click += (_, _) => Click(action.Index); + buttons.Children.Add(button); + } + + if (_instance.Definition.CancelAction is not null || _instance.Definition.CanCloseWithEscape) + { + var cancel = new Button + { + Content = Translations.tui_dialog_cancel, + Margin = new Thickness(0, 0, 1, 1) + }; + cancel.Click += (_, _) => TryCloseByUser(); + buttons.Children.Add(cancel); + } + + if (buttons.Children.Count > 0) + main.Children.Add(buttons); + + main.Children.Add(_status); + + return new ScrollViewer + { + Content = main, + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto + }; + } + + private Control BuildInput(DialogInput input) + { + _instance.Values.TryGetValue(input.Key, out var value); + value ??= input.InitialValue; + + var panel = new StackPanel + { + Spacing = 0, + Margin = new Thickness(0, 1) + }; + + if (input.LabelVisible && !string.IsNullOrWhiteSpace(input.Label)) + { + panel.Children.Add(new TextBlock + { + Text = input.Label, + Foreground = Brushes.LightGray, + TextWrapping = TextWrapping.Wrap + }); + } + + Control control = input.Kind switch + { + DialogInputKind.Boolean => BuildBooleanInput(value), + DialogInputKind.SingleOption => BuildOptionInput(input, value), + DialogInputKind.NumberRange => BuildNumberInput(input, value), + _ => BuildTextInput(input, value) + }; + + _inputControls[input.Key] = control; + panel.Children.Add(control); + return panel; + } + + private static Control BuildTextInput(DialogInput input, string value) + { + return new TextBox + { + Text = value, + AcceptsReturn = input.Multiline, + TextWrapping = TextWrapping.Wrap, + MaxLength = input.MaxLength, + Foreground = Brushes.White, + Background = Brushes.Black, + BorderBrush = Brushes.Gray + }; + } + + private static Control BuildBooleanInput(string value) + { + return new CheckBox + { + IsChecked = value.Equals("true", StringComparison.OrdinalIgnoreCase), + Foreground = Brushes.White + }; + } + + private static Control BuildOptionInput(DialogInput input, string value) + { + var combo = new ComboBox + { + ItemsSource = input.Options ?? [], + Foreground = Brushes.White + }; + combo.SelectionBoxItemTemplate = null; + combo.SelectedItem = input.Options?.FirstOrDefault(option => option.Id.Equals(value, StringComparison.Ordinal)) + ?? input.Options?.FirstOrDefault(); + return combo; + } + + private static Control BuildNumberInput(DialogInput input, string value) + { + var slider = new Slider + { + Minimum = Math.Min(input.Start, input.End), + Maximum = Math.Max(input.Start, input.End), + Value = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) + ? number + : input.InitialNumber ?? input.Start, + TickFrequency = input.Step ?? 1, + IsSnapToTickEnabled = input.Step is not null + }; + return slider; + } + + private void Click(int index) + { + if (!StoreInputs()) + return; + + var result = _handler.Dialogs.Click(index); + SetStatus(result.Message); + if (result.Success) + CloseIfInactive(); + } + + private bool StoreInputs() + { + foreach (var input in _instance.Definition.Inputs) + { + if (!_inputControls.TryGetValue(input.Key, out var control)) + continue; + + var value = control switch + { + TextBox textBox => textBox.Text ?? string.Empty, + CheckBox checkBox => checkBox.IsChecked == true ? "true" : "false", + ComboBox comboBox when comboBox.SelectedItem is DialogOption option => option.Id, + Slider slider => NumberToString((float)slider.Value), + _ => input.InitialValue + }; + + var result = _handler.Dialogs.SetInput(input.Key, value); + if (!result.Success) + { + SetStatus(result.Message); + return false; + } + } + + return true; + } + + private void CloseIfInactive() + { + var current = _handler.Dialogs.Current; + if (current is null) + { + DialogTuiHost.CloseCurrent(); + return; + } + + if (current.Revision != _instance.Revision) + DialogTuiHost.TryOpen(_handler, current, force: true); + } + + private void SetStatus(string text) + { + _status.Text = text; + } + + private void OnTunnelKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Escape) + return; + + TryCloseByUser(); + e.Handled = true; + } + + private static string NumberToString(float value) + { + var integer = (int)value; + return integer == value + ? integer.ToString(CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs index 9eda3c62..78ec2592 100644 --- a/MinecraftClient/Tui/MainTuiView.cs +++ b/MinecraftClient/Tui/MainTuiView.cs @@ -1207,6 +1207,15 @@ namespace MinecraftClient.Tui { if (e.Key == Key.Escape && _overlayContent != null) { + if (_overlayContent is IOverlayCloseHandler closeHandler) + { + if (closeHandler.TryCloseByUser()) + HideOverlay(); + + e.Handled = true; + return; + } + HideOverlay(); e.Handled = true; return;