build: rename repository folder ChatTwo to HellionChat

Repository folder, csproj, solution and all CI/build paths now use
the consolidated HellionChat name.

- ChatTwo/ → HellionChat/ (git mv preserves history with --follow)
- ChatTwo.csproj → HellionChat.csproj
- ChatTwo.sln → HellionChat.sln; obsolete Tests project entry removed
  (private/untracked sandbox)
- AssemblyInfo.cs InternalsVisibleTo for ChatTwo.Tests removed
  (file emptied; can be repopulated when actual tests land)
- repo.json and yaml image URLs updated (ChatTwo/images/ → HellionChat/images/)
- .github/workflows/{build,codeql,release}.yml csproj paths
- .github/dependabot.yml directory path

Functional behavior unchanged.
This commit is contained in:
2026-05-03 21:30:07 +02:00
parent cd6afb32cb
commit 1f7f0945c5
114 changed files with 18 additions and 25 deletions
+345
View File
@@ -0,0 +1,345 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Game;
using Dalamud.Utility;
using Lumina.Excel;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
using Pidgin;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
namespace HellionChat.Util;
internal static class AutoTranslate
{
private static readonly Dictionary<ClientLanguage, List<AutoTranslateEntry>> Entries = new();
private static readonly HashSet<(uint, uint)> ValidEntries = [];
private static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser()
{
var sheetName = Any
.AtLeastOnceUntil(Lookahead(Char('[').IgnoreResult().Or(End)))
.Select(string.Concat)
.Labelled("sheetName");
var numPair = Map(ISelectorPart (first, second) =>
new IndexRange(uint.Parse(string.Concat(first)), uint.Parse(string.Concat(second))),
Digit.AtLeastOnce().Before(Char('-')),
Digit.AtLeastOnce())
.Labelled("numPair");
var singleRow = Digit
.AtLeastOnce()
.Select(string.Concat)
.Select(ISelectorPart (num) => new SingleRow(uint.Parse(num)));
var column = String("col-")
.Then(Digit.AtLeastOnce())
.Select(string.Concat)
.Select(ISelectorPart (num) => new ColumnSpecifier(uint.Parse(num)));
var noun = String("noun")
.Select(ISelectorPart (_) => new NounMarker());
var selectorItems = OneOf(Try(numPair), singleRow, column, noun)
.Separated(Char(','))
.Labelled("selectorItems");
var selector = selectorItems
.Between(Char('['), Char(']'))
.Labelled("selector");
return Map((name, sel) => (name, sel), sheetName, selector.Optional());
}
/// <summary>
/// Preloads auto-translate entries into the cache for the current game
/// language. Without this, the first message will take a long time to send
/// (which causes a hitch in the main thread).
///
/// This spawns a new thread.
/// </summary>
internal static void PreloadCache()
{
new Thread(() =>
{
var sw = Stopwatch.StartNew();
AllEntries();
Plugin.Log.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms");
}).Start();
}
private static List<AutoTranslateEntry> AllEntries()
{
if (Entries.TryGetValue(Plugin.DataManager.Language, out var entries))
return entries;
var shouldAdd = ValidEntries.Count == 0;
var parser = Parser();
var list = new List<AutoTranslateEntry>();
foreach (var row in Sheets.CompletionSheet)
{
var lookup = string.Concat(row.LookupTable.Select(p => p.Type == ReadOnlySePayloadType.Text ? Encoding.UTF8.GetString(p.Body.Span) : p.MacroCode == MacroCode.Num && p.TryGetExpression(out var num) && num.TryGetInt(out var val) ? val.ToString(CultureInfo.InvariantCulture) : ",,,unexpected macro code,,,"));
try
{
if (lookup is not ("" or "@"))
{
// SE added whitespace to the newest additions, but ParseOrThrow doesn't see them as valid
lookup = lookup.Replace(" ", "");
var (sheetName, selector) = parser.ParseOrThrow(lookup);
var sheet = Plugin.DataManager.Excel.GetSheet<RawRow>(name: sheetName);
var columns = new List<int>();
var rows = new List<Range>();
if (selector.HasValue)
{
columns.Clear();
rows.Clear();
foreach (var part in selector.Value)
{
switch (part)
{
case IndexRange range:
{
var start = (int)range.Start;
var end = (int)(range.End + 1);
rows.Add(start..end);
break;
}
case SingleRow single:
{
var idx = (int)single.Row;
rows.Add(idx..(idx + 1));
break;
}
case ColumnSpecifier col:
columns.Add((int)col.Column);
break;
}
}
}
if (columns.Count == 0)
columns.Add(0);
if (rows.Count == 0)
// We can't use an "index from end" (like `^0`) here because
// we're iterating over integers, not an array directly.
// Previously, we were setting `0..^0` which caused these
// sheets to be completely skipped due to this bug.
// See below.
rows.Add(..Index.FromStart((int)sheet.GetRowAt(sheet.Count - 1).RowId + 1));
foreach (var range in rows)
{
// We iterate over the range by numerical values here, so
// we can't use an "index from end" otherwise nothing will
// happen.
// See above.
for (var i = range.Start.Value; i < range.End.Value; i++)
{
if (!sheet.TryGetRow((uint)i, out var rowParser))
continue;
foreach (var col in columns)
{
var rawName = rowParser.ReadStringColumn(col);
if (!rawName.IsEmpty)
{
list.Add(new AutoTranslateEntry(row.Group, (uint)i, rawName.ToString(), string.Empty));
if (shouldAdd)
ValidEntries.Add((row.Group, (uint)i));
}
}
}
}
}
else if (lookup is not "@")
{
if (row.Text.IsEmpty)
continue;
list.Add(new AutoTranslateEntry(row.Group, row.RowId, row.Text.ToString(), row.GroupTitle.ToString()));
if (shouldAdd)
ValidEntries.Add((row.Group, row.RowId));
}
}
catch (Exception ex)
{
Plugin.Log.Error(ex, $"failed to translate: {lookup}");
}
}
Entries[Plugin.DataManager.Language] = list;
return list;
}
internal static List<AutoTranslateEntry> Matching(string prefix, bool sort)
{
var wholeMatches = new List<AutoTranslateEntry>();
var prefixMatches = new List<AutoTranslateEntry>();
var otherMatches = new List<AutoTranslateEntry>();
foreach (var entry in AllEntries())
{
if (entry.Text.Equals(prefix, StringComparison.OrdinalIgnoreCase))
{
wholeMatches.Add(entry);
}
else if (entry.Text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
prefixMatches.Add(entry);
}
else if (entry.Text.Contains(prefix, StringComparison.OrdinalIgnoreCase))
{
otherMatches.Add(entry);
}
else if (entry.Title.Length > 0)
{
if (entry.Title.Equals(prefix, StringComparison.OrdinalIgnoreCase))
wholeMatches.Add(entry);
else if (entry.Title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
prefixMatches.Add(entry);
else if (entry.Title.Contains(prefix, StringComparison.OrdinalIgnoreCase))
otherMatches.Add(entry);
}
}
if (sort)
{
return wholeMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase)
.Concat(prefixMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase))
.Concat(otherMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase))
.ToList();
}
return wholeMatches
.Concat(prefixMatches)
.Concat(otherMatches)
.ToList();
}
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int memcmp(byte[] b1, byte[] b2, nuint count);
internal static void ReplaceWithPayload(ref byte[] bytes)
{
var search = "<at:"u8.ToArray();
if (bytes.Length <= search.Length)
return;
// populate the list of valid entries
if (ValidEntries.Count == 0)
AllEntries();
var start = -1;
for (var i = 0; i < bytes.Length; i++)
{
if (start != -1)
{
if (bytes[i] != '>')
continue;
var tag = Encoding.UTF8.GetString(bytes[start..(i + 1)]);
var parts = tag[4..^1].Split(',', 2);
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
{
var payload = ValidEntries.Contains((group, key)) ? CreateFixedTranslation(group, key) : [];
var oldBytes = bytes.ToArray();
var lengthDiff = payload.Length - (i - start);
bytes = new byte[oldBytes.Length + lengthDiff];
Array.Copy(oldBytes, bytes, start);
Array.Copy(payload, 0, bytes, start, payload.Length);
Array.Copy(oldBytes, i + 1, bytes, start + payload.Length, oldBytes.Length - (i + 1));
i += lengthDiff;
}
start = -1;
}
if (i + search.Length < bytes.Length && memcmp(bytes[i..], search, (nuint) search.Length) == 0)
start = i;
}
}
public static bool StartsWithCommand(ref byte[] bytes)
{
var search = "<at:"u8;
if (bytes.Length <= search.Length)
return false;
// populate the list of valid entries
if (ValidEntries.Count == 0)
AllEntries();
for (var i = 0; i < search.Length; i++)
{
if (bytes[i] != search[i])
return false;
}
for (var i = 0; i < bytes.Length; i++)
{
if (bytes[i] != '>')
continue;
var tag = Encoding.UTF8.GetString(bytes[..(i + 1)]);
var parts = tag[4..^1].Split(',', 2);
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
{
if (!ValidEntries.Contains((group, key)))
return false;
var evaluated = Plugin.Evaluator.Evaluate(new ReadOnlySeString(CreateFixedTranslation(group, key))).ToString();
if (!evaluated.StartsWith('/'))
return false;
bytes = Encoding.UTF8.GetBytes(evaluated);
return true;
}
}
return false;
}
private static byte[] CreateFixedTranslation(uint group, uint key)
{
using var rssb = new RentedSeStringBuilder();
return rssb.Builder
.BeginMacro(MacroCode.Fixed)
.AppendUIntExpression(group - 1)
.AppendUIntExpression(key)
.EndMacro()
.ToArray();
}
}
internal interface ISelectorPart { }
internal class SingleRow(uint row) : ISelectorPart
{
public uint Row { get; } = row;
}
internal class IndexRange(uint start, uint end) : ISelectorPart
{
public uint Start { get; } = start;
public uint End { get; } = end;
}
internal class NounMarker : ISelectorPart { }
internal class ColumnSpecifier(uint column) : ISelectorPart
{
public uint Column { get; } = column;
}
internal class AutoTranslateEntry(uint group, uint row, string str, string title)
{
internal uint Group { get; } = group;
internal uint Row { get; } = row;
internal string Text { get; } = str;
internal string Title { get; } = title;
}
+513
View File
@@ -0,0 +1,513 @@
using HellionChat.Code;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using System.Text;
using Lumina.Text.Payloads;
using PayloadType = Dalamud.Game.Text.SeStringHandling.PayloadType;
namespace HellionChat.Util;
internal static class ChunkUtil
{
// internal static IEnumerable<Chunk> ToChunks(ReadOnlySeString msg, ChunkSource source, ChatType? defaultColour)
// {
// var chunks = new List<Chunk>();
//
// var italic = false;
// var foreground = new Stack<uint>();
// var glow = new Stack<uint>();
// Payload? link = null;
//
// void Append(string text)
// {
// chunks.Add(new TextChunk(source, link, text)
// {
// FallbackColour = defaultColour,
// Foreground = foreground.Count > 0 ? foreground.Peek() : null,
// Glow = glow.Count > 0 ? glow.Peek() : null,
// Italic = italic,
// });
// }
//
// foreach (var payload in msg)
// {
// if (payload.Type == ReadOnlySePayloadType.Text)
// {
// // We don't want to parse any null string
// var str = payload.ToString();
// var nulIndex = str.IndexOf('\0');
// if (nulIndex > 0)
// str = str[..nulIndex];
// if (string.IsNullOrEmpty(str))
// continue;
//
// Append(str);
// continue;
// }
//
// switch (payload.MacroCode)
// {
// case MacroCode.Italic:
// var newStatus = payload.TryGetExpression(out var expression) && expression.TryGetUInt(out var value) && value == 1;
// italic = newStatus;
// break;
// case MacroCode.Color:
// if (payload.TryGetExpression(out var eColor))
// {
// if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor)
// {
// if (foreground.Count > 0)
// foreground.Pop();
// }
// else if (TryResolveUInt(eColor, out var eColorVal))
// {
// var color = ColourUtil.ArgbToRgba(eColorVal);
//
// if (color > 0)
// foreground.Push(color);
// else if (foreground.Count > 0) // Push the previous color as we don't want invisible text
// foreground.Push(foreground.Peek());
// }
// }
// break;
// case MacroCode.EdgeColor:
// if (payload.TryGetExpression(out eColor))
// {
// if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor)
// {
// if (glow.Count > 0)
// glow.Pop();
// }
// else if (TryResolveUInt(eColor, out var eColorVal))
// {
// glow.Push(ColourUtil.ArgbToRgba(eColorVal));
// }
// }
// break;
// case MacroCode.ColorType:
// if (!payload.TryGetExpression(out var eColorType) || !eColorType.TryGetUInt(out var eColorTypeVal))
// {
// if (foreground.Count > 0)
// foreground.Pop();
// break;
// }
//
// if (eColorTypeVal == 0)
// {
// if (foreground.Count > 0)
// foreground.Pop();
// }
// else if (Sheets.UIColorSheet.TryGetRow(eColorTypeVal, out var row))
// {
// foreground.Push(row.Dark);
// }
// break;
// case MacroCode.EdgeColorType:
// if (!payload.TryGetExpression(out var eEdgeColor) || !eEdgeColor.TryGetUInt(out var eEdgeColorVal))
// {
// if (glow.Count > 0)
// glow.Pop();
// break;
// }
//
// if (eEdgeColorVal == 0)
// {
// if (glow.Count > 0)
// glow.Pop();
// }
// else if (Sheets.UIColorSheet.TryGetRow(eEdgeColorVal, out var row))
// {
// glow.Push(row.Dark);
// }
// break;
// case MacroCode.Fixed:
// if (!payload.TryGetExpression(out var expr1, out var expr2))
// break;
//
// if (expr1.TryGetUInt(out var group) && expr2.TryGetUInt(out var key))
// {
// chunks.Add(new IconChunk(source, null, BitmapFontIcon.AutoTranslateBegin));
// using var rssb = new RentedSeStringBuilder();
// var translatePayload = rssb.Builder
// .BeginMacro(MacroCode.Fixed)
// .AppendUIntExpression(group - 1)
// .AppendUIntExpression(key)
// .EndMacro()
// .ToReadOnlySeString();
//
// Append(Plugin.Evaluator.Evaluate(translatePayload).ToString());
// chunks.Add(new IconChunk(source, null, BitmapFontIcon.AutoTranslateEnd));
// }
// break;
// case MacroCode.Icon:
// if (payload.TryGetExpression(out var eIcon) && TryResolveInt(eIcon, out var iconVal))
// chunks.Add(new IconChunk(source, link, (BitmapFontIcon)iconVal));
// break;
// case MacroCode.Link:
// if (!payload.TryGetExpression(
// out var linkTypeExpr1,
// out var uintExpr2,
// out var intExpr3,
// out var intExpr4,
// out var strExpr5))
// break;
//
// if (!linkTypeExpr1.TryGetUInt(out var linkType))
// break;
//
// switch ((LinkMacroPayloadType)linkType)
// {
// case LinkMacroPayloadType.Terminator:
// link = null;
// break;
// case LinkMacroPayloadType.MapPosition:
// if (!uintExpr2.TryGetUInt(out var ids))
// break;
//
// if (!intExpr3.TryGetInt(out var rawX))
// break;
//
// if (!intExpr4.TryGetInt(out var rawY))
// break;
//
// var mapId = ids & 0xFF;
// var territoryId = (ids >> 16) & 0xFF;
// break;
// case (LinkMacroPayloadType)Payload.EmbeddedInfoType.DalamudLink - 1:
// if (!uintExpr2.TryGetUInt(out var commandId))
// break;
//
// if (!intExpr3.TryGetInt(out var extra1))
// break;
//
// if (!intExpr4.TryGetInt(out var extra2))
// break;
//
// if (!strExpr5.TryGetString(out var extraStr))
// break;
// break;
// case LinkMacroPayloadType.Quest:
// if (!uintExpr2.TryGetUInt(out var questId))
// break;
// break;
// case LinkMacroPayloadType.Status:
// if (!uintExpr2.TryGetUInt(out var statusId))
// break;
// break;
// case LinkMacroPayloadType.Item:
// if (!uintExpr2.TryGetUInt(out var itemId))
// break;
// break;
// case LinkMacroPayloadType.Character:
// if (!uintExpr2.TryGetUInt(out var flags))
// break;
//
// if (!intExpr3.TryGetUInt(out var worldId))
// break;
// break;
// case LinkMacroPayloadType.PartyFinder:
// if (!uintExpr2.TryGetUInt(out var listingId))
// break;
//
// // intExpr3 is unused
//
// if (!intExpr4.TryGetUInt(out worldId))
// break;
// break;
// case LinkMacroPayloadType.PartyFinderNotification:
// // no expr used
// break;
// case LinkMacroPayloadType.Achievement:
// if (!uintExpr2.TryGetUInt(out var achievementId))
// break;
// break;
// }
// break;
// case MacroCode.NonBreakingSpace:
// Append(" ");
// break;
// case PayloadType.Unknown:
// var rawPayload = (RawPayload)payload;
// else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14)
// {
// if (glow.Count > 0)
// {
// glow.Pop();
// }
// else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6)
// {
// var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]);
// glow.Push(ColourUtil.ComponentsToRgba(r, g, b));
// }
// }
// break;
// }
// }
//
// return chunks;
// }
internal static IEnumerable<Chunk> ToChunks(SeString msg, ChunkSource source, ChatType? defaultColour)
{
var chunks = new List<Chunk>();
var italic = false;
var foreground = new Stack<uint>();
var glow = new Stack<uint>();
Payload? link = null;
void Append(string text)
{
chunks.Add(new TextChunk(source, link, text)
{
FallbackColour = defaultColour,
Foreground = foreground.Count > 0 ? foreground.Peek() : null,
Glow = glow.Count > 0 ? glow.Peek() : null,
Italic = italic,
});
}
foreach (var payload in msg.Payloads)
{
switch (payload.Type)
{
case PayloadType.EmphasisItalic:
var newStatus = ((EmphasisItalicPayload) payload).IsEnabled;
italic = newStatus;
break;
case PayloadType.UIForeground:
var foregroundPayload = (UIForegroundPayload) payload;
if (foregroundPayload.IsEnabled)
foreground.Push(foregroundPayload.UIColor.Value.Dark);
else if (foreground.Count > 0)
foreground.Pop();
break;
case PayloadType.UIGlow:
var glowPayload = (UIGlowPayload) payload;
if (glowPayload.IsEnabled)
glow.Push(glowPayload.UIColor.Value.Light);
else if (glow.Count > 0)
glow.Pop();
break;
case PayloadType.AutoTranslateText:
chunks.Add(new IconChunk(source, payload, BitmapFontIcon.AutoTranslateBegin));
var autoText = ((AutoTranslatePayload) payload).Text;
Append(autoText.Substring(2, autoText.Length - 4));
chunks.Add(new IconChunk(source, link, BitmapFontIcon.AutoTranslateEnd));
break;
case PayloadType.Icon:
chunks.Add(new IconChunk(source, link, ((IconPayload) payload).Icon));
break;
case PayloadType.MapLink:
case PayloadType.Quest:
case PayloadType.DalamudLink:
case PayloadType.Status:
case PayloadType.Item:
case PayloadType.Player:
link = payload;
break;
case PayloadType.PartyFinder:
link = payload;
break;
case PayloadType.Unknown:
var rawPayload = (RawPayload) payload;
var colorPayload = ColorPayload.From(rawPayload.Data);
if (colorPayload != null)
{
if (colorPayload.Enabled)
{
if (colorPayload.Color > 0)
foreground.Push(colorPayload.Color);
else if (foreground.Count > 0) // Push the previous color as we don't want invisible text
foreground.Push(foreground.Peek());
}
else if (foreground.Count > 0)
{
foreground.Pop();
}
}
else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14)
{
if (glow.Count > 0)
{
glow.Pop();
}
else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6)
{
var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]);
glow.Push(ColourUtil.ComponentsToRgba(r, g, b));
}
}
else if (rawPayload.Data.Length > 7 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x0A)
{
// pf payload
var reader = new BinaryReader(new MemoryStream(rawPayload.Data[4..]));
var id = GetInteger(reader);
link = new PartyFinderPayload(id);
}
else if (rawPayload.Data.Length > 5 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x06)
{
// achievement payload
var reader = new BinaryReader(new MemoryStream(rawPayload.Data[4..]));
var id = GetInteger(reader);
link = new AchievementPayload(id);
}
else if (rawPayload.Data is [_, (byte)MacroCode.NonBreakingSpace, _, _])
{
// NonBreakingSpace payload
Append(" ");
}
// NOTE: no URIPayload because it originates solely from
// new Message(). The game doesn't have a URI payload type.
else if (Equals(rawPayload, RawPayload.LinkTerminator))
{
link = null;
}
break;
default:
if (payload is ITextProvider textProvider)
{
// We don't want to parse any null string
var str = textProvider.Text;
var nulIndex = str.IndexOf('\0');
if (nulIndex > 0)
str = str[..nulIndex];
if (string.IsNullOrEmpty(str))
break;
Append(str);
}
break;
}
}
return chunks;
}
internal static string ToRawString(List<Chunk> chunks)
{
if (chunks.Count == 0)
return string.Empty;
var builder = new StringBuilder();
foreach (var chunk in chunks)
if (chunk is TextChunk text)
builder.Append(text.Content);
return builder.ToString();
}
// Hellion Chat — shared helper for Auto-Tell-Tabs and the MessageStore
// history-preload query. Walks the chunk list once and returns the
// first PlayerPayload it finds, or null when the message has no
// resolved player link (e.g. system messages, GM tells we already
// skipped earlier in the pipeline).
internal static PlayerPayload? TryGetPlayerPayload(IReadOnlyList<Chunk> chunks)
{
foreach (var chunk in chunks)
{
if (chunk.Link is PlayerPayload pp)
{
return pp;
}
}
return null;
}
// Fallback for tells where the PlayerPayload lives in the raw SeString
// payload list rather than on a chunk's Link slot. Same semantics as
// the chunk-walking variant above: returns the first PlayerPayload or
// null if the SeString has none.
internal static PlayerPayload? TryGetPlayerPayload(SeString? seString)
{
if (seString == null)
{
return null;
}
foreach (var payload in seString.Payloads)
{
if (payload is PlayerPayload pp)
{
return pp;
}
}
return null;
}
// True when the message's sender (or, as a fallback, content) carries a
// PlayerPayload that matches the given identity. Used by both the
// Tab.Matches sender filter and the MessageStore tell-history scan.
internal static bool MatchesSender(Message message, string senderName, uint senderWorld)
{
var payload = TryGetPlayerPayload(message.Sender) ?? TryGetPlayerPayload(message.Content);
if (payload == null)
{
return false;
}
if (!string.Equals(payload.PlayerName, senderName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return payload.World.RowId == senderWorld;
}
internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]);
private static uint GetInteger(BinaryReader input)
{
var num1 = (uint) input.ReadByte();
if (num1 < 208U)
return num1 - 1U;
var num2 = (uint) ((int) num1 + 1 & 15);
var numArray = new byte[4];
for (var index = 3; index >= 0; --index)
numArray[index] = (num2 & 1 << index) == 0L ? (byte) 0 : input.ReadByte();
return BitConverter.ToUInt32(numArray, 0);
}
// private static bool TryResolveUInt(in ReadOnlySeExpressionSpan expression, out uint value)
// {
// if (expression.TryGetUInt(out value))
// return true;
//
// if (expression.TryGetParameterExpression(out var exprType, out var operand1))
// {
// if (!TryResolveUInt(operand1, out var paramIndex))
// return false;
//
// if (paramIndex == 0)
// return false;
//
// paramIndex--;
// if ((ExpressionType)exprType == ExpressionType.GlobalNumber)
// {
// value = (uint) GlobalParametersCache.GetValue((int)paramIndex);
// return true;
// }
// // return (ExpressionType)exprType switch
// // {
// // // ExpressionType.LocalNumber => context.TryGetLNum((int)paramIndex, out value), // lnum
// // ExpressionType.GlobalNumber => (uint) GlobalParametersCache.GetValue((int)paramIndex), // gnum
// // _ => false, // gstr, lstr
// // };
// }
//
// return false;
// }
// [MethodImpl(MethodImplOptions.AggressiveInlining)]
// private static bool TryResolveInt(in ReadOnlySeExpressionSpan expression, out int value)
// {
// if (TryResolveUInt(expression, out var u32))
// {
// value = (int)u32;
// return true;
// }
//
// value = 0;
// return false;
// }
}
+51
View File
@@ -0,0 +1,51 @@
using System.Buffers.Binary;
using System.Numerics;
namespace HellionChat.Util;
internal static class ColourUtil {
private static (byte r, byte g, byte b) RgbaToRgbComponents(uint rgba)
{
var r = (byte) ((rgba & 0xFF000000) >> 24);
var g = (byte) ((rgba & 0xFF0000) >> 16);
var b = (byte) ((rgba & 0xFF00) >> 8);
return (r, g, b);
}
internal static uint RgbaToAbgr(uint rgba) => BinaryPrimitives.ReverseEndianness(rgba);
internal static Vector3 RgbaToVector3(uint rgba)
{
var (r, g, b) = RgbaToRgbComponents(rgba);
return new Vector3((float) r / 255, (float) g / 255, (float) b / 255);
}
internal static uint Vector3ToRgba(Vector3 col)
{
return ComponentsToRgba(
(byte) Math.Round(col.X * 255),
(byte) Math.Round(col.Y * 255),
(byte) Math.Round(col.Z * 255)
);
}
internal static uint Vector4ToAbgr(Vector4 col)
{
return RgbaToAbgr(ComponentsToRgba(
(byte) Math.Round(col.X * 255),
(byte) Math.Round(col.Y * 255),
(byte) Math.Round(col.Z * 255),
(byte) Math.Round(col.W * 255)
));
}
public static unsafe uint ArgbToRgba(uint x)
{
var buf = (byte*)&x;
(buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]);
return x;
}
internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF)
=> alpha | (uint) (red << 24) | (uint) (green << 16) | (uint) (blue << 8);
}
+273
View File
@@ -0,0 +1,273 @@
using System.Globalization;
using System.Numerics;
using HellionChat.Resources;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Util;
// From https://github.com/Flix01/imgui/blob/imgui_with_addons/addons/imguidatechooser/imguidatechooser.cpp
public static class DateWidget
{
private const int HeightInItems = 1 + 1 + 1 + 4;
private static readonly DateTime Sample = DateTime.UnixEpoch;
private static readonly Vector4 Transparent = new(1, 1, 1, 0);
private static readonly string[] DayNames = [Language.DateWidget_Day_Sun, Language.DateWidget_Day_Mon, Language.DateWidget_Day_Tue, Language.DateWidget_Day_Wed, Language.DateWidget_Day_Thu, Language.DateWidget_Day_Fri, Language.DateWidget_Day_Sat];
private static readonly string[] MonthNames = [Language.DateWidget_Month_January, Language.DateWidget_Month_February, Language.DateWidget_Month_March, Language.DateWidget_Month_April, Language.DateWidget_Month_May, Language.DateWidget_Month_June, Language.DateWidget_Month_July, Language.DateWidget_Month_August, Language.DateWidget_Month_September, Language.DateWidget_Month_October, Language.DateWidget_Month_November, Language.DateWidget_Month_December];
private static readonly int[] NumDaysPerMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
private static float LongestMonthWidth;
private static readonly float[] MonthWidths = new float[12];
private static uint LastOpenComboId;
public static bool Validate(DateTime minimal, ref DateTime currentMin, ref DateTime currentMax)
{
var needsRefresh = false;
if (minimal > currentMin)
{
currentMin = minimal;
Plugin.Notification.AddNotification(new Notification
{
Content = Language.DateWidget_InvalidDate.Format(minimal.ToShortDateString()),
Type = NotificationType.Warning,
Minimized = false,
});
needsRefresh = true;
}
else if (currentMin > currentMax)
{
currentMax = currentMin;
needsRefresh = true;
}
return needsRefresh;
}
public static void DatePickerWithInput(string label, int id, ref string dateString, ref DateTime date, string format, bool sameLine = false, bool closeWhenMouseLeavesIt = true)
{
if (sameLine)
ImGui.SameLine();
ImGui.SetNextItemWidth(ImGui.CalcTextSize(Sample.ToString(format)).X + ImGui.GetStyle().ItemInnerSpacing.X * 2);
if (ImGui.InputTextWithHint($"##{label}Input", format.ToUpper(), ref dateString, 32, ImGuiInputTextFlags.CallbackCompletion))
{
if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var tmp))
date = tmp;
}
ImGui.SameLine(0, 3.0f * ImGuiHelpers.GlobalScale);
ImGuiUtil.IconButton(FontAwesomeIcon.Calendar, id.ToString());
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.DatePicker_Tooltip);
if (DatePicker(label, ref date, closeWhenMouseLeavesIt))
dateString = date.ToString(format);
}
private static bool DatePicker(string label, ref DateTime dateOut, bool closeWhenMouseLeavesIt, string leftArrow = "", string rightArrow = "")
{
using var mono = ImRaii.PushFont(UiBuilder.MonoFont);
if (LongestMonthWidth == 0.0f)
{
for (var i = 0; i < 12; i++)
{
var mw = ImGui.CalcTextSize(MonthNames[i]).X;
MonthWidths[i] = mw;
LongestMonthWidth = Math.Max(LongestMonthWidth, mw);
}
}
var id = ImGui.GetID(label);
var style = ImGui.GetStyle();
var arrowLeft = leftArrow.Length > 0 ? leftArrow : "<";
var arrowRight = rightArrow.Length > 0 ? rightArrow : ">";
var arrowLeftWidth = ImGui.CalcTextSize(arrowLeft).X;
var arrowRightWidth = ImGui.CalcTextSize(arrowRight).X;
var labelSize = ImGui.CalcTextSize(label, true, 0);
var widthRequiredByCalendar = (2.0f * arrowLeftWidth) + (2.0f * arrowRightWidth) + LongestMonthWidth + ImGui.CalcTextSize("9999").X + (120.0f * ImGuiHelpers.GlobalScale);
var popupHeight = ((labelSize.Y + (2 * style.ItemSpacing.Y)) * HeightInItems) + (style.FramePadding.Y * 3);
var valueChanged = false;
ImGui.SetNextWindowSize(new Vector2(widthRequiredByCalendar, widthRequiredByCalendar));
ImGui.SetNextWindowSizeConstraints(new Vector2(widthRequiredByCalendar, popupHeight + 40), new Vector2(widthRequiredByCalendar, popupHeight + 40));
using var popupItem = ImRaii.ContextPopupItem(label, ImGuiPopupFlags.None);
if (!popupItem.Success)
return valueChanged;
if (ImGui.GetIO().MouseClicked[1])
{
// reset date when user right-clicks the date chooser header when the dialog is open
dateOut = DateTime.Now;
}
else if (LastOpenComboId != id)
{
LastOpenComboId = id;
if (dateOut.Year == 1)
dateOut = DateTime.Now;
}
using var windowPadding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, style.FramePadding);
using var buttonColor = ImRaii.PushColor(ImGuiCol.Button, Transparent);
ImGui.Spacing();
var yearString = $"{dateOut.Year}";
var yearPartWidth = arrowLeftWidth + arrowRightWidth + ImGui.CalcTextSize(yearString).X;
var oldWindowRounding = style.WindowRounding;
style.WindowRounding = 0;
using (ImRaii.PushId(1234))
{
if (ImGui.SmallButton(arrowLeft))
{
valueChanged = true;
dateOut = dateOut.AddMonths(-1);
}
ImGui.SameLine();
var color = ImGui.GetColorU32(style.Colors[(int)ImGuiCol.Text]);
var monthWidth = MonthWidths[dateOut.Month - 1];
var pos = ImGui.GetCursorScreenPos();
pos = pos with { X = pos.X + ((LongestMonthWidth - monthWidth) * 0.5f) };
ImGui.GetForegroundDrawList().AddText(pos, color, MonthNames[dateOut.Month - 1]);
ImGui.SameLine(0, LongestMonthWidth + style.ItemSpacing.X * 2);
if (ImGui.SmallButton(arrowRight))
{
valueChanged = true;
dateOut = dateOut.AddMonths(1);
}
}
ImGui.SameLine(ImGui.GetWindowWidth() - yearPartWidth - style.WindowPadding.X - style.ItemSpacing.X * 4.0f);
using (ImRaii.PushId(1235))
{
if (ImGui.SmallButton(arrowLeft))
{
valueChanged = true;
dateOut = dateOut.AddYears(-1);
}
ImGui.SameLine();
ImGui.Text($"{dateOut.Year}");
ImGui.SameLine();
if (ImGui.SmallButton(arrowRight))
{
valueChanged = true;
dateOut = dateOut.AddYears(1);
}
}
ImGui.Spacing();
// This could be calculated only when needed (but I guess it's fast in any case...)
var maxDayOfCurMonth = NumDaysPerMonth[dateOut.Month - 1];
if (maxDayOfCurMonth == 28)
{
var year = dateOut.Year;
var bis = ((year % 4) == 0) && ((year % 100) != 0 || (year % 400) == 0);
if (bis)
maxDayOfCurMonth = 29;
}
using var buttonHovered = ImRaii.PushColor(ImGuiCol.ButtonHovered, ImGuiColors.DalamudOrange);
using var buttonActive = ImRaii.PushColor(ImGuiCol.ButtonActive, ImGuiColors.DalamudYellow);
ImGui.Separator();
// Display items
var dayClicked = false;
var dayOfWeek = (int)new DateTime(dateOut.Year, dateOut.Month, 1).DayOfWeek;
for (var dw = 0; dw < 7; dw++)
{
using (ImRaii.Group())
{
using var textColor = ImRaii.PushColor(ImGuiCol.Text, CalculateTextColor(), dw == 0);
ImGui.Text($"{(dw == 0 ? "" : " ")}{DayNames[dw]}");
if (dw == 0)
ImGui.Separator();
else
ImGui.Spacing();
// Use dayOfWeek for spacing
var curDay = dw - dayOfWeek;
for (var row = 0; row < 7; row++)
{
var cday = curDay + (7 * row);
if (cday >= 0 && cday < maxDayOfCurMonth)
{
using var rowId = ImRaii.PushId(row * 10 + dw);
if (ImGui.SmallButton(string.Format(cday < 9 ? " {0}" : "{0}", cday + 1)))
{
ImGui.SetItemDefaultFocus();
dayClicked = true;
valueChanged = true;
dateOut = new DateTime(dateOut.Year, dateOut.Month, cday + 1);
}
}
else
{
ImGui.TextUnformatted(" ");
}
}
if (dw == 0)
ImGui.Separator();
}
if (dw != 6)
ImGui.SameLine(ImGui.GetWindowWidth() - (6 - dw) * (ImGui.GetWindowWidth() / 7.0f));
}
style.WindowRounding = oldWindowRounding;
var mustCloseCombo = dayClicked;
if (closeWhenMouseLeavesIt && !mustCloseCombo)
{
var distance = ImGui.GetFontSize() * 1.75f; //1.3334f; //24;
var pos = ImGui.GetWindowPos();
pos.X -= distance;
pos.Y -= distance;
var size = ImGui.GetWindowSize();
size.X += 2.0f * distance;
size.Y += 2.0f * distance;
var mousePos = ImGui.GetIO().MousePos;
if (mousePos.X < pos.X || mousePos.Y < pos.Y || mousePos.X > pos.X + size.X || mousePos.Y > pos.Y + size.Y)
mustCloseCombo = true;
}
// ImGui issue #273849, children keep popups from closing automatically
if (mustCloseCombo)
ImGui.CloseCurrentPopup();
return valueChanged;
}
private static Vector4 CalculateTextColor()
{
var textColor = ImGuiColors.DalamudGrey;
var l = (textColor.X + textColor.Y + textColor.Z) * 0.33334f;
return new Vector4(l * 2.0f > 1 ? 1 : l * 2.0f, l * .5f, l * .5f, textColor.W);
}
}
+67
View File
@@ -0,0 +1,67 @@
namespace HellionChat.Util;
public class ColorPayload
{
private const byte StartByte = 2;
public bool Enabled;
public uint Color;
public uint UnshiftedColor;
public static ColorPayload? From(byte[] data)
{
using var stream = new MemoryStream(data);
if (stream.ReadByte() != StartByte || stream.ReadByte() != 0x13)
return null;
stream.ReadByte(); // skip the length byte;
var typeByte = stream.ReadByte();
var payload = new ColorPayload();
switch (typeByte)
{
case 0xEC:
payload.Enabled = false;
return payload;
case 0xE9:
var param = stream.ReadByte();
var globalValue = (uint) GlobalParametersCache.GetValue(param - 2);
payload.Enabled = true;
payload.UnshiftedColor = globalValue;
payload.Color = ColourUtil.ArgbToRgba(globalValue);
return payload;
case >= 0xF0 and <= 0xFE:
// From: https://github.com/NotAdam/Lumina/blob/master/src/Lumina/Text/Expressions/IntegerExpression.cs#L119-L128
uint ShiftAndThrowIfZero(int v, int shift)
{
return v switch
{
// ReSharper disable once LocalizableElement
-1 => throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(v)),
// ReSharper disable once LocalizableElement
0 => throw new ArgumentException("Encountered premature end of input (unexpected null character).", nameof(v)),
_ => (uint)v << shift
};
}
typeByte += 1;
var argbValue = 0u;
if ((typeByte & 8) != 0)
argbValue |= ShiftAndThrowIfZero(stream.ReadByte(), 24);
else
argbValue |= 0xff000000u;
if( (typeByte & 4) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 16 );
if( (typeByte & 2) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 8 );
if( (typeByte & 1) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 0 );
payload.Enabled = true;
payload.Color = ColourUtil.ArgbToRgba(argbValue);
return payload;
default:
return null;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.Text;
namespace HellionChat.Util;
public static class GlobalParametersCache
{
private static int[] Cache = [];
public static int GetValue(int index)
{
if (index < 0 || index >= Cache.Length)
return 0;
return Cache[index];
}
/// <summary>
/// Refresh the cache of global parameters from RaptureTextModule.
/// </summary>
/// <remarks>
/// This should be called in the main thread when updates are necessary.
/// </remarks>
public static unsafe void Refresh()
{
if (!ThreadSafety.IsMainThread)
throw new InvalidOperationException("GlobalParametersCache.Refresh must be called on the main thread.");
var rtm = RaptureTextModule.Instance();
if (rtm is null)
return;
ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters;
if (Cache.Length != (int)gp.MySize)
Cache = new int[gp.MySize];
for (ulong i = 0; i < gp.MySize; i++)
{
var p = gp[(long)i];
if (p.Type == TextParameterType.Integer)
Cache[(int)i] = p.IntValue;
else
Cache[(int)i] = 0;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace HellionChat.Util;
// From Kizer: https://github.com/Soreepeong/Dalamud/blob/feature/log-wordwrap/Dalamud/Interface/Spannables/Internal/GfdFileView.cs
public readonly unsafe ref struct GfdFileView
{
private readonly ReadOnlySpan<byte> Span;
private readonly bool DirectLookup;
/// <summary>Initializes a new instance of the <see cref="GfdFileView"/> struct.</summary>
/// <param name="span">The data.</param>
public GfdFileView(ReadOnlySpan<byte> span)
{
Span = span;
if (span.Length < sizeof(GfdHeader))
throw new InvalidDataException($"Not enough space for a {nameof(GfdHeader)}");
if (span.Length < sizeof(GfdHeader) + (Header.Count * sizeof(GfdEntry)))
throw new InvalidDataException($"Not enough space for all the {nameof(GfdEntry)}");
var entries = Entries;
DirectLookup = true;
for (var i = 0; i < entries.Length && DirectLookup; i++)
DirectLookup &= i + 1 == entries[i].Id;
}
/// <summary>Gets the header.</summary>
private ref readonly GfdHeader Header => ref MemoryMarshal.AsRef<GfdHeader>(Span);
/// <summary>Gets the entries.</summary>
private ReadOnlySpan<GfdEntry> Entries => MemoryMarshal.Cast<byte, GfdEntry>(Span[sizeof(GfdHeader)..]);
/// <summary>Attempts to get an entry.</summary>
/// <param name="iconId">The icon ID.</param>
/// <param name="entry">The entry.</param>
/// <param name="followRedirect">Whether to follow redirects.</param>
/// <returns><c>true</c> if found.</returns>
public bool TryGetEntry(uint iconId, out GfdEntry entry, bool followRedirect = true)
{
if (iconId == 0)
{
entry = default;
return false;
}
var entries = Entries;
if (DirectLookup)
{
if (iconId <= entries.Length)
{
entry = entries[(int)(iconId - 1)];
return !entry.IsEmpty;
}
entry = default;
return false;
}
var lo = 0;
var hi = entries.Length;
while (lo <= hi)
{
var i = lo + ((hi - lo) >> 1);
if (entries[i].Id == iconId)
{
if (followRedirect && entries[i].Redirect != 0)
{
iconId = entries[i].Redirect;
lo = 0;
hi = entries.Length;
continue;
}
entry = entries[i];
return !entry.IsEmpty;
}
if (entries[i].Id < iconId)
lo = i + 1;
else
hi = i - 1;
}
entry = default;
return false;
}
/// <summary>Header of a .gfd file.</summary>
[StructLayout(LayoutKind.Sequential)]
public struct GfdHeader
{
/// <summary>Signature: "gftd0100".</summary>
public fixed byte Signature[8];
/// <summary>Number of entries.</summary>
public int Count;
/// <summary>Unused/unknown.</summary>
public fixed byte Padding[4];
}
/// <summary>An entry of a .gfd file.</summary>
[StructLayout(LayoutKind.Sequential, Size = 0x10)]
public struct GfdEntry
{
/// <summary>ID of the entry.</summary>
public ushort Id;
/// <summary>The left offset of the entry.</summary>
public ushort Left;
/// <summary>The top offset of the entry.</summary>
public ushort Top;
/// <summary>The width of the entry.</summary>
public ushort Width;
/// <summary>The height of the entry.</summary>
public ushort Height;
/// <summary>Unknown/unused.</summary>
public ushort Unk0A;
/// <summary>The redirected entry, maybe.</summary>
public ushort Redirect;
/// <summary>Unknown/unused.</summary>
public ushort Unk0E;
/// <summary>Gets a value indicating whether this entry is effectively empty.</summary>
public bool IsEmpty => Width == 0 || Height == 0;
}
}
internal static class IconUtil
{
private static byte[]? GfdFile;
public static unsafe GfdFileView GfdFileView
{
get
{
GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
}
}
public static byte[] ImageToRaw(this Image<Rgba32> image)
{
var data = new byte[4 * image.Width * image.Height];
image.CopyPixelDataTo(data);
return data;
}
}
+738
View File
@@ -0,0 +1,738 @@
using System.Buffers;
using System.Numerics;
using System.Text;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface;
using Dalamud.Interface.Components;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.ImGuiFontChooserDialog;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Util;
internal static class ImGuiUtil
{
private static Plugin Plugin = null!;
public static void Initialize(Plugin plugin)
{
Plugin = plugin;
}
private static readonly ImGuiMouseButton[] Buttons =
[
ImGuiMouseButton.Left,
ImGuiMouseButton.Middle,
ImGuiMouseButton.Right
];
private static Payload? Hovered;
private static Payload? LastLink;
private static readonly List<(Vector2, Vector2)> PayloadBounds = [];
internal static void PostPayload(Chunk chunk, PayloadHandler? handler)
{
var payload = chunk.Link;
if (payload != null && ImGui.IsItemHovered())
{
Hovered = payload;
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
handler?.Hover(payload);
}
else if (!ReferenceEquals(Hovered, payload))
{
Hovered = null;
}
if (handler == null)
return;
foreach (var button in Buttons)
if (ImGui.IsItemClicked(button))
handler.Click(chunk, payload, button);
}
// Ceiling on the byte buffer for a single rendered line. UTF-8 takes at
// most 4 bytes per char; ImGui's internal ImString limit is well below
// this and FFXIV's chat lines top out around a few hundred chars in
// practice. The cap prevents an unbounded ArrayPool rent if a caller
// ever feeds in a degenerate input.
private const int MaxLineByteCount = 16 * 1024;
internal static void WrapText(string csText, Chunk chunk, PayloadHandler? handler, Vector4 defaultText, float lineWidth)
{
if (csText.Length == 0)
return;
foreach (var part in csText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None))
{
if (part.Length == 0)
{
ImGui.TextUnformatted("");
continue;
}
// Allocate against the encoder's own MaxByteCount so the buffer
// we hand to ImGui is sized by us. The actual byte count
// returned by GetBytes is then validated against that ceiling
// before any pointer arithmetic touches it; CodeQL recognises
// that comparison as a sanitiser for the
// cs/unvalidated-local-pointer-arithmetic taint flow.
var maxBytes = Encoding.UTF8.GetMaxByteCount(part.Length);
if (maxBytes <= 0 || maxBytes > MaxLineByteCount)
{
ImGui.TextUnformatted("");
continue;
}
var buffer = ArrayPool<byte>.Shared.Rent(maxBytes);
try
{
var written = Encoding.UTF8.GetBytes(part, 0, part.Length, buffer, 0);
if (written <= 0 || written > maxBytes)
{
ImGui.TextUnformatted("");
continue;
}
WrapEncodedLine(buffer.AsSpan(0, written), chunk, handler, defaultText, lineWidth);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
private static unsafe void WrapEncodedLine(ReadOnlySpan<byte> bytes, Chunk chunk, PayloadHandler? handler, Vector4 defaultText, float lineWidth)
{
var byteCount = bytes.Length;
if (byteCount == 0)
{
ImGui.TextUnformatted("");
return;
}
fixed (byte* basePtr = bytes)
{
var widthLeft = ImGui.GetContentRegionAvail().X;
var endPrev = CalcWordWrap(basePtr, 0, byteCount, widthLeft);
if (endPrev < 0)
return;
var firstSpace = FindFirstSpace(bytes, 0, byteCount);
var properBreak = firstSpace <= endPrev;
if (properBreak)
{
DrawText(basePtr, 0, endPrev, chunk, handler, defaultText);
}
else if (lineWidth == 0f)
{
ImGui.TextUnformatted("");
}
else
{
// Check whether the next chunk would wrap at or past the
// first space. If yes, force a line break.
var wrapPos = CalcWordWrap(basePtr, 0, firstSpace, lineWidth);
if (wrapPos >= firstSpace)
ImGui.TextUnformatted("");
}
widthLeft = ImGui.GetContentRegionAvail().X;
var lineStart = 0;
while (endPrev < byteCount)
{
if (properBreak)
lineStart = endPrev;
// Skip a leading space at the start of a wrapped line.
if (lineStart < byteCount && bytes[lineStart] == (byte)' ')
lineStart++;
var newEnd = CalcWordWrap(basePtr, lineStart, byteCount, widthLeft);
if (properBreak && newEnd == endPrev)
break;
if (newEnd < 0)
{
ImGui.TextUnformatted("");
ImGui.TextUnformatted("");
break;
}
endPrev = newEnd;
DrawText(basePtr, lineStart, endPrev, chunk, handler, defaultText);
if (!properBreak)
{
properBreak = true;
widthLeft = ImGui.GetContentRegionAvail().X;
}
}
}
}
private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width)
{
var result = ImGuiNative.CalcWordWrapPositionA(
ImGui.GetFont().Handle,
ImGuiHelpers.GlobalScale,
basePtr + start,
basePtr + end,
width);
if (result == null)
return -1;
return (int)(result - basePtr);
}
private static unsafe void DrawText(byte* basePtr, int start, int end, Chunk chunk, PayloadHandler? handler, Vector4 defaultText)
{
var oldPos = ImGui.GetCursorScreenPos();
ImGuiNative.TextUnformatted(basePtr + start, basePtr + end);
PostPayload(chunk, handler);
if (!ReferenceEquals(LastLink, chunk.Link))
PayloadBounds.Clear();
LastLink = chunk.Link;
if (Hovered != null && ReferenceEquals(Hovered, chunk.Link))
{
defaultText.W = 0.25f;
var actualCol = ColourUtil.Vector4ToAbgr(defaultText);
ImGui.GetWindowDrawList().AddRectFilled(oldPos, oldPos + ImGui.GetItemRectSize(), actualCol);
foreach (var (boundsStart, boundsSize) in PayloadBounds)
ImGui.GetWindowDrawList().AddRectFilled(boundsStart, boundsStart + boundsSize, actualCol);
PayloadBounds.Clear();
}
if (Hovered == null && chunk.Link != null)
PayloadBounds.Add((oldPos, ImGui.GetItemRectSize()));
}
private static int FindFirstSpace(ReadOnlySpan<byte> bytes, int start, int end)
{
for (var i = start; i < end; i++)
if (char.IsWhiteSpace((char)bytes[i]))
return i;
return end;
}
internal static bool IconButton(FontAwesomeIcon icon, string? id = null, string? tooltip = null, int width = 0)
{
var label = icon.ToIconString();
if (id != null)
label += $"##{id}";
bool ret;
using (Plugin.FontManager.FontAwesome.Push())
{
var size = Vector2.Zero;
if (width > 0)
size.X = width - 2 * ImGui.GetStyle().CellPadding.X;
ret = ImGui.Button(label, size);
}
if (!string.IsNullOrEmpty(tooltip) && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
Tooltip(tooltip);
return ret;
}
internal static bool OptionCheckbox(ref bool value, string label, string? description = null)
{
var ret = ImGui.Checkbox(label, ref value);
if (!string.IsNullOrEmpty(description))
HelpText(description);
return ret;
}
internal static void HelpText(string text)
{
using (ImRaii.TextWrapPos(0.0f))
using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]))
ImGui.TextUnformatted(text);
}
// Hellion Chat — compact help affordance: a dimmed "(?)" glyph rendered
// on the same line as the previous item, with the long-form description
// tucked into a hover tooltip. Lets us keep the settings panes scannable
// instead of stacking a wall of HelpText paragraphs under every option.
internal static void HelpMarker(string description)
{
ImGui.SameLine();
using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]))
ImGui.TextUnformatted("(?)");
// AllowWhenDisabled — ohne das Flag liefert IsItemHovered bei
// ausgegrauten Settings false, der User könnte nicht mehr lesen
// warum eine Option nicht aktiv ist. Genau dann braucht er den
// Hover-Tooltip aber am dringendsten.
if (!ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
return;
using var tooltip = ImRaii.Tooltip();
using (ImRaii.TextWrapPos(35.0f * ImGui.GetFontSize()))
ImGui.TextUnformatted(description);
}
internal static void WarningText(string text, bool wrap = true)
{
var style = StyleModel.GetConfiguredStyle() ?? StyleModel.GetFromCurrent();
var dalamudOrange = style.BuiltInColors?.DalamudOrange;
using (ImRaii.TextWrapPos(wrap ? 0.0f : ImGui.GetFontSize() * 35.0f))
using (ImRaii.PushColor(ImGuiCol.Text, dalamudOrange ?? Vector4.Zero, dalamudOrange != null))
ImGui.TextUnformatted(text);
}
internal static ImRaii.ComboDisposable BeginComboVertical(string label, string previewValue, ImGuiComboFlags flags = ImGuiComboFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
return ImRaii.Combo($"##{label}", previewValue, flags);
}
internal static bool DragFloatVertical(string label, ref float value, float vSpeed = 1.0f, float vMin = float.MinValue, float vMax = float.MaxValue, string? format = null, ImGuiSliderFlags flags = ImGuiSliderFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
return ImGui.DragFloat($"##{label}", ref value, vSpeed, vMin, vMax, format, flags);
}
internal static bool DragFloatVertical(string label, string description, ref float value, float vSpeed = 1.0f, float vMin = float.MinValue, float vMax = float.MaxValue, string? format = null, ImGuiSliderFlags flags = ImGuiSliderFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
var r = ImGui.DragFloat($"##{label}", ref value, vSpeed, vMin, vMax, format, flags);
HelpText(description);
return r;
}
internal static bool InputIntVertical(string label, string description, ref int value, int step = 1, int stepFast = 100, ImGuiInputTextFlags flags = ImGuiInputTextFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
var r = ImGui.InputInt($"##{label}", ref value, step, stepFast, flags: flags);
HelpText(description);
return r;
}
internal static void Tooltip(string tooltip)
{
using (ImRaii.Tooltip())
using (ImRaii.TextWrapPos(ImGui.GetFontSize() * 35.0f))
ImGui.TextUnformatted(tooltip);
}
public static SingleFontChooserDialog? FontChooser(string label, SingleFontSpec font, bool checkbox, ref bool checkboxValue, Predicate<IFontFamilyId>? exclusion = null, string? preview = null)
{
using var id = ImRaii.PushId(label);
ImGui.TextUnformatted(label);
if (checkbox)
{
ImGui.Checkbox("##enabled", ref checkboxValue);
ImGui.SameLine();
}
var fontFamily = font.FontId.Family.EnglishName;
var fontStyle = font.FontId.EnglishName;
fontStyle = fontStyle.Equals(fontFamily) ? "" : $" - {fontStyle}";
var buttonText = $"{fontFamily}{fontStyle} ({font.SizePt}pt)";
if (!ImGui.Button($"{buttonText}##{label}"))
return null;
var chooser = SingleFontChooserDialog.CreateAuto((UiBuilder) Plugin.Interface.UiBuilder);
chooser.SelectedFont = font;
if (exclusion is not null)
chooser.FontFamilyExcludeFilter = exclusion;
if (preview is not null)
chooser.PreviewText = preview;
return chooser;
}
public static void FontSizeCombo(string label, ref float currentSize)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
using var combo = ImRaii.Combo($"##{label}", $"{currentSize:###.##}pt");
if (!combo.Success)
return;
foreach (var size in FontManager.AxisFontSizeList)
if (ImGui.Selectable($"{size:###.##}pt", currentSize.Equals(size)))
currentSize = size;
}
public static bool Button(string id, FontAwesomeIcon icon, bool disabled)
{
using (ImRaii.Disabled(disabled))
return ImGuiComponents.IconButton(id, icon);
}
internal static bool CtrlShiftButton(string label, string tooltip = "")
{
var ctrlShiftHeld = ImGui.GetIO() is { KeyCtrl: true, KeyShift: true };
bool ret;
using (ImRaii.Disabled(!ctrlShiftHeld))
ret = ImGui.Button(label) && ctrlShiftHeld;
if (tooltip.Length != 0 && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
Tooltip(tooltip);
return ret;
}
internal static void KeybindInput(string id, ref ConfigKeyBind? keybind)
{
var idUint = ImGui.GetID(id);
using var pushedId = ImRaii.PushId(id);
if (ImGui.GetStateStorage().GetBool(idUint))
{
var io = ImGui.GetIO();
var currentMods = ModifierFlag.None;
var modString = "";
if (io.KeyCtrl)
{
currentMods |= ModifierFlag.Ctrl;
modString += Language.Keybind_Modifier_Ctrl + " + ";
}
if (io.KeyShift)
{
currentMods |= ModifierFlag.Shift;
modString += Language.Keybind_Modifier_Shift + " + ";
}
if (io.KeyAlt)
{
currentMods |= ModifierFlag.Alt;
modString += Language.Keybind_Modifier_Alt + " + ";
}
var text = $"{modString}... ({Language.Keybind_EscToClear})";
using (ImRaii.PushColor(ImGuiCol.TextSelectedBg, Vector4.Zero))
{
ImGui.SetKeyboardFocusHere();
ImGui.InputText(id + "##keybind", ref text, 0, ImGuiInputTextFlags.ReadOnly);
}
if (ImGui.IsKeyPressed(ImGuiKey.Escape))
{
keybind = null;
ImGui.GetStateStorage().SetBool(idUint, false);
return;
}
foreach (var vk in Enum.GetValues<VirtualKey>())
{
if (vk is VirtualKey.NO_KEY or VirtualKey.CONTROL or VirtualKey.LCONTROL or VirtualKey.RCONTROL or VirtualKey.SHIFT or VirtualKey.LSHIFT or VirtualKey.RSHIFT or VirtualKey.MENU or VirtualKey.LMENU or VirtualKey.RMENU)
continue;
if (!vk.TryToImGui(out var imKey) || !ImGui.IsKeyPressed(imKey))
continue;
keybind = new ConfigKeyBind { Modifier = currentMods, Key = vk };
ImGui.GetStateStorage().SetBool(idUint, false);
return;
}
}
else
{
var text = $"({Language.Keybind_None})";
if (keybind != null)
text = keybind.ToString();
if (ImGui.Button(text, new Vector2(-1, 0)))
ImGui.GetStateStorage().SetBool(idUint, true);
}
}
public static void DrawArrows(ref int selected, int min, int max, float spacing, int id = 0, string? tooltipLeft = null, string? tooltipRight = null)
{
// Prevents changing values from triggering EndDisable
var isMin = selected == min;
var isMax = selected == max;
ImGui.SameLine(0, spacing);
using (ImRaii.Disabled(isMin))
{
if (IconButton(FontAwesomeIcon.ArrowLeft, id.ToString()))
selected--;
}
if (tooltipLeft != null && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(tooltipLeft);
ImGui.SameLine(0, spacing);
using (ImRaii.Disabled(isMax))
{
if (IconButton(FontAwesomeIcon.ArrowRight, id+1.ToString()))
selected++;
}
if (tooltipRight != null && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(tooltipRight);
}
public static void WrappedTextWithColor(Vector4 color, string text)
{
using (ImRaii.PushColor(ImGuiCol.Text, color))
ImGui.TextWrapped(text);
}
public static void CenterText(string text, float indent = 0.0f)
{
indent *= ImGuiHelpers.GlobalScale;
ImGui.SameLine(((ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize(text).X) * 0.5f) + indent);
ImGui.TextUnformatted(text);
}
internal static bool TryToImGui(this VirtualKey key, out ImGuiKey result)
{
result = key switch
{
VirtualKey.NO_KEY => ImGuiKey.None,
VirtualKey.BACK => ImGuiKey.Backspace,
VirtualKey.TAB => ImGuiKey.Tab,
VirtualKey.RETURN => ImGuiKey.Enter,
VirtualKey.SHIFT => ImGuiKey.ModShift,
VirtualKey.CONTROL => ImGuiKey.ModCtrl,
VirtualKey.MENU => ImGuiKey.ModAlt,
VirtualKey.PAUSE => ImGuiKey.Pause,
VirtualKey.CAPITAL => ImGuiKey.CapsLock,
VirtualKey.ESCAPE => ImGuiKey.Escape,
VirtualKey.SPACE => ImGuiKey.Space,
VirtualKey.PRIOR => ImGuiKey.PageUp,
VirtualKey.NEXT => ImGuiKey.PageDown,
VirtualKey.END => ImGuiKey.End,
VirtualKey.HOME => ImGuiKey.Home,
VirtualKey.LEFT => ImGuiKey.LeftArrow,
VirtualKey.UP => ImGuiKey.UpArrow,
VirtualKey.RIGHT => ImGuiKey.RightArrow,
VirtualKey.DOWN => ImGuiKey.DownArrow,
VirtualKey.SNAPSHOT => ImGuiKey.PrintScreen,
VirtualKey.INSERT => ImGuiKey.Insert,
VirtualKey.DELETE => ImGuiKey.Delete,
VirtualKey.KEY_0 => ImGuiKey.Key0,
VirtualKey.KEY_1 => ImGuiKey.Key1,
VirtualKey.KEY_2 => ImGuiKey.Key2,
VirtualKey.KEY_3 => ImGuiKey.Key3,
VirtualKey.KEY_4 => ImGuiKey.Key4,
VirtualKey.KEY_5 => ImGuiKey.Key5,
VirtualKey.KEY_6 => ImGuiKey.Key6,
VirtualKey.KEY_7 => ImGuiKey.Key7,
VirtualKey.KEY_8 => ImGuiKey.Key8,
VirtualKey.KEY_9 => ImGuiKey.Key9,
VirtualKey.A => ImGuiKey.A,
VirtualKey.B => ImGuiKey.B,
VirtualKey.C => ImGuiKey.C,
VirtualKey.D => ImGuiKey.D,
VirtualKey.E => ImGuiKey.E,
VirtualKey.F => ImGuiKey.F,
VirtualKey.G => ImGuiKey.G,
VirtualKey.H => ImGuiKey.H,
VirtualKey.I => ImGuiKey.I,
VirtualKey.J => ImGuiKey.J,
VirtualKey.K => ImGuiKey.K,
VirtualKey.L => ImGuiKey.L,
VirtualKey.M => ImGuiKey.M,
VirtualKey.N => ImGuiKey.N,
VirtualKey.O => ImGuiKey.O,
VirtualKey.P => ImGuiKey.P,
VirtualKey.Q => ImGuiKey.Q,
VirtualKey.R => ImGuiKey.R,
VirtualKey.S => ImGuiKey.S,
VirtualKey.T => ImGuiKey.T,
VirtualKey.U => ImGuiKey.U,
VirtualKey.V => ImGuiKey.V,
VirtualKey.W => ImGuiKey.W,
VirtualKey.X => ImGuiKey.X,
VirtualKey.Y => ImGuiKey.Y,
VirtualKey.Z => ImGuiKey.Z,
VirtualKey.LWIN => ImGuiKey.LeftSuper,
VirtualKey.RWIN => ImGuiKey.RightSuper,
VirtualKey.NUMPAD0 => ImGuiKey.Keypad0,
VirtualKey.NUMPAD1 => ImGuiKey.Keypad1,
VirtualKey.NUMPAD2 => ImGuiKey.Keypad2,
VirtualKey.NUMPAD3 => ImGuiKey.Keypad3,
VirtualKey.NUMPAD4 => ImGuiKey.Keypad4,
VirtualKey.NUMPAD5 => ImGuiKey.Keypad5,
VirtualKey.NUMPAD6 => ImGuiKey.Keypad6,
VirtualKey.NUMPAD7 => ImGuiKey.Keypad7,
VirtualKey.NUMPAD8 => ImGuiKey.Keypad8,
VirtualKey.NUMPAD9 => ImGuiKey.Keypad9,
VirtualKey.MULTIPLY => ImGuiKey.KeypadMultiply,
VirtualKey.ADD => ImGuiKey.KeypadAdd,
VirtualKey.SUBTRACT => ImGuiKey.KeypadSubtract,
VirtualKey.DECIMAL => ImGuiKey.KeypadDecimal,
VirtualKey.DIVIDE => ImGuiKey.KeypadDivide,
VirtualKey.F1 => ImGuiKey.F1,
VirtualKey.F2 => ImGuiKey.F2,
VirtualKey.F3 => ImGuiKey.F3,
VirtualKey.F4 => ImGuiKey.F4,
VirtualKey.F5 => ImGuiKey.F5,
VirtualKey.F6 => ImGuiKey.F6,
VirtualKey.F7 => ImGuiKey.F7,
VirtualKey.F8 => ImGuiKey.F8,
VirtualKey.F9 => ImGuiKey.F9,
VirtualKey.F10 => ImGuiKey.F10,
VirtualKey.F11 => ImGuiKey.F11,
VirtualKey.F12 => ImGuiKey.F12,
VirtualKey.NUMLOCK => ImGuiKey.NumLock,
VirtualKey.SCROLL => ImGuiKey.ScrollLock,
VirtualKey.OEM_NEC_EQUAL => ImGuiKey.KeypadEqual,
VirtualKey.LSHIFT => ImGuiKey.LeftShift,
VirtualKey.RSHIFT => ImGuiKey.RightShift,
VirtualKey.LCONTROL => ImGuiKey.LeftCtrl,
VirtualKey.RCONTROL => ImGuiKey.RightCtrl,
VirtualKey.LMENU => ImGuiKey.LeftAlt,
VirtualKey.RMENU => ImGuiKey.RightAlt,
VirtualKey.OEM_1 => ImGuiKey.Semicolon,
VirtualKey.OEM_PLUS => ImGuiKey.Equal,
VirtualKey.OEM_COMMA => ImGuiKey.Comma,
VirtualKey.OEM_MINUS => ImGuiKey.Minus,
VirtualKey.OEM_PERIOD => ImGuiKey.Period,
VirtualKey.OEM_2 => ImGuiKey.Slash,
VirtualKey.OEM_3 => ImGuiKey.GraveAccent,
VirtualKey.OEM_4 => ImGuiKey.LeftBracket,
VirtualKey.OEM_5 => ImGuiKey.Backslash,
VirtualKey.OEM_6 => ImGuiKey.RightBracket,
VirtualKey.OEM_7 => ImGuiKey.Apostrophe,
_ => 0,
};
return result != 0 || key == VirtualKey.NO_KEY;
}
public static void ChannelSelector(string headerText, Dictionary<ChatType, (ChatSource Source, ChatSource Target)> chatCodes)
{
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
using var channelNode = ImRaii.TreeNode(headerText);
if (!channelNode.Success)
return;
foreach (var (header, types) in ChatTypeExt.SortOrder)
{
using var pushedId = ImRaii.PushId(header);
if (ImGuiComponents.IconButton(FontAwesomeIcon.Check))
{
foreach (var type in types)
chatCodes.TryAdd(type, (ChatSourceExt.All, ChatSourceExt.All));
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.ChannelSelector_Select);
ImGui.SameLine(0, spacing);
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
{
foreach (var type in types)
chatCodes.Remove(type);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.ChannelSelector_Unselect);
ImGui.SameLine(0, spacing);
using var headerNode = ImRaii.TreeNode(header);
if (!headerNode.Success)
continue;
foreach (var type in types)
{
if (type.IsGm())
continue;
var enabled = chatCodes.ContainsKey(type);
if (ImGui.Checkbox($"##{type.Name()}", ref enabled))
{
if (enabled)
chatCodes[type] = (ChatSourceExt.All, ChatSourceExt.All);
else
chatCodes.Remove(type);
}
ImGui.SameLine();
if (!type.HasSource())
{
ImGui.TextUnformatted(type.Name());
continue;
}
using var typeNode = ImRaii.TreeNode($"{type.Name()}");
if (!typeNode.Success)
continue;
ImGui.Text(Language.ImGuiUtil_ChannelSelector_Source);
ImGui.SameLine(400.0f * ImGuiHelpers.GlobalScale);
ImGui.Text(Language.ImGuiUtil_ChannelSelector_Target);
chatCodes.TryGetValue(type, out var sourcesEnum);
var sources = (uint)sourcesEnum.Source;
var targets = (uint)sourcesEnum.Target;
foreach (var kind in Enum.GetValues<ChatSource>().Where(s => s != ChatSource.None))
{
if (ImGui.CheckboxFlags($"{kind.Name()}##source", ref sources, (uint)kind))
chatCodes[type] = ((ChatSource)sources, sourcesEnum.Target);
ImGui.SameLine(400.0f * ImGuiHelpers.GlobalScale);
if (ImGui.CheckboxFlags($"{kind.Name()}##target", ref targets, (uint)kind))
chatCodes[type] = (sourcesEnum.Source, (ChatSource)targets);
}
}
}
}
public static void ExtraChatSelector(string headerText, ref bool all, HashSet<Guid> extraChatChannels)
{
if (Plugin.ExtraChat.ChannelNames.Count <= 0)
return;
using var extraTree = ImRaii.TreeNode(headerText);
if (!extraTree.Success)
return;
ImGui.Checkbox(Language.Options_Tabs_ExtraChatAll, ref all);
ImGui.Separator();
using var _ = ImRaii.Disabled(all);
foreach (var (id, name) in Plugin.ExtraChat.ChannelNames)
{
var enabled = extraChatChannels.Contains(id);
if (!ImGui.Checkbox($"{name}##ec-{id}", ref enabled))
continue;
if (enabled)
extraChatChannels.Add(id);
else
extraChatChannels.Remove(id);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace HellionChat.Util;
internal class Lender<T>
{
private readonly Func<T> Ctor;
private readonly List<T> Items = [];
private int Counter;
internal Lender(Func<T> ctor)
{
Ctor = ctor;
}
internal void ResetCounter()
{
Counter = 0;
}
internal T Borrow()
{
if (Items.Count <= Counter)
Items.Add(Ctor());
return Items[Counter++];
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Numerics;
namespace HellionChat.Util;
public static class MathUtil
{
public record Rectangle
{
public int X;
public int Y;
public int Width;
public int Height;
public int SizeX;
public int SizeY;
public Rectangle(int x, int y, int width, int height)
{
X = x;
Y = y;
Width = width;
Height = height;
SizeX = X + Width;
SizeY = Y + Height;
}
public Rectangle(Vector2 pos, Vector2 size) : this((int) pos.X, (int) pos.Y, (int) size.X, (int) size.Y) { }
public override string ToString()
=> $"X: {X} Y: {Y} Width: {Width} Height: {Height}";
}
// From: https://stackoverflow.com/a/306379
/// <summary>
/// Checks if two rectangles overlap at any point.
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns>True if overlapping</returns>
public static bool HasOverlap(this Rectangle a, Rectangle b)
{
bool ValueInRange(int value, int min, int max)
=> value > min && value < max;
var xOverlap = ValueInRange(a.X, b.X, b.X + b.Width) || ValueInRange(b.X, a.X, a.X + a.Width);
var yOverlap = ValueInRange(a.Y, b.Y, b.Y + b.Height) || ValueInRange(b.Y, a.Y, a.Y + a.Height);
return xOverlap && yOverlap;
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Text;
namespace HellionChat.Util;
public static class MemoryUtil
{
public static unsafe void PrintMemoryArea(nint address, int length)
{
var ptr = (byte*)address;
var str = new StringBuilder("\n");
for(var i = 0; i < length; i++)
{
str.Append($"{ptr![i]:X02}");
if (i == 0)
continue;
if ((i+1) % 16 == 0)
str.Append('\n');
else if ((i+1) % 4 == 0)
str.Append(' ');
}
Plugin.Log.Information(str.ToString());
}
}
+111
View File
@@ -0,0 +1,111 @@
using Dalamud.Game.Text.SeStringHandling;
namespace HellionChat.Util;
internal class PartyFinderPayload : Payload
{
public override PayloadType Type => (PayloadType) 0x50;
internal uint Id { get; }
internal PartyFinderPayload(uint id)
{
Id = id;
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
}
internal class AchievementPayload : Payload
{
public override PayloadType Type => (PayloadType) 0x51;
internal uint Id { get; }
internal AchievementPayload(uint id)
{
Id = id;
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
}
internal class UriPayload(Uri uri) : Payload
{
public override PayloadType Type => (PayloadType) 0x52;
public Uri Uri { get; } = uri;
private const string DefaultScheme = "https";
private static readonly string[] ExpectedSchemes = ["http", "https"];
/// <summary>
/// Create a URIPayload from a raw URI string. If the URI does not have a
/// scheme, it will default to https://.
/// </summary>
/// <exception cref="UriFormatException">
/// If the URI is invalid, or if the scheme is not supported.
/// </exception>
public static UriPayload ResolveUri(string rawUri)
{
ArgumentNullException.ThrowIfNull(rawUri);
// Check for an expected scheme '://', if not add 'https://'
if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://")))
return new UriPayload(new Uri(rawUri));
if (rawUri.Contains("://"))
throw new UriFormatException($"Unsupported scheme in URL: {rawUri}");
return new UriPayload(new Uri($"{DefaultScheme}://{rawUri}"));
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
}
internal class EmotePayload : Payload
{
public override PayloadType Type => (PayloadType) 0x53;
public string Code = string.Empty;
public static EmotePayload ResolveEmote(string code)
{
return new EmotePayload { Code = code };
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
}
+163
View File
@@ -0,0 +1,163 @@
using System.Numerics;
using Dalamud.Interface.Utility;
using Dalamud.Bindings.ImGui;
using System.Collections;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Util;
// Modified from: https://github.com/UnknownX7/Hypostasis/blob/master/ImGui/ExcelSheet.cs
public static class SearchSelector
{
private static string[]? FilteredSearchSheet;
private static string SheetSearchText = null!;
private static string PrevSearchId = null!;
private static Type PrevSearchType = null!;
public record SelectorOptions
{
public Func<string, string> FormatRow { get; init; } = row => row.ToString();
public Func<string, string, bool>? SearchPredicate { get; init; } = null;
public Func<string, bool, bool>? DrawSelectable { get; init; } = null;
public string[] FilteredSheet { get; init; } = [];
public Vector2? Size { get; init; } = null;
}
public record SelectorPopupOptions: SelectorOptions
{
public ImGuiPopupFlags PopupFlags { get; init; } = ImGuiPopupFlags.None;
public bool CloseOnSelection { get; init; } = false;
public Func<string, bool> IsSelected { get; init; } = _ => false;
}
private static void SearchInput(string id, IEnumerable<string> filteredSheet, Func<string, string, bool> searchPredicate)
{
if (ImGui.IsWindowAppearing() && ImGui.IsWindowFocused() && !ImGui.IsAnyItemActive())
{
if (id != PrevSearchId)
{
if (typeof(string) != PrevSearchType)
{
SheetSearchText = string.Empty;
PrevSearchType = typeof(string);
}
FilteredSearchSheet = null;
PrevSearchId = id;
}
ImGui.SetKeyboardFocusHere(0);
}
if (ImGui.InputTextWithHint("##ExcelSheetSearch", "Search", ref SheetSearchText, 128, ImGuiInputTextFlags.AutoSelectAll))
FilteredSearchSheet = null;
FilteredSearchSheet ??= filteredSheet.Where(s => searchPredicate(s, SheetSearchText)).ToArray();
}
public static bool SelectorPopup(string id, out string selected, SelectorPopupOptions? options = null, bool close = false)
{
options ??= new SelectorPopupOptions();
var sheet = options.FilteredSheet;
selected = string.Empty;
if (close)
return false;
ImGui.SetNextWindowSize(options.Size ?? new Vector2(0, 250 * ImGuiHelpers.GlobalScale));
using var popup = ImRaii.ContextPopupItem(id, options.PopupFlags);
if (!popup.Success)
return false;
SearchInput(id, sheet, options.SearchPredicate ?? ((row, s) => options.FormatRow(row).Contains(s, StringComparison.CurrentCultureIgnoreCase)));
using var child = ImRaii.Child("SearchList", Vector2.Zero, true);
if (!child.Success)
return false;
var ret = false;
var drawSelectable = options.DrawSelectable ?? ((row, selected) => ImGui.Selectable(options.FormatRow(row), selected));
using (var clipper = new ListClipper(FilteredSearchSheet!.Length))
{
foreach (var i in clipper.Rows)
{
var searched = FilteredSearchSheet[i];
using var pushedId = ImRaii.PushId(id);
if (!drawSelectable(searched, options.IsSelected(searched)))
continue;
selected = searched;
ret = true;
}
}
// ImGui issue #273849, children keep popups from closing automatically
if (ret && options.CloseOnSelection)
ImGui.CloseCurrentPopup();
return ret;
}
}
public unsafe class ListClipper : IEnumerable<(int, int)>, IDisposable
{
private ImGuiListClipperPtr Clipper;
private readonly int CurrentRows;
private readonly int CurrentColumns;
private readonly bool TwoDimensional;
private readonly int ItemRemainder;
public int FirstRow { get; private set; } = -1;
public int CurrentRow { get; private set; }
public int DisplayEnd => Clipper.DisplayEnd;
public IEnumerable<int> Rows
{
get
{
while (Clipper.Step()) // Supposedly this calls End()
{
if (Clipper.ItemsHeight > 0 && FirstRow < 0)
FirstRow = (int)(ImGui.GetScrollY() / Clipper.ItemsHeight);
for (var i = Clipper.DisplayStart; i < Clipper.DisplayEnd; i++)
{
CurrentRow = i;
yield return TwoDimensional ? i : i * CurrentColumns;
}
}
}
}
private IEnumerable<int> Columns
{
get
{
var cols = (ItemRemainder == 0 || CurrentRows != DisplayEnd || CurrentRow != DisplayEnd - 1) ? CurrentColumns : ItemRemainder;
for (var j = 0; j < cols; j++)
yield return j;
}
}
public ListClipper(int items, int cols = 1, bool twoD = false, float itemHeight = 0)
{
TwoDimensional = twoD;
CurrentColumns = cols;
CurrentRows = TwoDimensional ? items : (int)MathF.Ceiling((float)items / CurrentColumns);
ItemRemainder = !TwoDimensional ? items % CurrentColumns : 0;
Clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
Clipper.Begin(CurrentRows, itemHeight);
}
public IEnumerator<(int, int)> GetEnumerator() => (from i in Rows from j in Columns select (i, j)).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose()
{
Clipper.Destroy(); // This also calls End() but I'm calling it anyway just in case
GC.SuppressFinalize(this);
}
}
+28
View File
@@ -0,0 +1,28 @@
using System.Text;
namespace HellionChat.Util;
internal static class StringUtil
{
internal static byte[] ToTerminatedBytes(this string s)
{
var utf8 = Encoding.UTF8;
var bytes = new byte[utf8.GetByteCount(s) + 1];
utf8.GetBytes(s, 0, s.Length, bytes, 0);
bytes[^1] = 0;
return bytes;
}
// Taken from https://stackoverflow.com/a/4975942
internal static string BytesToString(long byteCount)
{
string[] suf = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; // Longs run out around EB
if (byteCount == 0)
return "0" + suf[0];
var bytes = Math.Abs(byteCount);
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
return (Math.Sign(byteCount) * num).ToString("N0") + suf[place];
}
}
+227
View File
@@ -0,0 +1,227 @@
using HellionChat.Code;
using HellionChat.Resources;
namespace HellionChat.Util;
public static class TabsUtil
{
public static Dictionary<ChatType, (ChatSource, ChatSource)> AllChannels()
{
var channels = new Dictionary<ChatType, (ChatSource, ChatSource)>();
foreach (var chatType in Enum.GetValues<ChatType>())
channels[chatType] = (ChatSourceExt.All, ChatSourceExt.All);
return channels;
}
// Hellion-tuned General preset. The pure player-talk catch-all plus
// the active-gameplay event streams (loot, crafting, gathering, NPC
// dialogue, party-finder pings). Pure technical noise (System, Error,
// Login/Logout spam, retainer sales, alarms, sign messages) lives in
// the dedicated System tab so it doesn't bury actual conversation.
public static Tab VanillaGeneral => new()
{
Name = Language.Tabs_Presets_General,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
// Player chat
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Yell] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Shout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Party] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossParty] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Alliance] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompany] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeam] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
// Active-gameplay events
[ChatType.NpcDialogue] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.LootNotice] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.LootRoll] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Crafting] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Gathering] = (ChatSource.LocalPlayer, ChatSource.LocalPlayer),
[ChatType.PeriodicRecruitmentNotification] = (ChatSourceExt.All, ChatSourceExt.All),
}
};
public static Tab VanillaEvent => new()
{
Name = Language.Tabs_Presets_Event,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)> { [ChatType.NpcDialogue] = (ChatSourceExt.All, ChatSourceExt.All), },
};
public static Tab VanillaTellExclusive => new()
{
Name = Language.Tabs_Presets_Tell,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
Channel = InputChannel.Tell,
AllSenderMessages = true,
};
// Hellion default-tab presets used by the v10 wipe migration. Names are
// kept in HellionStrings (EN+DE) instead of Language.* so the upstream
// resource files stay untouched. Channel selections cover the channels
// a typical Eorzea raider uses without forcing the user to hand-tick
// each box on first start.
public static Tab HellionFreeCompany => new()
{
Name = HellionStrings.Tabs_Presets_FreeCompany,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.FreeCompany] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompanyAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompanyLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
},
Channel = InputChannel.FreeCompany,
};
public static Tab HellionParty => new()
{
Name = HellionStrings.Tabs_Presets_Party,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Party] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossParty] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Alliance] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeam] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeamAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeamLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.LootNotice] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.LootRoll] = (ChatSourceExt.All, ChatSourceExt.All),
},
// No automatic input-channel switch; the Gruppe tab is a read
// surface that pulls in Party, CrossParty, Alliance and PvpTeam
// together. Auto-routing /party into this tab would surprise the
// user when they actually wanted /alliance or /pvpteam.
};
public static Tab HellionBeginner => new()
{
Name = HellionStrings.Tabs_Presets_Beginner,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.NoviceNetwork] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.NoviceNetworkSystem] = (ChatSourceExt.All, ChatSourceExt.All),
},
Channel = InputChannel.NoviceNetwork,
};
public static Tab HellionSystem => new()
{
Name = HellionStrings.Tabs_Presets_System,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Debug] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Urgent] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Notice] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.System] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Error] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Echo] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.GatheringSystem] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.NoviceNetworkSystem] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.NpcAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompanyLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeamLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.RetainerSale] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Progress] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.RandomNumber] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Orchestrion] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.MessageBook] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Alarm] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Sign] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.GlamourNotifications] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
public static Tab HellionLinkshell => new()
{
Name = HellionStrings.Tabs_Presets_Linkshell,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Linkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
public static Dictionary<ChatType, (ChatSource, ChatSource)> MostlyPlayer => new()
{
// Special
[ChatType.Debug] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Urgent] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Notice] = (ChatSourceExt.All, ChatSourceExt.All),
// Chat
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Yell] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Shout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Party] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossParty] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Alliance] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompany] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeam] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CrossLinkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell1] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell2] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell3] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell4] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell5] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell6] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell7] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Linkshell8] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.NoviceNetwork] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.StandardEmote] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.CustomEmote] = (ChatSourceExt.All, ChatSourceExt.All),
// Announcements
[ChatType.System] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Error] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.Echo] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.NoviceNetworkSystem] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompanyAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeamAnnouncement] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.FreeCompanyLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.PvpTeamLoginLogout] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.RandomNumber] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.MessageBook] = (ChatSourceExt.All, ChatSourceExt.All),
};
}
+152
View File
@@ -0,0 +1,152 @@
using System.Text.RegularExpressions;
namespace HellionChat.Util;
// Modified from: https://jack-vanlightly.com/blog/2016/2/24/a-more-efficient-regex-tokenizer
public static class Tokenizer
{
public enum TokenType
{
CloseParenthesis,
Comma,
Dot,
QuestionMark,
ExclamationMark,
Semicolon,
Whitespace,
Equals,
OpenParenthesis,
UrlString,
StringValue,
Leftover,
SequenceTerminator
}
public class Token(TokenType tokenType, string value)
{
public Token(TokenType tokenType) : this(tokenType, string.Empty) { }
public TokenType TokenType { get; } = tokenType;
public string Value { get; } = value;
}
public static class PrecedenceBasedRegexTokenizer
{
private static readonly List<TokenDefinition> TokenDefinitions;
static PrecedenceBasedRegexTokenizer()
{
TokenDefinitions =
[
new TokenDefinition(TokenType.CloseParenthesis, "\\)", 1),
new TokenDefinition(TokenType.Comma, ",", 1),
new TokenDefinition(TokenType.Dot, "\\.", 1),
new TokenDefinition(TokenType.QuestionMark, "\\?", 1),
new TokenDefinition(TokenType.ExclamationMark, "!", 1),
new TokenDefinition(TokenType.Semicolon, ";", 1),
new TokenDefinition(TokenType.Whitespace, "\\s", 1),
new TokenDefinition(TokenType.Equals, "=", 1),
new TokenDefinition(TokenType.OpenParenthesis, "\\(", 1),
new TokenDefinition(TokenType.UrlString, UrlRegex, 1),
new TokenDefinition(TokenType.StringValue, "\\p{IsBasicLatin}", 2),
new TokenDefinition(TokenType.Leftover, ".", 3)
];
}
public static IEnumerable<Token> Tokenize(string lqlText)
{
var tokenMatches = FindTokenMatches(lqlText);
var groupedByIndex = tokenMatches.GroupBy(x => x.StartIndex)
.OrderBy(x => x.Key)
.ToList();
TokenMatch? lastMatch = null;
foreach (var t in groupedByIndex)
{
var bestMatch = t.OrderBy(x => x.Precedence).First();
if (lastMatch != null && bestMatch.StartIndex < lastMatch.EndIndex)
continue;
yield return new Token(bestMatch.TokenType, bestMatch.Value);
lastMatch = bestMatch;
}
yield return new Token(TokenType.SequenceTerminator);
}
private static List<TokenMatch> FindTokenMatches(string lqlText)
{
var tokenMatches = new List<TokenMatch>();
foreach (var tokenDefinition in TokenDefinitions)
tokenMatches.AddRange(tokenDefinition.FindMatches(lqlText).ToList());
return tokenMatches;
}
}
private class TokenDefinition
{
private readonly TokenType Type;
private readonly int Precedence;
private readonly Regex Regex;
public TokenDefinition(TokenType returnsToken, string regexPattern, int precedence)
{
Type = returnsToken;
Precedence = precedence;
Regex = new Regex(regexPattern, RegexOptions.IgnoreCase|RegexOptions.Compiled);
}
public TokenDefinition(TokenType returnsToken, Regex regex, int precedence)
{
Type = returnsToken;
Precedence = precedence;
Regex = regex;
}
public IEnumerable<TokenMatch> FindMatches(string inputString)
{
var matches = Regex.Matches(inputString);
for(var i = 0; i < matches.Count; i++)
{
yield return new TokenMatch
{
StartIndex = matches[i].Index,
EndIndex = matches[i].Index + matches[i].Length,
TokenType = Type,
Value = matches[i].Value,
Precedence = Precedence
};
}
}
}
private class TokenMatch
{
public TokenType TokenType { get; set; }
public required string Value { get; set; }
public int StartIndex { get; set; }
public int EndIndex { get; set; }
public int Precedence { get; set; }
}
/// <summary>
/// URLRegex returns a regex object that matches URLs like:
/// - https://example.com
/// - http://example.com
/// - www.example.com
/// - https://sub.example.com
/// - example.com
/// - sub.example.com
///
/// It matches URLs with www. or https:// prefix, and also matches URLs
/// without a prefix on specific TLDs.
/// </summary>
private static readonly Regex UrlRegex = new(
@"(?<URL>((https?:\/\/|www\.)[a-z0-9-]+(\.[a-z0-9-]+)*|([a-z0-9-]+(\.[a-z0-9-]+)*\.(com|net|org|co|io|app)))(:[\d]{1,5})?(\/[^\s]*)?)",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture
);
}
+26
View File
@@ -0,0 +1,26 @@
using HellionChat.Resources;
using Dalamud.Interface.ImGuiNotification;
namespace HellionChat.Util;
public static class WrapperUtil
{
public static void AddNotification(string content, NotificationType type, bool minimized = true)
{
Plugin.Notification.AddNotification(new Notification { Content = content, Type = type, Minimized = minimized });
}
public static void TryOpenUri(Uri uri)
{
try
{
Plugin.Log.Debug($"Opening URI {uri} in default browser");
Dalamud.Utility.Util.OpenLink(uri.ToString());
}
catch (Exception ex)
{
Plugin.Log.Error($"Error opening URI: {ex}");
AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error);
}
}
}