Dialog System First iteration, not tested

This commit is contained in:
Anon 2026-06-13 23:59:44 +02:00
parent 37cf19c175
commit 3d988c932f
14 changed files with 2250 additions and 20 deletions

View file

@ -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<CmdResult> 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);
}
}

View file

@ -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
};
}
}

View file

@ -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<int, DialogDefinition> _registryById = new();
private readonly Dictionary<string, DialogDefinition> _registryByName = new(StringComparer.Ordinal);
private readonly List<DialogServerLink> _serverLinks = [];
private DialogInstance? _current;
private int _revision;
public DialogManager(McClient client)
{
ArgumentNullException.ThrowIfNull(client);
_client = client;
}
public event Action<DialogInstance>? DialogShown;
public event Action<int>? 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<DialogServerLink> 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<string, string> templateValues = new(StringComparer.Ordinal);
Dictionary<string, object> 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<string, object> MergePayload(Dictionary<string, object>? basePayload, Dictionary<string, object> inputTags)
{
Dictionary<string, object> 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<string, string> 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<string, string> TemplateValues,
Dictionary<string, object> TagValues);
}

View file

@ -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<DialogOption>? 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<string, object>? 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<DialogBody> Body,
IReadOnlyList<DialogInput> Inputs,
IReadOnlyList<DialogButton> 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<string, string> Values,
DateTimeOffset ReceivedAt);
public sealed record DialogActionResult(bool Success, string Message);

View file

@ -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<string, object>? payload)
{
if (InvokeRequired)
return InvokeOnMainThread(() => SendCustomClickAction(id, payload));
return handler.SendCustomClickAction(id, payload);
}
/// <summary>
/// Allow to respawn after death
/// </summary>
@ -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<DialogServerLink> links)
{
Dialogs.SetServerLinks(links);
}
/// <summary>
/// Called when a server was successfully joined
/// </summary>

View file

