mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-11 20:38:58 +08:00
feat(text): 添加富文本效果系统和抖动效果实现
- 创建 RichTextEffectBase 基类提供统一的标签命名和环境参数读取逻辑 - 实现 RichTextJitterEffect 抖动效果类,支持振幅和速度参数调节 - 添加 DefaultRichTextEffectRegistry 默认效果注册表管理内置效果映射 - 创建 GfRichTextLabel 组合式富文本标签宿主,集成效果装配逻辑 - 定义 IRichTextEffectRegistry 接口实现效果注册表抽象 - 开发 RichTextEffectsController 装配控制器负责效果集合管理 - 实现 RichTextMarkup 工具类提供语义化富文本标签构建辅助方法 - 添加相关单元测试验证效果控制器和标记工具的功能正确性
This commit is contained in:
parent
e5ad29314e
commit
22882f68c4
126
GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
Normal file
126
GFramework.Godot.Tests/Text/RichTextEffectsControllerTests.cs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
using GFramework.Godot.Text;
|
||||||
|
using Godot;
|
||||||
|
using Array = Godot.Collections.Array;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Tests.Text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="RichTextEffectsController" /> 的纯托管行为测试。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class RichTextEffectsControllerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 验证启用框架效果时会开启宿主 BBCode,并在 Profile 为空时回退到内置默认配置。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void RefreshEffects_Should_Enable_Bbcode_And_Use_BuiltIn_Default_Profile()
|
||||||
|
{
|
||||||
|
var host = new FakeRichTextEffectHost();
|
||||||
|
var registry = new RecordingRegistry();
|
||||||
|
var controller = new RichTextEffectsController(
|
||||||
|
host,
|
||||||
|
() => registry,
|
||||||
|
() => null,
|
||||||
|
() => true,
|
||||||
|
() => false);
|
||||||
|
|
||||||
|
controller.RefreshEffects();
|
||||||
|
|
||||||
|
Assert.That(host.BbcodeEnabled, Is.True);
|
||||||
|
Assert.That(registry.CapturedAnimatedEffectsEnabled, Is.False);
|
||||||
|
Assert.That(registry.CapturedProfiles, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(registry.CapturedProfiles[0].Effects.Select(static entry => entry.Key), Is.EqualTo(new[]
|
||||||
|
{
|
||||||
|
"green",
|
||||||
|
"red",
|
||||||
|
"gold",
|
||||||
|
"blue",
|
||||||
|
"fade_in",
|
||||||
|
"sine",
|
||||||
|
"jitter",
|
||||||
|
"fly_in"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证关闭框架效果时不会调用注册表,并会清空宿主的自定义效果集合。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void RefreshEffects_Should_Clear_CustomEffects_When_Framework_Effects_Are_Disabled()
|
||||||
|
{
|
||||||
|
var existingEffects = new Array();
|
||||||
|
existingEffects.Add("placeholder");
|
||||||
|
|
||||||
|
var host = new FakeRichTextEffectHost
|
||||||
|
{
|
||||||
|
BbcodeEnabled = true,
|
||||||
|
CustomEffects = existingEffects
|
||||||
|
};
|
||||||
|
var registry = new RecordingRegistry();
|
||||||
|
var controller = new RichTextEffectsController(
|
||||||
|
host,
|
||||||
|
() => registry,
|
||||||
|
() => RichTextProfile.CreateBuiltInDefault(),
|
||||||
|
() => false,
|
||||||
|
() => true);
|
||||||
|
|
||||||
|
controller.RefreshEffects();
|
||||||
|
|
||||||
|
Assert.That(host.BbcodeEnabled, Is.True);
|
||||||
|
Assert.That(host.CustomEffects.Count, Is.EqualTo(0));
|
||||||
|
Assert.That(registry.CapturedProfiles, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证控制器会在每次刷新时读取最新的注册表访问器结果,避免缓存旧注册表。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void RefreshEffects_Should_Use_The_Current_Registry_From_Accessor()
|
||||||
|
{
|
||||||
|
var host = new FakeRichTextEffectHost();
|
||||||
|
var firstRegistry = new RecordingRegistry();
|
||||||
|
var secondRegistry = new RecordingRegistry();
|
||||||
|
IRichTextEffectRegistry currentRegistry = firstRegistry;
|
||||||
|
|
||||||
|
var controller = new RichTextEffectsController(
|
||||||
|
host,
|
||||||
|
() => currentRegistry,
|
||||||
|
() => RichTextProfile.CreateBuiltInDefault(),
|
||||||
|
() => true,
|
||||||
|
() => true);
|
||||||
|
|
||||||
|
controller.RefreshEffects();
|
||||||
|
currentRegistry = secondRegistry;
|
||||||
|
controller.RefreshEffects();
|
||||||
|
|
||||||
|
Assert.That(firstRegistry.CapturedProfiles, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(secondRegistry.CapturedProfiles, Has.Count.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeRichTextEffectHost : IRichTextEffectHost
|
||||||
|
{
|
||||||
|
public bool BbcodeEnabled { get; set; }
|
||||||
|
|
||||||
|
public Array CustomEffects { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingRegistry : IRichTextEffectRegistry
|
||||||
|
{
|
||||||
|
public List<RichTextProfile> CapturedProfiles { get; } = [];
|
||||||
|
|
||||||
|
public bool CapturedAnimatedEffectsEnabled { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
|
||||||
|
{
|
||||||
|
CapturedProfiles.Add(profile);
|
||||||
|
CapturedAnimatedEffectsEnabled = animatedEffectsEnabled;
|
||||||
|
return System.Array.Empty<RichTextEffect>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RichTextEffect? CreateEffect(string key, bool animatedEffectsEnabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,16 +23,43 @@ public sealed class RichTextMarkupTests
|
|||||||
/// 验证效果方法会按稳定顺序拼接环境参数。
|
/// 验证效果方法会按稳定顺序拼接环境参数。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public void Effect_Should_Append_Environment_Parameters()
|
public void Effect_Should_Sort_Environment_Parameters_By_Key()
|
||||||
{
|
{
|
||||||
var env = new Dictionary<string, object?>
|
var env = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["speed"] = 4,
|
["tick"] = 0.1f,
|
||||||
["tick"] = 0.1f
|
["speed"] = 4
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = RichTextMarkup.Effect("Hello", "fade_in", env);
|
var result = RichTextMarkup.Effect("Hello", "fade_in", env);
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("[fade_in speed=4 tick=0.1]Hello[/fade_in]"));
|
Assert.That(result, Is.EqualTo("[fade_in speed=4 tick=0.1]Hello[/fade_in]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证非法标签 token 会被拒绝,避免生成损坏的 BBCode。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Effect_Should_Reject_Invalid_Tag_Tokens()
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => RichTextMarkup.Effect("Hello", "fade=in"));
|
||||||
|
|
||||||
|
Assert.That(exception!.ParamName, Is.EqualTo("tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证非法环境参数键会被拒绝,避免注入无效的 BBCode token。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Effect_Should_Reject_Invalid_Environment_Key_Tokens()
|
||||||
|
{
|
||||||
|
var env = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["bad key"] = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => RichTextMarkup.Effect("Hello", "fade_in", env));
|
||||||
|
|
||||||
|
Assert.That(exception!.ParamName, Is.EqualTo("env"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,9 @@ public sealed class DefaultRichTextEffectRegistry : IRichTextEffectRegistry
|
|||||||
/// <param name="profile">效果组合配置。</param>
|
/// <param name="profile">效果组合配置。</param>
|
||||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||||
/// <returns>内置效果实例集合。</returns>
|
/// <returns>内置效果实例集合。</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">
|
||||||
|
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 时抛出。
|
||||||
|
/// </exception>
|
||||||
public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
|
public IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(profile);
|
ArgumentNullException.ThrowIfNull(profile);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public abstract partial class RichTextEffectBase : RichTextEffect
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取 Godot 识别当前效果所需的 `bbcode` 属性。
|
/// 获取 Godot 识别当前效果所需的 `bbcode` 属性。
|
||||||
|
/// 属性名使用小写是 Godot `RichTextEffect` 的约定,不是框架对公共成员命名的放宽。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string bbcode => TagName;
|
public string bbcode => TagName;
|
||||||
|
|
||||||
@ -23,10 +24,11 @@ public abstract partial class RichTextEffectBase : RichTextEffect
|
|||||||
/// <param name="transform">当前字符变换上下文。</param>
|
/// <param name="transform">当前字符变换上下文。</param>
|
||||||
/// <param name="key">参数键。</param>
|
/// <param name="key">参数键。</param>
|
||||||
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
||||||
/// <returns>最终布尔值。</returns>
|
/// <returns>最终布尔值;当环境参数不存在或类型不是 <see cref="Variant.Type.Bool" /> 时返回默认值。</returns>
|
||||||
protected bool GetBool(CharFXTransform transform, string key, bool defaultValue = false)
|
protected bool GetBool(CharFXTransform transform, string key, bool defaultValue = false)
|
||||||
{
|
{
|
||||||
if (transform.Env.TryGetValue(Variant.From(key), out var value))
|
if (transform.Env.TryGetValue(Variant.From(key), out var value) &&
|
||||||
|
value.VariantType == Variant.Type.Bool)
|
||||||
{
|
{
|
||||||
return value.AsBool();
|
return value.AsBool();
|
||||||
}
|
}
|
||||||
@ -40,10 +42,14 @@ public abstract partial class RichTextEffectBase : RichTextEffect
|
|||||||
/// <param name="transform">当前字符变换上下文。</param>
|
/// <param name="transform">当前字符变换上下文。</param>
|
||||||
/// <param name="key">参数键。</param>
|
/// <param name="key">参数键。</param>
|
||||||
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
/// <param name="defaultValue">读取失败时使用的默认值。</param>
|
||||||
/// <returns>最终浮点值。</returns>
|
/// <returns>
|
||||||
|
/// 最终浮点值;当环境参数不存在,或类型既不是 <see cref="Variant.Type.Float" /> 也不是
|
||||||
|
/// <see cref="Variant.Type.Int" /> 时返回默认值。
|
||||||
|
/// </returns>
|
||||||
protected float GetFloat(CharFXTransform transform, string key, float defaultValue)
|
protected float GetFloat(CharFXTransform transform, string key, float defaultValue)
|
||||||
{
|
{
|
||||||
if (transform.Env.TryGetValue(Variant.From(key), out var value))
|
if (transform.Env.TryGetValue(Variant.From(key), out var value) &&
|
||||||
|
(value.VariantType == Variant.Type.Float || value.VariantType == Variant.Type.Int))
|
||||||
{
|
{
|
||||||
return (float)value.AsDouble();
|
return (float)value.AsDouble();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,11 @@ namespace GFramework.Godot.Text.Effects;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 为文本提供抖动效果。
|
/// 为文本提供抖动效果。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 默认注册表会在每次宿主刷新时为该效果创建独立实例。
|
||||||
|
/// <see cref="_ProcessCustomFX" /> 内部会复用并修改 <see cref="_noise" /> 的 Seed,因此该类型假定 Godot 在主线程顺序
|
||||||
|
/// 执行字符效果,不支持跨多个 <see cref="RichTextLabel" /> 共享同一实例,也不保证并发调用下的线程安全。
|
||||||
|
/// </remarks>
|
||||||
[GlobalClass]
|
[GlobalClass]
|
||||||
[Tool]
|
[Tool]
|
||||||
public partial class RichTextJitterEffect : RichTextEffectBase
|
public partial class RichTextJitterEffect : RichTextEffectBase
|
||||||
|
|||||||
@ -6,7 +6,7 @@ namespace GFramework.Godot.Text;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[GlobalClass]
|
[GlobalClass]
|
||||||
[Tool]
|
[Tool]
|
||||||
public partial class GfRichTextLabel : RichTextLabel
|
public partial class GfRichTextLabel : RichTextLabel, IRichTextEffectHost
|
||||||
{
|
{
|
||||||
private IRichTextEffectRegistry? _effectRegistry;
|
private IRichTextEffectRegistry? _effectRegistry;
|
||||||
private RichTextEffectsController? _effectsController;
|
private RichTextEffectsController? _effectsController;
|
||||||
@ -20,6 +20,7 @@ public partial class GfRichTextLabel : RichTextLabel
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置是否启用框架管理的富文本效果装配。
|
/// 获取或设置是否启用框架管理的富文本效果装配。
|
||||||
|
/// 关闭后只会停止框架效果安装,不会覆盖调用方手动维护的其他 BBCode 解析状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Export]
|
[Export]
|
||||||
public bool EnableFrameworkEffects { get; set; } = true;
|
public bool EnableFrameworkEffects { get; set; } = true;
|
||||||
@ -34,6 +35,9 @@ public partial class GfRichTextLabel : RichTextLabel
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取当前使用的效果注册表。
|
/// 获取当前使用的效果注册表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentNullException">
|
||||||
|
/// 当设置值为 <see langword="null" /> 时抛出。
|
||||||
|
/// </exception>
|
||||||
internal IRichTextEffectRegistry EffectRegistry
|
internal IRichTextEffectRegistry EffectRegistry
|
||||||
{
|
{
|
||||||
get => _effectRegistry ??= new DefaultRichTextEffectRegistry();
|
get => _effectRegistry ??= new DefaultRichTextEffectRegistry();
|
||||||
@ -45,11 +49,6 @@ public partial class GfRichTextLabel : RichTextLabel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
if (EnableFrameworkEffects && !BbcodeEnabled)
|
|
||||||
{
|
|
||||||
BbcodeEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnsureController().Initialize();
|
EnsureController().Initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,11 +58,6 @@ public partial class GfRichTextLabel : RichTextLabel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void RefreshFrameworkEffects()
|
public void RefreshFrameworkEffects()
|
||||||
{
|
{
|
||||||
if (EnableFrameworkEffects && !BbcodeEnabled)
|
|
||||||
{
|
|
||||||
BbcodeEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnsureController().RefreshEffects();
|
EnsureController().RefreshEffects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +69,7 @@ public partial class GfRichTextLabel : RichTextLabel
|
|||||||
{
|
{
|
||||||
return _effectsController ??= new RichTextEffectsController(
|
return _effectsController ??= new RichTextEffectsController(
|
||||||
this,
|
this,
|
||||||
EffectRegistry,
|
() => EffectRegistry,
|
||||||
() => Profile,
|
() => Profile,
|
||||||
() => EnableFrameworkEffects,
|
() => EnableFrameworkEffects,
|
||||||
() => AnimatedEffectsEnabled);
|
() => AnimatedEffectsEnabled);
|
||||||
|
|||||||
21
GFramework.Godot/Text/IRichTextEffectHost.cs
Normal file
21
GFramework.Godot/Text/IRichTextEffectHost.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using Array = Godot.Collections.Array;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 抽象可被富文本效果控制器驱动的宿主。
|
||||||
|
/// 该接口把装配决策从 Godot 原生 <see cref="RichTextLabel" /> 生命周期中解耦出来,便于在纯托管测试中验证开关、
|
||||||
|
/// 配置回退和注册表替换行为。
|
||||||
|
/// </summary>
|
||||||
|
internal interface IRichTextEffectHost
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置宿主是否启用 BBCode 解析。
|
||||||
|
/// </summary>
|
||||||
|
bool BbcodeEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置当前安装到宿主上的自定义富文本效果集合。
|
||||||
|
/// </summary>
|
||||||
|
Array CustomEffects { get; set; }
|
||||||
|
}
|
||||||
@ -3,18 +3,31 @@ namespace GFramework.Godot.Text;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 富文本效果注册表,负责把配置中的效果键解析为可安装的 <see cref="RichTextEffect" /> 实例。
|
/// 富文本效果注册表,负责把配置中的效果键解析为可安装的 <see cref="RichTextEffect" /> 实例。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="RichTextEffectsController" /> 会在 <see cref="GfRichTextLabel" /> 就绪或显式刷新时调用该注册表,重建
|
||||||
|
/// <see cref="RichTextLabel.CustomEffects" />。
|
||||||
|
/// 该抽象存在的目的,是把“配置里声明了哪些效果”与“这些效果如何实例化”解耦,使宿主节点、配置资源和测试替身都不必
|
||||||
|
/// 直接依赖具体内置效果类型。
|
||||||
|
/// 当项目只需要组合现有标签时,应优先使用 <see cref="RichTextProfile" />;当项目需要替换内置映射、注入自定义
|
||||||
|
/// <see cref="RichTextEffect" />,或按“动态效果是否启用”的边界切换效果实现时,应实现该接口。
|
||||||
|
/// </remarks>
|
||||||
public interface IRichTextEffectRegistry
|
public interface IRichTextEffectRegistry
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据指定配置创建完整的效果实例集合。
|
/// 根据指定配置创建完整的效果实例集合。
|
||||||
|
/// 该方法会在每次宿主刷新时执行,因此实现应保持可重复、确定且与宿主生命周期兼容。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="profile">效果组合配置。</param>
|
/// <param name="profile">效果组合配置。</param>
|
||||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||||
/// <returns>可直接写入 <see cref="RichTextLabel.CustomEffects" /> 的效果实例集合。</returns>
|
/// <returns>可直接写入 <see cref="RichTextLabel.CustomEffects" /> 的效果实例集合。</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">
|
||||||
|
/// 当 <paramref name="profile" /> 为 <see langword="null" /> 且实现不接受空配置时抛出。
|
||||||
|
/// </exception>
|
||||||
IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled);
|
IReadOnlyList<RichTextEffect> CreateEffects(RichTextProfile profile, bool animatedEffectsEnabled);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据单个效果键创建对应效果实例。
|
/// 根据单个效果键创建对应效果实例。
|
||||||
|
/// 该方法主要用于将配置声明的 key 映射为宿主可安装的单个效果对象。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="key">效果键。</param>
|
/// <param name="key">效果键。</param>
|
||||||
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
/// <param name="animatedEffectsEnabled">当前是否允许字符级动态效果生效。</param>
|
||||||
|
|||||||
@ -10,27 +10,32 @@ internal sealed class RichTextEffectsController
|
|||||||
{
|
{
|
||||||
private readonly Func<bool> _animatedEffectsEnabledAccessor;
|
private readonly Func<bool> _animatedEffectsEnabledAccessor;
|
||||||
private readonly Func<bool> _frameworkEffectsEnabledAccessor;
|
private readonly Func<bool> _frameworkEffectsEnabledAccessor;
|
||||||
private readonly RichTextLabel _host;
|
private readonly IRichTextEffectHost _host;
|
||||||
private readonly Func<RichTextProfile?> _profileAccessor;
|
private readonly Func<RichTextProfile?> _profileAccessor;
|
||||||
private readonly IRichTextEffectRegistry _registry;
|
private readonly Func<IRichTextEffectRegistry> _registryAccessor;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化控制器实例。
|
/// 初始化控制器实例。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="host">目标富文本标签。</param>
|
/// <param name="host">目标富文本标签。</param>
|
||||||
/// <param name="registry">效果注册表。</param>
|
/// <param name="registryAccessor">当前效果注册表访问器。</param>
|
||||||
/// <param name="profileAccessor">当前配置访问器。</param>
|
/// <param name="profileAccessor">当前配置访问器。</param>
|
||||||
/// <param name="frameworkEffectsEnabledAccessor">框架效果总开关访问器。</param>
|
/// <param name="frameworkEffectsEnabledAccessor">框架效果总开关访问器。</param>
|
||||||
/// <param name="animatedEffectsEnabledAccessor">字符动画开关访问器。</param>
|
/// <param name="animatedEffectsEnabledAccessor">字符动画开关访问器。</param>
|
||||||
|
/// <exception cref="ArgumentNullException">
|
||||||
|
/// 当 <paramref name="host" />、<paramref name="registryAccessor" />、<paramref name="profileAccessor" />、
|
||||||
|
/// <paramref name="frameworkEffectsEnabledAccessor" /> 或 <paramref name="animatedEffectsEnabledAccessor" />
|
||||||
|
/// 为 <see langword="null" /> 时抛出。
|
||||||
|
/// </exception>
|
||||||
public RichTextEffectsController(
|
public RichTextEffectsController(
|
||||||
RichTextLabel host,
|
IRichTextEffectHost host,
|
||||||
IRichTextEffectRegistry registry,
|
Func<IRichTextEffectRegistry> registryAccessor,
|
||||||
Func<RichTextProfile?> profileAccessor,
|
Func<RichTextProfile?> profileAccessor,
|
||||||
Func<bool> frameworkEffectsEnabledAccessor,
|
Func<bool> frameworkEffectsEnabledAccessor,
|
||||||
Func<bool> animatedEffectsEnabledAccessor)
|
Func<bool> animatedEffectsEnabledAccessor)
|
||||||
{
|
{
|
||||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
_registryAccessor = registryAccessor ?? throw new ArgumentNullException(nameof(registryAccessor));
|
||||||
_profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor));
|
_profileAccessor = profileAccessor ?? throw new ArgumentNullException(nameof(profileAccessor));
|
||||||
_frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor
|
_frameworkEffectsEnabledAccessor = frameworkEffectsEnabledAccessor
|
||||||
?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor));
|
?? throw new ArgumentNullException(nameof(frameworkEffectsEnabledAccessor));
|
||||||
@ -51,14 +56,23 @@ internal sealed class RichTextEffectsController
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void RefreshEffects()
|
public void RefreshEffects()
|
||||||
{
|
{
|
||||||
if (!_frameworkEffectsEnabledAccessor())
|
var frameworkEffectsEnabled = _frameworkEffectsEnabledAccessor();
|
||||||
|
if (frameworkEffectsEnabled && !_host.BbcodeEnabled)
|
||||||
|
{
|
||||||
|
_host.BbcodeEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frameworkEffectsEnabled)
|
||||||
{
|
{
|
||||||
_host.CustomEffects = new Array();
|
_host.CustomEffects = new Array();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault();
|
var profile = _profileAccessor() ?? RichTextProfile.CreateBuiltInDefault();
|
||||||
var effects = _registry.CreateEffects(profile, _animatedEffectsEnabledAccessor());
|
var registry = _registryAccessor()
|
||||||
|
?? throw new InvalidOperationException("The rich text effect registry accessor returned null.");
|
||||||
|
|
||||||
|
var effects = registry.CreateEffects(profile, _animatedEffectsEnabledAccessor());
|
||||||
var customEffects = new Array();
|
var customEffects = new Array();
|
||||||
foreach (var effect in effects)
|
foreach (var effect in effects)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -15,6 +15,9 @@ public static class RichTextMarkup
|
|||||||
/// <param name="text">原始文本。</param>
|
/// <param name="text">原始文本。</param>
|
||||||
/// <param name="tag">标签名。</param>
|
/// <param name="tag">标签名。</param>
|
||||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// 当 <paramref name="tag" /> 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。
|
||||||
|
/// </exception>
|
||||||
public static string Color(string text, string tag)
|
public static string Color(string text, string tag)
|
||||||
{
|
{
|
||||||
return Wrap(text, tag);
|
return Wrap(text, tag);
|
||||||
@ -62,14 +65,19 @@ public static class RichTextMarkup
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 使用指定效果标签包裹文本,并可附带参数环境。
|
/// 使用指定效果标签包裹文本,并可附带参数环境。
|
||||||
|
/// 环境参数会按键名进行稳定排序,避免不同字典实现导致输出顺序漂移。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="text">原始文本。</param>
|
/// <param name="text">原始文本。</param>
|
||||||
/// <param name="tag">标签名。</param>
|
/// <param name="tag">标签名。</param>
|
||||||
/// <param name="env">可选的标签参数集合。</param>
|
/// <param name="env">可选的标签参数集合。</param>
|
||||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// 当 <paramref name="tag" /> 为空、仅包含空白字符,包含 BBCode token 不允许的控制字符,
|
||||||
|
/// 或 <paramref name="env" /> 中存在包含非法控制字符的参数键时抛出。
|
||||||
|
/// </exception>
|
||||||
public static string Effect(string text, string tag, IReadOnlyDictionary<string, object?>? env = null)
|
public static string Effect(string text, string tag, IReadOnlyDictionary<string, object?>? env = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
ValidateToken(tag, nameof(tag));
|
||||||
|
|
||||||
var builder = new StringBuilder();
|
var builder = new StringBuilder();
|
||||||
builder.Append('[');
|
builder.Append('[');
|
||||||
@ -77,13 +85,8 @@ public static class RichTextMarkup
|
|||||||
|
|
||||||
if (env is not null)
|
if (env is not null)
|
||||||
{
|
{
|
||||||
foreach (var pair in env)
|
foreach (var pair in CollectEnvironmentPairs(env))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.Append(' ');
|
builder.Append(' ');
|
||||||
builder.Append(pair.Key);
|
builder.Append(pair.Key);
|
||||||
builder.Append('=');
|
builder.Append('=');
|
||||||
@ -107,10 +110,61 @@ public static class RichTextMarkup
|
|||||||
/// <returns>包裹后的 BBCode 文本。</returns>
|
/// <returns>包裹后的 BBCode 文本。</returns>
|
||||||
private static string Wrap(string text, string tag)
|
private static string Wrap(string text, string tag)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
ValidateToken(tag, nameof(tag));
|
||||||
return $"[{tag}]{text ?? string.Empty}[/{tag}]";
|
return $"[{tag}]{text ?? string.Empty}[/{tag}]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收集并排序可写入 BBCode 的环境参数。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="env">原始环境参数。</param>
|
||||||
|
/// <returns>按键名稳定排序后的参数集合。</returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// 当参数键包含 BBCode token 不允许的控制字符时抛出。
|
||||||
|
/// </exception>
|
||||||
|
private static IReadOnlyList<KeyValuePair<string, object>> CollectEnvironmentPairs(
|
||||||
|
IReadOnlyDictionary<string, object?> env)
|
||||||
|
{
|
||||||
|
var pairs = new List<KeyValuePair<string, object>>(env.Count);
|
||||||
|
foreach (var pair in env)
|
||||||
|
{
|
||||||
|
if (pair.Value is null || string.IsNullOrWhiteSpace(pair.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateToken(pair.Key, nameof(env));
|
||||||
|
pairs.Add(new KeyValuePair<string, object>(pair.Key, pair.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Key, right.Key));
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 BBCode 标签或参数键是否满足 token 约束。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">待验证的 token。</param>
|
||||||
|
/// <param name="paramName">异常参数名。</param>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// 当 token 为空、仅包含空白字符,或包含 BBCode token 不允许的控制字符时抛出。
|
||||||
|
/// </exception>
|
||||||
|
private static void ValidateToken(string token, string paramName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("BBCode token cannot be null, empty, or whitespace.", paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in token)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(character) || character is '[' or ']' or '=')
|
||||||
|
{
|
||||||
|
throw new ArgumentException("BBCode token contains invalid control characters.", paramName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将标签参数值格式化为稳定的 BBCode 字符串表示。
|
/// 将标签参数值格式化为稳定的 BBCode 字符串表示。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user