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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions osucatch-editor-realtimeviewer/DrawingHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ public void LoadBeatmap(IBeatmap convertedBeatmap, int mods = 0)
if (CustomComboColours.Count <= 0) CustomComboColours = DefaultCustomComboColours;
}

/// <summary>
/// 由 approach time(ms)反推 AR:<see cref="LoadBeatmap"/> 里正向换算的逆运算
/// (AR &lt; 5 段 AT = 1800 - 120·AR,AR ≥ 5 段 AT = 1200 - 150·(AR-5),两段在 AR=5 / AT=1200 处衔接)。
/// <para />用于把“当前 Y 轴缩放看到的纵向时间尺度”折算成大家熟悉的 AR:
/// 缩放到 <c>r</c> 时画面上 432 像素对应的时间是 <c>ApproachTime / r</c>,
/// 把它当成 AR 的 approach time 代进来即可。
/// </para>
/// </summary>
/// <param name="approachTime">approach time(ms)。</param>
/// <returns>等效 AR;超出 0~10 的值照实返回(不夹取)。</returns>
public static double ApproachRateFromApproachTime(double approachTime)
=> (approachTime > 1200) ? (1800 - approachTime) / 120.0 : (1950 - approachTime) / 150.0;

/// <summary>
/// 将后台流水线构建好的数据原子地应用到当前绘制实例。
/// 只替换装载期字段,不动 CurrentTime / NearbyHitObjects / Bookmarks 等运行时状态。
Expand Down
173 changes: 173 additions & 0 deletions osucatch-editor-realtimeviewer/Form1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ public partial class Form1 : Form
/// <summary>滑块刻度间隔(同样以 0.1 为单位):每 0.5 倍画一根刻度线。</summary>
private const int VerticalScaleTickTenths = 5;

/// <summary>一格滚轮(Ctrl + 滚轮)调整多少:1 = 0.1,与滑块步进一致。</summary>
private const int VerticalScaleTenthsPerWheelNotch = 1;

/// <summary>Y 缩放功能区里“当前缩放相当于多少 AR”那一段文字的标识。</summary>
private const string VerticalScaleArTextKey = "VerticalScaleAR";

/// <summary>
/// 当前 Y 轴缩放(以 0.1 为单位)。与滑块位置、<see cref="DrawingHelper.VerticalScale"/>
/// 一起由 <see cref="ApplyVerticalScale"/> 统一维护,Ctrl + 滚轮也走同一条路径,
/// 这样“滚轮改完滑块跟着动”天然成立(浮点倍率不适合拿来累加,故另存一份整数)。
/// </summary>
private int verticalScaleTenths = VerticalScaleDefaultTenths;

/// <summary>快捷开关条上的下拉框 / 滑块所用的值标识。</summary>
private const string FreezeValueKey = FreezePreviewTimeKey;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}

/// <summary>
Expand All @@ -1781,6 +1812,144 @@ private void ApplyVerticalScale(int tenths)
/// </summary>
private static string VerticalScaleStatusText() => "x" + drawingHelper.VerticalScale.ToString("0.0");

/// <summary>
/// 当前 Y 轴缩放相当于多少 AR(形如 <c>AR 9.3</c>)。
/// <para />折算口径:画面上 432 像素对应的时间就是 <see cref="DrawingHelper.TimePerPixels"/> 的来历
/// (<c>TimePerPixels = ApproachTime / 432</c>)。缩放 <c>r</c> 倍后同样 432 像素对应
/// <c>ApproachTime / r</c> 毫秒,把这个时间当成 AR 的 approach time 反推,就得到等效 AR:
/// 放大(x&gt;1)看到的时间尺度更短 → 等效 AR 更高,压扁则更低。
/// </para>
/// <para /><see cref="DrawingHelper.ApproachTime"/> 取自绘制状态,而它是由
/// <c>convertedBeatmap.Difficulty.ApproachRate</c> 算出来的——EZ / HR 属于
/// <c>IApplicableToDifficulty</c>,在换谱面的转换阶段就已经作用到难度值上,
/// 所以开 mod 后这里显示的自然是按 mod 后实际 AR 折算的结果。
/// </para>
/// <para />等效值可能超出 0~10(放大到 4x 会高于 AR10,压扁到 0.5x 可能低于 AR0),一律照实显示。
/// </para>
/// </summary>
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");
}

