diff --git a/src/Beacon.Plugin/Services/ProfileService.cs b/src/Beacon.Plugin/Services/ProfileService.cs index cbd9172..2f25870 100644 --- a/src/Beacon.Plugin/Services/ProfileService.cs +++ b/src/Beacon.Plugin/Services/ProfileService.cs @@ -296,6 +296,7 @@ public void Save(SaveProfileRequest request, byte[]? portrait, string? fileName, if (portrait is { Length: > 0 }) { var previousImages = profile.Gallery.Select(image => image.ImageId).ToHashSet(); + var replacing = profile.PortraitImageId; var uploaded = await api.UploadPortraitAsync(profile.Id, portrait, fileName ?? "portrait.png", lifetime.Token); if (uploaded.Ok && uploaded.Value is { } withImage) { @@ -320,7 +321,27 @@ public void Save(SaveProfileRequest request, byte[]? portrait, string? fileName, lifetime.Token); if (selected.Ok && selected.Value is { } withPortrait) + { profile = withPortrait; + + // Retire the likeness this one replaces. + // + // Without this, changing your portrait quietly stacks another image into a + // gallery that holds eight, and the ninth change fails outright with an + // error about a gallery the player never chose to fill. A picture added on + // purpose from the gallery is never the portrait unless it was picked, so + // only discarded portraits are swept up here. + if (replacing is { } previousPortrait && previousPortrait != newImage.ImageId) + { + var removed = await api.RemoveGalleryImageAsync( + profile.Id, + previousPortrait, + lifetime.Token); + + if (removed.Ok && removed.Value is { } tidied) + profile = tidied; + } + } else Post(() => LastError = $"The picture uploaded, but could not be selected as the portrait: {selected.Error}"); } diff --git a/src/Beacon.Plugin/UI/ProfileCard.cs b/src/Beacon.Plugin/UI/ProfileCard.cs new file mode 100644 index 0000000..4aa5171 --- /dev/null +++ b/src/Beacon.Plugin/UI/ProfileCard.cs @@ -0,0 +1,448 @@ +using System.Numerics; +using Beacon.Services; +using Beacon.Shared.Profiles; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Interface.Utility; + +namespace Beacon.UI; + +/// +/// One roleplay card, drawn the way everyone else sees it. +/// +/// This lives apart from the Chronicle because the editor needs to draw the same thing. A preview +/// that reimplements the card is a preview that lies the moment either copy changes -- which is +/// exactly what happened to the completeness meter, which kept its own duplicate of the rules and +/// silently stopped agreeing with them. +/// +/// It draws the card and nothing else. The buttons underneath -- travel, edit, report -- belong to +/// whoever is showing the card, not to the card. +/// +public sealed class ProfileCard(ImageCache images) +{ + /// Set while the reader has asked for the long prose. + private bool showLongProse; + + /// The gallery the reader has opened, if any. + public Guid? ViewingGallery { get; set; } + + /// + /// A portrait chosen but not yet uploaded, so the editor's preview shows the picture being + /// considered rather than the one it is about to replace. + /// + public IDalamudTextureWrap? PendingPortrait { get; set; } + + /// The card most recently drawn, so the gallery popup has something to show. + private ProfileDto? shown; + + /// Draws the card face. + public void Draw(ProfileDto profile, float scale) + { + shown = profile; + DrawCardContents(profile, scale); + } + + private void DrawCardContents(ProfileDto profile, float scale) + { + var portraitSize = 104f * scale; + + DrawPortrait(profile, portraitSize, scale); + + ImGui.SameLine(0, 14f * scale); + ImGui.BeginGroup(); + + Ornament.Text(Theme.Ink, profile.Identity.Name); + + if (!string.IsNullOrWhiteSpace(profile.Identity.Title)) + Ornament.Text(Theme.Wax, $"“{profile.Identity.Title}”"); + + var details = new List(); + if (!string.IsNullOrWhiteSpace(profile.Identity.Lineage)) + details.Add(profile.Identity.Lineage); + + if (profile.Identity.Age is { } age) + details.Add($"Age {age}"); + + if (details.Count > 0) + Ornament.Text(Theme.InkSoft, string.Join(" · ", details)); + + if (profile.Identity.Archetype.Count > 0) + { + ImGui.Spacing(); + foreach (var word in profile.Identity.Archetype) + { + Ornament.Tag(word, Theme.ParchmentRule, Theme.Ink); + ImGui.SameLine(0, 4f * scale); + } + + ImGui.NewLine(); + } + + if (!string.IsNullOrWhiteSpace(profile.Identity.Quote)) + { + ImGui.Spacing(); + Ornament.TextWrapped(Theme.InkSoft, $"“{profile.Identity.Quote}”"); + } + + ImGui.EndGroup(); + + Ornament.FleuronDivider(Theme.ParchmentRule, 4f); + + DrawPresence(profile, scale); + + DrawMoment(profile, scale); + + DrawAtFirstGlance(profile, scale); + + if (profile.Personality.Count > 0) + { + Ornament.PageLabel("PERSONALITY"); + foreach (var trait in profile.Personality) + { + Ornament.Tag(ProfileLabels.Describe(trait), Theme.Brass, Theme.Ink); + ImGui.SameLine(0, 4f * scale); + } + + ImGui.NewLine(); + ImGui.Spacing(); + } + + if (profile.Hooks.Count > 0) + { + Ornament.PageLabel("WHY YOU MIGHT APPROACH THEM"); + + foreach (var hook in profile.Hooks) + { + var cursor = ImGui.GetCursorScreenPos(); + ImGui.GetWindowDrawList().AddTriangleFilled( + new Vector2(cursor.X + (2f * scale), cursor.Y + (4f * scale)), + new Vector2(cursor.X + (7f * scale), cursor.Y + (8f * scale)), + new Vector2(cursor.X + (2f * scale), cursor.Y + (12f * scale)), + Theme.Wax.Packed()); + + ImGui.Indent(14f * scale); + Ornament.TextWrapped(Theme.InkSoft, hook.Text); + ImGui.Unindent(14f * scale); + } + + ImGui.Spacing(); + } + + DrawStyle(profile); + + if (!string.IsNullOrWhiteSpace(profile.Overview)) + { + Ornament.PageLabel("IN BRIEF"); + Ornament.TextWrapped(Theme.InkSoft, profile.Overview!); + ImGui.Spacing(); + } + + DrawLongProse(profile); + DrawLinks(profile); + } + + /// + /// The part of the card that is true today: the live line, the IC/OOC flag, and the player's own + /// note. + /// + /// The age of the live line is shown rather than hidden. A "currently" written three weeks ago is + /// not current, and presenting it as though it were is the exact failure that makes people stop + /// trusting the field at all. + /// + private void DrawMoment(ProfileDto profile, float scale) + { + var moment = profile.Moment; + if (!moment.HasAnything) + return; + + if (moment.Stance is not RpStance.Unstated) + { + var inCharacter = moment.Stance is RpStance.InCharacter; + Ornament.Tag( + inCharacter ? "In character" : "Out of character", + inCharacter ? Theme.Wax : Theme.Brass, + Theme.Ink); + + ImGui.Spacing(); + } + + if (!string.IsNullOrWhiteSpace(moment.Currently)) + { + Ornament.PageLabel("CURRENTLY"); + Ornament.TextWrapped(Theme.Ink, moment.Currently!); + + if (moment.UpdatedAt is { } when) + Ornament.Text(moment.IsFresh ? Theme.InkFaint : Theme.Wax, Ornament.Ago(when)); + + ImGui.Spacing(); + } + + if (!string.IsNullOrWhiteSpace(moment.OutOfCharacter)) + { + Ornament.PageLabel("OUT OF CHARACTER"); + Ornament.TextWrapped(Theme.InkSoft, moment.OutOfCharacter!); + ImGui.Spacing(); + } + } + + /// What a stranger notices before a word is exchanged. + private void DrawAtFirstGlance(ProfileDto profile, float scale) + { + if (profile.AtFirstGlance.Count == 0) + return; + + Ornament.PageLabel("AT FIRST GLANCE"); + + foreach (var note in profile.AtFirstGlance) + { + if (note.Label.Length > 0) + { + Ornament.Text(Theme.InkFaint, note.Label); + ImGui.SameLine(0, 6f * scale); + } + + Ornament.TextWrapped(Theme.InkSoft, note.Text); + } + + ImGui.Spacing(); + } + + private void DrawPortrait(ProfileDto profile, float size, float scale) + { + ImGui.BeginGroup(); + + var origin = ImGui.GetCursorScreenPos(); + var draw = ImGui.GetWindowDrawList(); + var box = new Vector2(origin.X + size, origin.Y + (size * 1.25f)); + + var portrait = PendingPortrait + ?? (profile.HasPortrait ? images.Get(profile.PortraitImageId!.Value, thumb: false) : null); + + if (portrait is not null) + { + draw.AddImage(portrait.Handle, origin, box); + } + else if (profile.HasPortrait) + { + // Fetching. Say so, rather than showing an empty well that reads as "the picture is gone". + draw.AddRectFilled(origin, box, Theme.ParchmentShade.Packed()); + const string Fetching = "Loading..."; + var fetchingSize = ImGui.CalcTextSize(Fetching); + draw.AddText( + new Vector2(origin.X + ((size - fetchingSize.X) / 2f), origin.Y + ((size * 1.25f - fetchingSize.Y) / 2f)), + Theme.InkFaint.Packed(), + Fetching); + } + else + { + draw.AddRectFilled(origin, box, Theme.ParchmentShade.Packed()); + const string Label = "No likeness"; + var textSize = ImGui.CalcTextSize(Label); + draw.AddText( + new Vector2(origin.X + ((size - textSize.X) / 2f), origin.Y + ((size * 1.25f - textSize.Y) / 2f)), + Theme.InkFaint.Packed(), + Label); + } + + draw.AddRect(origin, box, Theme.Brass.Packed(), 0f, ImDrawFlags.None, 1.4f * scale); + + ImGui.Dummy(new Vector2(size, size * 1.25f)); + + if (profile.Gallery.Count > 1) + { + if (ImGui.SmallButton($"Gallery ({profile.Gallery.Count})##gal{profile.Id}")) + ViewingGallery = profile.Id; + } + + ImGui.EndGroup(); + } + + private void DrawPresence(ProfileDto profile, float scale) + { + var presence = profile.Presence; + var open = presence.IsOpen; + + ImGui.PushStyleColor(ImGuiCol.ChildBg, Theme.ParchmentShade); + + if (ImGui.BeginChild("##presence", new Vector2(0, ImGui.GetTextLineHeightWithSpacing() * 2.3f), false)) + { + var cursor = ImGui.GetCursorScreenPos(); + ImGui.GetWindowDrawList().AddCircleFilled( + new Vector2(cursor.X + (5f * scale), cursor.Y + (ImGui.GetTextLineHeight() / 2f)), + 4.5f * scale, + (open ? Theme.VerdigrisInk : Theme.InkFaint).Packed(), + 12); + + ImGui.Indent(16f * scale); + Ornament.Text(open ? Theme.VerdigrisInk : Theme.InkFaint, ProfileLabels.Describe(presence.State)); + + if (open && !string.IsNullOrWhiteSpace(presence.BeaconName)) + { + var left = presence.LitUntil is { } until + ? $" · {Ornament.Remaining(until - DateTimeOffset.UtcNow)}" + : string.Empty; + + Ornament.Text(Theme.InkSoft, $"At {presence.BeaconName} · {presence.ZoneName}{left}"); + } + else + { + Ornament.Text(Theme.InkFaint, ProfileLabels.LastSeen(presence.LastActiveAt)); + } + + ImGui.Unindent(16f * scale); + } + + ImGui.EndChild(); + ImGui.PopStyleColor(); + ImGui.Spacing(); + } + + private void DrawStyle(ProfileDto profile) + { + var style = profile.Style; + + Ornament.PageLabel("HOW THEY PLAY"); + + var parts = new List { ProfileLabels.Describe(style.Length) }; + + if (style.Tones.Count > 0) + parts.Add(string.Join(", ", style.Tones.Select(ProfileLabels.Describe))); + + if (style.Activities.Count > 0) + parts.Add(string.Join(", ", style.Activities.Select(ProfileLabels.Describe))); + + Ornament.TextWrapped(Theme.InkSoft, string.Join(" · ", parts)); + + // Boundaries sit on the face of the card, not three clicks down. Somebody who finds out after + // the fact has already had the interaction that makes people stop using a plugin. + var notes = new List { style.WalkupsWelcome ? "Walk-ups welcome" : "Ask before approaching" }; + + if (!string.IsNullOrWhiteSpace(style.Boundaries)) + notes.Add(style.Boundaries!); + + Ornament.TextWrapped(Theme.Wax, string.Join(" · ", notes)); + + if (style.MatureThemes.Count > 0) + { + // Phrased as willingness, matching how it was offered in the editor. "Open to" reads very + // differently from "looking for", and only one of those is what this field means. + Ornament.TextWrapped( + Theme.Wax, + "Open to: " + string.Join(", ", style.MatureThemes.Select(ProfileLabels.Describe))); + } + + ImGui.Spacing(); + } + + private void DrawLongProse(ProfileDto profile) + { + var hasProse = !string.IsNullOrWhiteSpace(profile.History) || !string.IsNullOrWhiteSpace(profile.Goals); + if (!hasProse) + return; + + if (ImGui.SmallButton(showLongProse ? "Less" : "Read their chronicle")) + showLongProse = !showLongProse; + + if (!showLongProse) + return; + + if (!string.IsNullOrWhiteSpace(profile.Goals)) + { + Ornament.PageLabel("WHAT THEY WANT"); + Ornament.TextWrapped(Theme.InkSoft, profile.Goals!); + } + + if (!string.IsNullOrWhiteSpace(profile.History)) + { + Ornament.PageLabel("HISTORY"); + Ornament.TextWrapped(Theme.InkSoft, profile.History!); + } + + ImGui.Spacing(); + } + + private void DrawLinks(ProfileDto profile) + { + if (profile.Links.Count == 0) + return; + + Ornament.PageLabel("TIES"); + + foreach (var link in profile.Links) + { + var note = string.IsNullOrWhiteSpace(link.Note) ? string.Empty : $" — {link.Note}"; + var claim = link.Confirmed ? string.Empty : " (unconfirmed)"; + + Ornament.Text( + link.Confirmed ? Theme.InkSoft : Theme.InkFaint, + $"{ProfileLabels.Describe(link.Kind)}: {link.OtherName}{note}{claim}"); + } + + ImGui.Spacing(); + } + + /// The gallery, shown over the card when the reader opens it. + public void DrawGalleryPopup() + { + if (ViewingGallery is not { } id) + return; + + var profile = shown; + if (profile is null || profile.Id != id) + { + ViewingGallery = null; + return; + } + + ImGui.OpenPopup("Gallery###BeaconGallery"); + + var open = true; + ImGui.SetNextWindowSizeConstraints(new Vector2(420, 300), new Vector2(1400, 1000)); + + if (!ImGui.BeginPopupModal("Gallery###BeaconGallery", ref open, ImGuiWindowFlags.None)) + { + ViewingGallery = null; + return; + } + + if (!open) + { + ViewingGallery = null; + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + return; + } + + var scale = ImGuiHelpers.GlobalScale; + var width = ImGui.GetContentRegionAvail().X; + + foreach (var image in profile.Gallery) + { + Ornament.Text(Theme.BrassBright, ProfileLabels.Describe(image.Category)); + + if (images.Get(image.ImageId, thumb: false) is { } texture) + { + var drawWidth = Math.Min(width, 520f * scale); + var drawHeight = drawWidth * texture.Height / Math.Max(1f, texture.Width); + ImGui.Image(texture.Handle, new Vector2(drawWidth, drawHeight)); + } + else + { + Ornament.Text(Theme.MutedDeep, "Loading..."); + } + + if (!string.IsNullOrWhiteSpace(image.Caption)) + Ornament.TextWrapped(Theme.Muted, image.Caption!); + + Ornament.FleuronDivider(Theme.BrassDim); + } + + if (ImGui.Button("Close")) + { + ViewingGallery = null; + ImGui.CloseCurrentPopup(); + } + + ImGui.EndPopup(); + } +} diff --git a/src/Beacon.Plugin/Windows/ChronicleWindow.cs b/src/Beacon.Plugin/Windows/ChronicleWindow.cs index b4654f7..d96199c 100644 --- a/src/Beacon.Plugin/Windows/ChronicleWindow.cs +++ b/src/Beacon.Plugin/Windows/ChronicleWindow.cs @@ -36,9 +36,8 @@ public sealed class ChronicleWindow : Window private string searchText = string.Empty; - private Guid? viewingGallery; - - private bool showLongProse; + /// The card face, shared with the editor's preview so the two cannot drift. + private readonly ProfileCard card; public ChronicleWindow( Configuration config, @@ -58,6 +57,7 @@ public ChronicleWindow( this.location = location; this.images = images; this.notifications = notifications; + card = new ProfileCard(images); this.openEditor = openEditor; SizeConstraints = new WindowSizeConstraints @@ -101,7 +101,7 @@ public override void Draw() ImGui.EndChild(); DrawStatusBar(); - DrawGalleryPopup(); + card.DrawGalleryPopup(); } // --- Filters --------------------------------------------------------- @@ -406,342 +406,18 @@ private void DrawCard(ProfileDto? profile) 42f * scale, Theme.Ink.Fade(0.09f)); - DrawCardContents(profile, scale); - } - - ImGui.EndChild(); - - ImGui.PopStyleVar(); - Theme.PopPage(); - } - - private void DrawCardContents(ProfileDto profile, float scale) - { - var portraitSize = 104f * scale; - - DrawPortrait(profile, portraitSize, scale); - - ImGui.SameLine(0, 14f * scale); - ImGui.BeginGroup(); - - Ornament.Text(Theme.Ink, profile.Identity.Name); - - if (!string.IsNullOrWhiteSpace(profile.Identity.Title)) - Ornament.Text(Theme.Wax, $"“{profile.Identity.Title}”"); - - var details = new List(); - if (!string.IsNullOrWhiteSpace(profile.Identity.Lineage)) - details.Add(profile.Identity.Lineage); - - if (profile.Identity.Age is { } age) - details.Add($"Age {age}"); - - if (details.Count > 0) - Ornament.Text(Theme.InkSoft, string.Join(" · ", details)); - - if (profile.Identity.Archetype.Count > 0) - { - ImGui.Spacing(); - foreach (var word in profile.Identity.Archetype) - { - Ornament.Tag(word, Theme.ParchmentRule, Theme.Ink); - ImGui.SameLine(0, 4f * scale); - } - - ImGui.NewLine(); - } - - if (!string.IsNullOrWhiteSpace(profile.Identity.Quote)) - { - ImGui.Spacing(); - Ornament.TextWrapped(Theme.InkSoft, $"“{profile.Identity.Quote}”"); - } - - ImGui.EndGroup(); - - Ornament.FleuronDivider(Theme.ParchmentRule, 4f); - - DrawPresence(profile, scale); - - DrawMoment(profile, scale); - - DrawAtFirstGlance(profile, scale); - - if (profile.Personality.Count > 0) - { - Ornament.PageLabel("PERSONALITY"); - foreach (var trait in profile.Personality) - { - Ornament.Tag(ProfileLabels.Describe(trait), Theme.Brass, Theme.Ink); - ImGui.SameLine(0, 4f * scale); - } - - ImGui.NewLine(); - ImGui.Spacing(); - } - - if (profile.Hooks.Count > 0) - { - Ornament.PageLabel("WHY YOU MIGHT APPROACH THEM"); - - foreach (var hook in profile.Hooks) - { - var cursor = ImGui.GetCursorScreenPos(); - ImGui.GetWindowDrawList().AddTriangleFilled( - new Vector2(cursor.X + (2f * scale), cursor.Y + (4f * scale)), - new Vector2(cursor.X + (7f * scale), cursor.Y + (8f * scale)), - new Vector2(cursor.X + (2f * scale), cursor.Y + (12f * scale)), - Theme.Wax.Packed()); - - ImGui.Indent(14f * scale); - Ornament.TextWrapped(Theme.InkSoft, hook.Text); - ImGui.Unindent(14f * scale); - } - - ImGui.Spacing(); - } - - DrawStyle(profile); + card.Draw(profile, scale); - if (!string.IsNullOrWhiteSpace(profile.Overview)) - { - Ornament.PageLabel("IN BRIEF"); - Ornament.TextWrapped(Theme.InkSoft, profile.Overview!); - ImGui.Spacing(); - } - - DrawLongProse(profile); - DrawLinks(profile); - - ImGui.Spacing(); - DrawActions(profile, scale); - } - - /// - /// The part of the card that is true today: the live line, the IC/OOC flag, and the player's own - /// note. - /// - /// The age of the live line is shown rather than hidden. A "currently" written three weeks ago is - /// not current, and presenting it as though it were is the exact failure that makes people stop - /// trusting the field at all. - /// - private void DrawMoment(ProfileDto profile, float scale) - { - var moment = profile.Moment; - if (!moment.HasAnything) - return; - - if (moment.Stance is not RpStance.Unstated) - { - var inCharacter = moment.Stance is RpStance.InCharacter; - Ornament.Tag( - inCharacter ? "In character" : "Out of character", - inCharacter ? Theme.Wax : Theme.Brass, - Theme.Ink); - - ImGui.Spacing(); - } - - if (!string.IsNullOrWhiteSpace(moment.Currently)) - { - Ornament.PageLabel("CURRENTLY"); - Ornament.TextWrapped(Theme.Ink, moment.Currently!); - - if (moment.UpdatedAt is { } when) - Ornament.Text(moment.IsFresh ? Theme.InkFaint : Theme.Wax, Ornament.Ago(when)); - - ImGui.Spacing(); - } - - if (!string.IsNullOrWhiteSpace(moment.OutOfCharacter)) - { - Ornament.PageLabel("OUT OF CHARACTER"); - Ornament.TextWrapped(Theme.InkSoft, moment.OutOfCharacter!); ImGui.Spacing(); - } - } - - /// What a stranger notices before a word is exchanged. - private void DrawAtFirstGlance(ProfileDto profile, float scale) - { - if (profile.AtFirstGlance.Count == 0) - return; - - Ornament.PageLabel("AT FIRST GLANCE"); - - foreach (var note in profile.AtFirstGlance) - { - if (note.Label.Length > 0) - { - Ornament.Text(Theme.InkFaint, note.Label); - ImGui.SameLine(0, 6f * scale); - } - - Ornament.TextWrapped(Theme.InkSoft, note.Text); - } - - ImGui.Spacing(); - } - - private void DrawPortrait(ProfileDto profile, float size, float scale) - { - ImGui.BeginGroup(); - - var origin = ImGui.GetCursorScreenPos(); - var draw = ImGui.GetWindowDrawList(); - var box = new Vector2(origin.X + size, origin.Y + (size * 1.25f)); - - if (profile.HasPortrait && images.Get(profile.PortraitImageId!.Value, thumb: false) is { } texture) - { - draw.AddImage(texture.Handle, origin, box); - } - else - { - draw.AddRectFilled(origin, box, Theme.ParchmentShade.Packed()); - const string Label = "No likeness"; - var textSize = ImGui.CalcTextSize(Label); - draw.AddText( - new Vector2(origin.X + ((size - textSize.X) / 2f), origin.Y + ((size * 1.25f - textSize.Y) / 2f)), - Theme.InkFaint.Packed(), - Label); - } - - draw.AddRect(origin, box, Theme.Brass.Packed(), 0f, ImDrawFlags.None, 1.4f * scale); - - ImGui.Dummy(new Vector2(size, size * 1.25f)); - - if (profile.Gallery.Count > 1) - { - if (ImGui.SmallButton($"Gallery ({profile.Gallery.Count})##gal{profile.Id}")) - viewingGallery = profile.Id; - } - - ImGui.EndGroup(); - } - - private void DrawPresence(ProfileDto profile, float scale) - { - var presence = profile.Presence; - var open = presence.IsOpen; - - ImGui.PushStyleColor(ImGuiCol.ChildBg, Theme.ParchmentShade); - - if (ImGui.BeginChild("##presence", new Vector2(0, ImGui.GetTextLineHeightWithSpacing() * 2.3f), false)) - { - var cursor = ImGui.GetCursorScreenPos(); - ImGui.GetWindowDrawList().AddCircleFilled( - new Vector2(cursor.X + (5f * scale), cursor.Y + (ImGui.GetTextLineHeight() / 2f)), - 4.5f * scale, - (open ? Theme.VerdigrisInk : Theme.InkFaint).Packed(), - 12); - - ImGui.Indent(16f * scale); - Ornament.Text(open ? Theme.VerdigrisInk : Theme.InkFaint, ProfileLabels.Describe(presence.State)); - - if (open && !string.IsNullOrWhiteSpace(presence.BeaconName)) - { - var left = presence.LitUntil is { } until - ? $" · {Ornament.Remaining(until - DateTimeOffset.UtcNow)}" - : string.Empty; - - Ornament.Text(Theme.InkSoft, $"At {presence.BeaconName} · {presence.ZoneName}{left}"); - } - else - { - Ornament.Text(Theme.InkFaint, ProfileLabels.LastSeen(presence.LastActiveAt)); - } - - ImGui.Unindent(16f * scale); + DrawActions(profile, scale); } ImGui.EndChild(); - ImGui.PopStyleColor(); - ImGui.Spacing(); - } - - private void DrawStyle(ProfileDto profile) - { - var style = profile.Style; - - Ornament.PageLabel("HOW THEY PLAY"); - - var parts = new List { ProfileLabels.Describe(style.Length) }; - - if (style.Tones.Count > 0) - parts.Add(string.Join(", ", style.Tones.Select(ProfileLabels.Describe))); - - if (style.Activities.Count > 0) - parts.Add(string.Join(", ", style.Activities.Select(ProfileLabels.Describe))); - - Ornament.TextWrapped(Theme.InkSoft, string.Join(" · ", parts)); - - // Boundaries sit on the face of the card, not three clicks down. Somebody who finds out after - // the fact has already had the interaction that makes people stop using a plugin. - var notes = new List { style.WalkupsWelcome ? "Walk-ups welcome" : "Ask before approaching" }; - - if (!string.IsNullOrWhiteSpace(style.Boundaries)) - notes.Add(style.Boundaries!); - - Ornament.TextWrapped(Theme.Wax, string.Join(" · ", notes)); - - if (style.MatureThemes.Count > 0) - { - // Phrased as willingness, matching how it was offered in the editor. "Open to" reads very - // differently from "looking for", and only one of those is what this field means. - Ornament.TextWrapped( - Theme.Wax, - "Open to: " + string.Join(", ", style.MatureThemes.Select(ProfileLabels.Describe))); - } - - ImGui.Spacing(); - } - - private void DrawLongProse(ProfileDto profile) - { - var hasProse = !string.IsNullOrWhiteSpace(profile.History) || !string.IsNullOrWhiteSpace(profile.Goals); - if (!hasProse) - return; - - if (ImGui.SmallButton(showLongProse ? "Less" : "Read their chronicle")) - showLongProse = !showLongProse; - - if (!showLongProse) - return; - - if (!string.IsNullOrWhiteSpace(profile.Goals)) - { - Ornament.PageLabel("WHAT THEY WANT"); - Ornament.TextWrapped(Theme.InkSoft, profile.Goals!); - } - - if (!string.IsNullOrWhiteSpace(profile.History)) - { - Ornament.PageLabel("HISTORY"); - Ornament.TextWrapped(Theme.InkSoft, profile.History!); - } - ImGui.Spacing(); + ImGui.PopStyleVar(); + Theme.PopPage(); } - private void DrawLinks(ProfileDto profile) - { - if (profile.Links.Count == 0) - return; - - Ornament.PageLabel("TIES"); - - foreach (var link in profile.Links) - { - var note = string.IsNullOrWhiteSpace(link.Note) ? string.Empty : $" — {link.Note}"; - var claim = link.Confirmed ? string.Empty : " (unconfirmed)"; - - Ornament.Text( - link.Confirmed ? Theme.InkSoft : Theme.InkFaint, - $"{ProfileLabels.Describe(link.Kind)}: {link.OtherName}{note}{claim}"); - } - - ImGui.Spacing(); - } private void DrawActions(ProfileDto profile, float scale) { @@ -781,72 +457,6 @@ private void DrawActions(ProfileDto profile, float scale) // --- Gallery --------------------------------------------------------- - private void DrawGalleryPopup() - { - if (viewingGallery is not { } id) - return; - - var profile = profiles.Selected; - if (profile is null || profile.Id != id) - { - viewingGallery = null; - return; - } - - ImGui.OpenPopup("Gallery###BeaconGallery"); - - var open = true; - ImGui.SetNextWindowSizeConstraints(new Vector2(420, 300), new Vector2(1400, 1000)); - - if (!ImGui.BeginPopupModal("Gallery###BeaconGallery", ref open, ImGuiWindowFlags.None)) - { - viewingGallery = null; - return; - } - - if (!open) - { - viewingGallery = null; - ImGui.CloseCurrentPopup(); - ImGui.EndPopup(); - return; - } - - var scale = ImGuiHelpers.GlobalScale; - var width = ImGui.GetContentRegionAvail().X; - - foreach (var image in profile.Gallery) - { - Ornament.Text(Theme.BrassBright, ProfileLabels.Describe(image.Category)); - - if (images.Get(image.ImageId, thumb: false) is { } texture) - { - var drawWidth = Math.Min(width, 520f * scale); - var drawHeight = drawWidth * texture.Height / Math.Max(1f, texture.Width); - ImGui.Image(texture.Handle, new Vector2(drawWidth, drawHeight)); - } - else - { - Ornament.Text(Theme.MutedDeep, "Loading..."); - } - - if (!string.IsNullOrWhiteSpace(image.Caption)) - Ornament.TextWrapped(Theme.Muted, image.Caption!); - - Ornament.FleuronDivider(Theme.BrassDim); - } - - if (ImGui.Button("Close")) - { - viewingGallery = null; - ImGui.CloseCurrentPopup(); - } - - ImGui.EndPopup(); - } - - // --- Footer ---------------------------------------------------------- - private void DrawStatusBar() { Ornament.FleuronDivider(Theme.BrassDim, 2f); diff --git a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs index 807e6ee..70798c1 100644 --- a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs +++ b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs @@ -86,6 +86,11 @@ public sealed class ProfileEditorWindow : Window, IDisposable private float portraitPanY = 0.5f; private bool portraitLoading; + /// The card face, the same one the Chronicle draws, so a preview cannot flatter the truth. + private readonly ProfileCard preview; + + private bool previewing; + private string? validationError; private bool saving; @@ -106,6 +111,7 @@ public ProfileEditorWindow( this.screenshots = screenshots; this.images = images; this.notifications = notifications; + preview = new ProfileCard(images); SizeConstraints = new WindowSizeConstraints { @@ -215,6 +221,7 @@ public void Open(ProfileDto? profile) WindowName = $"Editing {identity.Name}###BeaconProfileEditor"; } + previewing = false; IsOpen = true; } @@ -228,6 +235,12 @@ public override void Draw() return; } + if (previewing) + { + DrawPreview(scale); + return; + } + DrawEssentials(scale); Ornament.FleuronDivider(Theme.BrassDim); @@ -831,6 +844,12 @@ private void DrawFooter(float scale) if (Ornament.AccentButton(saving ? "Saving..." : "Save my card", new Vector2(150f * scale, 28f * scale), !saving)) _ = SubmitAsync(); + ImGui.SameLine(); + if (ImGui.Button("Preview", new Vector2(90f * scale, 28f * scale))) + previewing = true; + + Ornament.Tooltip("See the card the way everybody else will, before you publish it."); + ImGui.SameLine(); if (ImGui.Button("Close", new Vector2(90f * scale, 28f * scale))) IsOpen = false; @@ -853,6 +872,43 @@ private void DrawFooter(float scale) ImGui.PopStyleColor(2); } + /// + /// The card exactly as everybody else will read it, drawn by the same code the Chronicle uses. + /// + /// Not a mock-up of one. The point of a preview is to be trustworthy, and a second implementation + /// is only trustworthy until the first one changes. + /// + private void DrawPreview(float scale) + { + Ornament.Text(Theme.BrassBright, "How others will read your card"); + Ornament.Text(Theme.MutedDeep, "Nothing here is saved yet."); + + ImGui.SameLine(ImGui.GetContentRegionAvail().X - (110f * scale)); + if (ImGui.Button("Back to editing", new Vector2(110f * scale, 0))) + { + previewing = false; + preview.PendingPortrait = null; + } + + Ornament.FleuronDivider(Theme.BrassDim); + + var height = ImGui.GetContentRegionAvail().Y - (40f * scale); + + Theme.PushPage(); + if (ImGui.BeginChild("##previewPage", new Vector2(0, height), true)) + { + // The picture being considered, not the one it is about to replace. + preview.PendingPortrait = pendingPortraitTexture; + preview.Draw(BuildDraft(), scale); + } + + ImGui.EndChild(); + Theme.PopPage(); + + ImGui.Spacing(); + DrawCompletionMeter(scale); + } + /// /// A meter showing how filled the card is. /// @@ -861,19 +917,17 @@ private void DrawFooter(float scale) /// private void DrawCompletionMeter(float scale) { - var earned = 0; - const int Total = 8; - - if (pendingPortraitTexture is not null || editing?.HasPortrait == true) earned++; - if (!string.IsNullOrWhiteSpace(name)) earned++; - if (traits.Count > 0) earned++; - if (hooks.Any(h => !string.IsNullOrWhiteSpace(h))) earned++; - if (!string.IsNullOrWhiteSpace(overview)) earned++; - if (tones.Count > 0 || activities.Count > 0) earned++; - if (archetype.Any(a => !string.IsNullOrWhiteSpace(a)) || !string.IsNullOrWhiteSpace(quote)) earned++; - if (!string.IsNullOrWhiteSpace(goals) || !string.IsNullOrWhiteSpace(history)) earned++; - - var fraction = earned / (float)Total; + // Read from the draft, not from a second copy of the rules. The duplicate that used to live + // here had already stopped agreeing with ProfileDto.Completeness. + var draft = BuildDraft(); + var fraction = draft.Completeness; + + // A picture chosen but not yet uploaded still counts; the draft only knows about saved ones. + if (pendingPortraitTexture is not null && !draft.HasPortrait) + fraction = Math.Min(1f, fraction + (1f / ProfileDto.CompletenessCriteria)); + + var total = ProfileDto.CompletenessCriteria; + var earned = (int)MathF.Round(fraction * total); var width = 160f * scale; var origin = ImGui.GetCursorScreenPos(); var draw = ImGui.GetWindowDrawList(); @@ -891,7 +945,85 @@ private void DrawCompletionMeter(float scale) Ornament.Text( fraction >= 1f ? Theme.Verdigris : Theme.MutedDeep, - fraction >= 1f ? "A full card" : $"{earned} of {Total} filled · publish whenever you like"); + fraction >= 1f ? "A full card" : $"{earned} of {total} filled · publish whenever you like"); + } + + /// + /// The card as it stands in the editor right now, shaped exactly like one the server would send. + /// + /// Built rather than approximated, so the preview and the completeness meter both read the same + /// thing every other player will. The meter used to keep its own copy of the rules and had already + /// drifted out of agreement with them, which is the whole argument for doing it this way. + /// + private ProfileDto BuildDraft() + { + _ = int.TryParse(age, out var parsedAge); + + return new ProfileDto + { + Id = editing?.Id ?? Guid.Empty, + OwnerAccountId = editing?.OwnerAccountId ?? config.AccountId, + CharacterName = editing?.CharacterName ?? location.CurrentCharacterName, + WorldId = editing?.WorldId ?? location.CurrentWorldId, + WorldName = editing?.WorldName ?? location.CurrentWorldName, + DataCenter = editing?.DataCenter ?? location.CurrentDataCenter, + Identity = new ProfileIdentity + { + Name = name.Trim(), + Title = Blank(title), + Race = Blank(race), + Clan = Blank(clan), + Age = parsedAge > 0 ? parsedAge : null, + Gender = Blank(gender), + Archetype = archetype.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).ToList(), + Quote = Blank(quote), + }, + Style = new ProfileStyle + { + Length = length, + Tones = [.. tones], + Activities = [.. activities], + Boundaries = Blank(boundaries), + WalkupsWelcome = walkups, + MatureThemes = [.. matureThemes], + IsMature = matureThemes.Count > 0, + }, + Player = new PlayerNotes + { + Timezone = Blank(timezone), + Availability = Blank(playtimes), + Contact = Blank(contact), + }, + + // Presence is the server's to derive from lit beacons, so the preview shows whatever the + // card currently has rather than inventing a state. + Presence = editing?.Presence ?? ProfilePresence.Unknown, + Moment = new ProfileMoment + { + Currently = Blank(currently), + OutOfCharacter = Blank(outOfCharacter), + Stance = stance, + UpdatedAt = editing?.Moment.UpdatedAt, + }, + AtFirstGlance = glances + .Where(g => !string.IsNullOrWhiteSpace(g.Text)) + .Select((g, i) => new GlanceNote { Label = g.Label.Trim(), Text = g.Text.Trim(), Order = i }) + .ToList(), + Personality = [.. traits], + Hooks = hooks + .Where(h => !string.IsNullOrWhiteSpace(h)) + .Select((h, i) => new ProfileHook { Text = h.Trim(), Order = i }) + .ToList(), + Gallery = editing?.Gallery ?? [], + Links = editing?.Links ?? [], + Overview = Blank(overview), + History = Blank(history), + Goals = Blank(goals), + PortraitImageId = editing?.PortraitImageId, + Visibility = visibility, + Availability = availability, + ShareCode = editing?.ShareCode ?? string.Empty, + }; } private async Task SubmitAsync() diff --git a/src/Beacon.Shared/Profiles/ProfileDto.cs b/src/Beacon.Shared/Profiles/ProfileDto.cs index ae79be4..c3a5f33 100644 --- a/src/Beacon.Shared/Profiles/ProfileDto.cs +++ b/src/Beacon.Shared/Profiles/ProfileDto.cs @@ -287,13 +287,19 @@ public sealed record ProfileDto /// is what actually persuades somebody to finish one; refusing to publish an incomplete profile /// just means they publish nothing. /// + /// + /// How many things the completeness meter looks for. Public so the editor can show "n of m" + /// without keeping its own copy of the rules, which is how the two fell out of agreement before. + /// + public const int CompletenessCriteria = 9; + [JsonIgnore] public float Completeness { get { var earned = 0; - const int Total = 9; + const int Total = CompletenessCriteria; if (HasPortrait) earned++; if (!string.IsNullOrWhiteSpace(Identity.Name)) earned++; diff --git a/tools/contrast-audit.py b/tools/contrast-audit.py index 01fcb84..3bd1c88 100644 --- a/tools/contrast-audit.py +++ b/tools/contrast-audit.py @@ -92,6 +92,8 @@ def contrast(fg: tuple[int, int, int], bg: tuple[int, int, int]) -> float: ("VerdigrisInk", "ParchmentShade", BODY, "'open to walk-ups' on the card"), ("InkFaint", "ParchmentShade", BODY, "'last seen' on the card"), ("BrassText", "Panel", BODY, "portrait placeholder initial"), + ("Verdigris", "Well", BODY, "'Loading...' in the editor's portrait well"), + ("InkFaint", "ParchmentShade", BODY, "'Loading...' in the card's portrait well"), # Ornament.Tag draws an outlined chip, not a filled one: the border is Brass but the label sits # on the page behind it. Checking the label against Brass would be measuring a pairing that is # never actually drawn.