@ -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<string, object> nbt)
{
var type = NormalizeType(GetString(nbt, "type") ?? "minecraft:notice");
var common = ParseCommon(nbt, type);
var actions = new List<DialogButton>();
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<string, object>? nbt)
{
return nbt is null ? null : Parse(nbt);
}
private static DialogCommon ParseCommon(Dictionary<string, object> 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<DialogBody> ParseBody(object? value)
{
if (value is null)
return [];
List<DialogBody> body = [];
foreach (var item in Enumerate(value))
{
if (item is Dictionary<string, object> 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<string, object> compound && compound.TryGetValue("contents", out var contents))
return ParseComponent(contents);
return ParseComponent(value);
}
private static IReadOnlyList<DialogInput> ParseInputs(object? value)
{
if (value is null)
return [];
List<DialogInput> inputs = [];
foreach (var item in Enumerate(value))
{
if (item is not Dictionary<string, object> inputData)
continue;
var key = GetString(inputData, "key");
if (string.IsNullOrWhiteSpace(key))
continue;
var control = inputData.TryGetValue("control", out var controlValue) && controlValue is Dictionary<string, object> 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<string, object> 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<string, object> 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<string, object> 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<string, object> control)
{
var range = control.TryGetValue("range_info", out var rangeValue) && rangeValue is Dictionary<string, object> 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<DialogOption> ParseOptions(object? value)
{
if (value is null)
return [];
List<DialogOption> options = [];
foreach (var item in Enumerate(value))
{
if (item is string id)
{
options.Add(new DialogOption(id, id, false));
continue;
}
if (item is Dictionary<string, object> 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<DialogButton> ParseButtonList(object? value)
{
List<DialogButton> buttons = [];
var index = 1;
foreach (var item in Enumerate(value))
{
if (item is Dictionary<string, object> buttonData)
buttons.Add(ParseButton(buttonData, index++) ?? new DialogButton(index - 1, Translations.dialog_action_unnamed, null));
}
return buttons;
}
private static IEnumerable<DialogButton> ParseDialogListActions(object? value)
{
List<DialogButton> 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<string, object> 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<string, object> owner, string key, int index)
{
return owner.TryGetValue(key, out var value) && value is Dictionary<string, object> data
? ParseButton(data, index)
: null;
}
private static DialogButton? ParseButton(Dictionary<string, object> 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<string, object> actionData
? ParseAction(actionData)
: null;
return new DialogButton(index, label, action);
}
private static DialogActionDefinition ParseAction(Dictionary<string, object> 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<string, object> action, string type)
{
if (!action.TryGetValue("dialog", out var value))
return new DialogActionDefinition(DialogActionKind.ShowDialog, Type: type);
if (value is Dictionary<string, object> 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<string, object> 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<object> 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<string, object> data, string key)
{
return data.TryGetValue(key, out var value) ? value : null;
}
private static string? GetString(Dictionary<string, object> data, string key)
{
return data.TryGetValue(key, out var value) ? value as string ?? value.ToString() : null;
}
private static Dictionary<string, object>? GetCompound(Dictionary<string, object> data, string key)
{
return data.TryGetValue(key, out var value) && value is Dictionary<string, object> compound ? compound : null;
}
private static bool GetBool(Dictionary<string, object> 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<string, object> 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<string, object> data, string key, float fallback)
{
return TryGetFloat(data, key) ?? fallback;
}
private static float? TryGetFloat(Dictionary<string, object> 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<DialogButton> 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<DialogBody> Body,
IReadOnlyList<DialogInput> Inputs);
}

View file

@ -266,6 +266,11 @@ namespace MinecraftClient.Protocol.Handlers
throw new NotImplementedException();
}
public bool SendCustomClickAction(string id, Dictionary<string, object>? payload)
{
return false;
}
public void Dispose()
{
try

View file

@ -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<Thread, CancellationTokenSource>? netMain = null; // main thread
Tuple<Thread, CancellationTokenSource>? 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<int, string>() : null;
var dimensionIdMap = isDimension ? new Dictionary<int, string>() : 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<byte> 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<DialogServerLink> ReadServerLinks(Queue<byte> packetData)
{
var linksCount = dataTypes.ReadNextVarInt(packetData);
List<DialogServerLink> 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)
};
}
/// <summary>
/// Send a packet to the server. Compression and encryption will be handled automatically.
/// </summary>
@ -5025,6 +5084,49 @@ namespace MinecraftClient.Protocol.Handlers
}
}
public bool SendCustomClickAction(string id, Dictionary<string, object>? payload)
{
if (protocolVersion < MC_1_21_6_Version)
return false;
try
{
List<byte> 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;
}
}
/// <summary>
/// Send a chat message to the server
/// </summary>

View file

@ -48,6 +48,14 @@ namespace MinecraftClient.Protocol
/// <returns>True if successfully sent</returns>
bool SendChatMessage(string message, PlayerKeyPair? playerKeyPair = null);
/// <summary>
/// Send a custom click action packet introduced for dialogs in Minecraft 1.21.6.
/// </summary>
/// <param name="id">Custom action resource location</param>
/// <param name="payload">Optional NBT payload</param>
/// <returns>True if successfully sent</returns>
bool SendCustomClickAction(string id, Dictionary<string, object>? payload);
/// <summary>
/// Allow to respawn after death
/// </summary>

View file

@ -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
/// <param name="message">Message received</param>
public void OnTextReceived(ChatMessage message);
/// <summary>
/// Called when the server synchronizes a dialog registry entry.
/// </summary>
void OnDialogRegistryData(int protocolId, string resourceId, DialogDefinition dialog);
/// <summary>
/// Called when the server shows a custom dialog.
/// </summary>
void OnDialogShown(DialogDefinition dialog, DialogPhase phase);
/// <summary>
/// Called when the server shows a custom dialog by registry protocol ID.
/// </summary>
void OnDialogRegistryReferenceShown(int protocolId, DialogPhase phase);
/// <summary>
/// Called when the server clears the current custom dialog.
/// </summary>
void OnDialogCleared();
/// <summary>
/// Called when the server sends updated server links.
/// </summary>
void OnServerLinksUpdated(IReadOnlyList<DialogServerLink> links);
/// <summary>
/// Will be called every animations of the hit and place block
/// </summary>

View file

@ -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); }
}
}
}