/// <summary>上次刷新“等效 AR”用的输入,避免每帧都重新格式化字符串。</summary>
private int arTextCachedApproachTime = -1;
private float arTextCachedScale = -1f;

/// <summary>
/// 刷新“等效 AR”文字(只在 UI 线程调用)。
/// <para />两个触发点:比例变化时由 <see cref="ApplyVerticalScale"/> 立即刷新;
/// 后台重建提交后 <see cref="DrawingHelper.ApproachTime"/> 会变(开 / 关 EZ、HR 或换谱面都走这条路,
/// 而提交发生在后台线程),由每帧绘制前的 <see cref="RequestDraw"/> 顺带补上。
/// </para>
/// </summary>
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());
}

/// <summary>
/// Y 缩放比例文字的悬停提示:条上只显示“x1.0”,这里把 Ctrl + 滚轮的快捷操作一并说明。
/// </summary>
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)";
}

/// <summary>“等效 AR”文字的悬停提示:说明它是按当前缩放折算出来的,并点明 mod 口径。</summary>
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 + 滚轮

/// <summary>滚轮消息(WM_MOUSEWHEEL);wParam 高 16 位是带符号的滚动量。</summary>
private const int WM_MOUSEWHEEL = 0x020A;

/// <summary>Ctrl + 滚轮的消息过滤器实例(随窗体关闭一并注销)。</summary>
private VerticalScaleWheelFilter? wheelFilter;

/// <summary>
/// Ctrl + 滚轮调整 Y 轴缩放的消息过滤器。
/// <para /><b>为什么用消息过滤器而不是重写 <c>OnMouseWheel</c></b>:滚轮消息由系统发给
/// “当前拥有键盘焦点的窗口”。焦点可能落在画布上(<see cref="Canvas"/> 是 GLControl,
/// 有自己独立的窗口句柄),也可能落在主窗口本身;过滤器在消息进入控件之前就能看到,
/// 因此不必依赖“未处理的滚轮消息会被 WinForms 冒泡给父控件”这个实现细节。
/// </para>
/// <para />回调里的三道闸(前台、光标在窗口内、按住 Ctrl)都在 <see cref="TryZoomVerticalScaleByWheel"/>。
/// </para>
/// </summary>
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);
}
}

/// <summary>
/// 处理一次滚轮消息:只有“viewer 在前台 + 光标在 viewer 窗口内 + 按住 Ctrl”时才调整 Y 轴缩放。
/// <para />向上滚 = 放大(把时间轴拉长),一格 0.1,与滑块步进一致;缩放基准仍是判定线,
/// 与拖滑块完全等价(滚轮只是另一个入口)。
/// </para>
/// </summary>
/// <returns>true 表示这条滚轮消息已被消费,不再交给画布 / 快捷开关栏等控件。</returns>
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




Expand Down Expand Up @@ -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());
}

/// <summary>
Expand Down
19 changes: 19 additions & 0 deletions osucatch-editor-realtimeviewer/QuickToggleBar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,15 @@ internal void SetGroupLabelText(string groupKey, string labelKey, string text)
if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelText(labelKey, text);
}

/// <summary>
/// 设置功能区里一段只读文字的悬停提示(语言切换等场景调用)。
/// 条上的文字通常很短(如“x1.0”),快捷键之类的补充说明放在这里。
/// </summary>
internal void SetGroupLabelToolTip(string groupKey, string labelKey, string toolTip)
{
if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelToolTip(labelKey, toolTip);
}

#endregion

#region 停靠 / 浮动
Expand Down Expand Up @@ -1120,6 +1129,16 @@ internal void SetLabelText(string labelKey, string text)
if (labels.TryGetValue(labelKey, out ToolStripLabel? item)) item.Text = text;
}

/// <summary>设置功能区里一段只读文字的悬停提示。</summary>
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;
}

/// <summary>功能区里的只读文字项,键为文字标识。</summary>
private readonly Dictionary<string, ToolStripLabel> labels = new(StringComparer.Ordinal);

Expand Down
Loading