Reported as "1d 2h 51sh m" — the running countdown drawing straight through the d/h/m labels. The readout is authored across the same strip as the three number boxes, so the strip has to be one thing or the other; I hid the boxes and left their labels behind. Retail's ShowEditableTimer @0x00495770 toggles SIX elements, not three: m_pDaysEditBox AND m_pDaysStaticText, and the same for hours and minutes, plus the readout inverse. Reading the swap as "hide the inputs" instead of "hide the input ROWS" is what produced the overlap. Also settles the Record question the same round raised. Nothing was broken: indoors, retail's own gid_to_lcoord fails and nothing is recorded, and UpdateLocation @0x004958F0 only ever formats coordinates already stored — there is no "you are indoors" message in that function to port. The silence is faithful, and it is now commented as such rather than left looking like a gap. JournalPanelLiveBindTests is new and is the test that should have existed first: it builds the panel from the real DATs, constructs the controllers, and asserts every button actually receives an OnClick. Every other test so far checked either the layout or the logic — none of them proved the controller finds its elements in the real tree, which is where an id typo or a subtree assumption produces a panel where nothing responds and nothing fails. The temporary ACDREAM_PROBE_JOURNAL instrumentation is removed; the question it was added for is answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
317 lines
12 KiB
C#
317 lines
12 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using AcDream.Core.Journal;
|
|
using AcDream.Core.Ui;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// The Journal panel's notes page — retail <c>gmJournalUI</c> (element type
|
|
/// <c>0x10000048</c>, page <c>0x10000563</c>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A per-character notebook: label, title, free notes, a recorded location and
|
|
/// a countdown timer, paged with First/Previous/Next/Last.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every navigation commits the current page first.</b> Retail's
|
|
/// <c>ListenToElementMessage @0x004968D0</c> calls <c>SaveThisPage</c> on the
|
|
/// way out of every one of the five navigation buttons, which is why paging
|
|
/// away never loses what you just typed. Skipping that is the obvious
|
|
/// simplification and it silently eats edits.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class JournalNotesPageController
|
|
{
|
|
// Retail switches on (idElement - 0x10000565), so these two carry no
|
|
// caption of their own: they are the prev/next arrows.
|
|
private const uint PreviousButtonId = 0x10000565u;
|
|
private const uint NextButtonId = 0x10000566u;
|
|
private const uint NewButtonId = 0x10000567u;
|
|
|
|
private const uint LabelFieldId = 0x10000569u;
|
|
private const uint TitleFieldId = 0x1000056Bu;
|
|
private const uint NotesFieldId = 0x1000056Du;
|
|
|
|
private const uint FirstButtonId = 0x1000056Fu;
|
|
private const uint PageNumberId = 0x10000570u;
|
|
private const uint LastButtonId = 0x10000571u;
|
|
|
|
private const uint LocationTextId = 0x10000573u;
|
|
private const uint RecordButtonId = 0x10000574u;
|
|
|
|
private const uint TimerDaysFieldId = 0x10000576u;
|
|
private const uint TimerHoursFieldId = 0x10000578u;
|
|
private const uint TimerMinutesFieldId = 0x1000057Au;
|
|
|
|
// The "d" / "h" / "m" unit labels. Retail's ShowEditableTimer @0x00495770
|
|
// toggles each box AND its label — m_pDaysStaticText, m_pHoursStaticText,
|
|
// m_pMinutesStaticText — so the running readout, which is authored over
|
|
// the same strip, does not draw through them.
|
|
private const uint TimerDaysLabelId = 0x10000577u;
|
|
private const uint TimerHoursLabelId = 0x10000579u;
|
|
private const uint TimerMinutesLabelId = 0x1000057Bu;
|
|
private const uint RunningTimerTextId = 0x1000057Cu;
|
|
private const uint StartButtonId = 0x1000057Du;
|
|
|
|
/// <param name="Journal">The canonical owner.</param>
|
|
/// <param name="Commands">Mutations, kept off the read view.</param>
|
|
/// <param name="PlayerCell">
|
|
/// The player's current landcell, for the "Record" button. Returns 0 when
|
|
/// there is no valid cell — indoors, or not in the world.
|
|
/// </param>
|
|
/// <param name="Now">The clock the countdown runs against.</param>
|
|
public sealed record Bindings(
|
|
IRuntimeJournalView Journal,
|
|
RuntimeJournalState Commands,
|
|
Func<uint> PlayerCell,
|
|
Func<DateTime> Now);
|
|
|
|
private readonly Bindings _bindings;
|
|
private readonly UiField? _label;
|
|
private readonly UiField? _title;
|
|
private readonly UiField? _notes;
|
|
private readonly UiField? _timerDays;
|
|
private readonly UiField? _timerHours;
|
|
private readonly UiField? _timerMinutes;
|
|
private readonly UiText? _pageNumber;
|
|
|
|
/// <summary>
|
|
/// The location readout is authored EDITABLE (<c>0x16</c>), so it builds as
|
|
/// a <see cref="UiField"/> — not the <see cref="UiText"/> its placeholder
|
|
/// text suggests. Resolving it as text yielded null and threw every write
|
|
/// away silently: Record reached the model and the file, and never the
|
|
/// screen.
|
|
/// </summary>
|
|
private readonly UiField? _location;
|
|
private readonly UiText? _runningTimer;
|
|
private readonly UiElement? _timerDaysLabel;
|
|
private readonly UiElement? _timerHoursLabel;
|
|
private readonly UiElement? _timerMinutesLabel;
|
|
private readonly UiButton? _start;
|
|
|
|
private long _renderedRevision = -1;
|
|
|
|
public JournalNotesPageController(UiElement page, Bindings bindings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(page);
|
|
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
|
|
|
_label = UiElement.FindDescendant(page, LabelFieldId) as UiField;
|
|
_title = UiElement.FindDescendant(page, TitleFieldId) as UiField;
|
|
_notes = UiElement.FindDescendant(page, NotesFieldId) as UiField;
|
|
_timerDays = UiElement.FindDescendant(page, TimerDaysFieldId) as UiField;
|
|
_timerHours = UiElement.FindDescendant(page, TimerHoursFieldId) as UiField;
|
|
_timerMinutes = UiElement.FindDescendant(page, TimerMinutesFieldId) as UiField;
|
|
_pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText;
|
|
_location = UiElement.FindDescendant(page, LocationTextId) as UiField;
|
|
_runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText;
|
|
_timerDaysLabel = UiElement.FindDescendant(page, TimerDaysLabelId);
|
|
_timerHoursLabel = UiElement.FindDescendant(page, TimerHoursLabelId);
|
|
_timerMinutesLabel = UiElement.FindDescendant(page, TimerMinutesLabelId);
|
|
_start = UiElement.FindDescendant(page, StartButtonId) as UiButton;
|
|
|
|
// The three timer boxes take digits only. Their authored 0x1E is 2, so
|
|
// the width is already handled; this stops a letter reaching an int
|
|
// parse that would silently read as zero.
|
|
foreach (UiField? field in new[] { _timerDays, _timerHours, _timerMinutes })
|
|
{
|
|
if (field is not null)
|
|
field.CharacterFilter = static c => char.IsAsciiDigit(c);
|
|
}
|
|
|
|
// Committing on focus loss is what makes clicking straight from a text
|
|
// box to another tab keep the edit.
|
|
foreach (UiField? field in new[] { _label, _title, _notes })
|
|
{
|
|
if (field is not null)
|
|
field.OnFocusLost = _ => CommitText();
|
|
}
|
|
|
|
// Retail's ListenToElementMessage @0x004968D0 ends every one of these
|
|
// in Update() — the panel redraws at the moment of the click, not on
|
|
// the next frame. Deferring to Tick would work in the client and makes
|
|
// the behaviour untestable and a frame late.
|
|
Bind(page, NewButtonId, () =>
|
|
{
|
|
CommitText();
|
|
_bindings.Commands.NewPage();
|
|
Refresh();
|
|
});
|
|
Bind(page, FirstButtonId, () => Navigate(1));
|
|
Bind(page, LastButtonId, () => Navigate(_bindings.Journal.Snapshot.PageCount));
|
|
Bind(page, PreviousButtonId,
|
|
() => Navigate(_bindings.Journal.Snapshot.CurrentPage - 1));
|
|
Bind(page, NextButtonId,
|
|
() => Navigate(_bindings.Journal.Snapshot.CurrentPage + 1));
|
|
Bind(page, RecordButtonId, RecordLocation);
|
|
Bind(page, StartButtonId, ToggleTimer);
|
|
|
|
Refresh();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits the edit boxes into the current page — retail's
|
|
/// <c>SaveThisPage @0x00495360</c>.
|
|
/// </summary>
|
|
public void CommitText()
|
|
{
|
|
if (_bindings.Journal.Snapshot.CurrentPage == 0)
|
|
return;
|
|
|
|
_bindings.Commands.UpdateCurrent(
|
|
_label?.Text ?? string.Empty,
|
|
_title?.Text ?? string.Empty,
|
|
_notes?.Text ?? string.Empty);
|
|
|
|
_bindings.Commands.SetTimer(
|
|
ParseField(_timerDays),
|
|
ParseField(_timerHours),
|
|
ParseField(_timerMinutes));
|
|
}
|
|
|
|
public void Tick()
|
|
{
|
|
if (_bindings.Journal.Snapshot.Revision != _renderedRevision)
|
|
Refresh();
|
|
else
|
|
RefreshTimer(); // the countdown ticks without a page rebuild
|
|
}
|
|
|
|
/// <summary>Called when the page is hidden — retail saves here.</summary>
|
|
public void OnHidden() => CommitText();
|
|
|
|
public void Refresh()
|
|
{
|
|
RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot;
|
|
_renderedRevision = snapshot.Revision;
|
|
|
|
JournalPage page = _bindings.Journal.Current;
|
|
|
|
_label?.SetText(page.Label);
|
|
_title?.SetText(page.Title);
|
|
_notes?.SetText(page.Notes);
|
|
_timerDays?.SetText(Field(page.TimerDays));
|
|
_timerHours?.SetText(Field(page.TimerHours));
|
|
_timerMinutes?.SetText(Field(page.TimerMinutes));
|
|
|
|
// Retail's own "~ N ~". An empty journal shows no number rather than
|
|
// "~ 0 ~", which would name a page that does not exist.
|
|
SetText(_pageNumber, snapshot.CurrentPage == 0
|
|
? string.Empty
|
|
: $"~ {snapshot.CurrentPage.ToString(CultureInfo.InvariantCulture)} ~");
|
|
|
|
_location?.SetText(page.HasLocation
|
|
? FormatLocation(page.LocationX, page.LocationY)
|
|
: string.Empty);
|
|
|
|
RefreshTimer();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The editable-fields / running-readout swap
|
|
/// (<c>ShowEditableTimer @0x00495770</c> versus <c>ShowRunningTimer</c>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The readout is authored at the SAME x as the three number boxes, so
|
|
/// showing both at once overlaps them illegibly — the strip is one or the
|
|
/// other.
|
|
/// </remarks>
|
|
private void RefreshTimer()
|
|
{
|
|
double remaining = _bindings.Journal.RemainingTimerSeconds(_bindings.Now());
|
|
bool running = remaining > 0d;
|
|
|
|
// Each box AND its unit label, exactly the six elements retail toggles.
|
|
// Hiding only the boxes leaves "d h m" drawn underneath the readout,
|
|
// which is authored across the same strip.
|
|
if (_timerDays is not null) _timerDays.Visible = !running;
|
|
if (_timerHours is not null) _timerHours.Visible = !running;
|
|
if (_timerMinutes is not null) _timerMinutes.Visible = !running;
|
|
if (_timerDaysLabel is not null) _timerDaysLabel.Visible = !running;
|
|
if (_timerHoursLabel is not null) _timerHoursLabel.Visible = !running;
|
|
if (_timerMinutesLabel is not null) _timerMinutesLabel.Visible = !running;
|
|
if (_runningTimer is not null) _runningTimer.Visible = running;
|
|
|
|
SetText(_runningTimer, running
|
|
? RetailDurationText.Format(remaining)
|
|
: string.Empty);
|
|
|
|
if (_start is not null)
|
|
_start.Label = running ? "Stop" : "Start";
|
|
}
|
|
|
|
private void Navigate(int pageNumber)
|
|
{
|
|
CommitText();
|
|
_bindings.Commands.GotoPage(pageNumber);
|
|
Refresh();
|
|
}
|
|
|
|
private void RecordLocation()
|
|
{
|
|
uint cell = _bindings.PlayerCell();
|
|
if (cell == 0u)
|
|
return;
|
|
|
|
// Indoors, retail's own gid_to_lcoord fails and nothing is recorded —
|
|
// UpdateLocation @0x004958F0 only ever formats coordinates already
|
|
// stored, so there is no "you are indoors" message to port. Silence
|
|
// here is faithful, not an omission.
|
|
if (!AcDream.Core.Ui.RadarCoordinates.TryFromCell(cell, out var coordinates))
|
|
return;
|
|
|
|
_bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y);
|
|
Refresh();
|
|
}
|
|
|
|
private void ToggleTimer()
|
|
{
|
|
if (_bindings.Journal.RemainingTimerSeconds(_bindings.Now()) > 0d)
|
|
{
|
|
_bindings.Commands.ResetTimer();
|
|
Refresh();
|
|
return;
|
|
}
|
|
|
|
CommitText(); // the fields the countdown reads
|
|
_bindings.Commands.StartTimer(_bindings.Now());
|
|
Refresh();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The journal's own authored placeholder is <c>"00.0S, 00.0W"</c> — with
|
|
/// a space after the comma, unlike the radar's own combined form.
|
|
/// </summary>
|
|
private static string FormatLocation(float x, float y)
|
|
{
|
|
var coordinates = new AcDream.Core.Ui.RadarCoordinates(x, y);
|
|
return $"{coordinates.YText}, {coordinates.XText}";
|
|
}
|
|
|
|
private void Bind(UiElement page, uint elementId, Action action)
|
|
{
|
|
if (UiElement.FindDescendant(page, elementId) is UiButton button)
|
|
button.OnClick = action;
|
|
}
|
|
|
|
private static int ParseField(UiField? field) =>
|
|
int.TryParse(
|
|
field?.Text,
|
|
NumberStyles.Integer,
|
|
CultureInfo.InvariantCulture,
|
|
out int value) && value >= 0
|
|
? value
|
|
: 0;
|
|
|
|
private static string Field(int value) =>
|
|
value == 0 ? string.Empty : value.ToString(CultureInfo.InvariantCulture);
|
|
|
|
private static void SetText(UiText? text, string value)
|
|
{
|
|
if (text is null) return;
|
|
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
|
|
}
|
|
}
|