View file

@ -2827,4 +2827,211 @@ see item details.</value>
<data name="debug.packet.loop_reason.cancelled" xml:space="preserve">
<value>cancelled</value>
</data>
<data name="cmd.dialog.usage" xml:space="preserve">
<value>dialog [show|open|set|click|click-label|cancel|dismiss]</value>
</data>
<data name="cmd.dialog.desc" xml:space="preserve">
<value>View and interact with the current server custom dialog.</value>
</data>
<data name="dialog.received" xml:space="preserve">
<value>Server showed custom dialog: {0}. Use dialog show.</value>
</data>
<data name="dialog.cleared" xml:space="preserve">
<value>Server cleared the custom dialog.</value>
</data>
<data name="dialog.none" xml:space="preserve">
<value>No custom dialog is active.</value>
</data>
<data name="dialog.dismissed" xml:space="preserve">
<value>Dialog dismissed locally.</value>
</data>
<data name="dialog.unresolved_title" xml:space="preserve">
<value>Unresolved dialog {0}</value>
</data>
<data name="dialog.unresolved_body" xml:space="preserve">
<value>The server referenced dialog registry entry {0}, but MCC has no data for it.</value>
</data>
<data name="dialog.unresolved_action_disabled" xml:space="preserve">
<value>This dialog could not be resolved, so actions are disabled.</value>
</data>
<data name="dialog.input_unknown" xml:space="preserve">
<value>Unknown dialog input: {0}</value>
</data>
<data name="dialog.input_set" xml:space="preserve">
<value>Dialog input {0} set to {1}.</value>
</data>
<data name="dialog.input_too_long" xml:space="preserve">
<value>Input {0} is longer than {1} characters.</value>
</data>
<data name="dialog.input_boolean_invalid" xml:space="preserve">
<value>Input {0} expects true or false.</value>
</data>
<data name="dialog.input_option_invalid" xml:space="preserve">
<value>Input {0} expects one of the listed option IDs.</value>
</data>
<data name="dialog.input_number_invalid" xml:space="preserve">
<value>Input {0} expects a number.</value>
</data>
<data name="dialog.input_number_range_invalid" xml:space="preserve">
<value>Input {0} must be between {1} and {2}.</value>
</data>
<data name="dialog.action_unknown" xml:space="preserve">
<value>Unknown dialog action index: {0}</value>
</data>
<data name="dialog.action_label_unknown" xml:space="preserve">
<value>Unknown dialog action label: {0}</value>
</data>
<data name="dialog.action_label_ambiguous" xml:space="preserve">
<value>Dialog action label is ambiguous: {0}</value>
</data>
<data name="dialog.cannot_cancel" xml:space="preserve">
<value>This dialog cannot be closed with cancel.</value>
</data>
<data name="dialog.action_closed" xml:space="preserve">
<value>Dialog action closed locally.</value>
</data>
<data name="dialog.action_command_not_in_play" xml:space="preserve">
<value>This dialog command action is only available in play state.</value>
</data>
<data name="dialog.action_invalid" xml:space="preserve">
<value>Dialog action is invalid.</value>
</data>
<data name="dialog.action_custom_failed" xml:space="preserve">
<value>Custom dialog action packet could not be sent.</value>
</data>
<data name="dialog.action_command_sent" xml:space="preserve">
<value>Dialog command sent: {0}</value>
</data>
<data name="dialog.action_custom_sent" xml:space="preserve">
<value>Dialog custom action sent: {0}</value>
</data>
<data name="dialog.action_nested_opened" xml:space="preserve">
<value>Nested dialog opened.</value>
</data>
<data name="dialog.action_open_url" xml:space="preserve">
<value>Dialog URL action: {0}</value>
</data>
<data name="dialog.action_suggest_command" xml:space="preserve">
<value>Dialog suggested command: {0}</value>
</data>
<data name="dialog.action_copy" xml:space="preserve">
<value>Dialog copy action: {0}</value>
</data>
<data name="dialog.action_unsupported" xml:space="preserve">
<value>Unsupported dialog action: {0}</value>
</data>
<data name="dialog.tui_pending" xml:space="preserve">
<value>Custom dialog is pending. Use dialog open after closing the current overlay.</value>
</data>
<data name="dialog.tui_unavailable" xml:space="preserve">
<value>Dialog TUI is unavailable.</value>
</data>
<data name="dialog.tui_opened" xml:space="preserve">
<value>Dialog TUI opened.</value>
</data>
<data name="dialog.action_unnamed" xml:space="preserve">
<value>Action</value>
</data>
<data name="dialog.action_ok" xml:space="preserve">
<value>OK</value>
</data>
<data name="dialog.item_body" xml:space="preserve">
<value>Item preview</value>
</data>
<data name="dialog.server_link.report_bug" xml:space="preserve">
<value>Report bug</value>
</data>
<data name="dialog.server_link.community_guidelines" xml:space="preserve">
<value>Community guidelines</value>
</data>
<data name="dialog.server_link.support" xml:space="preserve">
<value>Support</value>
</data>
<data name="dialog.server_link.status" xml:space="preserve">
<value>Status</value>
</data>
<data name="dialog.server_link.feedback" xml:space="preserve">
<value>Feedback</value>
</data>
<data name="dialog.server_link.community" xml:space="preserve">
<value>Community</value>
</data>
<data name="dialog.server_link.website" xml:space="preserve">
<value>Website</value>
</data>
<data name="dialog.server_link.forums" xml:space="preserve">
<value>Forums</value>
</data>
<data name="dialog.server_link.news" xml:space="preserve">
<value>News</value>
</data>
<data name="dialog.server_link.announcements" xml:space="preserve">
<value>Announcements</value>
</data>
<data name="tui.dialog.cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="dialog.render.header" xml:space="preserve">
<value>Dialog #{0} [{1}]: {2}</value>
</data>
<data name="dialog.render.type" xml:space="preserve">
<value>Type: {0}</value>
</data>
<data name="dialog.render.body" xml:space="preserve">
<value>Body: {0}</value>
</data>
<data name="dialog.render.inputs" xml:space="preserve">
<value>Inputs:</value>
</data>
<data name="dialog.render.input" xml:space="preserve">
<value> {0} ({1}) {2} = {3} [{4}]</value>
</data>
<data name="dialog.render.actions" xml:space="preserve">
<value>Actions:</value>
</data>
<data name="dialog.render.action" xml:space="preserve">
<value> [{0}] {1} ({2})</value>
</data>
<data name="dialog.render.cancel_hint" xml:space="preserve">
<value>Use dialog cancel to close or run the cancel action.</value>
</data>
<data name="dialog.input_desc_text" xml:space="preserve">
<value>max {0} chars</value>
</data>
<data name="dialog.input_desc_boolean" xml:space="preserve">
<value>true={0}, false={1}</value>
</data>
<data name="dialog.input_desc_options" xml:space="preserve">
<value>options: {0}</value>
</data>
<data name="dialog.input_desc_number" xml:space="preserve">
<value>range {0}..{1}</value>
</data>
<data name="dialog.input_desc_unknown" xml:space="preserve">
<value>unknown input</value>
</data>
<data name="dialog.action_desc_close" xml:space="preserve">
<value>close</value>
</data>
<data name="dialog.action_desc_command" xml:space="preserve">
<value>command</value>
</data>
<data name="dialog.action_desc_custom" xml:space="preserve">
<value>custom</value>
</data>
<data name="dialog.action_desc_show_dialog" xml:space="preserve">
<value>show dialog</value>
</data>
<data name="dialog.action_desc_open_url" xml:space="preserve">
<value>open URL</value>
</data>
<data name="dialog.action_desc_suggest" xml:space="preserve">
<value>suggest command</value>
</data>
<data name="dialog.action_desc_copy" xml:space="preserve">
<value>copy</value>
</data>
<data name="dialog.action_desc_unknown" xml:space="preserve">
<value>unknown</value>
</data>
</root>

View file

@ -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<string, Control> _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);
}
}

View file

@ -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;