diff --git a/README.md b/README.md
index 7162fbc..32ee10b 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ These guides explain how to add the legacy self-contained 32-bit build to the os
- Template and bookmark tools, including an optional global hotkey for bookmarks
- Quick toggle bar under the menu bar: docked by default, or dragged out into a floating window for instant switches while editing
- "Freeze preview" switch: pins the preview at the current editor time while the editor keeps running (the distance helper still follows the editor)
+- Y axis (time) scale from x0.5 to x4.0 in 0.1 steps, via the quick toggle bar's "Y Scale" slider or by holding **Ctrl** and scrolling inside the viewer window (only while the viewer is in the foreground; the X axis is unaffected)
- Unified user settings and crash logs under a fixed `%LocalAppData%` path
## Requirements
diff --git a/osucatch-editor-realtimeviewer/DrawingHelper.cs b/osucatch-editor-realtimeviewer/DrawingHelper.cs
index 414b0ec..c24756d 100644
--- a/osucatch-editor-realtimeviewer/DrawingHelper.cs
+++ b/osucatch-editor-realtimeviewer/DrawingHelper.cs
@@ -163,6 +163,19 @@ public void LoadBeatmap(IBeatmap convertedBeatmap, int mods = 0)
if (CustomComboColours.Count <= 0) CustomComboColours = DefaultCustomComboColours;
}
+ ///
+ /// 由 approach time(ms)反推 AR: 里正向换算的逆运算
+ /// (AR < 5 段 AT = 1800 - 120·AR,AR ≥ 5 段 AT = 1200 - 150·(AR-5),两段在 AR=5 / AT=1200 处衔接)。
+ /// 用于把“当前 Y 轴缩放看到的纵向时间尺度”折算成大家熟悉的 AR:
+ /// 缩放到 r 时画面上 432 像素对应的时间是 ApproachTime / r,
+ /// 把它当成 AR 的 approach time 代进来即可。
+ ///
+ ///
+ /// approach time(ms)。
+ /// 等效 AR;超出 0~10 的值照实返回(不夹取)。
+ public static double ApproachRateFromApproachTime(double approachTime)
+ => (approachTime > 1200) ? (1800 - approachTime) / 120.0 : (1950 - approachTime) / 150.0;
+
///
/// 将后台流水线构建好的数据原子地应用到当前绘制实例。
/// 只替换装载期字段,不动 CurrentTime / NearbyHitObjects / Bookmarks 等运行时状态。
diff --git a/osucatch-editor-realtimeviewer/Form1.cs b/osucatch-editor-realtimeviewer/Form1.cs
index da96935..fa16063 100644
--- a/osucatch-editor-realtimeviewer/Form1.cs
+++ b/osucatch-editor-realtimeviewer/Form1.cs
@@ -87,6 +87,19 @@ public partial class Form1 : Form
/// 滑块刻度间隔(同样以 0.1 为单位):每 0.5 倍画一根刻度线。
private const int VerticalScaleTickTenths = 5;
+ /// 一格滚轮(Ctrl + 滚轮)调整多少:1 = 0.1,与滑块步进一致。
+ private const int VerticalScaleTenthsPerWheelNotch = 1;
+
+ /// Y 缩放功能区里“当前缩放相当于多少 AR”那一段文字的标识。
+ private const string VerticalScaleArTextKey = "VerticalScaleAR";
+
+ ///
+ /// 当前 Y 轴缩放(以 0.1 为单位)。与滑块位置、
+ /// 一起由 统一维护,Ctrl + 滚轮也走同一条路径,
+ /// 这样“滚轮改完滑块跟着动”天然成立(浮点倍率不适合拿来累加,故另存一份整数)。
+ ///
+ private int verticalScaleTenths = VerticalScaleDefaultTenths;
+
/// 快捷开关条上的下拉框 / 滑块所用的值标识。
private const string FreezeValueKey = FreezePreviewTimeKey;
@@ -345,6 +358,14 @@ private void Form1_Load(object sender, EventArgs e)
ApplyBarLineMode(BarLineSettings.CurrentMode, persist: false);
// 垂直缩放不持久化:每次启动都把滑块与画面恢复成默认的 x1.0
ApplyVerticalScale(VerticalScaleDefaultTenths);
+ // Ctrl + 滚轮缩放 Y 轴:滚轮消息发给“拥有键盘焦点的窗口”(焦点可能在画布上),
+ // 用消息过滤器在消息分发前拦下,不依赖 WinForms 的滚轮冒泡行为
+ wheelFilter = new VerticalScaleWheelFilter(this);
+ Application.AddMessageFilter(wheelFilter);
+ FormClosed += (sender, e) =>
+ {
+ if (wheelFilter != null) Application.RemoveMessageFilter(wheelFilter);
+ };
quickToggleDocking?.ApplyStartupState(
app.Default.QuickToggle_Visible,
app.Default.QuickToggle_Floating,
@@ -635,6 +656,8 @@ private void RequestDraw(int? editorTime, string? title, string statusText)
if (IsDisposed || Disposing) return;
if (title != null && this.Text != title) this.Text = title;
if (StateToolStripStatusLabel.Text != statusText) StateToolStripStatusLabel.Text = statusText;
+ // 后台重建刚提交的话 ApproachTime 可能已变(开/关 EZ、HR 或换谱面):顺带刷新“等效 AR”
+ RefreshVerticalScaleArText();
this.Canvas.Canvas_Paint(null, null);
}));
Log.ConsoleLog("Draw a frame successful.", Log.LogType.Drawing, Log.LogLevel.Debug);
@@ -1592,6 +1615,11 @@ private void CreateQuickToggles()
VerticalScaleDefaultTenths,
VerticalScaleTickTenths);
scaleGroup.AddLabel(VerticalScaleValueKey + "_Text", VerticalScaleStatusText());
+ // 当前缩放相当于多少 AR:紧跟在比例后面(开 EZ / HR 时按 mod 后的实际 AR 折算)
+ scaleGroup.AddLabel(VerticalScaleArTextKey, VerticalScaleArText());
+ // 条上只放得下“x1.0”“AR 9.3”,Ctrl+滚轮与折算口径写进悬停提示
+ quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleHintText());
+ quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleArTextKey, VerticalScaleArHintText());
ApplyQuickToggleIcons();
}
@@ -1769,10 +1797,13 @@ private static string LabelToggleToolTip(QuickLabelMode mode)
private void ApplyVerticalScale(int tenths)
{
int clamped = Math.Clamp(tenths, VerticalScaleMinTenths, VerticalScaleMaxTenths);
+ verticalScaleTenths = clamped;
drawingHelper.VerticalScale = clamped / 10f;
quickToggleBar?.SetGroupSliderValue(VerticalScaleGroupKey, VerticalScaleValueKey, clamped);
quickToggleBar?.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleStatusText());
+ // 比例一变,等效 AR 跟着变(同一帧内一起刷新,两个数字不会出现一帧的错位)
+ RefreshVerticalScaleArText();
}
///
@@ -1781,6 +1812,144 @@ private void ApplyVerticalScale(int tenths)
///
private static string VerticalScaleStatusText() => "x" + drawingHelper.VerticalScale.ToString("0.0");
+ ///
+ /// 当前 Y 轴缩放相当于多少 AR(形如 AR 9.3)。
+ /// 折算口径:画面上 432 像素对应的时间就是 的来历
+ /// (TimePerPixels = ApproachTime / 432)。缩放 r 倍后同样 432 像素对应
+ /// ApproachTime / r 毫秒,把这个时间当成 AR 的 approach time 反推,就得到等效 AR:
+ /// 放大(x>1)看到的时间尺度更短 → 等效 AR 更高,压扁则更低。
+ ///
+ /// 取自绘制状态,而它是由
+ /// convertedBeatmap.Difficulty.ApproachRate 算出来的——EZ / HR 属于
+ /// IApplicableToDifficulty,在换谱面的转换阶段就已经作用到难度值上,
+ /// 所以开 mod 后这里显示的自然是按 mod 后实际 AR 折算的结果。
+ ///
+ /// 等效值可能超出 0~10(放大到 4x 会高于 AR10,压扁到 0.5x 可能低于 AR0),一律照实显示。
+ ///
+ ///
+ private static string VerticalScaleArText()
+ {
+ int approachTime = drawingHelper.ApproachTime;
+ float scale = drawingHelper.VerticalScale;
+ // 还没读到谱面(ApproachTime 尚未装载)时给个占位符,避免显示 AR NaN / 负无穷
+ if (approachTime <= 0 || !(scale > 0)) return "AR -";
+
+ double ar = DrawingHelper.ApproachRateFromApproachTime(approachTime / (double)scale);
+ return "AR" + ar.ToString("0.0");
+ }
+
+ /// 上次刷新“等效 AR”用的输入,避免每帧都重新格式化字符串。
+ private int arTextCachedApproachTime = -1;
+ private float arTextCachedScale = -1f;
+
+ ///
+ /// 刷新“等效 AR”文字(只在 UI 线程调用)。
+ /// 两个触发点:比例变化时由 立即刷新;
+ /// 后台重建提交后 会变(开 / 关 EZ、HR 或换谱面都走这条路,
+ /// 而提交发生在后台线程),由每帧绘制前的 顺带补上。
+ ///
+ ///
+ private void RefreshVerticalScaleArText()
+ {
+ int approachTime = drawingHelper.ApproachTime;
+ float scale = drawingHelper.VerticalScale;
+ if (approachTime == arTextCachedApproachTime && scale == arTextCachedScale) return;
+
+ arTextCachedApproachTime = approachTime;
+ arTextCachedScale = scale;
+ quickToggleBar?.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleArTextKey, VerticalScaleArText());
+ }
+
+ ///
+ /// Y 缩放比例文字的悬停提示:条上只显示“x1.0”,这里把 Ctrl + 滚轮的快捷操作一并说明。
+ ///
+ private static string VerticalScaleHintText()
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return chinese
+ ? "Y 轴缩放比例(在 viewer 窗口内按住 Ctrl 滚滚轮也能调,每格 0.1)"
+ : "Y axis scale (hold Ctrl and scroll inside the viewer window; 0.1 per notch)";
+ }
+
+ /// “等效 AR”文字的悬停提示:说明它是按当前缩放折算出来的,并点明 mod 口径。
+ private static string VerticalScaleArHintText()
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return chinese
+ ? "按当前 Y 缩放折算的等效 AR:把画面上“432 像素对应的时间”当成 AR 的 approach time 反推;"
+ + "开 EZ / HR 时按 mod 后的实际 AR 计算(缩放只改显示比例,不改谱面数据)"
+ : "Effective AR implied by the current Y scale (the 432-pixel approach distance is re-expressed as an AR); "
+ + "uses the modded AR when EZ / HR is on (the scale only changes the view, not the beatmap)";
+ }
+
+ #region Y 轴缩放:Ctrl + 滚轮
+
+ /// 滚轮消息(WM_MOUSEWHEEL);wParam 高 16 位是带符号的滚动量。
+ private const int WM_MOUSEWHEEL = 0x020A;
+
+ /// Ctrl + 滚轮的消息过滤器实例(随窗体关闭一并注销)。
+ private VerticalScaleWheelFilter? wheelFilter;
+
+ ///
+ /// Ctrl + 滚轮调整 Y 轴缩放的消息过滤器。
+ /// 为什么用消息过滤器而不是重写 OnMouseWheel:滚轮消息由系统发给
+ /// “当前拥有键盘焦点的窗口”。焦点可能落在画布上( 是 GLControl,
+ /// 有自己独立的窗口句柄),也可能落在主窗口本身;过滤器在消息进入控件之前就能看到,
+ /// 因此不必依赖“未处理的滚轮消息会被 WinForms 冒泡给父控件”这个实现细节。
+ ///
+ /// 回调里的三道闸(前台、光标在窗口内、按住 Ctrl)都在 。
+ ///
+ ///
+ private sealed class VerticalScaleWheelFilter : IMessageFilter
+ {
+ private readonly Form1 owner;
+
+ internal VerticalScaleWheelFilter(Form1 owner) => this.owner = owner;
+
+ public bool PreFilterMessage(ref Message m)
+ {
+ if (m.Msg != WM_MOUSEWHEEL) return false;
+
+ int delta = (short)((m.WParam.ToInt64() >> 16) & 0xFFFF);
+ return owner.TryZoomVerticalScaleByWheel(delta);
+ }
+ }
+
+ ///
+ /// 处理一次滚轮消息:只有“viewer 在前台 + 光标在 viewer 窗口内 + 按住 Ctrl”时才调整 Y 轴缩放。
+ /// 向上滚 = 放大(把时间轴拉长),一格 0.1,与滑块步进一致;缩放基准仍是判定线,
+ /// 与拖滑块完全等价(滚轮只是另一个入口)。
+ ///
+ ///
+ /// true 表示这条滚轮消息已被消费,不再交给画布 / 快捷开关栏等控件。
+ private bool TryZoomVerticalScaleByWheel(int wheelDelta)
+ {
+ // 正在关闭时不再动控件(消息过滤器要等 FormClosed 才注销,期间可能还有滚轮消息在途)
+ if (IsDisposed || Disposing) return false;
+
+ // ① 本窗口必须在前台:焦点在窗口内的任意子控件上才算。
+ // “viewer 不在前台”包含两类情况,这里一并挡掉:
+ // - 焦点在别的程序(或本程序的浮窗 / 设置窗口)上;
+ // - Windows 的“悬停时滚动非活动窗口”把滚轮直接送到悬停窗口,但焦点并不在我们的窗口上。
+ if (!ContainsFocus) return false;
+
+ // ② 光标必须落在 viewer 窗口内(Ctrl+滚轮甩到窗口外时不该改缩放)
+ if (!Bounds.Contains(Cursor.Position)) return false;
+
+ // ③ 必须按住 Ctrl:没按 Ctrl 时把滚轮让给其它控件(菜单栏溢出滚动、下拉框等)
+ if ((ModifierKeys & Keys.Control) != Keys.Control) return false;
+
+ int notches = wheelDelta / SystemInformation.MouseWheelScrollDelta;
+ // 高精度滚轮 / 触摸板可能只报小于一格(120)的增量:按方向算作一格,避免完全没反应
+ if (notches == 0) notches = Math.Sign(wheelDelta);
+ if (notches == 0) return false;
+
+ ApplyVerticalScale(verticalScaleTenths + notches * VerticalScaleTenthsPerWheelNotch);
+ return true;
+ }
+
+ #endregion
+
@@ -2088,6 +2257,10 @@ private void ApplyQuickToggleLanguage()
quickToggleBar.SetGroupLabelText(BarLineGroupKey, BarLineValueKey + "_Text", BarLineStatusText());
quickToggleBar.SetGroupLabelText(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleStatusText());
+ // 等效 AR 的文字本身与语言无关(“AR 9.3”),但提示要跟着语言走
+ RefreshVerticalScaleArText();
+ quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleValueKey + "_Text", VerticalScaleHintText());
+ quickToggleBar.SetGroupLabelToolTip(VerticalScaleGroupKey, VerticalScaleArTextKey, VerticalScaleArHintText());
}
///
diff --git a/osucatch-editor-realtimeviewer/QuickToggleBar.cs b/osucatch-editor-realtimeviewer/QuickToggleBar.cs
index c55f70d..3cd43c2 100644
--- a/osucatch-editor-realtimeviewer/QuickToggleBar.cs
+++ b/osucatch-editor-realtimeviewer/QuickToggleBar.cs
@@ -729,6 +729,15 @@ internal void SetGroupLabelText(string groupKey, string labelKey, string text)
if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelText(labelKey, text);
}
+ ///
+ /// 设置功能区里一段只读文字的悬停提示(语言切换等场景调用)。
+ /// 条上的文字通常很短(如“x1.0”),快捷键之类的补充说明放在这里。
+ ///
+ internal void SetGroupLabelToolTip(string groupKey, string labelKey, string toolTip)
+ {
+ if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelToolTip(labelKey, toolTip);
+ }
+
#endregion
#region 停靠 / 浮动
@@ -1120,6 +1129,16 @@ internal void SetLabelText(string labelKey, string text)
if (labels.TryGetValue(labelKey, out ToolStripLabel? item)) item.Text = text;
}
+ /// 设置功能区里一段只读文字的悬停提示。
+ internal void SetLabelToolTip(string labelKey, string toolTip)
+ {
+ if (!labels.TryGetValue(labelKey, out ToolStripLabel? item)) return;
+
+ // AutoToolTip = true 会用 Text 覆盖 ToolTipText,这里必须关掉才显示得出提示
+ item.AutoToolTip = false;
+ item.ToolTipText = toolTip;
+ }
+
/// 功能区里的只读文字项,键为文字标识。
private readonly Dictionary labels = new(StringComparer.Ordinal);