using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.UI.Style; using LightlessSync.UI.Tags; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.PixelFormats; using System; using System.Collections.Generic; using System.IO; using System.Numerics; using System.Threading.Tasks; using System.Linq; using LightlessSync.Services.Profiles; namespace LightlessSync.UI; public partial class EditProfileUi : WindowMediatorSubscriberBase { private readonly ApiController _apiController; private readonly FileDialogManager _fileDialogManager; private readonly LightlessProfileManager _lightlessProfileManager; private readonly UiSharedService _uiSharedService; private readonly ProfileTagService _profileTagService; private const string LoadingProfileDescription = "Loading Profile Data from server..."; private const string DescriptionMacroTooltip = "Supported SeString markup:\n" + "
- insert a line break (Enter already emits these).\n" + "<-> - optional soft hyphen / word break.\n" + " / - show game icons by numeric id;" + "text or - tint text; reset with or .\n" + " / - use UI palette colours (0 restores defaults).\n" + " / - change outline colour.\n" + " - toggle style flags.\n" + " - create clickable links."; private static readonly HashSet SupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) { "png", "jpg", "jpeg", "webp", "bmp" }; private const string ImageFileDialogFilter = "Images{.png,.jpg,.jpeg,.webp,.bmp}"; private readonly List _tagEditorSelection = new(); private int[] _profileTagIds = Array.Empty(); private readonly List _tagPreviewSegments = new(); private enum ProfileEditorMode { User, Group } private ProfileEditorMode _mode = ProfileEditorMode.User; private GroupFullInfoDto? _groupInfo; private LightlessGroupProfileData? _groupProfileData; private bool _groupIsNsfw; private bool _groupIsDisabled; private bool _groupServerIsNsfw; private bool _groupServerIsDisabled; private byte[]? _queuedProfileImage; private byte[]? _queuedBannerImage; private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); private readonly Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); private const int MaxProfileTags = 12; private const int AvailableTagsPerPage = 6; private int _availableTagPage; private UserData? _selfProfileUserData; private string _descriptionText = string.Empty; private IDalamudTextureWrap? _pfpTextureWrap; private IDalamudTextureWrap? _bannerTextureWrap; private string _profileDescription = string.Empty; private byte[] _profileImage = []; private byte[] _bannerImage = []; private bool _showProfileImageError = false; private bool _showBannerImageError = false; private bool _wasOpen; private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f); private bool textEnabled; private bool glowEnabled; private Vector4 textColor; private Vector4 glowColor; private sealed record VanityState(bool TextEnabled, bool GlowEnabled, Vector4 TextColor, Vector4 GlowColor); private VanityState? _savedVanity; public EditProfileUi(ILogger logger, LightlessMediator mediator, ApiController apiController, UiSharedService uiSharedService, FileDialogManager fileDialogManager, LightlessProfileManager lightlessProfileManager, ProfileTagService profileTagService, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Lightless Sync Edit Profile###LightlessSyncEditProfileUI", performanceCollectorService) { IsOpen = false; var scale = ImGuiHelpers.GlobalScale; var editorSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale); Size = editorSize; SizeCondition = ImGuiCond.FirstUseEver; SizeConstraints = new() { MinimumSize = editorSize, MaximumSize = editorSize }; Flags |= ImGuiWindowFlags.NoResize; _apiController = apiController; _uiSharedService = uiSharedService; _fileDialogManager = fileDialogManager; _lightlessProfileManager = lightlessProfileManager; _profileTagService = profileTagService; Mediator.Subscribe(this, (_) => { _wasOpen = IsOpen; IsOpen = false; }); Mediator.Subscribe(this, (_) => IsOpen = _wasOpen); Mediator.Subscribe(this, (_) => { IsOpen = false; _selfProfileUserData = null; }); Mediator.Subscribe(this, (msg) => { if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal)) { _pfpTextureWrap?.Dispose(); _pfpTextureWrap = null; } }); Mediator.Subscribe(this, msg => { _selfProfileUserData = msg.Connection.User with { IsAdmin = msg.Connection.IsAdmin, IsModerator = msg.Connection.IsModerator, HasVanity = msg.Connection.HasVanity, TextColorHex = msg.Connection.TextColorHex, TextGlowColorHex = msg.Connection.TextGlowColorHex }; LoadVanity(); }); Mediator.Subscribe(this, msg => OpenGroupEditor(msg.Group)); } private void LoadVanity() { textEnabled = !string.IsNullOrEmpty(_apiController.TextColorHex); glowEnabled = !string.IsNullOrEmpty(_apiController.TextGlowColorHex); textColor = textEnabled ? UIColors.HexToRgba(_apiController.TextColorHex!) : Vector4.One; glowColor = glowEnabled ? UIColors.HexToRgba(_apiController.TextGlowColorHex!) : Vector4.Zero; _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); } public override async void OnOpen() { if (_mode == ProfileEditorMode.Group) { if (_groupInfo is not null) { var scale = ImGuiHelpers.GlobalScale; var viewport = ImGui.GetMainViewport(); ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); } return; } var user = await EnsureSelfProfileUserDataAsync().ConfigureAwait(false); if (user is not null) { ProfileEditorLayoutCoordinator.Enable(user.UID); var scale = ImGuiHelpers.GlobalScale; var viewport = ImGui.GetMainViewport(); ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); Mediator.Publish(new OpenSelfProfilePreviewMessage(user)); } } public override void OnClose() { if (_mode == ProfileEditorMode.Group) { if (_groupInfo is not null) { ProfileEditorLayoutCoordinator.Disable(_groupInfo.Group.GID); Mediator.Publish(new CloseGroupProfilePreviewMessage(_groupInfo)); } ResetGroupEditorState(); _mode = ProfileEditorMode.User; return; } if (_selfProfileUserData is not null) { ProfileEditorLayoutCoordinator.Disable(_selfProfileUserData.UID); Mediator.Publish(new CloseSelfProfilePreviewMessage(_selfProfileUserData)); } } private async Task EnsureSelfProfileUserDataAsync() { if (_selfProfileUserData is not null) return _selfProfileUserData; try { var connection = await _apiController.GetConnectionDtoAsync(publishConnected: false).ConfigureAwait(false); _selfProfileUserData = connection.User with { IsAdmin = connection.IsAdmin, IsModerator = connection.IsModerator, HasVanity = connection.HasVanity, TextColorHex = connection.TextColorHex, TextGlowColorHex = connection.TextGlowColorHex }; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to acquire connection information for profile preview."); } return _selfProfileUserData; } protected override void DrawInternal() { var scale = ImGuiHelpers.GlobalScale; if (_mode == ProfileEditorMode.Group) { DrawGroupEditor(scale); return; } var viewport = ImGui.GetMainViewport(); var linked = _selfProfileUserData is not null && ProfileEditorLayoutCoordinator.IsActive(_selfProfileUserData.UID); if (linked) { ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); var desiredSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale); if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize)) ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); var currentPos = ImGui.GetWindowPos(); if (IsWindowBeingDragged()) ProfileEditorLayoutCoordinator.UpdateAnchorFromEditor(currentPos, scale); var desiredPos = ProfileEditorLayoutCoordinator.GetEditorPosition(scale); if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos)) ImGui.SetWindowPos(desiredPos, ImGuiCond.Always); } else { var defaultProfilePos = viewport.WorkPos + new Vector2(50f, 70f) * scale; var defaultEditorPos = defaultProfilePos + ProfileEditorLayoutCoordinator.GetEditorOffset(scale); ImGui.SetWindowPos(defaultEditorPos, ImGuiCond.FirstUseEver); } var profile = _lightlessProfileManager.GetLightlessUserProfile(new UserData(_apiController.UID)); var accent = UIColors.Get("LightlessPurple"); var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.015f); var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.07f); using var panelBg = ImRaii.PushColor(ImGuiCol.ChildBg, accentBg); using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder); ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##ProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar)) { DrawGuidelinesSection(scale); ImGui.Dummy(new Vector2(0f, 4f * scale)); DrawTabInterface(profile, scale); } ImGui.EndChild(); ImGui.PopStyleVar(); } private void DrawGuidelinesSection(float scale) { DrawSection("Guidelines", scale, () => { ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, 1)); _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile pictures, tags and description."); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report your profile for breaking the rules."); _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)"); _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive"); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling your profile forever or terminating your Lightless account indefinitely."); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of your profile validity from reports through staff is not up to debate and the decisions to disable your profile/account permanent."); _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If your profile picture or profile description could be considered NSFW, enable the toggle in visibility settings."); ImGui.PopStyleVar(); }); } private void DrawTabInterface(LightlessUserProfileData profile, float scale) { ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 4f * scale); ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(8f, 4f) * scale); if (ImGui.BeginTabBar("##ProfileEditorTabs", ImGuiTabBarFlags.NoCloseWithMiddleMouseButton | ImGuiTabBarFlags.FittingPolicyResizeDown)) { if (ImGui.BeginTabItem("Profile")) { DrawProfileTabContent(profile, scale); ImGui.EndTabItem(); } if (ImGui.BeginTabItem("Vanity")) { DrawVanityTabContent(scale); ImGui.EndTabItem(); } ImGui.EndTabBar(); } ImGui.PopStyleVar(2); } private void DrawProfileTabContent(LightlessUserProfileData profile, float scale) { if (profile.IsFlagged) { DrawSection("Moderation Status", scale, () => { UiSharedService.ColorTextWrapped(profile.Description, ImGuiColors.DalamudRed); }); return; } SyncProfileState(profile); DrawSection("Profile Preview", scale, () => DrawProfileSnapshot(profile, scale)); DrawSection("Profile Image", scale, () => DrawProfileImageControls(profile, scale)); DrawSection("Profile Banner", scale, () => DrawProfileBannerControls(profile, scale)); DrawSection("Profile Description", scale, () => DrawProfileDescriptionEditor(profile, scale)); DrawSection("Profile Tags", scale, () => DrawProfileTagsEditor(profile, scale)); DrawSection("Visibility", scale, () => DrawProfileVisibilityControls(profile)); } private void DrawProfileSnapshot(LightlessUserProfileData profile, float scale) { var bannerHeight = 140f * scale; ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##ProfileBannerPreview", new Vector2(-1f, bannerHeight), true)) { if (_bannerTextureWrap != null) { var childSize = ImGui.GetWindowSize(); var padding = ImGui.GetStyle().WindowPadding; var contentSize = new Vector2( MathF.Max(childSize.X - padding.X * 2f, 1f), MathF.Max(childSize.Y - padding.Y * 2f, 1f)); var imageSize = ImGuiHelpers.ScaledVector2(_bannerTextureWrap.Width, _bannerTextureWrap.Height); if (imageSize.X > contentSize.X || imageSize.Y > contentSize.Y) { var ratio = MathF.Min(contentSize.X / MathF.Max(imageSize.X, 1f), contentSize.Y / MathF.Max(imageSize.Y, 1f)); imageSize *= ratio; } var offset = new Vector2( MathF.Max((contentSize.X - imageSize.X) * 0.5f, 0f), MathF.Max((contentSize.Y - imageSize.Y) * 0.5f, 0f)); ImGui.SetCursorPos(padding + offset); ImGui.Image(_bannerTextureWrap.Handle, imageSize); } else { ImGui.TextColored(UIColors.Get("LightlessPurple"), "No Profile Banner"); } } ImGui.EndChild(); ImGui.PopStyleVar(); ImGui.Dummy(new Vector2(0f, 6f * scale)); if (_pfpTextureWrap != null) { var size = ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height); var maxEdge = 150f * scale; if (size.X > maxEdge || size.Y > maxEdge) { var ratio = MathF.Min(maxEdge / MathF.Max(size.X, 1f), maxEdge / MathF.Max(size.Y, 1f)); size *= ratio; } ImGui.Image(_pfpTextureWrap.Handle, size); } else { ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##ProfileImagePlaceholder", new Vector2(150f * scale, 150f * scale), true)) ImGui.TextColored(UIColors.Get("LightlessPurple"), "No Profile Picture"); ImGui.EndChild(); ImGui.PopStyleVar(); } ImGui.Dummy(new Vector2(0f, 4f * scale)); using (_uiSharedService.GameFont.Push()) { ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##CurrentProfileDescription", new Vector2(-1f, 120f * scale), true)) { ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); if (string.IsNullOrWhiteSpace(profile.Description)) { ImGui.TextDisabled("-- No description --"); } else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(profile.Description!)) { UiSharedService.TextWrapped(profile.Description); } ImGui.PopTextWrapPos(); } ImGui.EndChild(); ImGui.PopStyleVar(); } ImGui.Dummy(new Vector2(0f, 4f * scale)); ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); var savedTags = _profileTagService.ResolveTags(_profileTagIds); if (savedTags.Count == 0) { ImGui.TextDisabled("-- No tags set --"); } else { bool first = true; for (int i = 0; i < savedTags.Count; i++) { if (!savedTags[i].HasContent) continue; if (!first) ImGui.SameLine(0f, 6f * scale); first = false; using (ImRaii.PushId($"snapshot-tag-{i}")) DrawTagPreview(savedTags[i], scale, "##snapshotTagPreview"); } if (!first) ImGui.NewLine(); } } private void DrawProfileImageControls(LightlessUserProfileData profile, float scale) { _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile pictures must be 512x512 and under 2 MiB."); if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) { var existingBanner = GetCurrentProfileBannerBase64(profile); _fileDialogManager.OpenFileDialog("Select new Profile picture", ImageFileDialogFilter, (success, file) => { if (!success) return; _ = Task.Run(async () => { var fileContent = File.ReadAllBytes(file); using MemoryStream ms = new(fileContent); var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); if (!IsSupportedImageFormat(format)) { _showProfileImageError = true; return; } using var image = Image.Load(fileContent); if (image.Width > 512 || image.Height > 512 || fileContent.Length > 2000 * 1024) { _showProfileImageError = true; return; } _showProfileImageError = false; var currentTags = GetServerTagPayload(); _queuedProfileImage = fileContent; await _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: Convert.ToBase64String(fileContent), BannerPictureBase64: existingBanner, Description: null, Tags: currentTags)).ConfigureAwait(false); }); }); } UiSharedService.AttachToolTip("Select an image up to 512x512 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile picture")) { _ = _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: string.Empty, BannerPictureBase64: GetCurrentProfileBannerBase64(profile), Description: null, Tags: GetServerTagPayload())); } UiSharedService.AttachToolTip("Remove your current profile picture."); if (_showProfileImageError) { UiSharedService.ColorTextWrapped("Your profile picture must be no larger than 512x512 pixels and under 2 MiB.", ImGuiColors.DalamudRed); } } private void DrawProfileBannerControls(LightlessUserProfileData profile, float scale) { _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile banners must be 840x260 and under 2 MiB."); if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner")) { var existingProfile = GetCurrentProfilePictureBase64(profile); _fileDialogManager.OpenFileDialog("Select new Profile banner", ImageFileDialogFilter, (success, file) => { if (!success) return; _ = Task.Run(async () => { var fileContent = File.ReadAllBytes(file); using MemoryStream ms = new(fileContent); var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); if (!IsSupportedImageFormat(format)) { _showBannerImageError = true; return; } using var image = Image.Load(fileContent); if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024) { _showBannerImageError = true; return; } _showBannerImageError = false; var currentTags = GetServerTagPayload(); await _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: existingProfile, BannerPictureBase64: Convert.ToBase64String(fileContent), Description: null, Tags: currentTags)).ConfigureAwait(false); }); }); } UiSharedService.AttachToolTip("Select an image up to 840x260 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile banner")) { _ = _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), BannerPictureBase64: string.Empty, Description: null, Tags: GetServerTagPayload())); } UiSharedService.AttachToolTip("Remove your current profile banner."); if (_showBannerImageError) { UiSharedService.ColorTextWrapped("Your banner image must be no larger than 840x260 pixels and under 2 MiB.", ImGuiColors.DalamudRed); } } private void DrawProfileDescriptionEditor(LightlessUserProfileData profile, float scale) { ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500"); ImGui.SameLine(); ImGuiComponents.HelpMarker(DescriptionMacroTooltip); using (_uiSharedService.GameFont.Push()) { var inputSize = new Vector2(-1f, 160f * scale); ImGui.InputTextMultiline("##profileDescriptionInput", ref _descriptionText, 1500, inputSize); } ImGui.Dummy(new Vector2(0f, 3f * scale)); ImGui.TextColored(UIColors.Get("LightlessBlue"), "Preview"); using (_uiSharedService.GameFont.Push()) { ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); if (ImGui.BeginChild("##profileDescriptionPreview", new Vector2(-1f, 140f * scale), true)) { ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); if (string.IsNullOrWhiteSpace(_descriptionText)) { ImGui.TextDisabled("-- Description preview --"); } else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(_descriptionText)) { UiSharedService.TextWrapped(_descriptionText); } ImGui.PopTextWrapPos(); } ImGui.EndChild(); ImGui.PopStyleVar(); } ImGui.Dummy(new Vector2(0f, 4f * scale)); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) { _ = _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), BannerPictureBase64: GetCurrentProfileBannerBase64(profile), _descriptionText, Tags: GetServerTagPayload())); } UiSharedService.AttachToolTip("Apply the text above to your profile."); ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) { _descriptionText = string.Empty; _ = _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), BannerPictureBase64: GetCurrentProfileBannerBase64(profile), Description: string.Empty, Tags: GetServerTagPayload())); } UiSharedService.AttachToolTip("Remove the description from your profile."); } private void DrawProfileTagsEditor(LightlessUserProfileData profile, float scale) { DrawTagEditor( scale, contextPrefix: "user", saveTooltip: "Apply the selected tags to your profile.", submitAction: payload => _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), BannerPictureBase64: GetCurrentProfileBannerBase64(profile), Description: null, Tags: payload)), allowReorder: true, sortPayloadBeforeSubmit: false); } private void DrawTagEditor( float scale, string contextPrefix, string saveTooltip, Func submitAction, bool allowReorder, bool sortPayloadBeforeSubmit, Action? onPayloadPrepared = null) { var tagLibrary = _profileTagService.GetTagLibrary(); if (tagLibrary.Count == 0) { ImGui.TextDisabled("No profile tags are available."); return; } var style = ImGui.GetStyle(); var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); var selectedCount = _tagEditorSelection.Count; ImGui.TextColored(UIColors.Get("LightlessBlue"), $"Selected Tags ({selectedCount}/{MaxProfileTags})"); int? tagToRemove = null; int? moveUpRequest = null; int? moveDownRequest = null; if (selectedCount == 0) { ImGui.TextDisabled("-- No tags selected --"); } else { var selectedFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchSame; var selectedTableId = $"##{contextPrefix}SelectedTagsTable"; var columnCount = allowReorder ? 3 : 2; if (ImGui.BeginTable(selectedTableId, columnCount, selectedFlags)) { ImGui.TableSetupColumn("Preview", ImGuiTableColumnFlags.WidthStretch, allowReorder ? 0.55f : 0.75f); if (allowReorder) ImGui.TableSetupColumn("##order", ImGuiTableColumnFlags.WidthStretch, 0.1f); ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 90f); ImGui.TableHeadersRow(); for (int i = 0; i < _tagEditorSelection.Count; i++) { var tagId = _tagEditorSelection[i]; if (!tagLibrary.TryGetValue(tagId, out var definition) || !definition.HasContent) continue; var displayName = GetTagDisplayName(definition, tagId); var idLabel = $"ID {tagId}"; var previewSize = ProfileTagRenderer.MeasureTag(definition, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); var textHeight = ImGui.CalcTextSize(displayName).Y + style.ItemSpacing.Y + ImGui.CalcTextSize(idLabel).Y; var rowHeight = MathF.Max(previewSize.Y + style.CellPadding.Y * 2f, textHeight + style.CellPadding.Y * 2f); ImGui.TableNextRow(ImGuiTableRowFlags.None, rowHeight); ImGui.TableNextColumn(); using (ImRaii.PushId($"{contextPrefix}-selected-tag-{tagId}-{i}")) DrawCenteredTagCell(definition, scale, previewSize, rowHeight, defaultTextColorU32); if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); ImGui.TextUnformatted(displayName); ImGui.TextDisabled(idLabel); ImGui.EndTooltip(); } if (allowReorder) { ImGui.TableNextColumn(); DrawReorderCell(contextPrefix, tagId, i, _tagEditorSelection.Count, rowHeight, scale, ref moveUpRequest, ref moveDownRequest); } ImGui.TableNextColumn(); DrawFullCellButton("Remove", UIColors.Get("DimRed"), ref tagToRemove, tagId, false, scale, rowHeight, $"{contextPrefix}-remove-{tagId}-{i}"); } ImGui.EndTable(); } } if (allowReorder) { if (moveUpRequest.HasValue && moveUpRequest.Value > 0) { var idx = moveUpRequest.Value; (_tagEditorSelection[idx - 1], _tagEditorSelection[idx]) = (_tagEditorSelection[idx], _tagEditorSelection[idx - 1]); } if (moveDownRequest.HasValue && moveDownRequest.Value < _tagEditorSelection.Count - 1) { var idx = moveDownRequest.Value; (_tagEditorSelection[idx], _tagEditorSelection[idx + 1]) = (_tagEditorSelection[idx + 1], _tagEditorSelection[idx]); } } if (tagToRemove.HasValue) _tagEditorSelection.Remove(tagToRemove.Value); bool limitReached = _tagEditorSelection.Count >= MaxProfileTags; if (limitReached) UiSharedService.ColorTextWrapped($"You have reached the maximum of {MaxProfileTags} tags. Remove one before adding more.", UIColors.Get("DimRed")); ImGui.Dummy(new Vector2(0f, 6f * scale)); ImGui.TextColored(UIColors.Get("LightlessPurple"), "Available Tags"); var availableIds = new List(tagLibrary.Count); var seenDefinitions = new HashSet(); foreach (var kvp in tagLibrary) { var definition = kvp.Value; if (!definition.HasContent) continue; if (!seenDefinitions.Add(definition)) continue; if (_tagEditorSelection.Contains(kvp.Key)) continue; availableIds.Add(kvp.Key); } availableIds.Sort(); int totalAvailable = availableIds.Count; if (totalAvailable == 0) { ImGui.TextDisabled("-- No additional tags available --"); } else { int pageCount = Math.Max(1, (totalAvailable + AvailableTagsPerPage - 1) / AvailableTagsPerPage); _availableTagPage = Math.Clamp(_availableTagPage, 0, pageCount - 1); int start = _availableTagPage * AvailableTagsPerPage; int end = Math.Min(totalAvailable, start + AvailableTagsPerPage); ImGui.SameLine(); ImGui.TextDisabled($"Page {_availableTagPage + 1}/{pageCount}"); ImGui.SameLine(); ImGui.BeginDisabled(_availableTagPage == 0); if (ImGui.SmallButton($"<##{contextPrefix}TagPagePrev")) _availableTagPage--; ImGui.EndDisabled(); ImGui.SameLine(); ImGui.BeginDisabled(_availableTagPage >= pageCount - 1); if (ImGui.SmallButton($">##{contextPrefix}TagPageNext")) _availableTagPage++; ImGui.EndDisabled(); var availableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchSame; int? tagToAdd = null; if (ImGui.BeginTable($"##{contextPrefix}AvailableTagsTable", 2, availableFlags)) { ImGui.TableSetupColumn("Preview", ImGuiTableColumnFlags.WidthStretch, 0.75f); ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 90f); ImGui.TableHeadersRow(); for (int idx = start; idx < end; idx++) { var tagId = availableIds[idx]; if (!tagLibrary.TryGetValue(tagId, out var definition) || !definition.HasContent) continue; var previewSize = ProfileTagRenderer.MeasureTag(definition, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); var rowHeight = previewSize.Y + style.CellPadding.Y * 2f; ImGui.TableNextRow(ImGuiTableRowFlags.None, rowHeight); ImGui.TableNextColumn(); using (ImRaii.PushId($"{contextPrefix}-available-tag-{tagId}")) DrawCenteredTagCell(definition, scale, previewSize, rowHeight, defaultTextColorU32); if (ImGui.IsItemHovered()) { var name = GetTagDisplayName(definition, tagId); ImGui.BeginTooltip(); ImGui.TextUnformatted(name); ImGui.TextDisabled($"ID {tagId}"); ImGui.EndTooltip(); } ImGui.TableNextColumn(); DrawFullCellButton("Add", UIColors.Get("LightlessGreen"), ref tagToAdd, tagId, limitReached, scale, rowHeight, $"{contextPrefix}-add-{tagId}"); } ImGui.EndTable(); } if (tagToAdd.HasValue) { _tagEditorSelection.Add(tagToAdd.Value); if (_availableTagPage > 0 && (totalAvailable - 1) <= start) _availableTagPage = Math.Max(0, _availableTagPage - 1); } } bool hasChanges = !TagsEqual(_tagEditorSelection, _profileTagIds); ImGui.Dummy(new Vector2(0f, 6f * scale)); if (!hasChanges) ImGui.BeginDisabled(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, $"Save Tags##{contextPrefix}")) { var payload = _tagEditorSelection.Count == 0 ? Array.Empty() : _tagEditorSelection.ToArray(); if (sortPayloadBeforeSubmit && payload.Length > 1) Array.Sort(payload); onPayloadPrepared?.Invoke(payload); _ = submitAction(payload); } if (!hasChanges) ImGui.EndDisabled(); UiSharedService.AttachToolTip(saveTooltip); } private void DrawProfileVisibilityControls(LightlessUserProfileData profile) { var isNsfw = profile.IsNSFW; if (DrawCheckboxRow("Mark profile as NSFW", isNsfw, out var newValue, "Enable when your profile could be considered NSFW.")) { _ = _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, newValue, ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), Description: null, BannerPictureBase64: GetCurrentProfileBannerBase64(profile), Tags: GetServerTagPayload())); } } private string? GetCurrentProfilePictureBase64(LightlessUserProfileData profile) { if (_queuedProfileImage is { Length: > 0 }) return Convert.ToBase64String(_queuedProfileImage); if (!string.IsNullOrWhiteSpace(profile.Base64ProfilePicture) && !string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) return profile.Base64ProfilePicture; return _profileImage.Length > 0 ? Convert.ToBase64String(_profileImage) : null; } private string? GetCurrentProfileBannerBase64(LightlessUserProfileData profile) { if (!string.IsNullOrWhiteSpace(profile.Base64BannerPicture) && !string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) return profile.Base64BannerPicture; return _bannerImage.Length > 0 ? Convert.ToBase64String(_bannerImage) : null; } private void DrawTagPreview(ProfileTagDefinition tag, float scale, string id) { var style = ImGui.GetStyle(); var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); ImGui.InvisibleButton(id, tagSize); var rectMin = ImGui.GetItemRectMin(); var drawList = ImGui.GetWindowDrawList(); ProfileTagRenderer.RenderTag(tag, rectMin, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); } private static bool IsSupportedImageFormat(IImageFormat? format) { if (format is null) return false; foreach (var ext in format.FileExtensions) { if (SupportedImageExtensions.Contains(ext)) return true; } return false; } private void DrawCenteredTagCell(ProfileTagDefinition tag, float scale, Vector2 tagSize, float rowHeight, uint defaultTextColorU32) { var style = ImGui.GetStyle(); var cellStart = ImGui.GetCursorPos(); var available = ImGui.GetContentRegionAvail(); var innerHeight = MathF.Max(0f, rowHeight - style.CellPadding.Y * 2f); var offsetX = MathF.Max(0f, (available.X - tagSize.X) * 0.5f); var offsetY = MathF.Max(0f, innerHeight - tagSize.Y) * 0.5f; ImGui.SetCursorPos(new Vector2(cellStart.X + offsetX, cellStart.Y + style.CellPadding.Y + offsetY)); ImGui.InvisibleButton("##tagPreview", tagSize); var rectMin = ImGui.GetItemRectMin(); var drawList = ImGui.GetWindowDrawList(); ProfileTagRenderer.RenderTag(tag, rectMin, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); } private void DrawInfoCell(string displayName, string idLabel, float rowHeight, ImGuiStylePtr style) { var cellStart = ImGui.GetCursorPos(); var nameSize = ImGui.CalcTextSize(displayName); var idSize = ImGui.CalcTextSize(idLabel); var totalHeight = nameSize.Y + style.ItemSpacing.Y + idSize.Y; var offsetY = MathF.Max(0f, (rowHeight - totalHeight) * 0.5f) - style.CellPadding.Y; if (offsetY < 0f) offsetY = 0f; ImGui.SetCursorPos(new Vector2(cellStart.X + style.CellPadding.X, cellStart.Y + offsetY)); ImGui.TextUnformatted(displayName); ImGui.SetCursorPos(new Vector2(cellStart.X + style.CellPadding.X, ImGui.GetCursorPosY() + style.ItemSpacing.Y)); ImGui.TextDisabled(idLabel); } private void DrawReorderCell( string contextPrefix, int tagId, int index, int count, float rowHeight, float scale, ref int? moveUpTarget, ref int? moveDownTarget) { var style = ImGui.GetStyle(); var cellStart = ImGui.GetCursorPos(); var availableWidth = ImGui.GetContentRegionAvail().X; var innerHeight = MathF.Max(0f, rowHeight - style.CellPadding.Y * 2f); var spacing = MathF.Min(style.ItemSpacing.Y * 0.5f, innerHeight * 0.15f); var buttonHeight = MathF.Max(1f, (innerHeight - spacing) * 0.5f); var width = MathF.Max(1f, availableWidth); var upColor = UIColors.Get("LightlessBlue"); using (ImRaii.PushId($"{contextPrefix}-order-{tagId}-{index}")) { ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y)); if (ColoredButton("\u2191##tagMoveUp", upColor, new Vector2(width, buttonHeight), scale, index == 0)) moveUpTarget = index; ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y + buttonHeight + spacing)); if (ColoredButton("\u2193##tagMoveDown", upColor, new Vector2(width, buttonHeight), scale, index >= count - 1)) moveDownTarget = index; } } private void DrawFullCellButton(string label, Vector4 baseColor, ref int? target, int tagId, bool disabled, float scale, float rowHeight, string idSuffix) { var style = ImGui.GetStyle(); var cellStart = ImGui.GetCursorPos(); var available = ImGui.GetContentRegionAvail(); var buttonHeight = MathF.Max(1f, rowHeight - style.CellPadding.Y * 2f); var hovered = BlendTowardsWhite(baseColor, 0.15f); var active = BlendTowardsWhite(baseColor, 0.3f); ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y)); using (ImRaii.PushId(idSuffix)) { if (ColoredButton(label, baseColor, new Vector2(MathF.Max(available.X, 1f), buttonHeight), scale, disabled)) target = tagId; } } private bool ColoredButton(string label, Vector4 baseColor, Vector2 size, float scale, bool disabled) { var style = ImGui.GetStyle(); var hovered = BlendTowardsWhite(baseColor, 0.15f); var active = BlendTowardsWhite(baseColor, 0.3f); ImGui.PushStyleColor(ImGuiCol.Button, baseColor); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, hovered); ImGui.PushStyleColor(ImGuiCol.ButtonActive, active); ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, style.FrameRounding); ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(MathF.Max(2f, style.FramePadding.X), MathF.Max(1f, style.FramePadding.Y * 0.3f)) * scale); ImGui.BeginDisabled(disabled); bool clicked = ImGui.Button(label, size); ImGui.EndDisabled(); ImGui.PopStyleVar(2); ImGui.PopStyleColor(3); return clicked; } private static float Clamp01(float value) => value < 0f ? 0f : value > 1f ? 1f : value; private static Vector4 BlendTowardsWhite(Vector4 color, float amount) { var result = Vector4.Lerp(color, Vector4.One, Clamp01(amount)); result.W = color.W; return result; } private static string GetTagDisplayName(ProfileTagDefinition tag, int tagId) { if (!string.IsNullOrWhiteSpace(tag.Text)) return tag.Text!; if (!string.IsNullOrWhiteSpace(tag.SeStringPayload)) { var stripped = SeStringUtils.StripMarkup(tag.SeStringPayload!); if (!string.IsNullOrWhiteSpace(stripped)) return stripped; } return $"Tag {tagId}"; } private IDalamudTextureWrap? ResolveIconWrap(uint iconId) { if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) return wrap; return null; } private int[] GetServerTagPayload() { if (_profileTagIds.Length == 0) return Array.Empty(); var copy = new int[_profileTagIds.Length]; Array.Copy(_profileTagIds, copy, _profileTagIds.Length); return copy; } private static bool TagsEqual(IReadOnlyList? current, IReadOnlyList? reference) { if (ReferenceEquals(current, reference)) return true; if (current is null || reference is null) return false; if (current.Count != reference.Count) return false; for (int i = 0; i < current.Count; i++) { if (current[i] != reference[i]) return false; } return true; } private void DrawVanityTabContent(float scale) { DrawSection("Colored UID", scale, () => { var hasVanity = _apiController.HasVanity; if (!hasVanity) { UiSharedService.ColorTextWrapped("You do not currently have vanity access. Become a supporter to unlock these features. (If you already are, interact with the bot to update)", UIColors.Get("DimRed")); } var monoFont = UiBuilder.MonoFont; using (ImRaii.PushFont(monoFont)) { var previewTextColor = textEnabled ? textColor : Vector4.One; var previewGlowColor = glowEnabled ? glowColor : Vector4.Zero; var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.DisplayName, previewTextColor, previewGlowColor); var drawList = ImGui.GetWindowDrawList(); var textSize = ImGui.CalcTextSize(seString.TextValue); float minWidth = 160f * ImGuiHelpers.GlobalScale; float bgWidth = Math.Max(textSize.X + 20f * ImGuiHelpers.GlobalScale, minWidth); float paddingY = 5f * ImGuiHelpers.GlobalScale; var cursor = ImGui.GetCursorScreenPos(); var rectMin = cursor; var rectMax = rectMin + new Vector2(bgWidth, textSize.Y + paddingY * 2f); float boost = Luminance.ComputeHighlight(previewTextColor, previewGlowColor); var baseBg = new Vector4(0.15f + boost, 0.15f + boost, 0.15f + boost, 1f); var bgColor = Luminance.BackgroundContrast(previewTextColor, previewGlowColor, baseBg, ref _currentBg); var borderColor = UIColors.Get("LightlessPurple"); drawList.AddRectFilled(rectMin, rectMax, ImGui.GetColorU32(bgColor), 5f); drawList.AddRect(rectMin, rectMax, ImGui.GetColorU32(borderColor), 5f, ImDrawFlags.None, 1.2f); var textPos = new Vector2(rectMin.X + (bgWidth - textSize.X) * 0.5f, rectMin.Y + paddingY); SeStringUtils.RenderSeStringWithHitbox(seString, textPos, monoFont); ImGui.Dummy(new Vector2(0f, 1.5f)); } ImGui.TextColored(UIColors.Get("LightlessPurple"), "Colors"); if (!hasVanity) ImGui.BeginDisabled(); if (DrawCheckboxRow("Enable custom text color", textEnabled, out var newTextEnabled)) textEnabled = newTextEnabled; ImGui.SameLine(); ImGui.BeginDisabled(!textEnabled); ImGui.ColorEdit4("Text Color##vanityTextColor", ref textColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); ImGui.EndDisabled(); if (DrawCheckboxRow("Enable glow color", glowEnabled, out var newGlowEnabled)) glowEnabled = newGlowEnabled; ImGui.SameLine(); ImGui.BeginDisabled(!glowEnabled); ImGui.ColorEdit4("Glow Color##vanityGlowColor", ref glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); ImGui.EndDisabled(); bool changed = !Equals(_savedVanity, new VanityState(textEnabled, glowEnabled, textColor, glowColor)); if (!changed) ImGui.BeginDisabled(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Vanity Changes")) { string? newText = textEnabled ? UIColors.RgbaToHex(textColor) : string.Empty; string? newGlow = glowEnabled ? UIColors.RgbaToHex(glowColor) : string.Empty; _ = _apiController.UserUpdateVanityColors(new UserVanityColorsDto(newText, newGlow)); _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); } if (!changed) ImGui.EndDisabled(); if (!hasVanity) ImGui.EndDisabled(); }); } private void DrawSection(string title, float scale, Action body) { ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6f, 4f) * scale); var flags = ImGuiTreeNodeFlags.SpanFullWidth | ImGuiTreeNodeFlags.Framed | ImGuiTreeNodeFlags.AllowItemOverlap | ImGuiTreeNodeFlags.DefaultOpen; var open = ImGui.CollapsingHeader(title, flags); ImGui.PopStyleVar(); if (open) { ImGui.Dummy(new Vector2(0f, 3f * scale)); body(); ImGui.Dummy(new Vector2(0f, 2f * scale)); } } private bool DrawCheckboxRow(string label, bool currentValue, out bool newValue, string? tooltip = null) { bool value = currentValue; bool changed = UiSharedService.CheckboxWithBorder(label, ref value, UIColors.Get("LightlessPurple"), 1.5f); if (!string.IsNullOrEmpty(tooltip)) UiSharedService.AttachToolTip(tooltip); newValue = value; return changed; } private void SyncProfileState(LightlessUserProfileData profile) { if (string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) return; var profileBytes = profile.ImageData.Value; if (_pfpTextureWrap == null || !_profileImage.SequenceEqual(profileBytes)) { _profileImage = profileBytes; _pfpTextureWrap?.Dispose(); _pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null; _queuedProfileImage = null; } var bannerBytes = profile.BannerImageData.Value; if (_bannerTextureWrap == null || !_bannerImage.SequenceEqual(bannerBytes)) { _bannerImage = bannerBytes; _bannerTextureWrap?.Dispose(); _bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null; } if (!string.Equals(_profileDescription, profile.Description, StringComparison.Ordinal)) { _profileDescription = profile.Description; _descriptionText = _profileDescription; } var serverTags = profile.Tags ?? Array.Empty(); if (!TagsEqual(serverTags, _profileTagIds)) { var previous = _profileTagIds; _profileTagIds = serverTags.Count == 0 ? Array.Empty() : serverTags.ToArray(); if (TagsEqual(_tagEditorSelection, previous)) { _tagEditorSelection.Clear(); if (_profileTagIds.Length > 0) _tagEditorSelection.AddRange(_profileTagIds); } } } private static bool IsWindowBeingDragged() { return ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows) && ImGui.GetIO().MouseDown[0]; } protected override void Dispose(bool disposing) { base.Dispose(disposing); _pfpTextureWrap?.Dispose(); _bannerTextureWrap?.Dispose(); } }