diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml
index f16e389..26b5188 100644
--- a/deploy/portainer-stack.yml
+++ b/deploy/portainer-stack.yml
@@ -1,6 +1,6 @@
services:
beacon-server:
- image: beacon-server:0.1.11
+ image: beacon-server:0.1.12
restart: always
environment:
ASPNETCORE_ENVIRONMENT: Production
@@ -59,7 +59,7 @@ services:
/bin/sh -c '(while sleep 86400; do nginx -s reload; done) & exec nginx -g "daemon off;"'
beacon-backup:
- image: beacon-backup:0.1.11
+ image: beacon-backup:0.1.12
restart: always
environment:
BACKUP_INTERVAL_SECONDS: "86400"
diff --git a/src/Beacon.Plugin/Beacon.Plugin.csproj b/src/Beacon.Plugin/Beacon.Plugin.csproj
index 7c1c611..2023ee6 100644
--- a/src/Beacon.Plugin/Beacon.Plugin.csproj
+++ b/src/Beacon.Plugin/Beacon.Plugin.csproj
@@ -4,7 +4,7 @@
Beacon
Beacon
- 0.1.11.0
+ 0.1.12.0
enable
diff --git a/src/Beacon.Plugin/Plugin.cs b/src/Beacon.Plugin/Plugin.cs
index 164450c..629a9c2 100644
--- a/src/Beacon.Plugin/Plugin.cs
+++ b/src/Beacon.Plugin/Plugin.cs
@@ -56,6 +56,8 @@ public sealed class Plugin : IDalamudPlugin
private readonly ProfileEditorWindow profileEditorWindow;
+ private readonly ImageViewerWindow imageViewerWindow;
+
public Plugin(IDalamudPluginInterface pluginInterface)
{
Svc.Initialise(pluginInterface);
@@ -75,6 +77,8 @@ public Plugin(IDalamudPluginInterface pluginInterface)
stagehand = new StagehandIpc();
stages = new StageDressing(config, api, stagehand, location);
+ imageViewerWindow = new ImageViewerWindow(images);
+
editorWindow = new BeaconEditorWindow(config, atlas, location, screenshots, images, notifications, stages);
settingsWindow = new SettingsWindow(config, api, atlas, hub, travel, stages);
atlasWindow = new AtlasWindow(
@@ -90,7 +94,7 @@ public Plugin(IDalamudPluginInterface pluginInterface)
OpenChronicle,
OpenCharacterCard);
- profileEditorWindow = new ProfileEditorWindow(config, profiles, atlas, location, screenshots, images, notifications);
+ profileEditorWindow = new ProfileEditorWindow(config, profiles, atlas, location, screenshots, images, notifications, imageViewerWindow);
chronicleWindow = new ChronicleWindow(
config,
profiles,
@@ -99,6 +103,7 @@ public Plugin(IDalamudPluginInterface pluginInterface)
location,
images,
notifications,
+ imageViewerWindow,
profileEditorWindow.Open);
overlay = new BeaconOverlay(config, atlas, travel, location, () => atlasWindow.IsOpen);
@@ -108,6 +113,7 @@ public Plugin(IDalamudPluginInterface pluginInterface)
windows.AddWindow(settingsWindow);
windows.AddWindow(chronicleWindow);
windows.AddWindow(profileEditorWindow);
+ windows.AddWindow(imageViewerWindow);
windows.AddWindow(overlay);
// Restore the filters the player left the atlas on, so it opens where they left off.
diff --git a/src/Beacon.Plugin/UI/ProfileCard.cs b/src/Beacon.Plugin/UI/ProfileCard.cs
index 4aa5171..9a8103a 100644
--- a/src/Beacon.Plugin/UI/ProfileCard.cs
+++ b/src/Beacon.Plugin/UI/ProfileCard.cs
@@ -23,8 +23,16 @@ 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; }
+ ///
+ /// Called when the reader clicks a likeness, with the image to show full size.
+ ///
+ /// A callback rather than state of its own: the card draws inside a child window, and anything
+ /// opened from in there has to be drawn by whoever owns the window, not by the card.
+ ///
+ public Action? OpenImage { get; set; }
+
+ /// Called when the reader asks for the whole gallery.
+ public Action? OpenGallery { get; set; }
///
/// A portrait chosen but not yet uploaded, so the editor's preview shows the picture being
@@ -32,29 +40,42 @@ public sealed class ProfileCard(ImageCache images)
///
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)
+ public void Draw(ProfileDto profile, float scale) => DrawCardContents(profile, scale);
+
+ /// Indents the cursor so something of this width sits in the middle of the card.
+ private static void Centre(float itemWidth, float available)
{
- shown = profile;
- DrawCardContents(profile, scale);
+ var indent = (available - itemWidth) / 2f;
+ if (indent > 0f)
+ ImGui.SetCursorPosX(ImGui.GetCursorPosX() + indent);
}
+ private static void CentreText(string text, float available) => Centre(ImGui.CalcTextSize(text).X, available);
+
private void DrawCardContents(ProfileDto profile, float scale)
{
- var portraitSize = 104f * scale;
+ var width = ImGui.GetContentRegionAvail().X;
+ // The likeness leads, centred, at a size worth looking at. Everything else sits beneath it and
+ // scrolls -- a face is what somebody decides on, and it was previously a thumbnail beside a
+ // column of text with half the card left empty.
+ var portraitSize = Math.Clamp(width * 0.52f, 220f * scale, 460f * scale);
+
+ Centre(portraitSize, width);
DrawPortrait(profile, portraitSize, scale);
- ImGui.SameLine(0, 14f * scale);
- ImGui.BeginGroup();
+ ImGui.Spacing();
+ CentreText(profile.Identity.Name, width);
Ornament.Text(Theme.Ink, profile.Identity.Name);
if (!string.IsNullOrWhiteSpace(profile.Identity.Title))
- Ornament.Text(Theme.Wax, $"“{profile.Identity.Title}”");
+ {
+ var title = $"“{profile.Identity.Title}”";
+ CentreText(title, width);
+ Ornament.Text(Theme.Wax, title);
+ }
var details = new List();
if (!string.IsNullOrWhiteSpace(profile.Identity.Lineage))
@@ -64,11 +85,23 @@ private void DrawCardContents(ProfileDto profile, float scale)
details.Add($"Age {age}");
if (details.Count > 0)
- Ornament.Text(Theme.InkSoft, string.Join(" · ", details));
+ {
+ var line = string.Join(" · ", details);
+ CentreText(line, width);
+ Ornament.Text(Theme.InkSoft, line);
+ }
if (profile.Identity.Archetype.Count > 0)
{
ImGui.Spacing();
+
+ // Measure the row of chips so it can be centred as a block rather than left-hung.
+ var chipsWidth = 0f;
+ foreach (var word in profile.Identity.Archetype)
+ chipsWidth += ImGui.CalcTextSize(word).X + (18f * scale);
+
+ Centre(chipsWidth, width);
+
foreach (var word in profile.Identity.Archetype)
{
Ornament.Tag(word, Theme.ParchmentRule, Theme.Ink);
@@ -81,10 +114,14 @@ private void DrawCardContents(ProfileDto profile, float scale)
if (!string.IsNullOrWhiteSpace(profile.Identity.Quote))
{
ImGui.Spacing();
- Ornament.TextWrapped(Theme.InkSoft, $"“{profile.Identity.Quote}”");
- }
+ var quote = $"“{profile.Identity.Quote}”";
- ImGui.EndGroup();
+ // Only centre a quote that fits on one line; a wrapped one reads better ranged left.
+ if (ImGui.CalcTextSize(quote).X < width)
+ CentreText(quote, width);
+
+ Ornament.TextWrapped(Theme.InkSoft, quote);
+ }
Ornament.FleuronDivider(Theme.ParchmentRule, 4f);
@@ -246,12 +283,28 @@ private void DrawPortrait(ProfileDto profile, float size, float scale)
draw.AddRect(origin, box, Theme.Brass.Packed(), 0f, ImDrawFlags.None, 1.4f * scale);
- ImGui.Dummy(new Vector2(size, size * 1.25f));
+ // Clicking the likeness opens it at a size you can actually look at.
+ if (portrait is not null && profile.HasPortrait)
+ {
+ ImGui.InvisibleButton($"##portrait{profile.Id}", new Vector2(size, size * 1.25f));
+
+ if (ImGui.IsItemHovered())
+ {
+ ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
+ draw.AddRect(origin, box, Theme.Gold.Packed(), 0f, ImDrawFlags.None, 2f * scale);
+ Ornament.Tooltip("Click to see it full size.");
+ }
+
+ if (ImGui.IsItemClicked())
+ OpenImage?.Invoke(profile.PortraitImageId!.Value);
+ }
+ else
+ 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;
+ OpenGallery?.Invoke(profile);
}
ImGui.EndGroup();
@@ -381,68 +434,4 @@ private void DrawLinks(ProfileDto profile)
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 d96199c..333c12c 100644
--- a/src/Beacon.Plugin/Windows/ChronicleWindow.cs
+++ b/src/Beacon.Plugin/Windows/ChronicleWindow.cs
@@ -37,6 +37,8 @@ public sealed class ChronicleWindow : Window
private string searchText = string.Empty;
/// The card face, shared with the editor's preview so the two cannot drift.
+ private readonly ImageViewerWindow viewer;
+
private readonly ProfileCard card;
public ChronicleWindow(
@@ -47,6 +49,7 @@ public ChronicleWindow(
LocationService location,
ImageCache images,
NotificationService notifications,
+ ImageViewerWindow viewer,
Action openEditor)
: base("The Chronicle###BeaconChronicle")
{
@@ -57,7 +60,12 @@ public ChronicleWindow(
this.location = location;
this.images = images;
this.notifications = notifications;
- card = new ProfileCard(images);
+ this.viewer = viewer;
+ card = new ProfileCard(images)
+ {
+ OpenImage = id => viewer.Show(id, "Likeness"),
+ OpenGallery = viewer.ShowGallery,
+ };
this.openEditor = openEditor;
SizeConstraints = new WindowSizeConstraints
@@ -101,7 +109,6 @@ public override void Draw()
ImGui.EndChild();
DrawStatusBar();
- card.DrawGalleryPopup();
}
// --- Filters ---------------------------------------------------------
diff --git a/src/Beacon.Plugin/Windows/ImageViewerWindow.cs b/src/Beacon.Plugin/Windows/ImageViewerWindow.cs
new file mode 100644
index 0000000..6e9b9a4
--- /dev/null
+++ b/src/Beacon.Plugin/Windows/ImageViewerWindow.cs
@@ -0,0 +1,162 @@
+using System.Numerics;
+using Beacon.Services;
+using Beacon.Shared.Profiles;
+using Beacon.UI;
+using Dalamud.Bindings.ImGui;
+using Dalamud.Interface.Utility;
+using Dalamud.Interface.Windowing;
+
+namespace Beacon.Windows;
+
+///
+/// One picture, or a whole gallery, at a size worth looking at.
+///
+/// A window rather than a modal popup. The card draws inside a child window, and an ImGui popup
+/// opened from inside a child cannot be begun outside it -- the id stacks do not match, so the popup
+/// silently never appears. That is exactly what happened to the gallery button. A window has no such
+/// coupling, and it is resizable and movable, which is what somebody wanting a bigger picture
+/// actually wants.
+///
+public sealed class ImageViewerWindow : Window
+{
+ private readonly ImageCache images;
+
+ /// The single image being shown, if the reader opened one picture.
+ private Guid? single;
+
+ /// The card whose gallery is being shown, if the reader opened the gallery.
+ private ProfileDto? gallery;
+
+ public ImageViewerWindow(ImageCache images)
+ : base("Likeness###BeaconLikeness")
+ {
+ this.images = images;
+
+ SizeConstraints = new WindowSizeConstraints
+ {
+ MinimumSize = new Vector2(320, 260),
+ MaximumSize = new Vector2(3000, 2400),
+ };
+ }
+
+ /// Opens one picture on its own.
+ public void Show(Guid imageId, string title)
+ {
+ single = imageId;
+ gallery = null;
+ WindowName = $"{title}###BeaconLikeness";
+ Size = FitToScreen();
+ SizeCondition = ImGuiCond.Appearing;
+ IsOpen = true;
+ }
+
+ /// Opens every picture on a card.
+ public void ShowGallery(ProfileDto profile)
+ {
+ gallery = profile;
+ single = null;
+ WindowName = $"{profile.Identity.Name} — gallery###BeaconLikeness";
+ Size = FitToScreen();
+ SizeCondition = ImGuiCond.Appearing;
+ IsOpen = true;
+ }
+
+ /// A generous default that still fits on the monitor it opens on.
+ private static Vector2 FitToScreen()
+ {
+ var work = ImGui.GetMainViewport().WorkSize;
+ return new Vector2(MathF.Min(work.X * 0.6f, 900f), MathF.Min(work.Y * 0.8f, 1000f));
+ }
+
+ public override void Draw()
+ {
+ if (single is { } imageId)
+ {
+ DrawSingle(imageId);
+ return;
+ }
+
+ if (gallery is { } profile)
+ {
+ DrawGallery(profile);
+ return;
+ }
+
+ IsOpen = false;
+ }
+
+ private void DrawSingle(Guid imageId)
+ {
+ var texture = images.Get(imageId, thumb: false);
+
+ if (texture is null)
+ {
+ Ornament.Text(Theme.MutedDeep, "Loading...");
+ return;
+ }
+
+ DrawFitted(texture.Handle, texture.Width, texture.Height, ImGui.GetContentRegionAvail());
+ }
+
+ private void DrawGallery(ProfileDto profile)
+ {
+ 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 = MathF.Min(width, texture.Width);
+ var drawHeight = drawWidth * texture.Height / MathF.Max(1f, texture.Width);
+
+ ImGui.PushID(image.ImageId.ToString());
+ if (ImGui.ImageButton(texture.Handle, new Vector2(drawWidth, drawHeight)))
+ Show(image.ImageId, profile.Identity.Name);
+
+ ImGui.PopID();
+ Ornament.Tooltip("Click to see this one on its own.");
+ }
+ else
+ Ornament.Text(Theme.MutedDeep, "Loading...");
+
+ if (!string.IsNullOrWhiteSpace(image.Caption))
+ Ornament.TextWrapped(Theme.Muted, image.Caption!);
+
+ Ornament.FleuronDivider(Theme.BrassDim);
+ }
+
+ if (profile.Gallery.Count == 0)
+ Ornament.Text(Theme.MutedDeep, "Nothing in this gallery yet.");
+
+ ImGui.Dummy(new Vector2(0, 4f * scale));
+ }
+
+ ///
+ /// Draws a picture as large as the space allows without distorting or cropping it, centred.
+ /// A tall portrait and a wide scene both arrive whole.
+ ///
+ private static void DrawFitted(ImTextureID handle, float imageWidth, float imageHeight, Vector2 available)
+ {
+ if (imageWidth <= 0 || imageHeight <= 0)
+ return;
+
+ available = new Vector2(MathF.Max(32f, available.X), MathF.Max(32f, available.Y));
+
+ var fit = MathF.Min(available.X / imageWidth, available.Y / imageHeight);
+ var size = new Vector2(imageWidth * fit, imageHeight * fit);
+
+ var indentX = MathF.Max(0f, (available.X - size.X) / 2f);
+ var indentY = MathF.Max(0f, (available.Y - size.Y) / 2f);
+
+ if (indentY > 0f)
+ ImGui.SetCursorPosY(ImGui.GetCursorPosY() + indentY);
+
+ if (indentX > 0f)
+ ImGui.SetCursorPosX(ImGui.GetCursorPosX() + indentX);
+
+ ImGui.Image(handle, size);
+ }
+}
diff --git a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs
index 70798c1..8d3518d 100644
--- a/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs
+++ b/src/Beacon.Plugin/Windows/ProfileEditorWindow.cs
@@ -86,6 +86,8 @@ public sealed class ProfileEditorWindow : Window, IDisposable
private float portraitPanY = 0.5f;
private bool portraitLoading;
+ private readonly ImageViewerWindow viewer;
+
/// The card face, the same one the Chronicle draws, so a preview cannot flatter the truth.
private readonly ProfileCard preview;
@@ -101,7 +103,8 @@ public ProfileEditorWindow(
LocationService location,
ScreenshotService screenshots,
ImageCache images,
- NotificationService notifications)
+ NotificationService notifications,
+ ImageViewerWindow viewer)
: base("My card###BeaconProfileEditor")
{
this.config = config;
@@ -111,7 +114,12 @@ public ProfileEditorWindow(
this.screenshots = screenshots;
this.images = images;
this.notifications = notifications;
- preview = new ProfileCard(images);
+ this.viewer = viewer;
+ preview = new ProfileCard(images)
+ {
+ OpenImage = id => viewer.Show(id, "Likeness"),
+ OpenGallery = viewer.ShowGallery,
+ };
SizeConstraints = new WindowSizeConstraints
{