Add SeStringEvaluator service (#2188)

* Add SeStringEvaluator service

* Move DrawCopyableText into WidgetUtil

* Use Icon2RemapTable in SeStringRenderer

* Beautify some code

* Make sure to use the correct language

* Add SeString Creator widget

* Fix getting local parameters

* Update expressionNames

* misc changes

* Use InvariantCulture in TryResolveSheet

* Add SeStringEvaluatorAgingStep

* Fix item id comparisons

* Add SheetRedirectResolverAgingStep

* Add NounProcessorAgingStep

* Update SeString.CreateItemLink

This also adds the internal ItemUtil class.

* Fix name of SeStringCreator widget

* Add Global Parameters tab to SeStringCreatorWidget

* Load widgets on demand

* Update SeStringCreatorWidget

* Resizable SeStringCreatorWidget panels

* Update GamepadStateAgingStep

* Experimental status was removed in #2144

* Update SheetRedirectResolver, rewrite Noun params

* Fixes for 4 am code

* Remove incorrect column offset

I have no idea how that happened.

* Draw names of linked things

---------

Co-authored-by: Soreepeong <3614868+Soreepeong@users.noreply.github.com>
Co-authored-by: KazWolfe <KazWolfe@users.noreply.github.com>
This commit is contained in:
Haselnussbomber 2025-03-24 17:00:27 +01:00 committed by GitHub
parent 7cac19ce81
commit fdbfdbb2cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 5831 additions and 196 deletions

View file

@ -0,0 +1,26 @@
using Dalamud.Game;
namespace Dalamud.Utility;
/// <summary>
/// Extension methods for the <see cref="ActionKind"/> enum.
/// </summary>
public static class ActionKindExtensions
{
/// <summary>
/// Converts the id of an ActionKind to the id used in the ActStr sheet redirect.
/// </summary>
/// <param name="actionKind">The ActionKind this id is for.</param>
/// <param name="id">The id.</param>
/// <returns>An id that can be used in the ActStr sheet redirect.</returns>
public static uint GetActStrId(this ActionKind actionKind, uint id)
{
// See "83 F9 0D 76 0B"
var idx = (uint)actionKind;
if (idx is <= 13 or 19 or 20)
return id + (1000000 * idx);
return 0;
}
}

View file

@ -23,4 +23,40 @@ public static class ClientLanguageExtensions
_ => throw new ArgumentOutOfRangeException(nameof(language)),
};
}
/// <summary>
/// Gets the language code from a ClientLanguage.
/// </summary>
/// <param name="value">The ClientLanguage to convert.</param>
/// <returns>The language code (ja, en, de, fr).</returns>
/// <exception cref="ArgumentOutOfRangeException">An exception that is thrown when no valid ClientLanguage was given.</exception>
public static string ToCode(this ClientLanguage value)
{
return value switch
{
ClientLanguage.Japanese => "ja",
ClientLanguage.English => "en",
ClientLanguage.German => "de",
ClientLanguage.French => "fr",
_ => throw new ArgumentOutOfRangeException(nameof(value)),
};
}
/// <summary>
/// Gets the ClientLanguage from a language code.
/// </summary>
/// <param name="value">The language code to convert (ja, en, de, fr).</param>
/// <returns>The ClientLanguage.</returns>
/// <exception cref="ArgumentOutOfRangeException">An exception that is thrown when no valid language code was given.</exception>
public static ClientLanguage ToClientLanguage(this string value)
{
return value switch
{
"ja" => ClientLanguage.Japanese,
"en" => ClientLanguage.English,
"de" => ClientLanguage.German,
"fr" => ClientLanguage.French,
_ => throw new ArgumentOutOfRangeException(nameof(value)),
};
}
}

159
Dalamud/Utility/ItemUtil.cs Normal file
View file

