Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/VcrSharp.Core/Session/SessionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ public class SessionOptions
/// </summary>
public bool FitToContent { get; set; }

/// <summary>
/// Gets or sets whether fit-to-content crops only the height, keeping the full grid
/// width (Cols × cell width). Set via <c>Set Size "fit-height"</c>. Use this to keep a
/// uniform width across a batch of screenshots — so apparent font size stays constant
/// when they are scaled to a fixed display width — while still trimming trailing blank
/// rows. Implies <see cref="FitToContent"/>. Defaults to false.
/// </summary>
public bool FitHeightOnly { get; set; }

// Font Settings

/// <summary>
Expand Down Expand Up @@ -625,9 +634,14 @@ private static void ApplySetting(SessionOptions options, string name, object val
case "mode": // capture mode: "animated" (default, capture frames) or "static" (one settled frame)
options.StaticOutput = string.Equals(value.ToString(), "static", StringComparison.OrdinalIgnoreCase);
break;
case "size": // canvas sizing: "grid" (default, exact Cols×Rows) or "fit" (crop to content + scale)
options.FitToContent = string.Equals(value.ToString(), "fit", StringComparison.OrdinalIgnoreCase);
case "size": // canvas sizing: "grid" (viewport), "fit" (crop to content), "fit-height" (keep Cols width, crop height)
{
var size = (value.ToString() ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty).Trim();
options.FitHeightOnly = size.Equals("fitheight", StringComparison.OrdinalIgnoreCase)
|| size.Equals("fitrows", StringComparison.OrdinalIgnoreCase);
options.FitToContent = size.StartsWith("fit", StringComparison.OrdinalIgnoreCase);
break;
}
}
}

Expand Down
16 changes: 14 additions & 2 deletions src/VcrSharp.Core/Settings/SettingDeprecations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,20 @@ public static class SettingDeprecations
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["Mode"] = new[] { "animated", "static" },
["Size"] = new[] { "grid", "fit" },
// Size accepts grid | fit | fit-height (alias fit-rows). Separators are normalized away
// before comparison (see NormalizeEnumValue), mirroring SessionOptions.ApplySetting, so
// "fit-height", "fit_height" and "fitheight" all validate.
["Size"] = new[] { "grid", "fit", "fit-height", "fit-rows" },
};

/// <summary>
/// Strips separators and case so enum-value comparison matches how <c>SessionOptions.ApplySetting</c>
/// reads the value (e.g. <c>Set Size "fit-height"</c> ≡ <c>fitheight</c>). Keeps the typo lint from
/// false-flagging a spelling the engine actually honors.
/// </summary>
private static string NormalizeEnumValue(string? value) =>
(value ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty).Trim();

