using System; using System.Globalization; using AcDream.Core.Journal; using AcDream.Core.Ui; using AcDream.Runtime.Gameplay; namespace AcDream.App.UI.Layout; /// /// The Journal panel's notes page — retail gmJournalUI (element type /// 0x10000048, page 0x10000563). /// /// /// /// A per-character notebook: label, title, free notes, a recorded location and /// a countdown timer, paged with First/Previous/Next/Last. /// /// /// Every navigation commits the current page first. Retail's /// ListenToElementMessage @0x004968D0 calls SaveThisPage 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. /// /// 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; /// The canonical owner. /// Mutations, kept off the read view. /// /// The player's current landcell, for the "Record" button. Returns 0 when /// there is no valid cell — indoors, or not in the world. /// /// The clock the countdown runs against. public sealed record Bindings( IRuntimeJournalView Journal, RuntimeJournalState Commands, Func PlayerCell, Func 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; /// /// The location readout is authored EDITABLE (0x16), so it builds as /// a — not the 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. /// 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(); } /// /// Commits the edit boxes into the current page — retail's /// SaveThisPage @0x00495360. /// 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 } /// Called when the page is hidden — retail saves here. 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(); } /// /// The editable-fields / running-readout swap /// (ShowEditableTimer @0x00495770 versus ShowRunningTimer). /// /// /// 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. /// 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(); } /// /// The journal's own authored placeholder is "00.0S, 00.0W" — with /// a space after the comma, unlike the radar's own combined form. /// 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)]; } }