diff --git a/MIQ.Core/Parsing/MifParser.cs b/MIQ.Core/Parsing/MifParser.cs
index c2b7f99..836ca04 100644
--- a/MIQ.Core/Parsing/MifParser.cs
+++ b/MIQ.Core/Parsing/MifParser.cs
@@ -157,6 +157,7 @@ private static MiqImage BuildImage(
};
var orientationFrame = BuildOrientationFrame(transformRows, layout, order);
+ var (sclSlope, sclInter) = ParseScaling(fields);
var dimensions = new int[4];
for (var i = 0; i < 4; i++) dimensions[i] = i < storedDim.Length ? storedDim[i] : 1;
@@ -168,8 +169,8 @@ private static MiqImage BuildImage(
Pixdim = pixdim,
Datatype = datatype,
VoxOffset = payloadOffset,
- SclSlope = 0f,
- SclInter = 0f,
+ SclSlope = sclSlope,
+ SclInter = sclInter,
QformCode = 0,
SformCode = 0,
SrowX = new float[] { 0f, 0f, 0f, 0f },
@@ -232,6 +233,27 @@ private static byte[] UnpackBits(byte[] data, int payloadOffset, long totalEleme
return unpacked;
}
+ /// MRtrix intensity scaling, scaling: offset,scale (value = offset +
+ /// scale × stored; note the order is the reverse of NIfTI's slope/inter).
+ /// Written when the header carries non-identity scaling, e.g. integer data
+ /// converted from DICOM with Rescale Slope/Intercept.
+ ///
+ /// Returned as (slope, inter) in MiqHeader's convention, where slope 0 means
+ /// "unscaled". Absent, identity, malformed, non-finite, or zero-scale values
+ /// yield (0, 0): an optional field must not fail the parse, and a zero scale
+ /// would collapse the image to one value.
+ private static (float slope, float inter) ParseScaling(Dictionary fields)
+ {
+ if (!fields.TryGetValue("scaling", out var str)) return (0f, 0f);
+ var values = TryParseFloatList(str);
+ if (values is null || values.Length != 2) return (0f, 0f);
+ var (offset, scale) = (values[0], values[1]);
+ if (!MiqCompat.IsFinite(offset) || !MiqCompat.IsFinite(scale) || scale == 0f)
+ return (0f, 0f);
+ if (scale == 1f && offset == 0f) return (0f, 0f);
+ return (scale, offset);
+ }
+
// ── Orientation ──────────────────────────────────────────────────────────
///
diff --git a/MIQ.Core/Parsing/MiqBinaryReader.cs b/MIQ.Core/Parsing/MiqBinaryReader.cs
index 175bfcc..88e3fd4 100644
--- a/MIQ.Core/Parsing/MiqBinaryReader.cs
+++ b/MIQ.Core/Parsing/MiqBinaryReader.cs
@@ -167,6 +167,10 @@ internal static uint TrustedIsize(uint isize, long compressedLength) =>
// safety margin (gzip framing only adds bytes, lowering the real ratio).
private const long MaxDeflateExpansion = 1100;
+ // Largest single Read in the cancellable loops: one Read can otherwise fill a
+ // multi-GB buffer before the token is checked again.
+ private const int CancelChunk = 4 * 1024 * 1024;
+
/// Decompress into a single buffer pre-sized from
/// the gzip ISIZE — avoids the repeated MemoryStream reallocations + final
/// ToArray copy (2–3× the decompressed size) the naive approach incurs.
@@ -183,7 +187,7 @@ public static byte[] Gunzip(Stream input, uint sizeHint, CancellationToken ct =
var total = 0;
int n;
while (total < buf.Length &&
- (n = gzip.Read(buf, total, buf.Length - total)) > 0)
+ (n = gzip.Read(buf, total, Math.Min(CancelChunk, buf.Length - total))) > 0)
{
total += n;
ct.ThrowIfCancellationRequested(); // abandon promptly on nav-away
@@ -212,8 +216,9 @@ public static byte[] Gunzip(Stream input, uint sizeHint, CancellationToken ct =
/// Decompress at most bytes from a gzip stream,
/// stopping as soon as the buffer is full (no further input is consumed).
- /// Returns fewer bytes when the stream ends before the limit.
- public static byte[] GunzipPartial(Stream input, int maxBytes)
+ /// Returns fewer bytes when the stream ends before the limit. Checks
+ /// before each read (abandon promptly on nav-away).
+ public static byte[] GunzipPartial(Stream input, int maxBytes, CancellationToken ct = default)
{
try
{
@@ -221,9 +226,13 @@ public static byte[] GunzipPartial(Stream input, int maxBytes)
using var gzip = new GZipStream(input, CompressionMode.Decompress, leaveOpen: true);
var buf = new byte[maxBytes];
var total = 0;
- int n;
- while (total < maxBytes && (n = gzip.Read(buf, total, maxBytes - total)) > 0)
+ while (total < maxBytes)
+ {
+ ct.ThrowIfCancellationRequested();
+ var n = gzip.Read(buf, total, Math.Min(CancelChunk, maxBytes - total));
+ if (n <= 0) break;
total += n;
+ }
return total == maxBytes ? buf : Trim(buf, total);
}
catch (InvalidDataException)
diff --git a/MIQ.Core/Parsing/MiqParser.cs b/MIQ.Core/Parsing/MiqParser.cs
index 93d7f0e..4e2d8f6 100644
--- a/MIQ.Core/Parsing/MiqParser.cs
+++ b/MIQ.Core/Parsing/MiqParser.cs
@@ -63,6 +63,11 @@ public static MiqImage Parse(string filePath, CancellationToken ct = default)
};
}
+ /// Uncancellable . An
+ /// overload rather than an optional parameter so the method group still
+ /// converts to Func<string, MiqImage>.
+ public static MiqImage ParsePartial(string filePath) => ParsePartial(filePath, CancellationToken.None);
+
///
/// Fast path for multi-volume NIfTI: returns just volume 0 as a partial
/// ( = true) so the
@@ -73,8 +78,10 @@ public static MiqImage Parse(string filePath, CancellationToken ct = default)
/// Falls back to a full for 3-D / small files, or when the
/// data is shorter than the header implies; those return a complete image
/// (IsPartial = false).
+ /// abandons the read/decompress when the user navigates
+ /// away (throws ).
///
- public static MiqImage ParsePartial(string filePath)
+ public static MiqImage ParsePartial(string filePath, CancellationToken ct)
{
var kind = MiqFileKindExtensions.FromPath(filePath)
?? throw MiqException.UnsupportedFileFormat();
@@ -83,14 +90,14 @@ public static MiqImage ParsePartial(string filePath)
{
var len = new FileInfo(filePath).Length;
if (len > MaxArrayBytes)
- return ParseNiftiFirstVolume(filePath, blocked: true);
+ return ParseNiftiFirstVolume(filePath, blocked: true, ct);
if (len > PartialLoadThreshold)
- return ParseNiftiFirstVolume(filePath, blocked: false);
+ return ParseNiftiFirstVolume(filePath, blocked: false, ct);
}
// Only multi-volume .nii.gz benefits; everything else parses fully.
if (kind != MiqFileKind.NiiGz)
- return Parse(filePath);
+ return Parse(filePath, ct);
// Probe: decompress just enough to read the NIfTI header (max 1024 B;
// NIfTI-2 header is 540 B so 1024 covers both variants with margin).
@@ -99,19 +106,19 @@ public static MiqImage ParsePartial(string filePath)
try
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
- probe = MiqBinaryReader.GunzipPartial(fs, probeSize);
+ probe = MiqBinaryReader.GunzipPartial(fs, probeSize, ct);
}
- catch { return Parse(filePath); }
+ catch (Exception e) when (e is not OperationCanceledException) { return Parse(filePath, ct); }
if (probe.Length < 4)
- return Parse(filePath);
+ return Parse(filePath, ct);
MiqHeader header;
try { header = NiftiParser.ParseHeader(probe, compressed: true); }
- catch { return Parse(filePath); }
+ catch { return Parse(filePath, ct); }
if (header.Volumes <= 1)
- return Parse(filePath); // already 3-D — no benefit
+ return Parse(filePath, ct); // already 3-D — no benefit
// Budget = bytes needed for header + exactly one volume.
var budget = (long)header.VoxOffset
@@ -119,7 +126,7 @@ public static MiqImage ParsePartial(string filePath)
* header.Datatype.BytesPerVoxel();
if (budget > int.MaxValue)
- return Parse(filePath); // volume 0 alone can't be held — full path reports it
+ return Parse(filePath, ct); // volume 0 alone can't be held — full path reports it
// Full size from the header, NOT the gzip ISIZE footer: ISIZE is the size
// mod 2^32, so a >4 GiB series can report a small ISIZE and be offered an
@@ -129,20 +136,20 @@ public static MiqImage ParsePartial(string filePath)
// Too few volumes to pay off (see PartialGzipMinVolumes) — unless the full
// data can't fit a byte[], which must stay on the blocked vol-0 view.
if (header.Volumes < PartialGzipMinVolumes && !blocked)
- return Parse(filePath);
+ return Parse(filePath, ct);
byte[] partial;
try
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
- partial = MiqBinaryReader.GunzipPartial(fs, (int)budget);
+ partial = MiqBinaryReader.GunzipPartial(fs, (int)budget, ct);
}
- catch { return Parse(filePath); }
+ catch (Exception e) when (e is not OperationCanceledException) { return Parse(filePath, ct); }
// Stream ended inside volume 0: the file is shorter than its header says,
// so it is small enough for the full path, which reports the truncation.
if (partial.Length < budget)
- return Parse(filePath);
+ return Parse(filePath, ct);
return new MiqImage
{
@@ -174,32 +181,32 @@ internal static long NiftiFileExtent(MiqHeader header)
/// : true when the full data can't be
/// held (permanent volume-0 view); false to let the viewer expand in the
/// background (a latency optimisation for large-but-in-limit files).
- private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked)
+ private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked, CancellationToken ct)
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
// Probe enough for either NIfTI-1 (348 B) or NIfTI-2 (540 B) header.
var probe = new byte[1024];
- var probed = ReadFully(fs, probe, probe.Length);
+ var probed = ReadFully(fs, probe, probe.Length, ct);
MiqHeader header;
try { header = NiftiParser.ParseHeader(Trim(probe, probed), compressed: false); }
- catch { return Parse(filePath); }
+ catch { return Parse(filePath, ct); }
// A single volume is assumed to always fit; if a >2 GB file claims to be
// 3-D, fall back to the full path so it fails with a clear message.
if (header.Volumes <= 1)
- return Parse(filePath);
+ return Parse(filePath, ct);
var budget = (long)header.VoxOffset
+ (long)header.Width * header.Height * header.Depth
* header.Datatype.BytesPerVoxel();
if (budget <= 0 || budget > MaxArrayBytes)
- return Parse(filePath); // volume 0 itself doesn't fit — defer to full path
+ return Parse(filePath, ct); // volume 0 itself doesn't fit — defer to full path
var storage = new byte[budget];
fs.Seek(0, SeekOrigin.Begin);
- if (ReadFully(fs, storage, storage.Length) < storage.Length)
- return Parse(filePath); // file shorter than the header implies
+ if (ReadFully(fs, storage, storage.Length, ct) < storage.Length)
+ return Parse(filePath, ct); // file shorter than the header implies
return new MiqImage
{
@@ -213,12 +220,19 @@ private static MiqImage ParseNiftiFirstVolume(string filePath, bool blocked)
/// Reads up to bytes into ,
/// looping over short reads. Returns the number actually read (< count at EOF).
- private static int ReadFully(Stream s, byte[] buf, int count)
+ /// Reads in chunks, checking between them, so a
+ /// multi-GB volume-0 read abandons promptly on nav-away.
+ private static int ReadFully(Stream s, byte[] buf, int count, CancellationToken ct)
{
+ const int chunk = 4 * 1024 * 1024;
var total = 0;
- int n;
- while (total < count && (n = s.Read(buf, total, count - total)) > 0)
+ while (total < count)
+ {
+ ct.ThrowIfCancellationRequested();
+ var n = s.Read(buf, total, Math.Min(chunk, count - total));
+ if (n <= 0) break;
total += n;
+ }
return total;
}
diff --git a/MIQ.Tests/MifScalingTests.cs b/MIQ.Tests/MifScalingTests.cs
new file mode 100644
index 0000000..1daaa6c
--- /dev/null
+++ b/MIQ.Tests/MifScalingTests.cs
@@ -0,0 +1,80 @@
+using System.Text;
+using MIQ.Parsing;
+using MIQ.Rendering;
+using Xunit;
+
+namespace MIQ.Tests;
+
+// MRtrix `scaling: offset,scale` (value = offset + scale × stored). Previously
+// ignored, so the voxel readout showed raw stored integers and a negative scale
+// rendered inverted.
+public class MifScalingTests
+{
+ /// 2×2×2 uint8 MIF whose first voxel (or, with , every
+ /// voxel) stores 10. null omits the field;
+ /// otherwise it is written verbatim.
+ static byte[] MifFile(string? scaling, bool fill = false)
+ {
+ var header = new StringBuilder();
+ header.Append("mrtrix image\n");
+ header.Append("dim: 2,2,2\n");
+ header.Append("vox: 1,1,1\n");
+ header.Append("layout: +0,+1,+2\n");
+ header.Append("datatype: UInt8\n");
+ if (scaling is not null) header.Append("scaling: ").Append(scaling).Append('\n');
+ header.Append("file: .\n");
+ header.Append("END\n");
+
+ var bytes = Encoding.ASCII.GetBytes(header.ToString());
+ var file = new byte[bytes.Length + 8];
+ bytes.CopyTo(file, 0);
+ for (var i = 0; i < (fill ? 8 : 1); i++) file[bytes.Length + i] = 10;
+ return file;
+ }
+
+ static MiqImage Parse(string? scaling, bool fill = false) =>
+ MifParser.Parse(MifFile(scaling, fill));
+
+ static float FirstVoxel(MiqImage image) => new MiqVolume(image).VoxelValue(0, 0, 0, 0);
+
+ [Fact]
+ public void Scaling_AppliesOffsetThenScale()
+ {
+ var image = Parse("5,2"); // offset 5, scale 2
+ Assert.Equal(2f, image.Header.SclSlope);
+ Assert.Equal(5f, image.Header.SclInter);
+ Assert.Equal(25f, FirstVoxel(image)); // 5 + 2 × 10
+ }
+
+ [Fact]
+ public void Scaling_ShowsInMetadata()
+ {
+ var lines = new MiqMetadata(Parse("5,2").Header, "MRtrix MIF", null).AsDisplayLines();
+ Assert.Contains(lines, e => e.Label == "Scaling" && e.Value == "x 2 + 5");
+ }
+
+ // A uniform label-like volume colours when unscaled; non-identity scaling
+ // marks it as intensity data, so it must fall back to windowing.
+ [Fact]
+ public void Scaling_NonIdentity_DisablesSegmentationColouring()
+ {
+ var auto = new MiqRenderingOptions(Segmentation: MiqSegmentationColoring.Auto);
+ Assert.NotNull(new MiqVolume(Parse(null, fill: true)).BuildSegmentationLut(auto));
+ Assert.Null(new MiqVolume(Parse("0,0.5", fill: true)).BuildSegmentationLut(auto));
+ }
+
+ [Theory]
+ [InlineData(null)] // absent
+ [InlineData("0,1")] // identity
+ [InlineData("1,0")] // zero scale
+ [InlineData("nan,2")] // non-finite
+ [InlineData("5")] // wrong arity
+ [InlineData("abc,def")] // malformed
+ public void Scaling_AbsentOrUnusable_IsUnscaled(string? scaling)
+ {
+ var image = Parse(scaling);
+ Assert.Equal(0f, image.Header.SclSlope);
+ Assert.Equal(0f, image.Header.SclInter);
+ Assert.Equal(10f, FirstVoxel(image));
+ }
+}
diff --git a/MIQ.Tests/ParseCancellationTests.cs b/MIQ.Tests/ParseCancellationTests.cs
new file mode 100644
index 0000000..f9b0fb8
--- /dev/null
+++ b/MIQ.Tests/ParseCancellationTests.cs
@@ -0,0 +1,95 @@
+using System.Buffers.Binary;
+using System.IO.Compression;
+using MIQ.Parsing;
+using Xunit;
+
+namespace MIQ.Tests;
+
+// Changes the process-wide MiqParser.PartialLoadThreshold seam in one test, so
+// this class must not overlap others that parse .nii files.
+[CollectionDefinition(nameof(PartialLoadSeam), DisableParallelization = true)]
+public class PartialLoadSeam { }
+
+// The first preview load (ParsePartial) must honour its token on every path, so
+// navigating away abandons the read/decompress instead of finishing it.
+[Collection(nameof(PartialLoadSeam))]
+public class ParseCancellationTests
+{
+ const int Side = 8;
+ const int VolumeBytes = Side * Side * Side * 2; // int16
+ const int Offset = 352;
+
+ static byte[] Nifti1(int volumes)
+ {
+ var file = new byte[Offset + volumes * VolumeBytes];
+ var h = file.AsSpan();
+ BinaryPrimitives.WriteInt32LittleEndian(h, 348);
+ short[] dims = [(short)(volumes > 1 ? 4 : 3), Side, Side, Side, (short)volumes];
+ for (var i = 0; i < dims.Length; i++)
+ BinaryPrimitives.WriteInt16LittleEndian(h[(40 + i * 2)..], dims[i]);
+ BinaryPrimitives.WriteInt16LittleEndian(h[70..], (short)MiqDatatype.Int16);
+ BinaryPrimitives.WriteInt16LittleEndian(h[72..], 16);
+ for (var i = 0; i < 8; i++)
+ BinaryPrimitives.WriteSingleLittleEndian(h[(76 + i * 4)..], 1f);
+ BinaryPrimitives.WriteSingleLittleEndian(h[108..], Offset);
+ return file;
+ }
+
+ static byte[] Gzip(byte[] raw)
+ {
+ using var ms = new MemoryStream();
+ using (var gz = new GZipStream(ms, CompressionLevel.Optimal, leaveOpen: true))
+ gz.Write(raw);
+ return ms.ToArray();
+ }
+
+ static CancellationToken Cancelled()
+ {
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+ return cts.Token;
+ }
+
+ static void AssertCancels(byte[] bytes, string suffix)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"miq-cancel-{Guid.NewGuid():N}{suffix}");
+ File.WriteAllBytes(path, bytes);
+ try
+ {
+ Assert.ThrowsAny(
+ () => MiqParser.ParsePartial(path, Cancelled()));
+ // The same file still loads with no token (default: never cancelled).
+ Assert.NotNull(MiqParser.ParsePartial(path));
+ }
+ finally { File.Delete(path); }
+ }
+
+ [Fact]
+ public void MultiVolumeGzip_PartialPath_Cancels() =>
+ AssertCancels(Gzip(Nifti1(MiqParser.PartialGzipMinVolumes)), ".nii.gz");
+
+ [Fact]
+ public void SingleVolumeGzip_FullParsePath_Cancels() =>
+ AssertCancels(Gzip(Nifti1(1)), ".nii.gz");
+
+ [Fact]
+ public void SmallUncompressed_FullParsePath_Cancels() =>
+ AssertCancels(Nifti1(2), ".nii");
+
+ [Fact]
+ public void LargeUncompressed_Volume0Path_Cancels()
+ {
+ var saved = MiqParser.PartialLoadThreshold;
+ MiqParser.PartialLoadThreshold = 0; // force the vol-0-first read on a tiny file
+ try { AssertCancels(Nifti1(2), ".nii"); }
+ finally { MiqParser.PartialLoadThreshold = saved; }
+ }
+
+ [Fact]
+ public void GunzipPartial_Cancels()
+ {
+ using var input = new MemoryStream(Gzip(Nifti1(1)));
+ Assert.ThrowsAny(
+ () => MiqBinaryReader.GunzipPartial(input, 1024, Cancelled()));
+ }
+}
diff --git a/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs b/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs
index 0e4cbe4..398381a 100644
--- a/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs
+++ b/QuickLook.Plugin.MIQ/MiqTriPlanarControl.cs
@@ -57,6 +57,9 @@ private static readonly (SlicePlane plane, double ax, double ay)[] Layout =
// through previews never triggers background work. Null once expanded/blocked.
private readonly Action? _onExpandRequested;
private bool _expansionRequested; // guards a single trigger; flips row to "loading…"
+ // The background load failed: volume 0 stays, the row shows a failure notice
+ // (reason in the tooltip) instead of "loading…" forever. No automatic retry.
+ private bool _expansionFailed;
// Per-volume window cache (populated lazily on scrub when PerVolumeWindow = true).
private readonly bool _perVolumeWindow;
@@ -162,6 +165,17 @@ private void RequestExpansion()
_onExpandRequested!();
}
+ /// Background expansion failed (called on the UI thread). Keeps volume 0 and
+ /// replaces the "loading…" row with a failure notice; the reason goes in the
+ /// tooltip since the metadata row has no room for it.
+ internal void ExpansionFailed(string message)
+ {
+ if (_isExpanded) return;
+ _expansionFailed = true;
+ ToolTip = $"Could not load all volumes: {message}";
+ InvalidateVisual();
+ }
+
///
/// Swaps in the fully-decompressed volume (called from the UI thread once
/// background expansion finishes). Enables the volume scrubber.
@@ -291,6 +305,7 @@ private void RenderContent(DrawingContext dc, Size sz)
var mode =
_isExpanded ? WpfPreviewRenderer.ScrubMode.Expanded :
_expansionBlocked ? WpfPreviewRenderer.ScrubMode.Blocked :
+ _expansionFailed ? WpfPreviewRenderer.ScrubMode.Failed :
_expansionRequested ? WpfPreviewRenderer.ScrubMode.Loading :
_onExpandRequested != null ? WpfPreviewRenderer.ScrubMode.Loadable :
WpfPreviewRenderer.ScrubMode.Loading;
diff --git a/QuickLook.Plugin.MIQ/Plugin.cs b/QuickLook.Plugin.MIQ/Plugin.cs
index 5daa70d..4f2af8f 100644
--- a/QuickLook.Plugin.MIQ/Plugin.cs
+++ b/QuickLook.Plugin.MIQ/Plugin.cs
@@ -86,8 +86,9 @@ public void View(string path, ContextObject context)
var kind = MiqFileKindExtensions.FromPath(path);
// Phase 1: volume-0-only load where it pays off (see ParsePartial);
- // otherwise a full parse (IsPartial = false).
- var image = MiqParser.ParsePartial(path);
+ // otherwise a full parse (IsPartial = false). The token abandons the
+ // read/decompress if the user navigates away mid-load.
+ var image = MiqParser.ParsePartial(path, cts.Token);
if (cts.IsCancellationRequested) return;
var fmt = image.Header.FormatLabel ?? kind?.DisplayName() ?? "Unknown";
@@ -137,6 +138,10 @@ public void View(string path, ContextObject context)
context.IsBusy = false;
});
}
+ catch (OperationCanceledException) when (cts.IsCancellationRequested)
+ {
+ // Navigated away mid-load: nothing to show.
+ }
catch (Exception ex)
{
control.Dispatcher.BeginInvoke(() =>
@@ -178,7 +183,21 @@ private void StartExpansion(string path, MiqRenderingOptions options,
view.ExpandVolume(fullVolume, fullLut);
});
}
- catch (Exception) { /* cancelled, or viewer cleaned up */ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ // Navigated away mid-load: nothing to report.
+ }
+ catch (Exception ex)
+ {
+ // A real failure (out of memory, I/O error, file changed): tell the
+ // view so it leaves "loading…" for the volume-0-only notice.
+ var message = ex.Message;
+ control.Dispatcher.BeginInvoke(() =>
+ {
+ if (token.IsCancellationRequested) return;
+ view.ExpansionFailed(message);
+ });
+ }
finally { thread.Priority = prevPriority; }
});
}
diff --git a/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs b/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs
index 6ca4e6f..1d0f89a 100644
--- a/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs
+++ b/QuickLook.Plugin.MIQ/WpfPreviewRenderer.cs
@@ -93,8 +93,9 @@ private static void DrawAxisLabels(
/// State of the Volumes row's scrubber. draws an
/// interactive track but the full volume isn't loaded yet — the first scrub
/// gesture triggers the background load (then the row shows
- /// until it expands). is the permanent volume-0-only view.
- internal enum ScrubMode { Expanded, Loadable, Loading, Blocked }
+ /// until it expands). is the permanent volume-0-only view;
+ /// is the same view after a background load error.
+ internal enum ScrubMode { Expanded, Loadable, Loading, Blocked, Failed }
///
/// Draws metadata entries. When ≥ 0 and the entry
@@ -148,10 +149,14 @@ internal static (Rect hitRect, double trackX0, double trackX1) DrawMetadata(
}
else
{
- // Loading the rest, or permanently blocked (too large) — vol 0 only.
- var valStr = scrubMode == ScrubMode.Blocked
- ? $"1 / {scrubTotal} · first volume only (too large for 4-D)"
- : $"{scrubVol + 1} / {scrubTotal} loading…";
+ // Loading the rest, permanently blocked (too large), or the load
+ // failed — vol 0 only.
+ var valStr = scrubMode switch
+ {
+ ScrubMode.Blocked => $"1 / {scrubTotal} · first volume only (too large for 4-D)",
+ ScrubMode.Failed => $"1 / {scrubTotal} · first volume only (full load failed)",
+ _ => $"{scrubVol + 1} / {scrubTotal} loading…",
+ };
values[i] = Text(valStr, s.MetadataLabelBrush, fs * 0.9, Regular);
}
}