@ -0,0 +1,159 @@
using System.Runtime.CompilerServices;
using Dalamud.Data;
using Dalamud.Game;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Game.Text;
using Lumina.Excel.Sheets;
using Lumina.Text.ReadOnly;
using static Dalamud.Game.Text.SeStringHandling.Payloads.ItemPayload;
using Lumina.Text;
namespace Dalamud.Utility;
/// <summary>
/// Utilities related to Items.
/// </summary>
internal static class ItemUtil
{
private static int? eventItemRowCount;
/// <summary>Converts raw item ID to item ID with its classification.</summary>
/// <param name="rawItemId">Raw item ID.</param>
/// <returns>Item ID and its classification.</returns>
internal static (uint ItemId, ItemKind Kind) GetBaseId(uint rawItemId)
{
if (IsEventItem(rawItemId)) return (rawItemId, ItemKind.EventItem); // EventItem IDs are NOT adjusted
if (IsHighQuality(rawItemId)) return (rawItemId - 1_000_000, ItemKind.Hq);
if (IsCollectible(rawItemId)) return (rawItemId - 500_000, ItemKind.Collectible);
return (rawItemId, ItemKind.Normal);
}
/// <summary>Converts item ID with its classification to raw item ID.</summary>
/// <param name="itemId">Item ID.</param>
/// <param name="kind">Item classification.</param>
/// <returns>Raw Item ID.</returns>
internal static uint GetRawId(uint itemId, ItemKind kind)
{
return kind switch
{
ItemKind.Collectible when itemId < 500_000 => itemId + 500_000,
ItemKind.Hq when itemId < 1_000_000 => itemId + 1_000_000,
ItemKind.EventItem => itemId, // EventItem IDs are not adjusted
_ => itemId,
};
}
/// <summary>
/// Checks if the item id belongs to a normal item.
/// </summary>
/// <param name="itemId">The item id to check.</param>
/// <returns><c>true</c> when the item id belongs to a normal item.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool IsNormalItem(uint itemId)
{
return itemId < 500_000;
}
/// <summary>
/// Checks if the item id belongs to a collectible item.
/// </summary>
/// <param name="itemId">The item id to check.</param>
/// <returns><c>true</c> when the item id belongs to a collectible item.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool IsCollectible(uint itemId)
{
return itemId is >= 500_000 and < 1_000_000;
}
/// <summary>
/// Checks if the item id belongs to a high quality item.
/// </summary>
/// <param name="itemId">The item id to check.</param>
/// <returns><c>true</c> when the item id belongs to a high quality item.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool IsHighQuality(uint itemId)
{
return itemId is >= 1_000_000 and < 2_000_000;
}
/// <summary>
/// Checks if the item id belongs to an event item.
/// </summary>
/// <param name="itemId">The item id to check.</param>
/// <returns><c>true</c> when the item id belongs to an event item.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool IsEventItem(uint itemId)
{
return itemId >= 2_000_000 && itemId - 2_000_000 < (eventItemRowCount ??= Service<DataManager>.Get().GetExcelSheet<EventItem>().Count);
}
/// <summary>
/// Gets the name of an item.
/// </summary>
/// <param name="itemId">The raw item id.</param>
/// <param name="includeIcon">Whether to include the High Quality or Collectible icon.</param>
/// <param name="language">An optional client language override.</param>
/// <returns>The item name.</returns>
internal static ReadOnlySeString GetItemName(uint itemId, bool includeIcon = true, ClientLanguage? language = null)
{
var dataManager = Service<DataManager>.Get();
if (IsEventItem(itemId))
{
return dataManager
.GetExcelSheet<EventItem>(language)
.TryGetRow(itemId, out var eventItem)
? eventItem.Name
: default;
}
var (baseId, kind) = GetBaseId(itemId);
if (!dataManager
.GetExcelSheet<Item>(language)
.TryGetRow(baseId, out var item))
{
return default;
}
if (!includeIcon || kind is not (ItemKind.Hq or ItemKind.Collectible))
return item.Name;
var builder = SeStringBuilder.SharedPool.Get();
builder.Append(item.Name);
switch (kind)
{
case ItemPayload.ItemKind.Hq:
builder.Append($" {(char)SeIconChar.HighQuality}");
break;
case ItemPayload.ItemKind.Collectible:
builder.Append($" {(char)SeIconChar.Collectible}");
break;
}
var itemName = builder.ToReadOnlySeString();
SeStringBuilder.SharedPool.Return(builder);
return itemName;
}
/// <summary>
/// Gets the color row id for an item name.
/// </summary>
/// <param name="itemId">The raw item Id.</param>
/// <param name="isEdgeColor">Wheather this color is used as edge color.</param>
/// <returns>The Color row id.</returns>
internal static uint GetItemRarityColorType(uint itemId, bool isEdgeColor = false)
{
var rarity = 1u;
if (!IsEventItem(itemId) && Service<DataManager>.Get().GetExcelSheet<Item>().TryGetRow(GetBaseId(itemId).ItemId, out var item))
rarity = item.Rarity;
return (isEdgeColor ? 548u : 547u) + (rarity * 2u);
}
}

View file