/// <summary>
/// Collects deprecation warnings (and Mode/Size typo warnings) for the commands a user authored.
/// Pass the parsed tape commands (before preset resolution) so line numbers point at the tape.
Expand All @@ -55,7 +66,8 @@ public static List<string> Collect(IEnumerable<ICommand> commands)
warnings.Add($"{where}'Set {set.SettingName}' is deprecated — {settingMessage}.");
}
else if (EnumSettings.TryGetValue(set.SettingName, out var allowed)
&& !allowed.Contains(set.Value.ToString(), StringComparer.OrdinalIgnoreCase))
&& !allowed.Any(a => NormalizeEnumValue(a)
.Equals(NormalizeEnumValue(set.Value.ToString()), StringComparison.OrdinalIgnoreCase)))
{
warnings.Add($"{where}'Set {set.SettingName} {set.Value}' is not recognized — use {string.Join(" or ", allowed)}.");
}
Expand Down
5 changes: 5 additions & 0 deletions src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,11 @@ private void CalculateDimensions()
/// </summary>
public void SetContentExtent(int cols, int rows)
{
// Fit-height mode keeps the full grid width (Cols) and crops only the height, so a
// batch of screenshots shares one width — and thus one apparent font size when scaled
// to a fixed display width. Grow, never shrink, so content wider than Cols isn't clipped.
if (_options.FitHeightOnly && _options.Cols.HasValue)
cols = Math.Max(cols, _options.Cols.Value);
_cropCols = Math.Max(cols, 1);
_cropRows = Math.Max(rows, 1);
CalculateDimensions();
Expand Down
32 changes: 24 additions & 8 deletions src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,26 +51,42 @@ public static async Task<int> WriteAnimatedAsync(IReadOnlyList<TerminalStateWith

/// <summary>
/// Down-samples a timestamp-ordered state stream to at most <paramref name="fps"/> frames per second
/// by keeping the first state in each 1/fps window and skipping everything until the window elapses.
/// The final state is always kept so the settled end-of-recording output is never dropped. Returns
/// the input unchanged when there is nothing to thin (fps &lt;= 0 or two-or-fewer states).
/// by keeping the LAST state in each 1/fps window (plus the first and final states). Returns the
/// input unchanged when there is nothing to thin (fps &lt;= 0 or two-or-fewer states).
/// <para>
/// Keeping the last — not the first — state in each window is what makes this safe for the
/// event-driven capture stream. A screen redraw (a table scrolling in, a TUI repaint) is captured
/// as a short-lived torn intermediate frame immediately followed by the settled frame, often only
/// ~10&#8211;15&#160;ms apart. Both fall inside one 1/fps window. Keeping the first would freeze the
/// torn frame for the whole window (~one display slot, e.g. a 2&#160;s plateau) — visible corruption
/// where rows from two different screens overlap. Keeping the last drops the transient and shows the
/// settled screen, which is also what the un-quantized raster/GIF path effectively displays (the tear
/// only ever flashes for its true sub-frame duration there). The final settled frame is always kept so
/// end-of-recording output survives.
/// </para>
/// </summary>
internal static List<TerminalStateWithTime> QuantizeToFramerate(List<TerminalStateWithTime> states, int fps)
{
if (fps <= 0 || states.Count <= 2) return states;

var minInterval = 1.0 / fps;
var result = new List<TerminalStateWithTime>(states.Count);
var lastKept = double.NegativeInfinity;

static long Window(double ts, double minInterval) => (long)(ts / minInterval);

for (var i = 0; i < states.Count; i++)
{
// Keep the first state (so t=0 already shows content) and the last state (settled end).
// Otherwise keep a state only when it is the last one in its frame window — i.e. the next
// state belongs to a later window. A state superseded within the same window is a transient
// mid-redraw frame and is skipped in favor of the settled state it resolves into.
var isFirst = i == 0;
var isLast = i == states.Count - 1;
if (isLast || states[i].TimestampSeconds - lastKept >= minInterval)
{
var lastInWindow = isLast ||
Window(states[i + 1].TimestampSeconds, minInterval) > Window(states[i].TimestampSeconds, minInterval);

if (isFirst || lastInWindow)
result.Add(states[i]);
lastKept = states[i].TimestampSeconds;
}
}

return result;
Expand Down
88 changes: 88 additions & 0 deletions tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using Shouldly;
using VcrSharp.Core.Rendering;
using VcrSharp.Infrastructure.Rendering;

namespace VcrSharp.Core.Tests.Rendering;

/// <summary>
/// Tests for <see cref="SvgWriter.QuantizeToFramerate"/>, the framerate down-sampler for animated SVG.
/// The critical contract is that when a settled frame is preceded by a short-lived transient inside the
/// same frame window (the event-driven capture's mid-redraw tear), the SETTLED frame is the one kept —
/// otherwise the torn intermediate is frozen for the whole display slot, producing visible corruption.
/// </summary>
public class SvgQuantizeTests
{
private static TerminalStateWithTime State(double ts, string tag = "x")
{
// Content identity doesn't matter for quantization (it keys purely on timestamps); the tag just
// lets a test assert which frame survived.
var cells = new[] { new[] { new TerminalCell { Character = tag, Width = 1 } } };
return new TerminalStateWithTime
{
Content = new TerminalContent { Cols = 1, Rows = 1, Cells = cells },
TimestampSeconds = ts,
};
}

private static string Tag(TerminalStateWithTime s) => s.Content.Cells[0][0].Character!;

[Fact]
public void TransientThenSettled_InSameWindow_KeepsSettled()
{
// Mirrors the real capture stream: each ~2 s plateau is a short transient tear immediately
// followed (here ~14 ms later, inside one 1/50 s = 20 ms window) by the settled frame.
const int fps = 50;
var states = new List<TerminalStateWithTime>
{
State(0.000, "settled0"),
State(2.000, "torn1"), State(2.014, "settled1"),
State(4.000, "torn2"), State(4.013, "settled2"),
State(6.000, "torn3"), State(6.015, "settled3"),
};

var kept = SvgWriter.QuantizeToFramerate(states, fps).Select(Tag).ToList();

// The torn intermediates are dropped; the settled frame of each plateau survives.
kept.ShouldBe(new[] { "settled0", "settled1", "settled2", "settled3" });
}

[Fact]
public void FinalSettledFrame_IsAlwaysKept()
{
// The end-of-recording settled frame must survive even when it trails a transient within a window.
var states = new List<TerminalStateWithTime>
{
State(0.000, "a"),
State(5.000, "torn"), State(5.012, "final"),
};

SvgWriter.QuantizeToFramerate(states, 50).Select(Tag).Last().ShouldBe("final");
}

[Fact]
public void ContinuousFastStream_IsDownsampledTowardFramerate()
{
// A progress-bar-style emitter that changes every ~16 ms over 2 s should be thinned to roughly
// the target framerate (one frame per 1/fps window), not collapsed and not left untouched.
const int fps = 25; // 40 ms window
var states = new List<TerminalStateWithTime>();
for (var i = 0; i <= 120; i++) // 0..1.92 s in 16 ms steps => 121 states
states.Add(State(i * 0.016, $"f{i}"));

var kept = SvgWriter.QuantizeToFramerate(states, fps);

// ~2 s at 25 fps ≈ 50 frames; allow slack but it must be a real reduction from 121 and not a collapse.
kept.Count.ShouldBeGreaterThan(30);
kept.Count.ShouldBeLessThan(70);
// Timestamps stay strictly increasing and the endpoints are preserved.
Tag(kept[0]).ShouldBe("f0");
Tag(kept[^1]).ShouldBe("f120");
}

[Fact]
public void TwoOrFewerStates_ReturnedUnchanged()
{
var states = new List<TerminalStateWithTime> { State(0.0, "a"), State(1.0, "b") };
SvgWriter.QuantizeToFramerate(states, 50).ShouldBeSameAs(states);
}
}
10 changes: 10 additions & 0 deletions tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ public void Collect_FlagsModeAndSizeTypos()
.ShouldContain(w => w.Contains("Size") && w.Contains("grid or fit"));
}

[Fact]
public void Collect_DoesNotFlagFitHeightSizeVariants()
{
// fit-height (and its separator/case variants and the fit-rows alias) are honored by
// ApplySetting, so the typo lint must not flag them.
foreach (var value in new[] { "fit-height", "fit_height", "fitheight", "fit-rows" })
SettingDeprecations.Collect(Parser.ParseTape($"Set Size \"{value}\"\nExec \"x\""))
.ShouldNotContain(w => w.Contains("not recognized"), $"Size {value} should be accepted");
}

[Fact]
public void Collect_CleanModernTape_NoWarnings()
{
Expand Down