fix #234: port retail appraisal floaty and inscriptions
This commit is contained in:
parent
643cdfe66e
commit
f1a7912160
23 changed files with 1310 additions and 106 deletions
|
|
@ -22,6 +22,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
public const uint ItemPanelId = 0x1000012Eu;
|
||||
public const uint ItemTextId = 0x1000013Cu;
|
||||
public const uint ItemScrollbarId = 0x1000013Du;
|
||||
public const uint InscriptionBackgroundId = 0x10000137u;
|
||||
public const uint InscriptionTextId = 0x1000013Eu;
|
||||
public const uint SignatureTextId = 0x1000013Fu;
|
||||
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
||||
|
|
@ -38,6 +39,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly ItemInteractionController _interaction;
|
||||
private readonly CombatState _combat;
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly Func<string> _playerName;
|
||||
private readonly Action<uint, string> _sendSetInscription;
|
||||
private readonly Action<string> _systemMessage;
|
||||
private readonly Action _show;
|
||||
private readonly UiElement _itemPanel;
|
||||
private readonly UiElement _creaturePanel;
|
||||
|
|
@ -47,6 +51,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly UiText? _inscriptionText;
|
||||
private readonly UiField? _inscriptionField;
|
||||
private readonly UiText? _signature;
|
||||
private readonly UiDatElement? _inscriptionBackground;
|
||||
private readonly UiButton? _close;
|
||||
private AppraisalView _activeView;
|
||||
private uint _itemObjectId;
|
||||
|
|
@ -56,6 +61,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private string _itemReport = string.Empty;
|
||||
private string _inscriptionValue = string.Empty;
|
||||
private string _signatureValue = string.Empty;
|
||||
private string _scribeName = string.Empty;
|
||||
private string _oldInscription = string.Empty;
|
||||
private bool _presentationInscribable;
|
||||
private double _refreshElapsed;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -65,6 +73,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
Func<string> playerName,
|
||||
Action<uint, string> sendSetInscription,
|
||||
Action<string> systemMessage,
|
||||
Action show,
|
||||
Action close,
|
||||
UiElement itemPanel,
|
||||
|
|
@ -77,6 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_interaction = interaction;
|
||||
_combat = combat;
|
||||
_spellbook = spellbook;
|
||||
_playerName = playerName;
|
||||
_sendSetInscription = sendSetInscription;
|
||||
_systemMessage = systemMessage;
|
||||
_show = show;
|
||||
_itemPanel = itemPanel;
|
||||
_creaturePanel = creaturePanel;
|
||||
|
|
@ -86,6 +100,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||
_inscriptionBackground =
|
||||
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
|
||||
_close = layout.FindElement(CloseId) as UiButton;
|
||||
|
||||
_title.LinesProvider = () =>
|
||||
|
|
@ -99,17 +115,26 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.SetText(string.Empty);
|
||||
// Retail conditionally enables this field only after its ownership
|
||||
// and inscription-permission checks. That write transaction remains
|
||||
// tracked by AP-110; keep the imported field display-only until the
|
||||
// matching SetInscriptionEditableState/submit path is present.
|
||||
_inscriptionField.Enabled = false;
|
||||
_inscriptionField.ClearOnSubmit = false;
|
||||
_inscriptionField.RecordHistory = false;
|
||||
_inscriptionField.Editable = false;
|
||||
_inscriptionField.Selectable = false;
|
||||
_inscriptionField.OnFocusGained = HandleInscriptionGainingFocus;
|
||||
_inscriptionField.OnFocusLost = HandleInscriptionLosingFocus;
|
||||
_inscriptionField.OnReadOnlyClick = ReportInscriptionUnavailable;
|
||||
if (layout.FindElement(InscriptionScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = _inscriptionField.Scroll;
|
||||
}
|
||||
if (_signature is not null)
|
||||
_signature.LinesProvider = () =>
|
||||
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
|
||||
if (_close is not null)
|
||||
_close.OnClick = close;
|
||||
if (_inscriptionBackground is not null)
|
||||
{
|
||||
_inscriptionBackground.OnClick = ReportInscriptionUnavailable;
|
||||
_inscriptionBackground.ClickThrough = false;
|
||||
}
|
||||
|
||||
SetActiveView(AppraisalView.Item);
|
||||
}
|
||||
|
|
@ -123,6 +148,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
Func<string> playerName,
|
||||
Action<uint, string> sendSetInscription,
|
||||
Action<string> systemMessage,
|
||||
Action show,
|
||||
Action close)
|
||||
{
|
||||
|
|
@ -131,6 +159,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ArgumentNullException.ThrowIfNull(interaction);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(playerName);
|
||||
ArgumentNullException.ThrowIfNull(sendSetInscription);
|
||||
ArgumentNullException.ThrowIfNull(systemMessage);
|
||||
ArgumentNullException.ThrowIfNull(show);
|
||||
ArgumentNullException.ThrowIfNull(close);
|
||||
|
||||
|
|
@ -146,6 +177,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
interaction,
|
||||
combat,
|
||||
spellbook,
|
||||
playerName,
|
||||
sendSetInscription,
|
||||
systemMessage,
|
||||
show,
|
||||
close,
|
||||
itemPanel,
|
||||
|
|
@ -228,7 +262,19 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_itemReport = string.Empty;
|
||||
_inscriptionValue = string.Empty;
|
||||
_inscriptionField?.SetText(string.Empty);
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.Editable = false;
|
||||
_inscriptionField.Selectable = false;
|
||||
_inscriptionField.Visible = false;
|
||||
_inscriptionField.Scroll.SetScrollY(0);
|
||||
}
|
||||
_signatureValue = string.Empty;
|
||||
if (_signature is not null)
|
||||
_signature.Visible = false;
|
||||
_scribeName = string.Empty;
|
||||
_oldInscription = string.Empty;
|
||||
_presentationInscribable = false;
|
||||
_itemObjectId = 0;
|
||||
_creatureObjectId = 0;
|
||||
_characterObjectId = 0;
|
||||
|
|
@ -246,19 +292,158 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
obj,
|
||||
appraisal,
|
||||
ResolveSpellName);
|
||||
_inscriptionValue = GetString(appraisal.Properties, 7u);
|
||||
_inscriptionField?.SetText(_inscriptionValue);
|
||||
string scribe = GetString(appraisal.Properties, 8u);
|
||||
_signatureValue = string.IsNullOrWhiteSpace(scribe)
|
||||
? string.Empty
|
||||
: $"Inscribed by {scribe}";
|
||||
SetInscription(obj, appraisal);
|
||||
if (newlySelected)
|
||||
{
|
||||
_itemText.Scroll.SetScrollY(0);
|
||||
_inscriptionText?.Scroll.SetScrollY(0);
|
||||
_inscriptionField?.Scroll.SetScrollY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>ItemExamineUI::SetInscription @ 0x004AE2F0</c> and
|
||||
/// <c>SetInscriptionEditableState @ 0x004AC720</c>.
|
||||
/// </summary>
|
||||
private void SetInscription(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal)
|
||||
{
|
||||
_oldInscription = string.Empty;
|
||||
_scribeName = string.Empty;
|
||||
_inscriptionValue = string.Empty;
|
||||
_signatureValue = string.Empty;
|
||||
|
||||
var publicFlags =
|
||||
(PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
||||
bool publicInscribable =
|
||||
(publicFlags & PublicWeenieFlags.Inscribable) != 0;
|
||||
_presentationInscribable = appraisal.HookProfile is { } hook
|
||||
? (hook.Flags & 0x1u) != 0
|
||||
: publicInscribable;
|
||||
|
||||
if (!_presentationInscribable)
|
||||
{
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.SetText(string.Empty);
|
||||
_inscriptionField.Editable = false;
|
||||
_inscriptionField.Selectable = false;
|
||||
_inscriptionField.Visible = false;
|
||||
}
|
||||
if (_inscriptionText is not null)
|
||||
_inscriptionText.Visible = false;
|
||||
if (_signature is not null)
|
||||
_signature.Visible = false;
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.ClickThrough = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_scribeName = GetString(appraisal.Properties, 8u);
|
||||
_oldInscription = GetString(appraisal.Properties, 7u);
|
||||
if (string.IsNullOrEmpty(_scribeName))
|
||||
{
|
||||
_inscriptionValue = "<Inscribe here>";
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_oldInscription))
|
||||
{
|
||||
_inscriptionValue = _oldInscription;
|
||||
_signatureValue = $"--{_scribeName}";
|
||||
}
|
||||
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.SetText(_inscriptionValue);
|
||||
_inscriptionField.Visible = true;
|
||||
}
|
||||
if (_inscriptionText is not null)
|
||||
_inscriptionText.Visible = true;
|
||||
if (_signature is not null)
|
||||
_signature.Visible = true;
|
||||
|
||||
bool editable = publicInscribable
|
||||
&& _interaction.IsOwnedByPlayer(obj.ObjectId)
|
||||
&& (string.IsNullOrEmpty(_scribeName)
|
||||
|| string.Equals(
|
||||
_scribeName,
|
||||
_playerName(),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.Editable = editable;
|
||||
_inscriptionField.Selectable = editable;
|
||||
}
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.ClickThrough = editable;
|
||||
}
|
||||
|
||||
private void HandleInscriptionGainingFocus()
|
||||
{
|
||||
if (_inscriptionField is not { Editable: true } field)
|
||||
return;
|
||||
if (string.IsNullOrEmpty(_scribeName))
|
||||
{
|
||||
field.SetText(string.Empty);
|
||||
_inscriptionValue = string.Empty;
|
||||
}
|
||||
_signatureValue = $"--{_playerName()}";
|
||||
}
|
||||
|
||||
private void HandleInscriptionLosingFocus(string text)
|
||||
{
|
||||
if (_itemObjectId == 0 || !_presentationInscribable)
|
||||
return;
|
||||
|
||||
bool skipEmptyUnscribed = string.IsNullOrEmpty(text)
|
||||
&& string.IsNullOrEmpty(_scribeName);
|
||||
if (!skipEmptyUnscribed
|
||||
&& !string.Equals(
|
||||
text,
|
||||
_oldInscription,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
_sendSetInscription(_itemObjectId, text);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
_inscriptionValue = "<Inscribe here>";
|
||||
_signatureValue = string.Empty;
|
||||
_scribeName = string.Empty;
|
||||
_inscriptionField?.SetText(_inscriptionValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
_inscriptionValue = text;
|
||||
_scribeName = _playerName();
|
||||
_signatureValue = $"--{_scribeName}";
|
||||
}
|
||||
_oldInscription = text;
|
||||
}
|
||||
|
||||
private void ReportInscriptionUnavailable()
|
||||
{
|
||||
if (_inscriptionField?.Editable == true)
|
||||
return;
|
||||
if (!string.IsNullOrEmpty(_scribeName)
|
||||
&& !string.Equals(
|
||||
_scribeName,
|
||||
_playerName(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_systemMessage($"Only {_scribeName} can change the inscription");
|
||||
return;
|
||||
}
|
||||
if (_itemObjectId == 0
|
||||
|| !_interaction.IsOwnedByPlayer(_itemObjectId))
|
||||
{
|
||||
_systemMessage("Item must be in your inventory to inscribe.");
|
||||
return;
|
||||
}
|
||||
_systemMessage("This item is not inscribable.");
|
||||
}
|
||||
|
||||
private void ApplyCreature(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal,
|
||||
|
|
@ -411,10 +596,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_disposed = true;
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.OnFocusGained = null;
|
||||
_inscriptionField.OnFocusLost = null;
|
||||
_inscriptionField.OnReadOnlyClick = null;
|
||||
}
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.OnClick = null;
|
||||
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
||||
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
||||
|| (_inscriptionText is not null
|
||||
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll)))
|
||||
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll))
|
||||
|| (_inscriptionField is not null
|
||||
&& ReferenceEquals(scrollbar.Model, _inscriptionField.Scroll)))
|
||||
scrollbar.Model = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,6 @@ public static class RetailPanelCatalog
|
|||
public const uint Character = 11u;
|
||||
public const uint Magic = 13u;
|
||||
public const uint Vitae = 15u;
|
||||
/// <summary>
|
||||
/// Private retained-host identity for retail's floating examination UI.
|
||||
/// The original opens it by notice rather than a gmPanelUI numeric child,
|
||||
/// so this value is deliberately outside the DAT panel-id range.
|
||||
/// </summary>
|
||||
public const uint Examination = 0x8000_0001u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
|
|
@ -34,7 +28,6 @@ public static class RetailPanelCatalog
|
|||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Vitae, WindowNames.Vitae),
|
||||
(Examination, WindowNames.Examination),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ public sealed record RetailUiCursorBindings(
|
|||
public sealed record ConfirmationRuntimeBindings(
|
||||
Action<uint, uint, bool> SendResponse);
|
||||
|
||||
public sealed record AppraisalRuntimeBindings(
|
||||
Func<string> PlayerName,
|
||||
Action<uint, string> SendSetInscription,
|
||||
Action<string> DisplaySystemMessage);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
RetailUiAssets Assets,
|
||||
|
|
@ -177,6 +182,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
ExternalContainerRuntimeBindings ExternalContainer,
|
||||
RetailUiCursorBindings Cursor,
|
||||
ConfirmationRuntimeBindings Confirmations,
|
||||
AppraisalRuntimeBindings Appraisal,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
BufferedUiRegistry? Plugins,
|
||||
RetailUiPersistenceBindings? Persistence,
|
||||
|
|
@ -431,7 +437,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
ResetSessionDialogs();
|
||||
AppraisalController?.ResetSession();
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
|
||||
Host.HideWindow(WindowNames.Examination);
|
||||
}
|
||||
|
||||
public void UpdateCursor(IEnumerable<IMouse> mice)
|
||||
|
|
@ -971,9 +977,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Inventory.ItemInteraction,
|
||||
_bindings.Combat.State,
|
||||
_bindings.Magic.Spellbook,
|
||||
show: () => _panelUi.SetPanelVisibility(
|
||||
RetailPanelCatalog.Examination,
|
||||
visible: true),
|
||||
_bindings.Appraisal.PlayerName,
|
||||
_bindings.Appraisal.SendSetInscription,
|
||||
_bindings.Appraisal.DisplaySystemMessage,
|
||||
show: () => Host.ShowWindow(WindowNames.Examination),
|
||||
close: () => CloseWindow(WindowNames.Examination));
|
||||
if (controller is null)
|
||||
{
|
||||
|
|
@ -984,7 +991,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
AppraisalController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
_ = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
|
|
@ -1006,10 +1013,6 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
RetailPanelCatalog.Examination,
|
||||
WindowNames.Examination,
|
||||
handle);
|
||||
Console.WriteLine(
|
||||
"[M4] retail examination window from LayoutDesc 0x2100006B.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using System.Numerics;
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
|
||||
/// Generic editable field widget. Port of retail <c>UIElement_Field</c>
|
||||
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
|
||||
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
|
||||
/// as stubs for future item-window use.
|
||||
|
|
@ -22,6 +22,8 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiField : UiElement
|
||||
{
|
||||
private readonly record struct WrappedLine(int Start, int Length, string Text);
|
||||
|
||||
public uint ElementId { get; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||
|
|
@ -39,6 +41,31 @@ public sealed class UiField : UiElement
|
|||
public bool RecordHistory { get; set; } = true;
|
||||
public Func<char, bool>? CharacterFilter { get; set; }
|
||||
public bool SelectAllOnFocus { get; set; }
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
public Action? OnReadOnlyClick { get; set; }
|
||||
|
||||
private bool _editable = true;
|
||||
|
||||
/// <summary>
|
||||
/// Retail property 0x16. Read-only fields remain mouse-visible so their
|
||||
/// authored background can report why editing is unavailable, but they do
|
||||
/// not take keyboard focus or accept mutations.
|
||||
/// </summary>
|
||||
public bool Editable
|
||||
{
|
||||
get => _editable;
|
||||
set
|
||||
{
|
||||
if (_editable == value) return;
|
||||
_editable = value;
|
||||
AcceptsFocus = value;
|
||||
IsEditControl = value;
|
||||
if (!value && _focused)
|
||||
FindRoot()?.SetKeyboardFocus(null);
|
||||
if (!value)
|
||||
_repeatKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
|
||||
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
|
|
@ -72,6 +99,9 @@ public sealed class UiField : UiElement
|
|||
private bool _selecting; // mouse drag in progress
|
||||
private bool _preserveFocusSelectionOnMouseDown;
|
||||
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
|
||||
private IReadOnlyList<WrappedLine> _wrappedLines = Array.Empty<WrappedLine>();
|
||||
private float _wrappedLineHeight = 14f;
|
||||
private bool _suppressNextNewlineChar;
|
||||
|
||||
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
|
||||
private Silk.NET.Input.Key? _repeatKey;
|
||||
|
|
@ -94,7 +124,12 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void InsertChar(char c)
|
||||
{
|
||||
if (c < 0x20 || c == 0x7F || (CharacterFilter is not null && !CharacterFilter(c))) return;
|
||||
if (!Editable) return;
|
||||
if (!OneLine && (c == '\r' || c == '\n'))
|
||||
c = '\n';
|
||||
else if (c < 0x20 || c == 0x7F)
|
||||
return;
|
||||
if (CharacterFilter is not null && !CharacterFilter(c)) return;
|
||||
DeleteSelection();
|
||||
if (_text.Length >= MaxCharacters) return;
|
||||
_text = _text.Insert(_caret, c.ToString());
|
||||
|
|
@ -104,6 +139,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void Backspace()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret == 0) return;
|
||||
_text = _text.Remove(_caret - 1, 1);
|
||||
|
|
@ -112,6 +148,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void DeleteForward()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret >= _text.Length) return;
|
||||
_text = _text.Remove(_caret, 1);
|
||||
|
|
@ -184,6 +221,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
private void CutSelection()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (!HasSelection) return;
|
||||
CopySelection();
|
||||
DeleteSelection();
|
||||
|
|
@ -192,15 +230,33 @@ public sealed class UiField : UiElement
|
|||
|
||||
private void Paste()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (Keyboard is null) return;
|
||||
string clip = Keyboard.ClipboardText ?? "";
|
||||
if (clip.Length == 0) return;
|
||||
|
||||
// Single-line field: strip control chars (newlines/tabs) from pasted text.
|
||||
// Retail one-line fields strip controls. Multi-line fields preserve CR/LF
|
||||
// as one normalized newline and still reject other controls.
|
||||
var sb = new System.Text.StringBuilder(clip.Length);
|
||||
bool previousCr = false;
|
||||
foreach (char ch in clip)
|
||||
{
|
||||
if (!OneLine && (ch == '\r' || ch == '\n'))
|
||||
{
|
||||
if (ch == '\n' && previousCr)
|
||||
{
|
||||
previousCr = false;
|
||||
continue;
|
||||
}
|
||||
sb.Append('\n');
|
||||
previousCr = ch == '\r';
|
||||
continue;
|
||||
}
|
||||
previousCr = false;
|
||||
if (ch >= 0x20 && ch != 0x7F
|
||||
&& (CharacterFilter is null || CharacterFilter(ch))) sb.Append(ch);
|
||||
&& (CharacterFilter is null || CharacterFilter(ch)))
|
||||
sb.Append(ch);
|
||||
}
|
||||
if (sb.Length == 0) return;
|
||||
|
||||
DeleteSelection();
|
||||
|
|
@ -216,6 +272,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void Submit()
|
||||
{
|
||||
if (!Editable) return;
|
||||
var t = _text;
|
||||
if (t.Trim().Length == 0)
|
||||
{
|
||||
|
|
@ -314,6 +371,12 @@ public sealed class UiField : UiElement
|
|||
}
|
||||
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
if (!OneLine)
|
||||
{
|
||||
DrawMultiLine(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
float ty = (Height - lh) * 0.5f;
|
||||
float visibleW = MathF.Max(1f, Width - 2f * Padding);
|
||||
|
|
@ -370,11 +433,198 @@ public sealed class UiField : UiElement
|
|||
return 0f;
|
||||
}
|
||||
|
||||
private void DrawMultiLine(UiRenderContext ctx)
|
||||
{
|
||||
float lineHeight = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
float visibleWidth = MathF.Max(1f, Width - (2f * Padding));
|
||||
float visibleHeight = MathF.Max(1f, Height - (2f * Padding));
|
||||
IReadOnlyList<WrappedLine> lines = BuildWrappedLines(visibleWidth);
|
||||
_wrappedLines = lines;
|
||||
_wrappedLineHeight = lineHeight;
|
||||
|
||||
Scroll.LineHeight = Math.Max(1, (int)MathF.Round(lineHeight));
|
||||
Scroll.SetExtents(
|
||||
Math.Max(1, (int)MathF.Ceiling(lines.Count * lineHeight)),
|
||||
Math.Max(1, (int)MathF.Floor(visibleHeight)),
|
||||
preserveEnd: false);
|
||||
|
||||
int caretLine = FindCaretLine(lines, _caret);
|
||||
if (_focused)
|
||||
{
|
||||
float caretTop = caretLine * lineHeight;
|
||||
float caretBottom = caretTop + lineHeight;
|
||||
if (caretTop < Scroll.ScrollY)
|
||||
Scroll.SetScrollY((int)MathF.Floor(caretTop));
|
||||
else if (caretBottom > Scroll.ScrollY + visibleHeight)
|
||||
Scroll.SetScrollY((int)MathF.Ceiling(caretBottom - visibleHeight));
|
||||
}
|
||||
|
||||
ctx.PushClip(0f, 0f, Width, Height);
|
||||
try
|
||||
{
|
||||
var (selectionLow, selectionHigh) = SelSpan();
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
WrappedLine line = lines[i];
|
||||
float y = Padding + (i * lineHeight) - Scroll.ScrollY;
|
||||
if (y + lineHeight <= Padding || y >= Height - Padding)
|
||||
continue;
|
||||
|
||||
int lineEnd = line.Start + line.Length;
|
||||
int highlightLow = Math.Max(selectionLow, line.Start);
|
||||
int highlightHigh = Math.Min(selectionHigh, lineEnd);
|
||||
if (HasSelection && highlightHigh > highlightLow)
|
||||
{
|
||||
float x0 = Padding + MeasureRange(
|
||||
line.Start,
|
||||
highlightLow - line.Start);
|
||||
float x1 = Padding + MeasureRange(
|
||||
line.Start,
|
||||
highlightHigh - line.Start);
|
||||
ctx.DrawFill(
|
||||
x0,
|
||||
y,
|
||||
MathF.Max(0f, x1 - x0),
|
||||
lineHeight,
|
||||
SelectionColor);
|
||||
}
|
||||
|
||||
if (DatFont is { } dat)
|
||||
ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor);
|
||||
else if (Font is { } bitmap)
|
||||
ctx.DrawString(line.Text, Padding, y, TextColor, bitmap);
|
||||
}
|
||||
|
||||
if (_focused && lines.Count > 0)
|
||||
{
|
||||
WrappedLine line = lines[caretLine];
|
||||
int lineColumn = Math.Clamp(
|
||||
_caret - line.Start,
|
||||
0,
|
||||
line.Length);
|
||||
float x = Padding + MeasureRange(line.Start, lineColumn);
|
||||
float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY;
|
||||
ctx.DrawFill(x, y, 1f, lineHeight, TextColor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.PopClip();
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<WrappedLine> BuildWrappedLines(float maximumWidth)
|
||||
{
|
||||
var lines = new List<WrappedLine>();
|
||||
if (_text.Length == 0)
|
||||
{
|
||||
lines.Add(new WrappedLine(0, 0, string.Empty));
|
||||
return lines;
|
||||
}
|
||||
|
||||
int start = 0;
|
||||
while (start < _text.Length)
|
||||
{
|
||||
if (_text[start] == '\n')
|
||||
{
|
||||
lines.Add(new WrappedLine(start, 0, string.Empty));
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int paragraphEnd = _text.IndexOf('\n', start);
|
||||
if (paragraphEnd < 0)
|
||||
paragraphEnd = _text.Length;
|
||||
int end = start;
|
||||
int lastWhitespaceEnd = -1;
|
||||
while (end < paragraphEnd)
|
||||
{
|
||||
int candidateEnd = end + 1;
|
||||
if (MeasureRange(start, candidateEnd - start) > maximumWidth
|
||||
&& end > start)
|
||||
break;
|
||||
end = candidateEnd;
|
||||
if (char.IsWhiteSpace(_text[end - 1]))
|
||||
lastWhitespaceEnd = end;
|
||||
}
|
||||
|
||||
if (end < paragraphEnd && lastWhitespaceEnd > start)
|
||||
end = lastWhitespaceEnd;
|
||||
if (end == start)
|
||||
end++;
|
||||
|
||||
int length = end - start;
|
||||
lines.Add(new WrappedLine(
|
||||
start,
|
||||
length,
|
||||
_text.Substring(start, length)));
|
||||
start = end;
|
||||
if (start == paragraphEnd && start < _text.Length)
|
||||
start++;
|
||||
}
|
||||
|
||||
if (_text.EndsWith('\n'))
|
||||
lines.Add(new WrappedLine(_text.Length, 0, string.Empty));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private int FindCaretLine(IReadOnlyList<WrappedLine> lines, int caret)
|
||||
{
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
WrappedLine line = lines[i];
|
||||
int end = line.Start + line.Length;
|
||||
if (caret < end || caret == end && i == lines.Count - 1)
|
||||
return i;
|
||||
if (caret == end && i + 1 < lines.Count
|
||||
&& lines[i + 1].Start > caret)
|
||||
return i;
|
||||
}
|
||||
return Math.Max(0, lines.Count - 1);
|
||||
}
|
||||
|
||||
private float MeasureRange(int start, int length)
|
||||
{
|
||||
if (length <= 0) return 0f;
|
||||
string value = _text.Substring(start, length);
|
||||
return DatFont?.MeasureWidth(value)
|
||||
?? Font?.MeasureWidth(value)
|
||||
?? value.Length * 8f;
|
||||
}
|
||||
|
||||
private int HitChar(float localX, float localY)
|
||||
{
|
||||
if (OneLine)
|
||||
return HitCharX(localX);
|
||||
if (_wrappedLines.Count == 0)
|
||||
return _text.Length;
|
||||
|
||||
int lineIndex = Math.Clamp(
|
||||
(int)MathF.Floor(
|
||||
(localY - Padding + Scroll.ScrollY)
|
||||
/ MathF.Max(1f, _wrappedLineHeight)),
|
||||
0,
|
||||
_wrappedLines.Count - 1);
|
||||
WrappedLine line = _wrappedLines[lineIndex];
|
||||
float target = MathF.Max(0f, localX - Padding);
|
||||
int best = 0;
|
||||
float bestDistance = float.MaxValue;
|
||||
for (int col = 0; col <= line.Length; col++)
|
||||
{
|
||||
float distance = MathF.Abs(
|
||||
MeasureRange(line.Start, col) - target);
|
||||
if (distance >= bestDistance) continue;
|
||||
bestDistance = distance;
|
||||
best = col;
|
||||
}
|
||||
return line.Start + best;
|
||||
}
|
||||
|
||||
// ── Auto-repeat ──────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
if (_repeatKey is not { } k) return;
|
||||
if (!Editable || _repeatKey is not { } k) return;
|
||||
_repeatTimer -= deltaSeconds;
|
||||
if (_repeatTimer > 0) return;
|
||||
_repeatTimer = RepeatRate;
|
||||
|
|
@ -425,8 +675,18 @@ public sealed class UiField : UiElement
|
|||
return true;
|
||||
|
||||
case UiEventType.Char:
|
||||
InsertChar((char)e.Data0);
|
||||
{
|
||||
char value = (char)e.Data0;
|
||||
if (_suppressNextNewlineChar
|
||||
&& (value == '\r' || value == '\n'))
|
||||
{
|
||||
_suppressNextNewlineChar = false;
|
||||
return true;
|
||||
}
|
||||
_suppressNextNewlineChar = false;
|
||||
InsertChar(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
if (_preserveFocusSelectionOnMouseDown)
|
||||
|
|
@ -435,12 +695,13 @@ public sealed class UiField : UiElement
|
|||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
_caret = HitCharX(e.Data1);
|
||||
_caret = HitChar(e.Data1, e.Data2);
|
||||
_selAnchor = Selectable ? _caret : null;
|
||||
_selecting = Selectable;
|
||||
return true;
|
||||
case UiEventType.MouseMove:
|
||||
if (Selectable && _selecting) _caret = HitCharX(e.Data1);
|
||||
if (Selectable && _selecting)
|
||||
_caret = HitChar(e.Data1, e.Data2);
|
||||
return true;
|
||||
case UiEventType.MouseUp:
|
||||
_selecting = false;
|
||||
|
|
@ -452,6 +713,8 @@ public sealed class UiField : UiElement
|
|||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
if (!Editable)
|
||||
return true;
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
if (CtrlHeld())
|
||||
{
|
||||
|
|
@ -470,6 +733,12 @@ public sealed class UiField : UiElement
|
|||
{
|
||||
case Silk.NET.Input.Key.Enter:
|
||||
case Silk.NET.Input.Key.KeypadEnter:
|
||||
if (!OneLine)
|
||||
{
|
||||
InsertChar('\n');
|
||||
_suppressNextNewlineChar = true;
|
||||
return true;
|
||||
}
|
||||
Submit();
|
||||
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
|
||||
return true;
|
||||
|
|
@ -484,6 +753,20 @@ public sealed class UiField : UiElement
|
|||
}
|
||||
return false;
|
||||
}
|
||||
case UiEventType.Scroll:
|
||||
if (!OneLine)
|
||||
{
|
||||
Scroll.ScrollByLines(-Math.Sign(e.Data0));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case UiEventType.Click:
|
||||
if (!Editable)
|
||||
{
|
||||
OnReadOnlyClick?.Invoke();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue