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.