From 28525c90aa5d7e21bdaaa71a2e9abb65a4707e15 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 15:19:28 -0700 Subject: [PATCH] feat(scoreboard): scroll speed is a property (20-400 px/s, persisted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marquee now has the dial it needs. `ScoreboardPresenter.PixelsPerSecond` is the single owner of the range (20-400, default 120), the layer's properties expose it with a live px/s readout, and the value is persisted as Scoreboard.ScrollSpeed because it is a READABILITY choice — re-dialling it every launch would be a tax on the creator who needs the strip to be readable. Advance() still takes an optional override so tests can drive exact distances. Guard: the presenter test proves speed is real (same elapsed time, twice the speed, different window) — not a value that is stored and ignored — and the panel test pins the control, its range and the persistence key. --- Controls/LeftPanel.xaml | 10 ++++++++++ Services/Compositor/ScoreboardPresenter.cs | 12 ++++++++++-- Services/LayoutStore.Settings.cs | 10 ++++++++++ ViewModels/MainViewModel.Scoreboard.cs | 14 ++++++++++++++ ViewModels/MainViewModel.cs | 1 + ytLive.Tests/ScoreboardPresenterTests.cs | 10 ++++++++++ ytLive.Tests/ScoreboardPropertiesTests.cs | 7 ++++++- 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index ca0dd2d..b0cd4b5 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -479,6 +479,16 @@ ItemsSource="{Binding ScoreboardEdgeOptions}" SelectedItem="{Binding ScoreboardEdge, Mode=TwoWay}"/> + + + + + + public bool Scroll { get; set; } = true; + /// Marquee speed in pixels per second — the creator's dial (a read-speed choice, not a + /// styling one). + public double PixelsPerSecond { get; set; } = DefaultPixelsPerSecond; + + public const double DefaultPixelsPerSecond = 120; + public const double MinPixelsPerSecond = 20; + public const double MaxPixelsPerSecond = 400; + /// Advances the scroll by . The strip is a marquee: one /// measured run, cropped to a moving window, so a tick is a row-wise byte copy rather than a /// re-rasterisation. - public void Advance(double seconds, double pixelsPerSecond = 120) + public void Advance(double seconds, double? pixelsPerSecond = null) { if (!Scroll) return; - _offsetPx += seconds * pixelsPerSecond; + _offsetPx += seconds * (pixelsPerSecond ?? PixelsPerSecond); } /// The strip frame for the current tallies, or null when nothing is enabled/shown. diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 58ae368..de6a4b7 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; +using ytLive.Services.Compositor; using ytLive.Models; namespace ytLive.Services; @@ -57,6 +58,15 @@ public partial class LayoutStore : IDisposable public void SaveScoreboardEnabled(bool enabled) => UpsertSetting("Scoreboard.Enabled", enabled ? "1" : "0"); + /// Scoreboard marquee speed (px/s), restored at startup. + public int LoadScoreboardScrollSpeed() + => int.TryParse(GetSetting("Scoreboard.ScrollSpeed"), out var px) + ? Math.Clamp(px, (int)ScoreboardPresenter.MinPixelsPerSecond, (int)ScoreboardPresenter.MaxPixelsPerSecond) + : (int)ScoreboardPresenter.DefaultPixelsPerSecond; + + public void SaveScoreboardScrollSpeed(int pixelsPerSecond) + => UpsertSetting("Scoreboard.ScrollSpeed", pixelsPerSecond.ToString()); + /// The user's chosen record output folder (TASK 18), or null to use the /// default %APPDATA%\ytLlive\recordings. Restored at startup. public string? LoadRecordFolder() diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs index f45b3e5..b6a4560 100644 --- a/ViewModels/MainViewModel.Scoreboard.cs +++ b/ViewModels/MainViewModel.Scoreboard.cs @@ -43,6 +43,20 @@ public partial class MainViewModel } public IReadOnlyList ScoreboardEdgeOptions { get; } = new[] { BarEdge.Top, BarEdge.Bottom }; + + /// How fast the ticker travels, in pixels per second. Persisted like the rest of the + /// strip's look: it is a readability choice, and re-dialling it every launch would be a tax. + public double ScoreboardScrollSpeed + { + get => _scoreboardPresenter?.PixelsPerSecond ?? ScoreboardPresenter.DefaultPixelsPerSecond; + set + { + var clamped = Math.Clamp(value, ScoreboardPresenter.MinPixelsPerSecond, ScoreboardPresenter.MaxPixelsPerSecond); + if (_scoreboardPresenter != null) _scoreboardPresenter.PixelsPerSecond = clamped; + _layoutStore.SaveScoreboardScrollSpeed((int)Math.Round(clamped)); + OnPropertyChanged(nameof(ScoreboardScrollSpeed)); + } + } private Source? _scoreboardPreviewSource; internal ScoreboardRecorder ScoreboardRecorder => _scoreboard; diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index cc31cda..f6620c7 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -280,6 +280,7 @@ public partial class MainViewModel : ViewModelBase _musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default InitScoreboard(); ScoreboardEnabled = _layoutStore.LoadScoreboardEnabled(); + ScoreboardScrollSpeed = _layoutStore.LoadScoreboardScrollSpeed(); try { var s = _layoutStore.GetSetting("ZoomShape"); diff --git a/ytLive.Tests/ScoreboardPresenterTests.cs b/ytLive.Tests/ScoreboardPresenterTests.cs index ae43954..90304c6 100644 --- a/ytLive.Tests/ScoreboardPresenterTests.cs +++ b/ytLive.Tests/ScoreboardPresenterTests.cs @@ -48,6 +48,16 @@ public class ScoreboardPresenterTests Assert.NotNull(ticked); Assert.NotEqual(first.BgraPixels, ticked!.BgraPixels); + // SPEED is a real dial: the same elapsed time at twice the speed moves twice as far, + // so the window lands somewhere else. + var slow = new ScoreboardPresenter(recorder, new ScoreboardStyle()) { PixelsPerSecond = 60 }; + var fast = new ScoreboardPresenter(recorder, new ScoreboardStyle()) { PixelsPerSecond = 120 }; + slow.SetTiles(tiles); + fast.SetTiles(tiles); + slow.Advance(1.0); + fast.Advance(1.0); + Assert.NotEqual(slow.Frame!.BgraPixels, fast.Frame!.BgraPixels); + // A visible change re-rasterises the run. recorder.Record(Member("Second")); var second = presenter.Frame; diff --git a/ytLive.Tests/ScoreboardPropertiesTests.cs b/ytLive.Tests/ScoreboardPropertiesTests.cs index 07d124f..ce76603 100644 --- a/ytLive.Tests/ScoreboardPropertiesTests.cs +++ b/ytLive.Tests/ScoreboardPropertiesTests.cs @@ -29,6 +29,11 @@ public class ScoreboardPropertiesTests Assert.Contains("Simulate (dev)", block); Assert.Contains("Brush.Attention", block); + // Scroll speed: a real dial, persisted, with its range owned by the presenter. + Assert.Contains("ScoreboardScrollSpeed", block); + Assert.Contains("Minimum=\"20\" Maximum=\"400\"", block); + Assert.Contains("Scoreboard.ScrollSpeed", SourceAssert.Text("Services", "LayoutStore.Settings.cs")); + // The master switch sits in the same panel: off hides the strip and stops the polling. Assert.Contains("ScoreboardEnabled", panel); @@ -61,7 +66,7 @@ public class ScoreboardPropertiesTests { "ScoreboardTextColor", "ScoreboardBackgroundColor", "ScoreboardBackgroundOpacity", "ScoreboardFontSize", "ScoreboardColorOptions", "ScoreboardShowSubscribers", - "ScoreboardEnabled", "CanUseScoreboard", + "ScoreboardEnabled", "CanUseScoreboard", "ScoreboardScrollSpeed", }) Assert.NotNull(type.GetProperty(name)); }