using System; using System.Numerics; using AetherBags.Helpers; using AetherBags.Hooks; using AetherBags.Inventory; using AetherBags.Inventory.Categories; using AetherBags.Inventory.Items; using AetherBags.Nodes.Layout; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; using KamiToolKit.Nodes; namespace AetherBags.Nodes.Inventory; public class InventoryCategoryNode : SimpleComponentNode { private readonly TextNode _categoryNameTextNode; private readonly HybridDirectionalFlexNode _itemGridNode; private const float FallbackItemSize = 46; private const float HeaderHeight = 16; private const float MinWidth = 40; private float? _fixedWidth; private int _hoverRefs; private bool _headerSuppressed; private bool _headerExpanded; private float _baseHeaderWidth = 96f; private string _fullHeaderText = string.Empty; public event Action? HeaderHoverChanged; public Action? OnRefreshRequested { get; set; } public Action? OnDragEnd { get; set; } public InventoryCategoryNode() { _categoryNameTextNode = new TextNode { Size = new Vector2(96, 16), AlignmentType = AlignmentType.Left, }; _categoryNameTextNode.AddEvent(AtkEventType.MouseOver, BeginHeaderHover); _categoryNameTextNode.AddEvent(AtkEventType.MouseOut, EndHeaderHover); _categoryNameTextNode.TextFlags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; _categoryNameTextNode.TextFlags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); _categoryNameTextNode.AddFlags(NodeFlags.EmitsEvents | NodeFlags.HasCollision); _categoryNameTextNode.AttachNode(this); _itemGridNode = new HybridDirectionalFlexNode { Position = new Vector2(0, HeaderHeight), Size = new Vector2(240, 92), FillRowsFirst = true, ItemsPerLine = 10, HorizontalPadding = 5, VerticalPadding = 2, }; _itemGridNode.NodeFlags |= NodeFlags.EmitsEvents; _itemGridNode.AttachNode(this); } public CategorizedInventory CategorizedInventory { get; set { field = value; _fullHeaderText = System.Config.General.ShowCategoryItemCount ? $"{value.Category.Name} ({value.Items.Count})" : value.Category.Name; _categoryNameTextNode.String = _fullHeaderText; _categoryNameTextNode.TextColor = value.Category.Color; _categoryNameTextNode.TextTooltip = value.Category.Description; UpdateItemGrid(); RecalculateSize(); } } public int ItemsPerLine { get => _itemGridNode.ItemsPerLine; set { if (_itemGridNode.ItemsPerLine == value) return; _itemGridNode.ItemsPerLine = value; RecalculateSize(); } } public float? FixedWidth { get => _fixedWidth; set { if (_fixedWidth.Equals(value)) return; _fixedWidth = value; RecalculateSize(); } } public bool IsPinnedInConfig => CategorizedInventory.Category?.IsPinned ?? false; public void BeginHeaderHover() { _hoverRefs++; if (_hoverRefs != 1) return; _headerExpanded = true; ApplyHeaderVisualStateAndSize(); HeaderHoverChanged?.Invoke(this, true); } public void EndHeaderHover() { if (_hoverRefs <= 0) return; _hoverRefs--; if (_hoverRefs != 0) return; _headerExpanded = false; ApplyHeaderVisualStateAndSize(); HeaderHoverChanged?.Invoke(this, false); } public void SetHeaderSuppressed(bool suppressed) { if (_headerSuppressed == suppressed) return; _headerSuppressed = suppressed; ApplyHeaderVisualStateAndSize(); } private void ApplyHeaderVisualStateAndSize() { _categoryNameTextNode.IsVisible = ! _headerSuppressed; if (_headerSuppressed) return; var flags = _categoryNameTextNode.TextFlags; flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); if (_headerExpanded) { flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis); _categoryNameTextNode.TextFlags = flags; if (! string.IsNullOrEmpty(_fullHeaderText)) _categoryNameTextNode.String = _fullHeaderText; Vector2 drawSize = _categoryNameTextNode.GetTextDrawSize(); float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f); _categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = expandedWidth }; } else { _categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = _baseHeaderWidth }; if (!string.IsNullOrEmpty(_fullHeaderText)) _categoryNameTextNode.String = _fullHeaderText; flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; _categoryNameTextNode.TextFlags = flags; } } private void RecalculateSize() { int itemCount = CategorizedInventory.Items.Count; if (itemCount == 0) { float width = _fixedWidth ?? MinWidth; Size = new Vector2(width, HeaderHeight); _baseHeaderWidth = width; _itemGridNode.Position = new Vector2(0, HeaderHeight); _itemGridNode.Size = new Vector2(width, 0); ApplyHeaderVisualStateAndSize(); return; } int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); int rows = (itemCount + itemsPerLine - 1) / itemsPerLine; int actualColumns = Math.Min(itemCount, itemsPerLine); float cellW = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Width : FallbackItemSize; float cellH = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Height : FallbackItemSize; float hPad = _itemGridNode.HorizontalPadding; float vPad = _itemGridNode.VerticalPadding; float calculatedWidth = _fixedWidth ?? Math.Max(MinWidth, actualColumns * cellW + (actualColumns - 1) * hPad); float height = HeaderHeight + rows * cellH + (rows - 1) * vPad; Size = new Vector2(calculatedWidth, height); _itemGridNode.Position = new Vector2(0, HeaderHeight); _itemGridNode.Size = new Vector2(calculatedWidth, height - HeaderHeight); _baseHeaderWidth = calculatedWidth; ApplyHeaderVisualStateAndSize(); } private void UpdateItemGrid() { _itemGridNode.SyncWithListData( CategorizedInventory.Items, node => node.ItemInfo, CreateInventoryDragDropNode); } private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) { InventoryItem item = data.Item; InventoryMappedLocation visualLocation = data.VisualLocation; var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container); int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot; DragDropPayload nodePayload = new DragDropPayload { // Int1 is always the container ID, for Item DragDrop Int2 is only used as a fallback // ReferenceIndex is the absolute index that's actually used Type = DragDropType.Item, Int1 = visualLocation.Container, Int2 = visualLocation.Slot, ReferenceIndex = (short)absoluteIndex }; return new InventoryDragDropNode { Size = new Vector2(42, 46), Alpha = data.VisualAlpha, AddColor = data.HighlightOverlayColor, IsDraggable = !data.IsSlotBlocked, IsVisible = true, IconId = item.IconId, AcceptedType = DragDropType.Item, Payload = nodePayload, IsClickable = true, OnDiscard = node => OnDiscard(node, data), OnEnd = _ => OnDragEnd?.Invoke(), OnPayloadAccepted = (node, acceptedPayload) => OnPayloadAccepted(node, acceptedPayload, data), OnRollOver = node => { BeginHeaderHover(); node.ShowInventoryItemTooltip(item.Container, item.Slot); }, OnRollOut = node => { EndHeaderHover(); ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(node)->Id; AtkStage.Instance()->TooltipManager.HideTooltip(addonId); }, ItemInfo = data }; } public void RefreshNodeVisuals() { foreach (var node in _itemGridNode.Nodes) { if (node is not InventoryDragDropNode itemNode || itemNode.ItemInfo == null) continue; itemNode.Alpha = itemNode.ItemInfo.VisualAlpha; itemNode.AddColor = itemNode.ItemInfo.HighlightOverlayColor; itemNode.IsDraggable = !itemNode.ItemInfo.IsSlotBlocked; } } private unsafe void OnDiscard(DragDropNode node, ItemInfo item) { uint addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(node)->Id; AgentInventoryContext.Instance()->DiscardItem(item.Item.GetLinkedItem(), item.Item.Container, item.Item.Slot, addonId); } private void OnPayloadAccepted(DragDropNode node, DragDropPayload acceptedPayload, ItemInfo targetItemInfo) { try { // KTK clears node.Payload before invoking this, so setting it manually again var nodePayload = new DragDropPayload { Type = DragDropType.Item, Int1 = targetItemInfo.VisualLocation.Container, Int2 = targetItemInfo.VisualLocation.Slot, ReferenceIndex = (short)(targetItemInfo.Item.Container.GetInventoryStartIndex + targetItemInfo.VisualLocation.Slot) }; Services.Logger.DebugOnly($"[OnPayload] ACCEPTED payload: Type={acceptedPayload.Type} Int1={acceptedPayload.Int1} Int2={acceptedPayload.Int2} Ref={acceptedPayload.ReferenceIndex}"); Services.Logger.DebugOnly($"[OnPayload] NODE payload: Type={nodePayload.Type} Int1={nodePayload.Int1} Int2={nodePayload.Int2} Ref={nodePayload.ReferenceIndex}"); if (!acceptedPayload.IsValidInventoryPayload || !nodePayload.IsValidInventoryPayload) { Services.Logger.Warning($"[OnPayload] Invalid payload type: Accepted={acceptedPayload.Type} Node={nodePayload.Type}"); return; } if (acceptedPayload.IsSameBaseContainer(nodePayload)) { Services.Logger.DebugOnly("[OnPayload] Source and target are in the same base container, skipping move."); node.IconId = targetItemInfo.IconId; node.Payload = nodePayload; return; } var sourceCopy = acceptedPayload; var targetCopy = nodePayload; InventoryMoveHelper.HandleItemMovePayload(sourceCopy, targetCopy); OnRefreshRequested?.Invoke(); } catch (Exception ex) { Services.Logger.Error(ex, "[OnPayload] Error handling payload acceptance"); } } }