From 05f1e4027ccbc0ed34dc5d8c99e464f2f224eb91 Mon Sep 17 00:00:00 2001 From: Shaala <156393271+ShaalaXIV@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:45:05 -0500 Subject: [PATCH] Preview a card before publishing it, and stop portraits piling up Preview ------- The editor can now show the card exactly as everybody else reads it, drawn by the same code the Chronicle uses. The card face moved out of ChronicleWindow into UI/ProfileCard so there is one implementation rather than two: a preview that reimplements the card is a preview that lies the moment either copy changes. A portrait chosen but not yet uploaded is shown in place of the one it would replace. One source of truth for completeness ------------------------------------ The editor kept its own copy of the completeness rules, and it had already drifted: ProfileDto.Completeness counts nine things including At first glance, the editor counted eight and ignored it, so filling in your impressions never moved the bar. The meter now reads the same draft the preview draws, and the number of criteria is a shared constant. Portraits replace rather than accumulate ---------------------------------------- Every portrait update added another image to a gallery that holds eight and never removed the one it replaced, so the ninth change would have failed outright with an error about a gallery the player never chose to fill. Updating a portrait now retires the likeness it replaces. Verified with ten consecutive updates: the gallery stays at one and the portrait changes every time. A picture that has not arrived yet ---------------------------------- An empty portrait well looked identical whether there was no portrait or one still downloading, which reads as "my picture did not save". Both the editor and the card now say when they are fetching. Co-Authored-By: Claude Opus 5 --- src/Beacon.Plugin/Services/ProfileService.cs | 21 + src/Beacon.Plugin/UI/ProfileCard.cs | 448 ++++++++++++++++++ src/Beacon.Plugin/Windows/ChronicleWindow.cs | 406 +--------------- .../Windows/ProfileEditorWindow.cs | 160 ++++++- src/Beacon.Shared/Profiles/ProfileDto.cs | 8 +- tools/contrast-audit.py | 2 + 6 files changed, 632 insertions(+), 413 deletions(-) create mode 100644 src/Beacon.Plugin/UI/ProfileCard.cs 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.