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 {