fix(ui): restore effect detail scrolling

Reflow selected Helpful/Harmful spell descriptions through the retained retail text shaper, bind their authored 0x10000127 information scrollbar independently from the list scrollbar, and allow display-only text to consume wheel scrolling without becoming selectable.

Format Vitae recovery experience through retail's grouped integer presentation. Release build succeeds and all 5,832 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 13:28:18 +02:00
parent 16c21e299c
commit 82789eea88
9 changed files with 117 additions and 18 deletions

View file

@ -16,8 +16,9 @@ public sealed class EffectsUiController : IRetainedPanelController
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint ScrollbarId = 0x10000124u;
public const uint ListScrollbarId = 0x10000124u;
public const uint InfoTextId = 0x10000126u;
public const uint InfoScrollbarId = 0x10000127u;
public const uint RowTemplateId = 0x10000128u;
public const uint RowIconId = 0x10000129u;
public const uint RowLabelId = 0x1000012Au;
@ -32,7 +33,8 @@ public sealed class EffectsUiController : IRetainedPanelController
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly UiScrollbar? _scrollbar;
private readonly UiScrollbar? _listScrollbar;
private readonly UiScrollbar? _infoScrollbar;
private readonly Dictionary<uint, EffectRowTemplateFactory.EffectRow> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
@ -60,13 +62,14 @@ public sealed class EffectsUiController : IRetainedPanelController
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
_scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar;
_listScrollbar = layout.FindElement(ListScrollbarId) as UiScrollbar;
_infoScrollbar = layout.FindElement(InfoScrollbarId) as UiScrollbar;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = templates.Width;
_list.CellHeight = templates.Height;
if (_scrollbar is not null)
_scrollbar.Model = _list.Scroll;
if (_listScrollbar is not null)
_listScrollbar.Model = _list.Scroll;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
@ -198,21 +201,25 @@ public sealed class EffectsUiController : IRetainedPanelController
{
if (_info is null) return;
_info.PreserveEndOnLayout = false;
_info.WheelScrollEnabled = true;
_info.ClickThrough = false;
if (_infoScrollbar is not null)
_infoScrollbar.Model = _info.Scroll;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
return IndicatorDetailText.Shape(_info, _selectPrompt);
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(string.Empty, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
return IndicatorDetailText.Shape(_info, _selectPrompt);
// gmEffectsUI::UpdateSelection @ 0x004B7F90 supplies one literal
// name + blank line + description to UIElement_Text::SetText. The
// text widget reflows it to the authored information width.
return IndicatorDetailText.Shape(
_info,
metadata.Name + "\n\n" + metadata.Description);
};
}
@ -224,6 +231,7 @@ public sealed class EffectsUiController : IRetainedPanelController
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
if (_scrollbar is not null) _scrollbar.Model = null;
if (_listScrollbar is not null) _listScrollbar.Model = null;
if (_infoScrollbar is not null) _infoScrollbar.Model = null;
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
@ -109,7 +110,9 @@ public sealed class VitaeUiController : IRetainedPanelController
+ lostPercent
+ _strings.SkillsSuffix
+ _strings.RecoveryPrefix
+ remaining
// StringInfo::AddVariable_Int -> LInt_StringInfoData::ToString
// @ 0x0042F7E0 always enables localized digit grouping.
+ FormatExperience(remaining)
+ _strings.RecoverySuffix;
}
_lines = IndicatorDetailText.Shape(_mainText, body);
@ -133,6 +136,9 @@ public sealed class VitaeUiController : IRetainedPanelController
* Math.Pow(vitae, 5d)
+ 0.5d);
internal static string FormatExperience(int experience)
=> experience.ToString("N0", CultureInfo.InvariantCulture);
public void Dispose()
{
if (_disposed) return;

View file

@ -24,7 +24,8 @@ public sealed class UiText : UiElement, IUiDatStateful
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
public override bool HandlesClick => OnClick is not null || base.HandlesClick;
public override bool HandlesClick
=> OnClick is not null || WheelScrollEnabled || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
@ -159,6 +160,14 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary>
public bool PreserveEndOnLayout { get; set; } = true;
/// <summary>
/// Allows a display-only text surface to consume mouse-wheel input without
/// making its text selectable/editable. Retail text scrolling and text
/// selection are independent capabilities; authored report/detail fields
/// commonly expose a scrollbar while remaining non-selectable.
/// </summary>
public bool WheelScrollEnabled { get; set; }
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
@ -551,7 +560,7 @@ public sealed class UiText : UiElement, IUiDatStateful
{
case UiEventType.Scroll:
{
if (!Selectable) return false;
if (!Selectable && !WheelScrollEnabled) return false;
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
// ScrollByLines sign: +down/newer, -up/older.
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.