Initial commit: AetherBags + KamiToolKit for FC Gitea
Debug Build and Test / Build against Latest Dalamud (push) Has been cancelled
Debug Build and Test / Build against Staging Dalamud (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 14:46:31 -05:00
commit 8db4ce6094
375 changed files with 34124 additions and 0 deletions
@@ -0,0 +1,184 @@
using System;
using System.Linq;
using FFXIVClientStructs.FFXIV.Client.System.Input;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Enums;
using Lumina.Text.ReadOnly;
namespace KamiToolKit.Nodes;
/// <summary>
/// Needs More Work.
/// </summary>
internal unsafe class TextMultiLineInputNodeScrollable : TextInputNode {
private int startLineIndex;
private bool isProgrammaticTextSet;
private ReadOnlySeString fullText;
private ReadOnlySeString lastDisplayedText;
public TextMultiLineInputNodeScrollable() {
TextLimitsNode.AlignmentType = AlignmentType.BottomRight;
CurrentTextNode.TextFlags |= TextFlags.MultiLine;
CurrentTextNode.LineSpacing = 14;
Flags |= TextInputFlags.MultiLine;
CollisionNode.AddEvent(AtkEventType.InputReceived, InputComplete);
CollisionNode.AddEvent(AtkEventType.MouseWheel, OnMouseScrolled);
Component->InputSanitizationFlags = AllowedEntities.UppercaseLetters | AllowedEntities.LowercaseLetters | AllowedEntities.Numbers |
AllowedEntities.SpecialCharacters | AllowedEntities.CharacterList | AllowedEntities.OtherCharacters |
AllowedEntities.Payloads | AllowedEntities.Unknown9;
Component->ComponentTextData.Flags2 = TextInputFlags2.MultiLine | TextInputFlags2.AllowSymbolInput | TextInputFlags2.AllowNumberInput;
Component->ComponentTextData.MaxLine = byte.MaxValue;
Component->ComponentTextData.MaxByte = ushort.MaxValue;
}
public uint MaxLines {
get => Component->ComponentTextData.MaxLine;
set => Component->ComponentTextData.MaxLine = value;
}
public uint MaxBytes {
get => Component->ComponentTextData.MaxByte;
set => Component->ComponentTextData.MaxByte = value;
}
public override ReadOnlySeString String {
get => fullText;
set {
isProgrammaticTextSet = true;
fullText = value;
UpdateCurrentTextDisplay();
isProgrammaticTextSet = false;
}
}
public override Action<ReadOnlySeString>? OnInputReceived {
get => base.OnInputReceived;
set {
base.OnInputReceived = currentComponentText => {
if (isProgrammaticTextSet) return;
ApplyDisplayChangesToFullText(currentComponentText.ToString());
lastDisplayedText = currentComponentText;
UpdateLineCountDisplay();
};
base.OnInputReceived += value;
}
}
private void OnMouseScrolled(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) {
var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None);
var lineHeight = CurrentTextNode.LineSpacing;
var maxVisibleLines = (int)(Height / lineHeight);
var oldStartLineIndex = startLineIndex;
if (atkEventData->IsScrollUp)
startLineIndex = Math.Max(0, startLineIndex - 1);
else if (atkEventData->IsScrollDown)
startLineIndex = Math.Min(Math.Max(0, lines.Length - maxVisibleLines), startLineIndex + 1);
if (oldStartLineIndex != startLineIndex) {
UpdateCurrentTextDisplay();
}
atkEvent->SetEventIsHandled();
}
private void ApplyDisplayChangesToFullText(string newDisplayedText) {
var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None).ToList();
var oldDisplayLines = lastDisplayedText.ToString().Split(['\r', '\n'], StringSplitOptions.None);
var newDisplayLines = newDisplayedText.Split(['\r', '\n'], StringSplitOptions.None);
if (startLineIndex < lines.Count) {
var removeCount = Math.Min(oldDisplayLines.Length, lines.Count - startLineIndex);
lines.RemoveRange(startLineIndex, removeCount);
lines.InsertRange(startLineIndex, newDisplayLines);
}
else {
lines.AddRange(newDisplayLines);
}
for (var i = lines.Count - 1; i >= 0; i--) {
if (string.IsNullOrEmpty(lines[i]))
lines.RemoveAt(i);
else
break;
}
if (lines.Count == 0)
lines.Add(string.Empty);
fullText = string.Join("\r", lines);
lastDisplayedText = newDisplayedText;
}
private void UpdateLineCountDisplay() {
var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None);
var lineHeight = CurrentTextNode.LineSpacing;
var totalLines = lines.Length;
var maxVisibleLines = (int)(Height / lineHeight);
if (maxVisibleLines <= 0) return;
startLineIndex = Math.Clamp(startLineIndex, 0, Math.Max(0, totalLines - maxVisibleLines));
var currentEndLine = Math.Min(startLineIndex + maxVisibleLines, totalLines);
var limitText = $"{startLineIndex + 1}-{currentEndLine}/{totalLines}";
TextLimitsNode.String = limitText;
}
private void UpdateCurrentTextDisplay() {
var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None);
var lineHeight = CurrentTextNode.LineSpacing;
var maxVisibleLines = (int)(Height / lineHeight);
if (maxVisibleLines <= 0) return;
startLineIndex = Math.Clamp(startLineIndex, 0, Math.Max(0, lines.Length - maxVisibleLines));
var displayText = startLineIndex > 0 && startLineIndex < lines.Length
? string.Join("\r", lines.Skip(startLineIndex).Take(maxVisibleLines))
: fullText.ToString();
lastDisplayedText = displayText;
var capturedProgrammaticFlag = isProgrammaticTextSet;
isProgrammaticTextSet = capturedProgrammaticFlag;
Component->SetText(displayText);
UpdateLineCountDisplay();
}
private void InputComplete() {
if (UIInputData.Instance()->IsKeyPressed(SeVirtualKey.RETURN)) {
var textInputComponent = Node->GetAsAtkComponentTextInput();
var cursorPos = textInputComponent->CursorPos;
using (var utf8String = new Utf8String()) {
utf8String.SetString("\r");
textInputComponent->WriteString(&utf8String);
}
textInputComponent->CursorPos = cursorPos + 1;
textInputComponent->SelectionStart = cursorPos + 1;
textInputComponent->SelectionEnd = cursorPos + 1;
}
OnInputComplete?.Invoke(Component->EvaluatedString.AsSpan());
}
}