Skip to content

Commit 8e84d8b

Browse files
fix(ui): stop cropping Nexus mod thumbnails (#244)
## Summary - display Detailed-row Nexus thumbnails in responsive 16:9 frames - fit complete source images and increase decode sizing for 2x displays - add regression coverage and synchronize the UI architecture and reference docs ## Validation - `dotnet build src/modificus-curator.sln --configuration Release` - `dotnet test src/modificus-curator.sln --configuration Release` (2,445 passed) - `dotnet format src/modificus-curator.sln --verify-no-changes` - operator visual verification Fixes #243
1 parent 501b8e7 commit 8e84d8b

9 files changed

Lines changed: 130 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -625,11 +625,14 @@ src/ Modificus Curator -- the mod manager app (.NET 10 + Avalonia 12)
625625
the automation name always carries the displayed
626626
summary/fallback), and row 2
627627
is a single `WrapPanel` action strip. Wide (card width
628-
greater than 680 DIP): a 112-DIP rounded `UniformToFill`
629-
thumbnail spans all three rows (column 1, `RowSpan=3`)
630-
and the action strip occupies only the content column.
628+
greater than 680 DIP): a 192x108 DIP rounded 16:9
629+
thumbnail (`Stretch=Uniform`, so the complete source
630+
image shows with the frame's neutral background
631+
letterboxing/pillarboxing non-16:9 assets) spans
632+
all three rows (column 1, `RowSpan=3`) and the
633+
action strip occupies only the content column.
631634
Constrained (card width at or below 680 DIP): the
632-
thumbnail shrinks to 72 DIP spanning only name +
635+
thumbnail shrinks to 128x72 DIP spanning only name +
633636
summary (`RowSpan=2`) and the same action strip moves to
634637
a full-width row beneath all three columns
635638
(`Grid.ColumnSpan=3`, via the

docs/architecture/MODIFICUS-CURATOR.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -650,10 +650,13 @@ is in [UI reference](../reference/ui.md).
650650
drag-reorder grip, column 1 is the thumbnail/placeholder slot, column 2
651651
holds the name + source badge (row 0) and a two-line summary (row 1), and
652652
row 2 is the action strip. When the card
653-
is wide (greater than 680 DIP) a 112-DIP thumbnail spans all three rows and
654-
the action strip occupies only the content column; when constrained (at or
655-
below 680 DIP) the thumbnail shrinks to 72 DIP spanning name + summary and
656-
the action strip moves to a full-width row beneath all three columns. The action
653+
is wide (greater than 680 DIP) a 192x108 DIP 16:9 thumbnail (stretched
654+
`Uniform`, so the complete source image shows with the frame's neutral
655+
background letterboxing/pillarboxing non-16:9 assets) spans all three rows
656+
and the action strip occupies only the content column; when constrained (at
657+
or below 680 DIP) the thumbnail shrinks to 128x72 DIP spanning name +
658+
summary and the action strip moves to a full-width row beneath all three
659+
columns. The action
657660
strip is a right-aligned `WrapPanel` that wraps at the edge (no horizontal
658661
scrolling); width, height, row span, and the action column/span are driven by
659662
styles so the breakpoint changes them. The summary is plain text with `CharacterEllipsis` trimming and the full

docs/architecture/ui-architecture.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,11 +1085,13 @@ subscription re-hydrates from the store when the result lands.
10851085
holds the name + source badge (row 0) and a two-line plain-text summary
10861086
(row 1, `MaxLines=2`, `TextWrapping=Wrap`, `TextTrimming=CharacterEllipsis`,
10871087
full text in the tooltip and the automation name), and row 2 is the action
1088-
strip. When the card is wide (greater than 680 DIP) a 112-DIP
1089-
`UniformToFill` thumbnail spans all three rows and the action strip occupies
1090-
only the right column; when constrained (at or below 680 DIP) the thumbnail
1091-
shrinks to 72 DIP spanning name + summary and the action strip moves to a
1092-
full-width row beneath both columns. Width, height, row span, and action
1088+
strip. When the card is wide (greater than 680 DIP) a 192x108 DIP 16:9
1089+
`Uniform` thumbnail (the complete source image; the frame's neutral
1090+
background letterboxes/pillarboxes non-16:9 assets) spans all three rows and
1091+
the action strip occupies only the right column; when constrained (at or
1092+
below 680 DIP) the thumbnail shrinks to 128x72 DIP spanning name + summary
1093+
and the action strip moves to a full-width row beneath both columns. Width,
1094+
height, row span, and action
10931095
column/span that change at the breakpoint are driven by styles (not local
10941096
values, which would outrank styles); constant row/column positions stay
10951097
local. Both roots bind the exact same per-row state and route to the exact

docs/reference/ui.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,11 +1955,12 @@ feature).
19551955
card-width breakpoint. Column 0 is the drag-reorder grip, column 1 is the
19561956
thumbnail/placeholder slot, column 2
19571957
holds the name + source badge (row 0) and the summary (row 1), and row 2 is
1958-
the action strip. Wide (card width greater than 680 DIP): a 112-DIP
1959-
`UniformToFill` thumbnail spans all three rows (`RowSpan=3`) and the action
1960-
strip occupies only the content column. Constrained (at or below 680 DIP): the
1961-
thumbnail shrinks to 72 DIP spanning only name + summary (`RowSpan=2`) and the
1962-
action strip moves to a full-width row beneath all three columns
1958+
the action strip. Wide (card width greater than 680 DIP): a 192x108 DIP
1959+
rounded 16:9 thumbnail spans all three rows (`RowSpan=3`) and the action
1960+
strip occupies only the content column. Constrained (at or below 680 DIP):
1961+
the thumbnail shrinks to 128x72 DIP spanning only name + summary
1962+
(`RowSpan=2`) and the action strip moves to a full-width row beneath all
1963+
three columns
19631964
(`Grid.ColumnSpan=3`, driven by the `ContentControl.detailedActions` styles).
19641965
Width, height, row span, and action column/span that
19651966
change at the breakpoint are style-driven (default wide styles + the
@@ -1971,7 +1972,10 @@ feature).
19711972
- **Summary.** `MaxLines="2"` + `TextWrapping="Wrap"` + `TextTrimming="CharacterEllipsis"`; the full text is retained in `ToolTip.Tip` (when non-null) + `AutomationProperties.Name` (always, so the fallback stays reachable by assistive tech).
19721973
- **Thumbnail area.** A rounded `Border` with `ClipToBounds`; the `Image` shows
19731974
only when `HasThumbnail`, otherwise a neutral drawn-geometry placeholder
1974-
(Material `image`) fills the box. The placeholder scales with the slot through
1975+
(Material `image`) fills the box. The `Image` stretches `Uniform` so the
1976+
complete source image is always visible; the frame's neutral background
1977+
supplies the letterbox/pillarbox space for non-16:9 assets. The placeholder
1978+
scales with the slot through
19751979
the same styles as the thumbnail (36 DIP wide; 28 DIP constrained). Adult rows
19761980
never receive a thumbnail (the coordinator skips them), so they fall through
19771981
to the placeholder.
@@ -2135,10 +2139,10 @@ Cache + failure behavior (`ModThumbnailService`):
21352139
- **Atomic write.** Download to a sibling temp file, then same-volume `File.Move`
21362140
into place. A download failure returns `null` without creating the final file.
21372141
- **Decode.** Production uses `Bitmap.DecodeToWidth(stream,
2138-
ModThumbnailService.DecodeWidth, BitmapInterpolationMode.HighQuality)` (256 px,
2139-
sized for the 112-DIP detailed-row thumbnail on scaled displays) on a
2140-
background thread; the render size is responsive (112 DIP wide, 72 DIP
2141-
constrained). The 256-px constant is the single named literal
2142+
ModThumbnailService.DecodeWidth, BitmapInterpolationMode.HighQuality)` (384 px,
2143+
sized for the 192-DIP detailed-row thumbnail frame at 2x display scaling) on a
2144+
background thread; the render size is responsive (192 DIP wide, 128 DIP
2145+
constrained). The 384-px constant is the single named literal
21422146
(`ModThumbnailService.DecodeWidth`) referenced from the DI wiring.
21432147
- **Four-slot load bound.** A `SemaphoreSlim(4)` bounds concurrent distinct-key
21442148
fetch/decode work.

src/tests/Modificus.Curator.UI.Tests/ModRowSharedTemplatesTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,37 @@ public void The_680dip_breakpoint_still_moves_the_strip_and_thumbnail()
164164
Assert.Contains("Path.detailedPlaceholder", selectors);
165165
}
166166

167+
[Fact]
168+
public void Detailed_thumbnail_frames_are_16by9_and_the_image_never_crops()
169+
{
170+
var xaml = LoadStrippedXaml("src/ui/Views/ModListView.axaml");
171+
172+
// The detailed thumbnail is a fixed 16:9 frame, never a square and
173+
// never a cropping stretch: wide cards carry 192x108 DIP (RowSpan 3)
174+
// and constrained cards (the 680-DIP container query) carry 128x72 DIP
175+
// (RowSpan 2), so an ordinary 16:9 Nexus image renders uncropped.
176+
var wide = Assert.Single(
177+
Elements(xaml.Root!, "Style"),
178+
s => A(s, "Selector") == "Border.detailedThumb"
179+
&& s.Parent?.Name.LocalName != "ContainerQuery");
180+
AssertThumbFrame(wide, width: 192, height: 108, rowSpan: 3);
181+
182+
var query = Assert.Single(Elements(xaml.Root!, "ContainerQuery"));
183+
var constrained = Assert.Single(
184+
query.Descendants().Where(e => e.Name.LocalName == "Style"),
185+
s => A(s, "Selector") == "Border.detailedThumb");
186+
AssertThumbFrame(constrained, width: 128, height: 72, rowSpan: 2);
187+
188+
// The thumbnail Image must fit the whole source inside the frame
189+
// (Uniform). UniformToFill fills the frame and hides roughly 44% of
190+
// the width of a 16:9 source, so it is banned here; the frame's
191+
// neutral background supplies the letterbox/pillarbox space instead.
192+
var image = Assert.Single(
193+
Elements(xaml.Root!, "Image"),
194+
i => A(i, "Source") == "{Binding Thumbnail}");
195+
Assert.Equal("Uniform", A(image, "Stretch"));
196+
}
197+
167198
[Fact]
168199
public void The_edit_band_is_one_definition_leading_both_row_roots()
169200
{
@@ -215,6 +246,28 @@ private static void AssertMargin(XDocument xaml, string selector, string margin)
215246
Assert.Equal(margin, A(setter, "Value"));
216247
}
217248

249+
/// <summary>
250+
/// Asserts one <c>Border.detailedThumb</c> style pins an exactly 16:9
251+
/// frame of the given DIP size with the given grid row span. The ratio is
252+
/// checked from the parsed setters (not the expected literals) so an edit
253+
/// that changes one dimension alone fails even if the literals are updated
254+
/// with it.
255+
/// </summary>
256+
private static void AssertThumbFrame(XElement style, int width, int height, int rowSpan)
257+
{
258+
var actualWidth = int.Parse(SetterValue(style, "Width"));
259+
var actualHeight = int.Parse(SetterValue(style, "Height"));
260+
Assert.Equal(width, actualWidth);
261+
Assert.Equal(height, actualHeight);
262+
Assert.Equal(width * 9, actualHeight * 16);
263+
Assert.Equal(rowSpan.ToString(), SetterValue(style, "Grid.RowSpan"));
264+
}
265+
266+
private static string SetterValue(XElement style, string property) =>
267+
style.Descendants()
268+
.Single(e => e.Name.LocalName == "Setter" && A(e, "Property") == property)
269+
.Attribute("Value")?.Value ?? string.Empty;
270+
218271
private static string RequireSourceFile(string relativeFromRepo)
219272
{
220273
var path = Path.Combine(

src/tests/Modificus.Curator.UI.Tests/ModThumbnailServiceTests.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ namespace Modificus.Curator.UI.Tests;
1818
/// coalescing, four-slot concurrency bound, oversize rejection (declared +
1919
/// streamed), HTTP/I/O failure retryability, corrupt-entry delete + one
2020
/// re-download + one re-decode, cancellation propagation + cleanup + retry,
21-
/// 90-day prune boundary + failure isolation, and no ConfigureAwait(false).
21+
/// 90-day prune boundary + failure isolation, production decode sizing (the
22+
/// 2x-scaled 192-DIP frame floor), and no ConfigureAwait(false).
2223
/// </summary>
2324
public sealed class ModThumbnailServiceTests
2425
{
@@ -722,6 +723,20 @@ public async Task Prune_sweeps_multiple_stale_files()
722723
}
723724
}
724725

726+
// ---- production decode sizing ------------------------------------------
727+
728+
[Fact]
729+
public void Production_decode_width_covers_the_widest_thumbnail_at_2x()
730+
{
731+
// The widest detailed-row thumbnail frame is 192 DIP; at 2x display
732+
// scaling that is 384 physical pixels. A DecodeWidth below the frame's
733+
// physical size would render upsampled (soft) on scaled displays.
734+
Assert.True(
735+
ModThumbnailService.DecodeWidth >= 384,
736+
$"DecodeWidth {ModThumbnailService.DecodeWidth} cannot keep the 192-DIP " +
737+
"detailed-row thumbnail frame sharp at 2x display scaling.");
738+
}
739+
725740
// ---- DI registration ---------------------------------------------------
726741

727742
[Fact]

src/ui/CuratorComposition.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ public static IServiceProvider Build()
217217
// (registered by AddIntegrations via AddHttpClient) for a plain factory-
218218
// created HttpClient per download; production decode uses
219219
// Bitmap.DecodeToWidth at ModThumbnailService.DecodeWidth px (sized for
220-
// the 112-DIP detailed-row thumbnail on scaled displays). Registered
220+
// the 192-DIP detailed-row thumbnail at 2x display scaling). Registered
221221
// before ModListViewModel and the later detailed-row coordinator so it is
222222
// available when they resolve.
223223
services.AddSingleton<IModThumbnailService>(sp => new ModThumbnailService(

src/ui/ModThumbnailService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ internal sealed class ModThumbnailService : IModThumbnailService
4848
{
4949
/// <summary>
5050
/// The physical-pixel width the production decode path targets. Large enough
51-
/// for the widest detailed-row thumbnail (112 DIP) to stay sharp on scaled
52-
/// displays. Referenced from the production DI wiring
51+
/// for the widest detailed-row thumbnail frame (192 DIP, 16:9) to stay sharp
52+
/// at 2x display scaling. Referenced from the production DI wiring
5353
/// (<see cref="CuratorComposition"/>) so the decode literal lives in one
5454
/// place. Tests inject their own decode seam and do not read this value.
5555
/// </summary>
56-
internal const int DecodeWidth = 256;
56+
internal const int DecodeWidth = 384;
5757
private const int MaxBytes = 8 * 1024 * 1024; // 8 MiB absolute maximum.
5858
private const int IoBufferSize = 81920;
5959
private static readonly TimeSpan PruneAge = TimeSpan.FromDays(90);

src/ui/Views/ModListView.axaml

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -586,16 +586,16 @@
586586
root (Border.detailedRow) carries Container.Name + Container.Sizing=Width
587587
so it measures its own width; repeated cards share the name. The card Grid
588588
has three columns (grip, thumbnail, content); these default (wide) styles
589-
set the large-card state: a 112-DIP thumbnail spanning all three content
590-
rows in the thumbnail column, and the right-column action strip in the
591-
content column. Width, height, row span, action column, and action column
592-
span are set HERE (not as local values on the elements) so the
589+
set the large-card state: a 192x108 DIP 16:9 thumbnail spanning all three
590+
content rows in the thumbnail column, and the right-column action strip
591+
in the content column. Width, height, row span, action column, and action
592+
column span are set HERE (not as local values on the elements) so the
593593
ContainerQuery below can override them; local values outrank styles and
594594
would defeat the query.
595595
-->
596596
<Style Selector="Border.detailedThumb">
597-
<Setter Property="Width" Value="112" />
598-
<Setter Property="Height" Value="112" />
597+
<Setter Property="Width" Value="192" />
598+
<Setter Property="Height" Value="108" />
599599
<Setter Property="Grid.RowSpan" Value="3" />
600600
</Style>
601601
<Style Selector="Path.detailedPlaceholder">
@@ -620,15 +620,16 @@
620620
</Style>
621621
<!--
622622
Constrained state (card width at or below 680 DIP): the thumbnail shrinks
623-
to 72 DIP and spans only name + summary; the single action strip moves to
624-
a full-width row beneath all three columns. The query targets descendants
623+
to 128x72 DIP (still 16:9) and spans only name + summary; the single
624+
action strip moves to a full-width row beneath all three columns. The
625+
query targets descendants
625626
of the named card root, never the queried Border itself, so the card chrome
626627
is untouched. Declared after the wide defaults so its setters win while the
627628
query is active.
628629
-->
629630
<ContainerQuery Name="detailedModRow" Query="max-width:680">
630631
<Style Selector="Border.detailedThumb">
631-
<Setter Property="Width" Value="72" />
632+
<Setter Property="Width" Value="128" />
632633
<Setter Property="Height" Value="72" />
633634
<Setter Property="Grid.RowSpan" Value="2" />
634635
</Style>
@@ -1362,15 +1363,19 @@
13621363
ContentTemplate="{StaticResource ModRowGripTemplate}" />
13631364

13641365
<!--
1365-
Thumbnail area (column 1). A rounded, clipped square
1366-
whose Width, Height, and Grid.RowSpan come from the
1367-
Border.detailedThumb styles (112 DIP spanning all three
1368-
rows when wide; 72 DIP spanning name + summary when
1369-
constrained). Border implements
1366+
Thumbnail area (column 1). A rounded, clipped 16:9
1367+
frame whose Width, Height, and Grid.RowSpan come from the
1368+
Border.detailedThumb styles (192x108 DIP spanning all
1369+
three rows when wide; 128x72 DIP spanning name + summary
1370+
when constrained). Border implements
13701371
IVisualWithRoundRectClip with ClipToBoundsRadius mapped
13711372
to CornerRadius, so ClipToBounds clips the child to the
1372-
rounded boundary. The Image shows only when a thumbnail
1373-
has been decoded; every no-image case (Untracked/Linked,
1373+
rounded boundary. The Image stretches Uniform so the
1374+
complete source image is always visible; the frame's
1375+
neutral background supplies the letterbox/pillarbox
1376+
space for non-16:9 assets. The Image shows only when a
1377+
thumbnail has been decoded; every no-image case
1378+
(Untracked/Linked,
13741379
missing metadata, no auth, offline, failed image, adult
13751380
flag, empty URL) shows the same neutral drawn-geometry
13761381
placeholder in the same box. Adult rows never receive a
@@ -1383,7 +1388,7 @@
13831388
HorizontalAlignment="Left" VerticalAlignment="Top"
13841389
Background="{DynamicResource SystemControlBackgroundBaseMediumLowBrush}">
13851390
<Panel>
1386-
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"
1391+
<Image Source="{Binding Thumbnail}" Stretch="Uniform"
13871392
IsVisible="{Binding HasThumbnail}" />
13881393
<!--
13891394
Neutral placeholder (Material "image", 24x24 source):

0 commit comments

Comments
 (0)