@ -0,0 +1,33 @@
using Dalamud.Game.ClientState.Objects.Enums;
namespace Dalamud.Utility;
/// <summary>
/// Extension methods for the <see cref="ObjectKind"/> enum.
/// </summary>
public static class ObjectKindExtensions
{
/// <summary>
/// Converts the id of an ObjectKind to the id used in the ObjStr sheet redirect.
/// </summary>
/// <param name="objectKind">The ObjectKind this id is for.</param>
/// <param name="id">The id.</param>
/// <returns>An id that can be used in the ObjStr sheet redirect.</returns>
public static uint GetObjStrId(this ObjectKind objectKind, uint id)
{
// See "8D 41 FE 83 F8 0C 77 4D"
return objectKind switch
{
ObjectKind.BattleNpc => id < 1000000 ? id : id - 900000,
ObjectKind.EventNpc => id,
ObjectKind.Treasure or
ObjectKind.Aetheryte or
ObjectKind.GatheringPoint or
ObjectKind.Companion or
ObjectKind.Housing => id + (1000000 * (uint)objectKind) - 2000000,
ObjectKind.EventObj => id + (1000000 * (uint)objectKind) - 4000000,
ObjectKind.CardStand => id + 3000000,
_ => 0,
};
}
}

View file

@ -1,3 +1,5 @@
using System.Linq;
using Lumina.Text.Parse;
using Lumina.Text.ReadOnly;
@ -74,4 +76,154 @@ public static class SeStringExtensions
/// <param name="value">character name to validate.</param>
/// <returns>indicator if character is name is valid.</returns>
public static bool IsValidCharacterName(this DSeString value) => value.ToString().IsValidCharacterName();
/// <summary>
/// Determines whether the <see cref="ReadOnlySeString"/> contains only text payloads.
/// </summary>
/// <param name="ross">The <see cref="ReadOnlySeString"/> to check.</param>
/// <returns><c>true</c> if the string contains only text payloads; otherwise, <c>false</c>.</returns>
public static bool IsTextOnly(this ReadOnlySeString ross)
{
return ross.AsSpan().IsTextOnly();
}
/// <summary>
/// Determines whether the <see cref="ReadOnlySeStringSpan"/> contains only text payloads.
/// </summary>
/// <param name="rosss">The <see cref="ReadOnlySeStringSpan"/> to check.</param>
/// <returns><c>true</c> if the span contains only text payloads; otherwise, <c>false</c>.</returns>
public static bool IsTextOnly(this ReadOnlySeStringSpan rosss)
{
foreach (var payload in rosss)
{
if (payload.Type != ReadOnlySePayloadType.Text)
return false;
}
return true;
}
/// <summary>
/// Determines whether the <see cref="ReadOnlySeString"/> contains the specified text.
/// </summary>
/// <param name="ross">The <see cref="ReadOnlySeString"/> to search.</param>
/// <param name="needle">The text to find.</param>
/// <returns><c>true</c> if the text is found; otherwise, <c>false</c>.</returns>
public static bool ContainsText(this ReadOnlySeString ross, ReadOnlySpan<byte> needle)
{
return ross.AsSpan().ContainsText(needle);
}
/// <summary>
/// Determines whether the <see cref="ReadOnlySeStringSpan"/> contains the specified text.
/// </summary>
/// <param name="rosss">The <see cref="ReadOnlySeStringSpan"/> to search.</param>
/// <param name="needle">The text to find.</param>
/// <returns><c>true</c> if the text is found; otherwise, <c>false</c>.</returns>
public static bool ContainsText(this ReadOnlySeStringSpan rosss, ReadOnlySpan<byte> needle)
{
foreach (var payload in rosss)
{
if (payload.Type != ReadOnlySePayloadType.Text)
continue;
if (payload.Body.IndexOf(needle) != -1)
return true;
}
return false;
}
/// <summary>
/// Determines whether the <see cref="LSeStringBuilder"/> contains the specified text.
/// </summary>
/// <param name="builder">The builder to search.</param>
/// <param name="needle">The text to find.</param>
/// <returns><c>true</c> if the text is found; otherwise, <c>false</c>.</returns>
public static bool ContainsText(this LSeStringBuilder builder, ReadOnlySpan<byte> needle)
{
return builder.ToReadOnlySeString().ContainsText(needle);
}
/// <summary>
/// Replaces occurrences of a specified text in a <see cref="ReadOnlySeString"/> with another text.
/// </summary>
/// <param name="ross">The original string.</param>
/// <param name="toFind">The text to find.</param>
/// <param name="replacement">The replacement text.</param>
/// <returns>A new <see cref="ReadOnlySeString"/> with the replacements made.</returns>
public static ReadOnlySeString ReplaceText(
this ReadOnlySeString ross,
ReadOnlySpan<byte> toFind,
ReadOnlySpan<byte> replacement)
{
if (ross.IsEmpty)
return ross;
var sb = LSeStringBuilder.SharedPool.Get();
foreach (var payload in ross)
{
if (payload.Type == ReadOnlySePayloadType.Invalid)
continue;
if (payload.Type != ReadOnlySePayloadType.Text)
{
sb.Append(payload);
continue;
}
var index = payload.Body.Span.IndexOf(toFind);
if (index == -1)
{
sb.Append(payload);
continue;
}
var lastIndex = 0;
while (index != -1)
{
sb.Append(payload.Body.Span[lastIndex..index]);
if (!replacement.IsEmpty)
{
sb.Append(replacement);
}
lastIndex = index + toFind.Length;
index = payload.Body.Span[lastIndex..].IndexOf(toFind);
if (index != -1)
index += lastIndex;
}
sb.Append(payload.Body.Span[lastIndex..]);
}
var output = sb.ToReadOnlySeString();
LSeStringBuilder.SharedPool.Return(sb);
return output;
}
/// <summary>
/// Replaces occurrences of a specified text in an <see cref="LSeStringBuilder"/> with another text.
/// </summary>
/// <param name="builder">The builder to modify.</param>
/// <param name="toFind">The text to find.</param>
/// <param name="replacement">The replacement text.</param>
public static void ReplaceText(
this LSeStringBuilder builder,
ReadOnlySpan<byte> toFind,
ReadOnlySpan<byte> replacement)
{
if (toFind.IsEmpty)
return;
var str = builder.ToReadOnlySeString();
if (str.IsEmpty)
return;
var replaced = ReplaceText(new ReadOnlySeString(builder.GetViewAsMemory()), toFind, replacement);
builder.Clear().Append(replaced);
}
}

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using FFXIVClientStructs.FFXIV.Client.UI;
@ -43,4 +44,48 @@ public static class StringExtensions
if (!UIGlobals.IsValidPlayerCharacterName(value)) return false;
return includeLegacy || value.Length <= 21;
}
/// <summary>
/// Converts the first character of the string to uppercase while leaving the rest of the string unchanged.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="culture"><inheritdoc cref="string.ToLower(CultureInfo)" path="/param[@name='cultureInfo']"/></param>
/// <returns>A new string with the first character converted to uppercase.</returns>
[return: NotNullIfNotNull("input")]
public static string? FirstCharToUpper(this string? input, CultureInfo? culture = null) =>
string.IsNullOrWhiteSpace(input)
? input
: $"{char.ToUpper(input[0], culture ?? CultureInfo.CurrentCulture)}{input.AsSpan(1)}";
/// <summary>
/// Converts the first character of the string to lowercase while leaving the rest of the string unchanged.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="culture"><inheritdoc cref="string.ToLower(CultureInfo)" path="/param[@name='cultureInfo']"/></param>
/// <returns>A new string with the first character converted to lowercase.</returns>
[return: NotNullIfNotNull("input")]
public static string? FirstCharToLower(this string? input, CultureInfo? culture = null) =>
string.IsNullOrWhiteSpace(input)
? input
: $"{char.ToLower(input[0], culture ?? CultureInfo.CurrentCulture)}{input.AsSpan(1)}";
/// <summary>
/// Removes soft hyphen characters (U+00AD) from the input string.
/// </summary>
/// <param name="input">The input string to remove soft hyphen characters from.</param>
/// <returns>A string with all soft hyphens removed.</returns>
public static string StripSoftHyphen(this string input) => input.Replace("\u00AD", string.Empty);
/// <summary>
/// Truncates the given string to the specified maximum number of characters,
/// appending an ellipsis if truncation occurs.
/// </summary>
/// <param name="input">The string to truncate.</param>
/// <param name="maxChars">The maximum allowed length of the string.</param>
/// <param name="ellipses">The string to append if truncation occurs (defaults to "...").</param>
/// <returns>The truncated string, or the original string if no truncation is needed.</returns>
public static string? Truncate(this string input, int maxChars, string ellipses = "...")
{
return string.IsNullOrEmpty(input) || input.Length <= maxChars ? input : input[..maxChars] + ellipses;
}
}