diff --git a/osucatch-editor-realtimeviewer/BarLineSettings.cs b/osucatch-editor-realtimeviewer/BarLineSettings.cs
new file mode 100644
index 0000000..2abc0f5
--- /dev/null
+++ b/osucatch-editor-realtimeviewer/BarLineSettings.cs
@@ -0,0 +1,247 @@
+using OpenTK.Graphics;
+
+namespace osucatch_editor_realtimeviewer
+{
+ ///
+ /// 拍线(小节线细分)显示模式。
+ /// 取值与用户设置 app.Default.BarLine_Mode 一一对应,数值即持久化值,
+ /// 因此只能往后追加,不能插入或重排(否则旧配置会错位)。
+ /// 枚举顺序同时也是快捷开关栏滑块与设置菜单下拉框的显示顺序(由疏到密)。
+ ///
+ public enum BarLineMode
+ {
+ /// 不显示拍线。
+ Hidden = 0,
+
+ /// 每 4 拍一条细分线(设置菜单旧文案“显示每4拍”)。
+ Every4Beats = 1,
+
+ /// 每 2 拍一条细分线。
+ Every2Beats = 2,
+
+ /// 每一拍一条细分线。
+ EveryBeat = 3,
+
+ /// 每 1/2 拍一条细分线(红色)。
+ EveryHalfBeat = 4,
+
+ /// 每 1/3 拍一条细分线(紫色)。
+ EveryThirdBeat = 5,
+
+ /// 每 1/4 拍一条细分线(1/2 拍红、1/4 拍蓝)。
+ EveryQuarterBeat = 6,
+
+ /// 每 1/5 拍一条细分线(黄色)。
+ EveryFifthBeat = 7,
+
+ /// 每 1/6 拍一条细分线(1/2 拍红、1/3 拍紫)。
+ EverySixthBeat = 8,
+
+ /// 每 1/7 拍一条细分线(黄色)。
+ EverySeventhBeat = 9,
+
+ /// 每 1/8 拍一条细分线(1/2 拍红、1/4 拍蓝、1/8 拍黄)。
+ EveryEighthBeat = 10,
+ }
+
+ ///
+ /// 拍线模式的取值 / 文案 / 配色助手:绘制()、
+ /// 设置窗口下拉框与快捷开关栏滑块共用,避免三处各写一套映射。
+ ///
+ internal static class BarLineSettings
+ {
+ /// 滑块 / 下拉框里的全部选项,顺序与 一致(由疏到密)。
+ internal static readonly BarLineMode[] AllModes =
+ {
+ BarLineMode.Hidden,
+ BarLineMode.Every4Beats,
+ BarLineMode.Every2Beats,
+ BarLineMode.EveryBeat,
+ BarLineMode.EveryHalfBeat,
+ BarLineMode.EveryThirdBeat,
+ BarLineMode.EveryQuarterBeat,
+ BarLineMode.EveryFifthBeat,
+ BarLineMode.EverySixthBeat,
+ BarLineMode.EverySeventhBeat,
+ BarLineMode.EveryEighthBeat,
+ };
+
+ /// 拍线模式数量(滑块 / 下拉框的选项个数)。
+ internal static int ModeCount => AllModes.Length;
+
+ ///
+ /// 当前生效的拍线模式(从用户设置里读)。
+ /// 读的时候顺带做一次旧配置迁移:旧版本把“是否显示小节线”和细分档位分两个设置存,
+ /// 新版本合并成一个模式值;旧配置里 BarLine_Mode 是 0(未写过),
+ /// 此时按旧的布尔值 + 细分下拉框换算,让升级后的用户保持原来的显示效果。
+ ///
+ internal static BarLineMode CurrentMode
+ {
+ get
+ {
+ int mode = app.Default.BarLine_Mode;
+ if (mode > 0) return Clamp(mode);
+ return FromLegacySettings(app.Default.BarLine_Show, app.Default.BarLine_Subdivide);
+ }
+ set
+ {
+ app.Default.BarLine_Mode = (int)Clamp((int)value);
+ // 旧设置一并写成“已显示”,避免下次升级/回退时被旧的 False 再次覆盖
+ app.Default.BarLine_Show = value != BarLineMode.Hidden;
+ }
+ }
+
+ /// 把任意整数折算成合法模式(越界时夹到最近的合法值)。
+ internal static BarLineMode Clamp(int value)
+ {
+ if (value <= (int)BarLineMode.Hidden) return BarLineMode.Hidden;
+ if (value >= (int)BarLineMode.EveryEighthBeat) return BarLineMode.EveryEighthBeat;
+ return (BarLineMode)value;
+ }
+
+ ///
+ /// 旧版本设置迁移:旧配置存的是“显示小节线”复选框 + 细分下拉框(0/1/2 = 小节 / 2拍 / 拍)。
+ /// 新版本把两者合并成一个模式值,因此读设置时按旧语义换算一次。
+ ///
+ internal static BarLineMode FromLegacySettings(bool show, int legacySubdivide)
+ {
+ if (!show) return BarLineMode.Hidden;
+ return legacySubdivide switch
+ {
+ >= 2 => BarLineMode.EveryBeat,
+ 1 => BarLineMode.Every2Beats,
+ _ => BarLineMode.Hidden,
+ };
+ }
+
+ ///
+ /// 整拍(1/1)细分线的颜色:淡白(沿用旧版本“显示到拍”的配色)。
+ /// 它比小节线更淡、也与红色 BPM 线区分,因此“每拍”以及 1/2 ~ 1/8 各档
+ /// 都要在每一拍上画出这条淡白线。
+ ///
+ /// 透明度用 150 而不是旧版本的 90:黑色背景 + 1px 线宽下,90 太接近底色,
+ /// 肉眼几乎分辨不出,用户会以为某一拍“没画”。
+ ///
+ ///
+ internal static readonly Color BeatLineColor = Color.FromArgb(150, Color.White);
+
+ /// 细分线的固定颜色(方案 A:与 osu! 编辑器的 1/2 红、1/4 蓝一致)。
+ private static readonly Color Color1_2 = Color.FromArgb(255, 246, 74, 74); // 红
+ private static readonly Color Color1_3 = Color.FromArgb(255, 176, 106, 255); // 紫
+ private static readonly Color Color1_4 = Color.FromArgb(255, 88, 148, 255); // 蓝
+ private static readonly Color Color1_5 = Color.FromArgb(255, 255, 216, 82); // 黄
+ private static readonly Color Color1_6 = Color.FromArgb(255, 255, 130, 214); // 品红
+ private static readonly Color Color1_7 = Color.FromArgb(255, 255, 216, 82); // 黄
+ private static readonly Color Color1_8 = Color.FromArgb(255, 255, 235, 120); // 浅黄
+ private static readonly Color FallbackColor = Color.FromArgb(255, 170, 170, 170);
+
+ ///
+ /// 细分密度档位:每 1/ 拍画一条线,
+ /// “每2拍 / 每4拍”这类跨拍间隔用 表示。
+ ///
+ internal readonly struct SubdivisionTier
+ {
+ internal SubdivisionTier(int denominator, int stepBeats)
+ {
+ Denominator = denominator;
+ StepBeats = stepBeats;
+ }
+
+ /// 每拍被分成的份数(1/2 拍 = 2,1/8 拍 = 8)。
+ internal int Denominator { get; }
+
+ /// 跨拍间隔(“每2拍”= 2,“每4拍”= 4);没有跨拍间隔时为 0。
+ internal int StepBeats { get; }
+ }
+
+ /// 取模式的细分档位; 返回的分母为 0。
+ internal static SubdivisionTier GetSubdivisionTier(BarLineMode mode)
+ {
+ return mode switch
+ {
+ BarLineMode.Every4Beats => new SubdivisionTier(1, 4),
+ BarLineMode.Every2Beats => new SubdivisionTier(1, 2),
+ BarLineMode.EveryBeat => new SubdivisionTier(1, 0),
+ BarLineMode.EveryHalfBeat => new SubdivisionTier(2, 0),
+ BarLineMode.EveryThirdBeat => new SubdivisionTier(3, 0),
+ BarLineMode.EveryQuarterBeat => new SubdivisionTier(4, 0),
+ BarLineMode.EveryFifthBeat => new SubdivisionTier(5, 0),
+ BarLineMode.EverySixthBeat => new SubdivisionTier(6, 0),
+ BarLineMode.EverySeventhBeat => new SubdivisionTier(7, 0),
+ BarLineMode.EveryEighthBeat => new SubdivisionTier(8, 0),
+ _ => new SubdivisionTier(0, 0),
+ };
+ }
+
+ /// 模式对应的“拍线”下拉项 / 滑块提示文本。
+ internal static string GetDisplayName(BarLineMode mode)
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return mode switch
+ {
+ BarLineMode.Every4Beats => chinese ? "每4拍" : "Every 4 beats",
+ BarLineMode.Every2Beats => chinese ? "每2拍" : "Every 2 beats",
+ BarLineMode.EveryBeat => chinese ? "每拍" : "Every beat",
+ BarLineMode.EveryHalfBeat => chinese ? "每1/2拍" : "Every 1/2 beat",
+ BarLineMode.EveryThirdBeat => chinese ? "每1/3拍" : "Every 1/3 beat",
+ BarLineMode.EveryQuarterBeat => chinese ? "每1/4拍" : "Every 1/4 beat",
+ BarLineMode.EveryFifthBeat => chinese ? "每1/5拍" : "Every 1/5 beat",
+ BarLineMode.EverySixthBeat => chinese ? "每1/6拍" : "Every 1/6 beat",
+ BarLineMode.EverySeventhBeat => chinese ? "每1/7拍" : "Every 1/7 beat",
+ BarLineMode.EveryEighthBeat => chinese ? "每1/8拍" : "Every 1/8 beat",
+ _ => chinese ? "不显示" : "Hidden",
+ };
+ }
+
+ ///
+ /// 拍线状态文字里的显示名称:拍线:{当前拍线显示模式}。
+ /// 只写档位、不写配色——线上什么颜色用户看一眼就知道,写进文字反而啰嗦。
+ ///
+ internal static string GetOptionName(BarLineMode mode) => GetDisplayName(mode);
+
+ ///
+ /// 某条细分线的颜色。颜色由该时刻在整拍里的最简分数决定,
+ /// 例如 1/8 拍模式下第 4 个细分(= 1/2 拍)用红色、第 2 个(= 1/4 拍)用蓝色。
+ ///
+ /// 细分序号(1 起)。
+ /// 每拍被分成的份数。
+ internal static Color GetBeatLineColor(int numerator, int denominator)
+ {
+ if (numerator <= 0 || denominator <= 0) return FallbackColor;
+ int divisor = GreatestCommonDivisor(numerator, denominator);
+ return GetColorForReducedDenominator(denominator / divisor);
+ }
+
+ /// 把“细分序号 / 每拍份数”约分成最简分数(绘制时判断该拍点是不是整拍用)。
+ internal static (int Numerator, int Denominator) Reduce(int numerator, int denominator)
+ {
+ if (numerator <= 0 || denominator <= 0) return (numerator, denominator);
+ int divisor = GreatestCommonDivisor(numerator, denominator);
+ return (numerator / divisor, denominator / divisor);
+ }
+
+ private static Color GetColorForReducedDenominator(int denominator)
+ {
+ return denominator switch
+ {
+ 2 => Color1_2,
+ 3 => Color1_3,
+ 4 => Color1_4,
+ 5 => Color1_5,
+ 6 => Color1_6,
+ 7 => Color1_7,
+ 8 => Color1_8,
+ _ => FallbackColor,
+ };
+ }
+
+ private static int GreatestCommonDivisor(int a, int b)
+ {
+ while (b != 0)
+ {
+ (a, b) = (b, a % b);
+ }
+ return Math.Max(a, 1);
+ }
+ }
+}
diff --git a/osucatch-editor-realtimeviewer/DrawingHelper.cs b/osucatch-editor-realtimeviewer/DrawingHelper.cs
index f05992f..4ef3ad2 100644
--- a/osucatch-editor-realtimeviewer/DrawingHelper.cs
+++ b/osucatch-editor-realtimeviewer/DrawingHelper.cs
@@ -1,4 +1,4 @@
-using OpenTK;
+using OpenTK;
using OpenTK.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@@ -255,14 +255,16 @@ public void Draw()
}
- if (app.Default.BarLine_Show)
+ // 拍线:模式为“不显示”时整数档位为 0,这里直接跳过整段绘制
+ BarLineMode barLineMode = BarLineSettings.CurrentMode;
+ if (BarLineSettings.GetSubdivisionTier(barLineMode).Denominator > 0)
{
scratchBarLines.Clear();
foreach (BarLine barLine in BarLines)
{
if (barLine.StartTime >= 0 && barLine.StartTime <= MaxStartTime + 1) scratchBarLines.Add(barLine);
}
- DrawBarLines(scratchBarLines);
+ DrawBarLines(scratchBarLines, barLineMode);
}
if (app.Default.TimingLine_ShowGreen)
@@ -363,83 +365,86 @@ private static int TemplateUpperBound(List objects, doub
return left < objects.Count ? left : objects.Count - 1;
}
- public void DrawBarLines(List barLines)
+ public void DrawBarLines(List barLines, BarLineMode mode)
{
- int subdivide = app.Default.BarLine_Subdivide;
- bool drawSubdivisions = subdivide > 0 && ControlPointInfo != null;
+ BarLineSettings.SubdivisionTier tier = BarLineSettings.GetSubdivisionTier(mode);
+ bool drawSubdivisions = tier.Denominator > 0 && ControlPointInfo != null;
barLines.ForEach(barLine =>
{
if (barLine.StartTime < 0) return;
double deltaTime = barLine.StartTime - CurrentTime;
+ int posY;
if (ScreensContain > 1)
{
double timeSpan = ScreensContain * ApproachTime * 1.25;
- if (deltaTime <= timeSpan && deltaTime >= -timeSpan)
- {
- int posY = (int)(240.0 * ScreensContain - deltaTime / TimePerPixels);
- Vector2 rp0 = new Vector2(64, posY);
- Vector2 rp1 = new Vector2(576, posY);
- if (barLine.Major) Canvas.DrawLine(rp0, rp1, Color.LightGray);
- else Canvas.DrawLine(rp0, rp1, Color.Gray);
- if (drawSubdivisions) DrawBarLineSubdivisions(barLine, subdivide);
- }
+ if (deltaTime > timeSpan || deltaTime < -timeSpan) return;
+ posY = (int)(240.0 * ScreensContain - deltaTime / TimePerPixels);
}
else
{
double upTime = ApproachTime;
double bottomTime = ApproachTime * 3 / 17;
- if (deltaTime <= upTime && deltaTime >= -bottomTime)
- {
- int posY = (int)(384 - deltaTime / TimePerPixels);
- Vector2 rp0 = new Vector2(64, posY);
- Vector2 rp1 = new Vector2(576, posY);
- if (barLine.Major) Canvas.DrawLine(rp0, rp1, Color.LightGray);
- else Canvas.DrawLine(rp0, rp1, Color.Gray);
- if (drawSubdivisions) DrawBarLineSubdivisions(barLine, subdivide);
- }
+ if (deltaTime > upTime || deltaTime < -bottomTime) return;
+ posY = (int)(384 - deltaTime / TimePerPixels);
}
+
+ Vector2 rp0 = new Vector2(64, posY);
+ Vector2 rp1 = new Vector2(576, posY);
+ if (barLine.Major) Canvas.DrawLine(rp0, rp1, Color.LightGray);
+ else Canvas.DrawLine(rp0, rp1, Color.Gray);
+ if (drawSubdivisions) DrawBarLineSubdivisions(barLine, tier);
});
}
///
- /// 绘制小节线的拍点细分线:
- /// “显示到2拍”每隔 2 拍一条,“显示到拍”每一拍一条。
- /// 统一用淡白线(比小节线更淡),避免与 editor 中表示“拍”的 1/2、1/4 混淆。
- /// 不越过下一条小节线。
+ /// 绘制一条小节线之后那一段(到下一根小节线为止)的拍点细分线。
+ /// 绘制区间取半开区间 [这条小节线, 下一根小节线):每条细分线正好只由
+ /// 它左边的那根小节线画一次,既不重复也不遗漏——包括谱面第一根小节线。
+ ///
+ ///
+ /// - “每1/N拍”(含“每拍”):每一拍 N 等分,整拍(1/1)用淡白线、
+ /// 更细的拍点用 的颜色;
+ /// - “每2拍 / 每4拍”:只在间隔的整数倍拍点画,画到的都是整拍 → 淡白线。
+ ///
+ /// 各拍点的时刻由这条小节线自身时刻 + 该处的拍长推出(不从“上一条小节线”反推,
+ /// 避免第一根小节线之前没有参照线时整段拍线缺失);上界用下一条小节线兜底,
+ /// 防止拍长/拍号变化时细分线越过小节线画到下一小节里。
///
- private void DrawBarLineSubdivisions(BarLine barLine, int subdivide)
+ private void DrawBarLineSubdivisions(BarLine barLine, BarLineSettings.SubdivisionTier tier)
{
- if (ControlPointInfo == null) return;
+ if (ControlPointInfo == null || tier.Denominator <= 0) return;
+
+ double barTime = barLine.StartTime;
+ double nextBarTime = NextBarLineTime(barTime);
- TimingControlPoint timing = ControlPointInfo.TimingPointAt(barLine.StartTime);
+ TimingControlPoint timing = ControlPointInfo.TimingPointAt(barTime);
double beatLength = timing.BeatLength;
- if (beatLength <= 0) return;
+ if (!(beatLength > 0)) return;
- int beatsPerMeasure = timing.TimeSignature.Numerator;
- double nextBarTime = NextBarLineTime(barLine.StartTime);
- Color subdivisionColor = Color.FromArgb(90, Color.White);
+ int beatsPerMeasure = Math.Max(1, timing.TimeSignature.Numerator);
+ int subdivisions = beatsPerMeasure * tier.Denominator;
+ // 跨拍间隔(每2拍 / 每4拍):只在间隔的整数倍拍点画 → 这些拍点都是整拍
+ int gridStep = tier.StepBeats > 0 ? Math.Max(1, tier.StepBeats * tier.Denominator) : 1;
- if (subdivide >= 2)
- {
- // 每一拍一条(2 拍位置也包含在内,颜色相同无需去重)
- for (int beat = 1; beat < beatsPerMeasure; beat++)
- {
- double time = barLine.StartTime + beat * beatLength;
- if (time < nextBarTime) DrawSubdivisionLine(time, subdivisionColor);
- }
- }
- else if (subdivide >= 1)
+ for (int i = gridStep; i < subdivisions; i += gridStep)
{
- // 每隔 2 拍一条
- for (int beat = 2; beat < beatsPerMeasure; beat += 2)
- {
- double time = barLine.StartTime + beat * beatLength;
- if (time < nextBarTime) DrawSubdivisionLine(time, subdivisionColor);
- }
+ double time = barTime + i * beatLength / tier.Denominator;
+ if (time >= nextBarTime - TimingEpsilon) break;
+ if (time < 0) continue;
+
+ // 约分成最简分数后判断:分母为 1 就是整拍,用淡白线
+ (int numerator, int denominator) = BarLineSettings.Reduce(i, tier.Denominator);
+
+ DrawSubdivisionLine(
+ time,
+ denominator == 1 ? BarLineSettings.BeatLineColor : BarLineSettings.GetBeatLineColor(numerator, denominator));
}
}
+ /// 拍线细分的比较容差(ms):避免浮点误差把“贴着小节线”的细分画出来。
+ private const double TimingEpsilon = 0.001;
+
private void DrawSubdivisionLine(double time, Color color)
{
double deltaTime = time - CurrentTime;
@@ -462,17 +467,18 @@ private void DrawSubdivisionLine(double time, Color color)
}
///
- /// BarLines 按时间升序,二分查找第一条晚于指定时间的小节线。
+ /// BarLines 按时间升序,二分查找指定时间之后最近的一条小节线;
+ /// 后面没有小节线时返回 。
///
private double NextBarLineTime(double time)
{
int left = 0;
int right = BarLines.Count - 1;
- double result = double.MaxValue;
+ double result = double.PositiveInfinity;
while (left <= right)
{
int mid = left + (right - left) / 2;
- if (BarLines[mid].StartTime > time)
+ if (BarLines[mid].StartTime > time + TimingEpsilon)
{
result = BarLines[mid].StartTime;
right = mid - 1;
diff --git a/osucatch-editor-realtimeviewer/Form1.cs b/osucatch-editor-realtimeviewer/Form1.cs
index b54e201..71e5de0 100644
--- a/osucatch-editor-realtimeviewer/Form1.cs
+++ b/osucatch-editor-realtimeviewer/Form1.cs
@@ -37,9 +37,62 @@ public partial class Form1 : Form
private QuickToggleDocking? quickToggleDocking;
private ToolStripMenuItem? quickToggleStripMenuItem;
+ #region 快捷开关条:开关标识与功能区
+
/// 快捷开关“固定预览时刻”的标识(勾选状态随其它开关一起持久化)。
private const string FreezePreviewTimeKey = "FreezePreviewTime";
+ // MOD 功能区(单选:NM / EZ / HR)
+ private const string ModGroupKey = "Mod";
+ private const string ModNoneKey = "Mod_None";
+ private const string ModEasyKey = "Mod_EZ";
+ private const string ModHardRockKey = "Mod_HR";
+
+ /// MOD 功能区的三个开关,顺序与 一致。
+ private static readonly string[] ModKeys = { ModNoneKey, ModEasyKey, ModHardRockKey };
+
+ // 果子标注功能区(单选:隐藏 / 距离-正常 / 距离-忽略SVM / 难度星数)
+ private const string LabelGroupKey = "HitObjectLabel";
+ private const string LabelHiddenKey = "Label_Hidden";
+ private const string LabelDistanceKey = "Label_Distance";
+ private const string LabelIgnoreSvmKey = "Label_IgnoreSVM";
+ private const string LabelStarsKey = "Label_Stars";
+
+ /// 果子标注功能区的四个开关,顺序与 一致。
+ private static readonly string[] LabelKeys = { LabelHiddenKey, LabelDistanceKey, LabelIgnoreSvmKey, LabelStarsKey };
+
+ // 拍线功能区(滑块 + 状态文字)
+ private const string BarLineGroupKey = "BarLine";
+ private const string BarLineValueKey = "BarLineMode";
+
+ /// 快捷开关条上的下拉框 / 滑块所用的值标识。
+ private const string FreezeValueKey = FreezePreviewTimeKey;
+
+ ///
+ /// 果子标注的快捷开关模式。只覆盖菜单里最常用的 4 种,
+ /// 因此与 (还含“比较行走速度 / 连击内果子数”)不是同一个枚举。
+ ///
+ private enum QuickLabelMode
+ {
+ Hidden = 0,
+ Distance = 1,
+ DistanceIgnoreSvm = 2,
+ Stars = 3,
+ }
+
+ /// MOD 快捷开关模式。
+ private enum ModMode
+ {
+ None = 0,
+ Easy = 1,
+ HardRock = 2,
+ }
+
+ /// 已经创建过快捷开关条内容(避免语言切换重复添加控件)。
+ private bool quickTogglesCreated;
+
+ #endregion
+
/// 预览时刻是否被固定(“固定预览时刻”开关打开):为 true 时预览画布不再跟随编辑器。
private bool previewTimeFrozen;
@@ -147,9 +200,10 @@ public Form1()
// 模板菜单在构造函数里创建,确保语言资源能应用到它
CreateTemplateMenu();
- // 快捷开关条(开关内容在 CreateQuickToggles 里添加)
+ // 快捷开关条(功能区内容在 CreateQuickToggles 里添加)
CreateQuickToggleBar();
CreateQuickToggles();
+ ApplyQuickToggleLanguage();
if (app.Default.Language_String != "")
{
@@ -258,10 +312,14 @@ private void Form1_Load(object sender, EventArgs e)
ReapplyBookmarkStyles();
- // 快捷开关栏:恢复开关状态与停靠 / 浮动状态(浮窗要等主窗口显示出来后再弹出)
+ // 快捷开关栏:恢复功能区显示状态、开关状态与停靠 / 浮动状态(浮窗要等主窗口显示出来后再弹出)
+ quickToggleBar?.ApplyHiddenGroups(app.Default.QuickToggle_HiddenGroups);
quickToggleBar?.ApplyCheckedStates(app.Default.QuickToggle_States);
- // ApplyCheckedStates 不触发事件,这里手动把“固定预览时刻”的内部状态同步过来
+ // ApplyCheckedStates 不触发事件,这里手动把各开关的内部状态同步过来
SetPreviewTimeFrozen(quickToggleBar?.IsChecked(FreezePreviewTimeKey) ?? false);
+ SyncModToggleFromMenu();
+ SyncLabelToggleFromMenu();
+ ApplyBarLineMode(BarLineSettings.CurrentMode, persist: false);
quickToggleDocking?.ApplyStartupState(app.Default.QuickToggle_Visible, app.Default.QuickToggle_Floating);
if (quickToggleStripMenuItem != null) quickToggleStripMenuItem.Checked = app.Default.QuickToggle_Visible;
@@ -399,6 +457,13 @@ private void ReapplySettings()
}));
runner.SetInterval(app.Default.Drawing_Interval, app.Default.Idle_Interval);
+
+ // 设置窗口改了拍线模式:把快捷开关栏上的滑块/状态文字同步过来
+ Invoke(new MethodInvoker(delegate ()
+ {
+ ApplyBarLineMode(BarLineSettings.CurrentMode, persist: false);
+ }));
+
if (app.Default.Backup_Enabled)
{
backup_timer.Interval = app.Default.Backup_Interval;
@@ -949,6 +1014,8 @@ private void openSettingsFileToolStripMenuItem_Click(object sender, EventArgs e)
else
{
SettingsFormInstance = new SettingsForm();
+ Form1 host = this;
+ SettingsFormInstance.SettingsApplied += () => host.ReapplySettings();
SettingsFormInstance.FormClosed += (s, args) => { SettingsFormInstance = null; }; // 关闭时重置变量
SettingsFormInstance.ShowDialog();
}
@@ -1419,6 +1486,7 @@ private void CreateQuickToggleBar()
// 先于停靠管理器订阅:停靠管理器在自己的 ToggleChanged 处理里统一保存设置,
// 这里先更新运行状态,保证同一次保存里带上的都是最新状态
quickToggleBar.ToggleChanged += quickToggleBar_ToggleChanged;
+ quickToggleBar.GroupChanged += quickToggleBar_GroupChanged;
quickToggleDockRow = new QuickToggleDockRow();
quickToggleDocking = new QuickToggleDocking(this, quickToggleBar, quickToggleDockRow, menuStrip1);
@@ -1440,17 +1508,118 @@ private void CreateQuickToggleBar()
}
///
- /// 创建快捷开关条上的即时开关。开关内容后续继续在这里添加。
+ /// 创建快捷开关条上的内容:最左边是独立的“固定预览时刻”,其后是各个功能区
+ /// (MOD / 果子标注 / 拍线)。
+ /// 只在首次调用时建控件;之后(语言切换)只刷新文本,避免重复添加。
///
private void CreateQuickToggles()
+ {
+ if (quickToggleBar == null || quickTogglesCreated) return;
+ quickTogglesCreated = true;
+
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+
+ // ---- 固定预览时刻:不属于任何功能区,固定排在条的最左边、始终显示 ----
+ (string freezeText, string freezeToolTip) = FreezePreviewTimeText(previewTimeFrozen);
+ quickToggleBar.AddStandaloneToggle(FreezePreviewTimeKey, freezeText, false);
+ quickToggleBar.SetToggleText(FreezePreviewTimeKey, freezeText, freezeToolTip);
+
+ // ---- MOD:三个按钮单选,当前 MOD 对应的按钮保持按下(NM/EZ/HR 一看便知,不加标题) ----
+ QuickToggleBar.QuickToggleGroup modGroup = quickToggleBar.AddGroup(ModGroupKey, "");
+ for (int i = 0; i < ModKeys.Length; i++)
+ {
+ modGroup.AddToggle(ModKeys[i], ModToggleText((ModMode)i), (ModMode)i == ModMode.None);
+ }
+
+ // ---- 果子标注:四个按钮单选,对应菜单栏里最常用的四种标注。
+ // 每个模式都有对应图标,因此整组不加标题、按钮只显示图标(名称见按钮提示)。 ----
+ QuickToggleBar.QuickToggleGroup labelGroup = quickToggleBar.AddGroup(LabelGroupKey, "");
+ for (int i = 0; i < LabelKeys.Length; i++)
+ {
+ labelGroup.AddToggle(LabelKeys[i], LabelToggleText((QuickLabelMode)i), (QuickLabelMode)i == QuickLabelMode.Hidden);
+ }
+
+ // ---- 拍线:滑块选择密度 + 当前模式文字 ----
+ QuickToggleBar.QuickToggleGroup barLineGroup = quickToggleBar.AddGroup(BarLineGroupKey, chinese ? "拍线" : "Bar Lines");
+ barLineGroup.AddSlider(
+ BarLineValueKey,
+ 0,
+ BarLineSettings.ModeCount - 1,
+ (int)BarLineSettings.CurrentMode,
+ 1);
+ barLineGroup.AddLabel(BarLineValueKey + "_Text", BarLineStatusText());
+
+ ApplyQuickToggleIcons();
+ }
+
+ #region 快捷开关:按钮图标
+
+ /// 图标目录:与贴图一样按程序所在目录解析(icons\ 就在 exe 旁边)。
+ private const string IconsFolder = "icons";
+
+ /// “固定预览时刻”两个状态的图标文件名(相对 icons\)。
+ private const string FreezeIconFile = "QuickLabelMode_Switch2Freeze.png";
+
+ private const string FollowIconFile = "QuickLabelMode_Switch2Following.png";
+
+ ///
+ /// 果子标注四个按钮的图标文件名,顺序与 / 一致。
+ ///
+ private static readonly string[] LabelIconFiles =
+ {
+ "QuickLabelMode_Hide.png",
+ "QuickLabelMode_Distance.png",
+ "QuickLabelMode_DistanceIgnoreSvm.png",
+ "QuickLabelMode_Stars.png",
+ };
+
+ /// 取 icons\ 目录下某个图标文件的完整路径(按 exe 目录解析,找不到时回退相对路径)。
+ private static string ResolveIconPath(string fileName)
+ => ResolveImagePath(Path.Combine(IconsFolder, fileName));
+
+ /// 果子标注模式对应的图标文件路径。
+ private static string LabelModeIconPath(QuickLabelMode mode)
+ {
+ int index = (int)mode;
+ return (index >= 0 && index < LabelIconFiles.Length) ? ResolveIconPath(LabelIconFiles[index]) : "";
+ }
+
+ ///
+ /// 给按钮装上图标(“固定预览时刻” + 果子标注四个按钮)。
+ /// 图标缺失时会退化成显示文字——所以即使 icons\ 没随程序一起发布,
+ /// 按钮也不会变成认不出来的空白图标。
+ ///
+ ///
+ private void ApplyQuickToggleIcons()
{
if (quickToggleBar == null) return;
- (string text, string toolTip) = FreezePreviewTimeText(previewTimeFrozen);
- quickToggleBar.AddToggle(FreezePreviewTimeKey, text, false);
- quickToggleBar.SetToggleText(FreezePreviewTimeKey, text, toolTip);
+ // 图标缺失时退化为文字显示:先按文字算好标签与提示,再尝试装图标
+ (string followText, string followToolTip) = FreezePreviewTimeText(false);
+ (string freezeText, string freezeToolTip) = FreezePreviewTimeText(true);
+
+ bool freeze = previewTimeFrozen;
+ quickToggleBar.SetToggleText(
+ FreezePreviewTimeKey,
+ freeze ? followText : freezeText,
+ freeze ? followToolTip : freezeToolTip);
+ quickToggleBar.SetToggleImage(
+ FreezePreviewTimeKey,
+ ResolveIconPath(freeze ? FollowIconFile : FreezeIconFile));
+
+ for (int i = 0; i < LabelKeys.Length; i++)
+ {
+ quickToggleBar.SetToggleImage(LabelKeys[i], LabelModeIconPath((QuickLabelMode)i));
+ }
+
+ // 整组只显示图标(果子标注这个标题已经按需求去掉,图标本身就能说明模式)
+ quickToggleBar.SetGroupTogglesDisplayStyle(LabelGroupKey, ToolStripItemDisplayStyle.Image);
}
+ #endregion
+
+ #region 快捷开关:设置与执行
+
///
/// “固定预览时刻”开关在当前语言下的按钮文本与提示。
/// 按钮文本按“点击后会做什么”显示(与右端“浮动/吸附”按钮一致):
@@ -1468,11 +1637,185 @@ private static (string Text, string ToolTip) FreezePreviewTimeText(bool frozen)
return (text, toolTip);
}
+ /// MOD 快捷按钮在当前语言下的文本(三种 MOD 名称各语言都相同)。
+ private static string ModToggleText(ModMode mode)
+ {
+ return mode switch
+ {
+ ModMode.Easy => "EZ",
+ ModMode.HardRock => "HR",
+ _ => "NM",
+ };
+ }
+
+ private static string ModToggleToolTip(ModMode mode)
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return mode switch
+ {
+ ModMode.Easy => chinese ? "切换到 EZ(Easy)" : "Switch to EZ (Easy)",
+ ModMode.HardRock => chinese ? "切换到 HR(HardRock)" : "Switch to HR (HardRock)",
+ _ => chinese ? "切换到 NM(NoMod)" : "Switch to NM (NoMod)",
+ };
+ }
+
+ /// 果子标注快捷按钮在当前语言下的文本。
+ private static string LabelToggleText(QuickLabelMode mode)
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return mode switch
+ {
+ QuickLabelMode.Distance => chinese ? "距离-正常" : "Dist",
+ QuickLabelMode.DistanceIgnoreSvm => chinese ? "距离-忽略SVM" : "Dist-IgnoreSV",
+ QuickLabelMode.Stars => chinese ? "难度星数" : "Stars",
+ _ => chinese ? "隐藏" : "Hide",
+ };
+ }
+
+ private static string LabelToggleToolTip(QuickLabelMode mode)
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return mode switch
+ {
+ QuickLabelMode.Distance => chinese ? "距离标注:与编辑器相同" : "Distance label: same as editor",
+ QuickLabelMode.DistanceIgnoreSvm => chinese ? "距离标注:忽略滑条速度倍率" : "Distance label: ignore slider velocity multiplier",
+ QuickLabelMode.Stars => chinese ? "难度标注:标注物件的难度星数" : "Difficulty label: star difficulty of the object",
+ _ => chinese ? "不显示果子标注" : "Hide fruit labels",
+ };
+ }
+
+ /// “拍线:{当前拍线显示模式}”文字。
+ private static string BarLineStatusText()
+ {
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
+ return (chinese ? "拍线:" : "Bar lines: ") + BarLineSettings.GetOptionName(BarLineSettings.CurrentMode);
+ }
+
+
+
+
+
private void quickToggleBar_ToggleChanged(object? sender, QuickToggleChangedEventArgs e)
{
- if (e.Key == FreezePreviewTimeKey) SetPreviewTimeFrozen(e.IsChecked);
+ if (e.Key == FreezePreviewTimeKey)
+ {
+ SetPreviewTimeFrozen(e.IsChecked);
+ return;
+ }
+
+ int modIndex = Array.IndexOf(ModKeys, e.Key);
+ if (modIndex >= 0)
+ {
+ // 单选:同一次点击里 GroupChanged 与 ToggleChanged 都会到,这里只负责执行切换
+ ApplyModMode((ModMode)modIndex);
+ return;
+ }
+
+ int labelIndex = Array.IndexOf(LabelKeys, e.Key);
+ if (labelIndex >= 0)
+ {
+ ApplyHitObjectLabelMode((QuickLabelMode)labelIndex);
+ }
+ }
+
+ private void quickToggleBar_GroupChanged(object? sender, QuickToggleGroupChangedEventArgs e)
+ {
+ if (e.Key != BarLineValueKey) return;
+
+ ApplyBarLineMode(BarLineSettings.Clamp(e.Value), persist: true);
+ }
+
+ ///
+ /// 切换 MOD:同步菜单栏勾选(物件重建由绘制循环按菜单状态自行触发),
+ /// 并把快捷按钮设为单选状态。
+ ///
+ private void ApplyModMode(ModMode mode)
+ {
+ noneToolStripMenuItem.Checked = mode == ModMode.None;
+ eZToolStripMenuItem.Checked = mode == ModMode.Easy;
+ hRToolStripMenuItem.Checked = mode == ModMode.HardRock;
+ SetQuickToggleSelection(ModKeys, (int)mode);
}
+ ///
+ /// 切换果子标注模式:同步菜单栏勾选并请求重建(标签文本在重建时才计算),
+ /// 同时把快捷按钮设为单选状态。
+ ///
+ private void ApplyHitObjectLabelMode(QuickLabelMode mode)
+ {
+ hideToolStripMenuItem.Checked = mode == QuickLabelMode.Hidden;
+ sameWithEditorToolStripMenuItem.Checked = mode == QuickLabelMode.Distance;
+ noSliderVelocityMultiplierToolStripMenuItem.Checked = mode == QuickLabelMode.DistanceIgnoreSvm;
+ compareWithWalkSpeedToolStripMenuItem.Checked = false;
+ difficultyStarsToolStripMenuItem.Checked = mode == QuickLabelMode.Stars;
+ fruitCountInComboToolStripMenuItem.Checked = false;
+
+ SetQuickToggleSelection(LabelKeys, (int)mode);
+ }
+
+ ///
+ /// 把一组快捷开关按钮设为“只有选中的那个按下”(单选式功能区)。
+ /// 先悄悄改内部状态(不触发事件),再逐个反射到按钮上;被选中的那个放在最后设置,
+ /// 它的 CheckedChanged 会触发一次 ——
+ /// 那次调用看到的已经是最终状态,因此不会再来回切换。
+ ///
+ ///
+ private void SetQuickToggleSelection(string[] keys, int selectedIndex)
+ {
+ if (quickToggleBar == null || selectedIndex < 0 || selectedIndex >= keys.Length) return;
+
+ quickToggleBar.SetCheckedSilently(keys, keys[selectedIndex]);
+ for (int i = 0; i < keys.Length; i++)
+ {
+ if (i == selectedIndex) continue;
+ quickToggleBar.SetChecked(keys[i], false);
+ }
+ quickToggleBar.SetChecked(keys[selectedIndex], true);
+ }
+
+ ///
+ /// 设置拍线模式(快捷开关滑块与设置菜单下拉框共用),并同步状态文字。
+ ///
+ /// 是否立刻写入设置(启动时恢复不需要重复落盘)。
+ private void ApplyBarLineMode(BarLineMode mode, bool persist)
+ {
+ BarLineMode clamped = BarLineSettings.Clamp((int)mode);
+ BarLineSettings.CurrentMode = clamped;
+
+ quickToggleBar?.SetGroupSliderValue(BarLineGroupKey, BarLineValueKey, (int)clamped);
+ quickToggleBar?.SetGroupLabelText(BarLineGroupKey, BarLineValueKey + "_Text", BarLineStatusText());
+
+ if (!persist) return;
+ try
+ {
+ app.Default.Save();
+ }
+ catch (Exception ex)
+ {
+ Log.ConsoleLog("Save bar line mode failed.\r\n" + ex, Log.LogType.Program, Log.LogLevel.Warning);
+ }
+ }
+
+ /// 按当前菜单状态把 MOD 快捷按钮同步过来(启动时恢复用,不触发重建)。
+ private void SyncModToggleFromMenu()
+ {
+ if (hRToolStripMenuItem.Checked) SetQuickToggleSelection(ModKeys, (int)ModMode.HardRock);
+ else if (eZToolStripMenuItem.Checked) SetQuickToggleSelection(ModKeys, (int)ModMode.Easy);
+ else SetQuickToggleSelection(ModKeys, (int)ModMode.None);
+ }
+
+ /// 按当前菜单状态把果子标注快捷按钮同步过来(启动时恢复用,不触发重建)。
+ private void SyncLabelToggleFromMenu()
+ {
+ QuickLabelMode mode = QuickLabelMode.Hidden;
+ if (sameWithEditorToolStripMenuItem.Checked) mode = QuickLabelMode.Distance;
+ else if (noSliderVelocityMultiplierToolStripMenuItem.Checked) mode = QuickLabelMode.DistanceIgnoreSvm;
+ else if (difficultyStarsToolStripMenuItem.Checked) mode = QuickLabelMode.Stars;
+ SetQuickToggleSelection(LabelKeys, (int)mode);
+ }
+
+ #endregion
+
///
/// 切换“固定预览时刻”:打开后预览画布钉在当前 editor 时刻不再跟随,关闭后恢复跟随。
/// 只影响预览时刻();
@@ -1485,9 +1828,8 @@ private void SetPreviewTimeFrozen(bool frozen)
// 判定线据此改为跟随 editor 时刻在画面上的位置
drawingHelper.FixedPreviewTime = frozen;
- // 按钮文本/提示跟随状态:⏸ 表示点击后固定,▶ 表示点击后恢复跟随
- (string text, string toolTip) = FreezePreviewTimeText(frozen);
- quickToggleBar?.SetToggleText(FreezePreviewTimeKey, text, toolTip);
+ // 按钮图标/提示跟随状态:冰冻图标表示点击后固定,播放图标表示点击后恢复跟随
+ ApplyQuickToggleIcons();
if (!frozen)
{
@@ -1564,13 +1906,35 @@ private void quickToggleStripMenuItem_Click(object? sender, EventArgs e)
app.Default.Save();
}
- /// 语言切换后刷新快捷开关条上的固定文本。
+ /// 语言切换后刷新快捷开关条上的文本(功能区标题、按钮、状态文字)。
private void ApplyQuickToggleLanguage()
{
quickToggleDocking?.ApplyLanguage();
+ if (quickToggleBar == null) return;
+
+ bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
(string text, string toolTip) = FreezePreviewTimeText(previewTimeFrozen);
- quickToggleBar?.SetToggleText(FreezePreviewTimeKey, text, toolTip);
+ quickToggleBar.SetToggleText(FreezePreviewTimeKey, text, toolTip);
+
+ // 功能区标题(AddGroup 对已存在的功能区只更新标题)
+ quickToggleBar.AddGroup(ModGroupKey, "");
+ quickToggleBar.AddGroup(LabelGroupKey, "");
+ quickToggleBar.AddGroup(BarLineGroupKey, chinese ? "拍线" : "Bar Lines");
+
+ for (int i = 0; i < ModKeys.Length; i++)
+ {
+ quickToggleBar.SetToggleText(ModKeys[i], ModToggleText((ModMode)i), ModToggleToolTip((ModMode)i));
+ }
+ for (int i = 0; i < LabelKeys.Length; i++)
+ {
+ quickToggleBar.SetToggleText(LabelKeys[i], LabelToggleText((QuickLabelMode)i), LabelToggleToolTip((QuickLabelMode)i));
+ }
+
+ // 提示文字按语言刷新后再重装图标(图标只显示时,文字只作为按钮提示)
+ ApplyQuickToggleIcons();
+
+ quickToggleBar.SetGroupLabelText(BarLineGroupKey, BarLineValueKey + "_Text", BarLineStatusText());
}
///
diff --git a/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj b/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj
index 2f9d719..be2bc8f 100644
--- a/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj
+++ b/osucatch-editor-realtimeviewer/OsuCatch-Editor-RealtimeViewer.csproj
@@ -68,6 +68,10 @@
PreserveNewest
+
+
+ PreserveNewest
+
SettingsSingleFileGenerator
Settings.Designer.cs
diff --git a/osucatch-editor-realtimeviewer/QuickToggleBar.cs b/osucatch-editor-realtimeviewer/QuickToggleBar.cs
index 459b710..a199613 100644
--- a/osucatch-editor-realtimeviewer/QuickToggleBar.cs
+++ b/osucatch-editor-realtimeviewer/QuickToggleBar.cs
@@ -6,14 +6,26 @@ namespace osucatch_editor_realtimeviewer
/// 快捷开关条:类似 的工具条控件。
/// 它既能吸附在主窗口菜单栏下方的一行里,也能拖出来变成浮动小窗口:
/// 按住左侧手柄(或条上的空白处)拖动即可拖出 / 拖回,双击手柄或点击右端按钮同样可以切换。
+ /// 条上的内容按“功能区”组织:每个功能区有一段文字标题和若干按钮 / 下拉框 / 滑块,
+ /// 功能区之间用分隔线隔开;在条上点右键可以在右键菜单里勾选要显示哪些功能区。
///
///
- /// 开关内容由调用方添加,例如:
+ /// 典型用法:
///
- /// quickToggleBar.AddToggle("ShowDistanceLabel", "距离标签", app.Default.Show_Distance_Helper);
+ /// // 不属于任何功能区、固定在最左边、始终显示的开关
+ /// quickToggleBar.AddStandaloneToggle("FreezePreviewTime", "⏸️");
+ ///
+ /// // 功能区;标题传空字符串就只留按钮(如 MOD 区的 NM / EZ / HR)
+ /// QuickToggleGroup mods = quickToggleBar.AddGroup("Mod", "");
+ /// mods.AddToggle("ModNM", "NM");
+ /// mods.AddToggle("ModEZ", "EZ");
+ /// mods.AddToggle("ModHR", "HR");
+ ///
/// quickToggleBar.ToggleChanged += (s, e) => { /* 立即生效 */ };
+ /// quickToggleBar.GroupChanged += (s, e) => { /* 下拉框/滑块切换 */ };
///
- /// 勾选状态可通过 / 持久化到用户设置。
+ /// 按钮勾选状态可通过 / 持久化,
+ /// 功能区显示状态可通过 / 持久化。
///
internal sealed class QuickToggleBar : ToolStrip
{
@@ -25,6 +37,14 @@ internal sealed class QuickToggleBar : ToolStrip
private const int DragPollIntervalMs = 15;
private readonly Dictionary toggles = new(StringComparer.Ordinal);
+ private readonly Dictionary groups = new(StringComparer.Ordinal);
+ private readonly List groupOrder = new();
+
+ ///
+ /// 不属于任何功能区、固定排在条最左边的开关(如“固定预览时刻”)。
+ /// 它们不带功能区标题,永远显示、也不出现在右键菜单的功能区勾选里。
+ ///
+ private readonly List standaloneToggles = new();
/// 浮窗/吸附切换按钮:手柄之外的第二种操作方式,便于发现该功能。
private readonly ToolStripButton floatingButton = new();
@@ -32,6 +52,7 @@ internal sealed class QuickToggleBar : ToolStrip
private readonly ContextMenuStrip barContextMenu = new();
private readonly ToolStripMenuItem floatMenuItem = new();
private readonly ToolStripMenuItem hideMenuItem = new();
+ private readonly ToolStripSeparator contextMenuSeparator = new();
private readonly System.Windows.Forms.Timer dragTimer = new() { Interval = DragPollIntervalMs };
@@ -42,6 +63,7 @@ internal sealed class QuickToggleBar : ToolStrip
private long lastDragAreaClickTicks;
private Point lastDragAreaClickPoint;
private bool suppressToggleEvents;
+ private bool rebuilding;
private string title = "Quick Toggles";
internal QuickToggleBar()
@@ -52,7 +74,9 @@ internal QuickToggleBar()
AutoSize = false;
Dock = DockStyle.None;
TabStop = false;
+ // 始终单行:窗口不够宽时放不下的项收进右端角标(Chevron)的下拉里,条本身不会变高
CanOverflow = true;
+ LayoutStyle = ToolStripLayoutStyle.HorizontalStackWithOverflow;
floatingButton.Name = "quickToggleFloatingButton";
floatingButton.DisplayStyle = ToolStripItemDisplayStyle.Text;
@@ -69,6 +93,7 @@ internal QuickToggleBar()
hideMenuItem.Click += (sender, e) => HideRequested?.Invoke(this, EventArgs.Empty);
barContextMenu.Items.Add(floatMenuItem);
barContextMenu.Items.Add(hideMenuItem);
+ barContextMenu.Items.Add(contextMenuSeparator);
ContextMenuStrip = barContextMenu;
dragTimer.Tick += DragTimer_Tick;
@@ -76,24 +101,197 @@ internal QuickToggleBar()
ApplyLanguage();
}
- #region 开关内容
+ #region 事件
- /// 开关被用户勾选/取消时触发(从设置恢复时不触发)。
+ /// 开关按钮被用户勾选/取消时触发(从设置恢复时不触发)。
internal event EventHandler? ToggleChanged;
- /// 开关增删导致内容变化时触发:宿主据此重新计算停靠行高度 / 浮窗尺寸。
+ /// 功能区里的下拉框 / 滑块被用户改动时触发。
+ internal event EventHandler? GroupChanged;
+
+ /// 显示的功能区增删导致内容变化时触发:宿主据此重新计算停靠行高度 / 浮窗尺寸。
internal event EventHandler? ContentChanged;
- /// 向条上添加一个即时开关,返回可继续定制(图标、提示等)的按钮。
+ #endregion
+
+ #region 功能区
+
+ ///
+ /// 添加一个功能区(可选标题 + 若干按钮/控件)。功能区之间自动插入分隔线。
+ /// 同一个 重复调用会返回已有的功能区(便于语言切换后重设文本)。
+ ///
+ /// 功能区标识,用于持久化显示状态,需保持稳定。
+ ///
+ /// 功能区标题(显示在按钮之前)。传空字符串表示不加标题——
+ /// 例如 MOD 区只有 NM/EZ/HR 三个按钮,用户一看便知,标题反而占地方;
+ /// 此时该功能区依然出现在右键菜单里(菜单项文字回退为 )。
+ ///
+ internal QuickToggleGroup AddGroup(string key, string label)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+
+ if (groups.TryGetValue(key, out QuickToggleGroup? existing))
+ {
+ existing.SetLabel(label);
+ return existing;
+ }
+
+ QuickToggleGroup group = new(this, key, label, showLabel: label.Length > 0);
+ groups[key] = group;
+ groupOrder.Add(group);
+ RebuildItems();
+ UpdateGroupMenuChecks();
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ return group;
+ }
+
+ /// 功能区的显示状态发生变化(右键菜单勾选)时触发。
+ internal event EventHandler? GroupsVisibilityChanged;
+
+ /// 功能区是否存在。
+ internal bool HasGroup(string key) => groups.ContainsKey(key);
+
+ /// 功能区当前是否显示。
+ internal bool IsGroupVisible(string key) => groups.TryGetValue(key, out QuickToggleGroup? group) && group.Visible;
+
+ /// 显示 / 隐藏指定功能区。
+ internal void SetGroupVisible(string key, bool visible)
+ {
+ if (!groups.TryGetValue(key, out QuickToggleGroup? group)) return;
+ if (group.Visible == visible) return;
+
+ group.Visible = visible;
+ RebuildItems();
+ UpdateGroupMenuChecks();
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// 把所有功能区显示状态序列化成可写入设置的字符串(只写被隐藏的功能区)。
+ internal string GetHiddenGroups()
+ {
+ StringBuilder builder = new();
+ foreach (QuickToggleGroup group in groupOrder)
+ {
+ if (group.Visible) continue;
+ if (builder.Length > 0) builder.Append(';');
+ builder.Append(group.Key);
+ }
+ return builder.ToString();
+ }
+
+ /// 从设置字符串恢复功能区显示状态(不触发 )。
+ internal void ApplyHiddenGroups(string? hidden)
+ {
+ HashSet hiddenKeys = new(StringComparer.Ordinal);
+ if (!string.IsNullOrEmpty(hidden))
+ {
+ foreach (string key in hidden.Split(';', StringSplitOptions.RemoveEmptyEntries))
+ {
+ hiddenKeys.Add(key.Trim());
+ }
+ }
+
+ // 没有记录过(空字符串)时保持默认:全部显示
+ bool firstRun = string.IsNullOrEmpty(hidden);
+ bool changed = false;
+ foreach (QuickToggleGroup group in groupOrder)
+ {
+ bool visible = firstRun || !hiddenKeys.Contains(group.Key);
+ if (group.Visible == visible) continue;
+ group.Visible = visible;
+ changed = true;
+ }
+
+ if (!changed) return;
+ RebuildItems();
+ UpdateGroupMenuChecks();
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// 按固定的功能区顺序重建条上的显示项(隐藏的功能区不加入)。
+ private void RebuildItems()
+ {
+ if (rebuilding) return;
+ rebuilding = true;
+ try
+ {
+ // 只摘掉功能区相关的项,右端“浮动/吸附”按钮始终保留。
+ // 功能区之间的分隔线是本方法每次新建的,摘下来要一并释放,否则反复切换会漏句柄
+ for (int i = Items.Count - 1; i >= 0; i--)
+ {
+ ToolStripItem item = Items[i];
+ if (ReferenceEquals(item, floatingButton)) continue;
+ Items.RemoveAt(i);
+ if (item is ToolStripSeparator separator) separator.Dispose();
+ }
+
+ // 最左边先放不归属任何功能区的固定开关,再按顺序排各功能区
+ foreach (ToolStripButton standalone in standaloneToggles) Items.Add(standalone);
+
+ bool anyVisible = false;
+ foreach (QuickToggleGroup group in groupOrder)
+ {
+ if (!group.Visible) continue;
+ if (anyVisible) Items.Add(new ToolStripSeparator());
+ group.InsertItems(Items);
+ anyVisible = true;
+ }
+ }
+ finally
+ {
+ rebuilding = false;
+ }
+ }
+
+ #endregion
+
+ #region 开关按钮
+
+ ///
+ /// 添加一个不属于任何功能区、固定排在条最左边的即时开关(如“固定预览时刻”)。
+ /// 它没有功能区标题,也不会出现在右键菜单的功能区勾选里——功能区开关是用来
+ /// 按需收起的,而这种独立的模式切换按钮必须一直看得见。
+ ///
+ ///
/// 开关标识,用于读写状态与持久化,需保持稳定。
+ /// 按钮文本。
+ /// 初始勾选状态。
+ internal ToolStripButton AddStandaloneToggle(string key, string text, bool isChecked = false)
+ {
+ if (toggles.TryGetValue(key, out ToolStripButton? existing)) return existing;
+
+ ToolStripButton item = CreateToggleButton(key, text, isChecked);
+ toggles[key] = item;
+ standaloneToggles.Add(item);
+ RebuildItems();
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ return item;
+ }
+
+ /// 向指定功能区添加一个即时开关(勾选式按钮),返回可继续定制(图标、提示等)的按钮。
+ /// 开关标识,用于读写状态与持久化,需保持稳定。
+ /// 所属功能区标识。
/// 按钮文本,后续语言资源可按 覆盖。
/// 初始勾选状态。
- internal ToolStripButton AddToggle(string key, string text, bool isChecked = false)
+ internal ToolStripButton AddToggle(string key, string groupKey, string text, bool isChecked = false)
{
ArgumentException.ThrowIfNullOrEmpty(key);
if (toggles.TryGetValue(key, out ToolStripButton? existing)) return existing;
+ ToolStripButton item = CreateToggleButton(key, text, isChecked);
+
+ toggles[key] = item;
+ AddItemToGroup(groupKey, item);
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ return item;
+ }
+
+ /// 创建开关按钮本体(两种添加方式共用)。
+ private ToolStripButton CreateToggleButton(string key, string text, bool isChecked)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+
ToolStripButton item = new()
{
Name = key,
@@ -104,28 +302,130 @@ internal ToolStripButton AddToggle(string key, string text, bool isChecked = fal
Checked = isChecked,
DisplayStyle = ToolStripItemDisplayStyle.Text,
Alignment = ToolStripItemAlignment.Left,
+ Overflow = ToolStripItemOverflow.AsNeeded,
};
item.CheckedChanged += (sender, e) =>
{
if (suppressToggleEvents) return;
ToggleChanged?.Invoke(this, new QuickToggleChangedEventArgs(key, item.Checked));
};
-
- toggles[key] = item;
- Items.Add(item);
- ContentChanged?.Invoke(this, EventArgs.Empty);
return item;
}
- /// 移除一个开关。
+ /// 移除一个开关(功能区开关或独立开关都适用)。
internal void RemoveToggle(string key)
{
if (!toggles.Remove(key, out ToolStripButton? item)) return;
- Items.Remove(item);
+ RemoveItemFromGroup(item);
+ standaloneToggles.Remove(item);
+ RebuildItems();
item.Dispose();
ContentChanged?.Invoke(this, EventArgs.Empty);
}
+ #region 按钮图标
+
+ ///
+ /// 按文件路径缓存的图标:同一个文件只从磁盘读一次。
+ /// 缓存的是从文件流解码出来的 ,所有按钮共用同一个实例,
+ /// 因此不要把返回的图片再交给会 Dispose 它的调用方。
+ ///
+ ///
+ private static readonly Dictionary iconCache = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// 设置开关按钮的图标。传 null 或文件不存在时只清空图标,按钮回退为显示文字。
+ ///
+ /// 开关标识。
+ /// 图标文件路径(通常是 exe 目录下的 icons\*.png)。
+ ///
+ /// 设置成功时要切换到的显示方式:只显示图标()
+ /// 或图标 + 文字。图标缺失时不变更显示方式,保证按钮不会变成空白。
+ ///
+ internal void SetToggleImage(string key, string? path, ToolStripItemDisplayStyle displayStyle = ToolStripItemDisplayStyle.Image)
+ {
+ if (!toggles.TryGetValue(key, out ToolStripButton? item)) return;
+
+ Image? image = LoadIcon(path);
+ if (image == null)
+ {
+ item.Image = null;
+ item.DisplayStyle = ToolStripItemDisplayStyle.Text;
+ }
+ else
+ {
+ item.Image = image;
+ item.ImageScaling = ToolStripItemImageScaling.SizeToFit;
+ item.DisplayStyle = displayStyle;
+ }
+
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// 把功能区里所有开关按钮的显示方式统一改掉(如“果子标注”整组只显示图标)。
+ internal void SetGroupTogglesDisplayStyle(string groupKey, ToolStripItemDisplayStyle displayStyle)
+ {
+ if (!groups.TryGetValue(groupKey, out QuickToggleGroup? group)) return;
+
+ bool changed = false;
+ foreach (ToolStripItem item in group.Items)
+ {
+ if (item is not ToolStripButton button) continue;
+ if (button.Image == null) continue; // 没有图标的按钮保持文字,避免变成空白
+ button.DisplayStyle = displayStyle;
+ changed = true;
+ }
+
+ if (changed) ContentChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// 把图标缩放到条的 大小。
+ /// 不缩放的话,每个按钮都会按图片原始像素绘制:一旦 Windows 缩放不是 100%,
+ /// 或者提供的是 24/32px 的高分屏图标,图标就会比按钮还高,把停靠行撑高。
+ ///
+ ///
+ private Image? LoadIcon(string? path)
+ {
+ if (string.IsNullOrEmpty(path)) return null;
+ if (iconCache.TryGetValue(path, out Image? cached)) return cached;
+
+ Image? image = null;
+ if (File.Exists(path))
+ {
+ try
+ {
+ using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ using Image source = Image.FromStream(stream);
+ Size target = new(Math.Max(ImageScalingSize.Width, 1), Math.Max(ImageScalingSize.Height, 1));
+ if (source.Width == target.Width && source.Height == target.Height)
+ {
+ // 尺寸已经匹配(最常见的 16x16 @100%):直接留一份位图副本,
+ // 避免持有文件流的解码依赖
+ image = new Bitmap(source);
+ }
+ else
+ {
+ Bitmap scaled = new(target.Width, target.Height);
+ using Graphics graphics = Graphics.FromImage(scaled);
+ graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
+ graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
+ graphics.DrawImage(source, 0, 0, target.Width, target.Height);
+ image = scaled;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.ConsoleLog("Read icon file failed: " + path + "\r\n" + ex, Log.LogType.Drawing, Log.LogLevel.Warning);
+ }
+ }
+
+ iconCache[path] = image;
+ return image;
+ }
+
+ #endregion
+
/// 读取开关的勾选状态;开关不存在时返回 false。
internal bool IsChecked(string key) => toggles.TryGetValue(key, out ToolStripButton? item) && item.Checked;
@@ -149,6 +449,27 @@ internal void SetChecked(string key, bool isChecked)
if (toggles.TryGetValue(key, out ToolStripButton? item)) item.Checked = isChecked;
}
+ ///
+ /// 批量设置一批开关的勾选状态,期间不触发 。
+ /// 用于“一组里只能选一个”的单选式功能区(MOD、果子标注):避免每设一个都触发一次重建。
+ ///
+ internal void SetCheckedSilently(IEnumerable keys, string checkedKey)
+ {
+ bool previous = suppressToggleEvents;
+ suppressToggleEvents = true;
+ try
+ {
+ foreach (string key in keys)
+ {
+ if (toggles.TryGetValue(key, out ToolStripButton? item)) item.Checked = key == checkedKey;
+ }
+ }
+ finally
+ {
+ suppressToggleEvents = previous;
+ }
+ }
+
/// 把所有开关状态序列化成可写入设置的字符串。
internal string GetCheckedStates()
{
@@ -190,6 +511,65 @@ internal void ApplyCheckedStates(string? states)
#endregion
+ #region 内部:功能区与条上项的同步
+
+ /// 把新按钮归入指定功能区,并重建条上的显示项。
+ private void AddItemToGroup(string targetGroupKey, ToolStripItem item)
+ {
+ if (!groups.TryGetValue(targetGroupKey, out QuickToggleGroup? group))
+ {
+ // 没有建功能区时退化成“不显示”:避免按钮被静默丢弃后找不到
+ QuickToggleGroup fallback = AddGroup(targetGroupKey, targetGroupKey);
+ fallback.AddItem(item);
+ RebuildItems();
+ return;
+ }
+
+ group.AddItem(item);
+ RebuildItems();
+ }
+
+ private void RemoveItemFromGroup(ToolStripItem item)
+ {
+ foreach (QuickToggleGroup group in groupOrder) group.RemoveItem(item);
+ }
+
+ ///
+ /// 功能区内部新增了控件(滑块 / 下拉框 / 文字)后,把该功能区的项重新插到条上并刷新宿主尺寸。
+ /// 必须显式调用:功能区的项是“先攒在自己列表里、再整体插入条上”的,
+ /// 少了这一步,新加的控件要等到下一次重建(例如用户在右键菜单里收起再展开该功能区)才会出现。
+ ///
+ ///
+ private void NotifyGroupItemsChanged()
+ {
+ RebuildItems();
+ ContentChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ #endregion
+
+ #region 功能区值控件(供宿主读写)
+
+ /// 设置功能区里滑块的取值(不触发 )。
+ internal void SetGroupSliderValue(string groupKey, string valueKey, int value)
+ {
+ if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetSliderValue(valueKey, value);
+ }
+
+ /// 设置功能区里下拉框的选中项(不触发 )。
+ internal void SetGroupComboIndex(string groupKey, string valueKey, int index)
+ {
+ if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetComboIndex(valueKey, index);
+ }
+
+ /// 更新功能区里的一段只读文字。
+ internal void SetGroupLabelText(string groupKey, string labelKey, string text)
+ {
+ if (groups.TryGetValue(groupKey, out QuickToggleGroup? group)) group.SetLabelText(labelKey, text);
+ }
+
+ #endregion
+
#region 停靠 / 浮动
/// 请求把条拖出为浮窗 / 吸附回工具栏(双击手柄、点击右端按钮或右键菜单)。
@@ -213,17 +593,24 @@ internal void ApplyCheckedStates(string? states)
/// 浮窗标题 / 提示用的名称。
internal string BarTitle => title;
- /// 浮窗尺寸:按内容计算,并限制在工作区之内。
+ ///
+ /// 浮窗尺寸:按内容计算,并限制在工作区之内。
+ /// 宽度先夹到工作区,再按这个宽度问首选高度:条是换行排布的(Flow),
+ /// 用一个很宽的宽度去问只会拿到“单行”的高度,窄屏上内容换行后就会被裁掉。
+ ///
+ ///
internal Size PreferredFloatSize()
{
- Size preferred = GetPreferredSize(new Size(4096, 0));
Rectangle workingArea = Screen.FromControl(this).WorkingArea;
- return new Size(
- Math.Max(96, Math.Min(preferred.Width, workingArea.Width)),
- Math.Max(24, Math.Min(preferred.Height, workingArea.Height)));
+ int width = Math.Max(96, Math.Min(GetPreferredSize(new Size(4096, 0)).Width, workingArea.Width));
+ int height = Math.Max(24, Math.Min(GetPreferredSize(new Size(width, 0)).Height, workingArea.Height));
+ return new Size(width, height);
}
- /// 按当前语言刷新条上的固定文本(按钮、右键菜单、浮窗标题)。
+ ///
+ /// 按当前语言刷新条上的固定文本(右端按钮、右键菜单、浮窗标题)。
+ /// 功能区标题与按钮文本由调用方在语言切换后重新设置(见 Form1.ApplyQuickToggleLanguage)。
+ ///
internal void ApplyLanguage()
{
bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh";
@@ -237,12 +624,55 @@ internal void ApplyLanguage()
? (chinese ? "吸附到工具栏" : "Dock to toolbar")
: (chinese ? "浮动为小窗口" : "Float as window");
hideMenuItem.Text = chinese ? "隐藏快捷开关栏" : "Hide quick toggle bar";
+ contextMenuSeparator.Visible = groupOrder.Count > 0;
+
+ for (int i = 0; i < groupOrder.Count; i++)
+ {
+ QuickToggleGroup group = groupOrder[i];
+ string text = group.DisplayText;
+ if (group.MenuItem != null) group.MenuItem.Text = text;
+ }
if (Parent is QuickToggleFloatForm floatForm) floatForm.Text = title;
}
#endregion
+ #region 右键菜单:功能区勾选
+
+ ///
+ /// 把功能区显示状态同步到右键菜单的勾选项。
+ /// 菜单项按功能区加入顺序排列,取消勾选即隐藏对应功能区。
+ ///
+ private void UpdateGroupMenuChecks()
+ {
+ foreach (QuickToggleGroup group in groupOrder)
+ {
+ if (group.MenuItem == null)
+ {
+ ToolStripMenuItem item = new()
+ {
+ Name = "quickToggleGroup_" + group.Key,
+ CheckOnClick = true,
+ };
+ QuickToggleGroup captured = group;
+ item.Click += (sender, e) =>
+ {
+ // CheckOnClick 已经翻转了勾选状态,这里只负责应用
+ SetGroupVisible(captured.Key, item.Checked);
+ GroupsVisibilityChanged?.Invoke(this, EventArgs.Empty);
+ };
+ group.MenuItem = item;
+ barContextMenu.Items.Add(item);
+ }
+
+ group.MenuItem.Text = group.DisplayText;
+ group.MenuItem.Checked = group.Visible;
+ }
+ }
+
+ #endregion
+
#region 拖出手柄
/// 手柄或空白处都算拖动区:手柄太窄时用户也能轻松把条拖出来。
@@ -343,6 +773,264 @@ protected override void Dispose(bool disposing)
}
#endregion
+
+ ///
+ /// 条上的一个功能区:可选的一段文字标题 + 若干按钮 / 下拉框 / 滑块,整体可以显示或隐藏。
+ ///
+ internal sealed class QuickToggleGroup
+ {
+ private readonly QuickToggleBar owner;
+ private readonly List items = new();
+ private readonly ToolStripLabel? label;
+ private readonly string fallbackText;
+ private ToolStripMenuItem? menuItem;
+
+ internal QuickToggleGroup(QuickToggleBar owner, string key, string labelText, bool showLabel)
+ {
+ this.owner = owner;
+ Key = key;
+ fallbackText = key;
+ DisplayText = labelText.Length > 0 ? labelText : key;
+
+ if (!showLabel) return;
+
+ label = new ToolStripLabel
+ {
+ Name = "quickToggleGroupLabel_" + key,
+ AutoToolTip = false,
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ Alignment = ToolStripItemAlignment.Left,
+ Text = labelText,
+ };
+ items.Add(label);
+ }
+
+ /// 功能区标识(持久化用的键)。
+ internal string Key { get; }
+
+ ///
+ /// 功能区标题文本。没有标题的功能区( 传空字符串)用标识兜底,
+ /// 这样右键菜单里的勾选项仍然有文字可显示。
+ ///
+ internal string DisplayText { get; private set; }
+
+ /// 功能区当前是否显示(右键菜单可切换)。
+ internal bool Visible { get; set; } = true;
+
+ /// 右键菜单里对应的勾选项,由条在同步菜单时创建。
+ internal ToolStripMenuItem? MenuItem
+ {
+ get => menuItem;
+ set => menuItem = value;
+ }
+
+ /// 该功能区已加入的项(含标题与值控件)。
+ internal IReadOnlyList Items => items;
+
+ /// 设置功能区标题(语言切换时调用)。传空字符串只影响标题项,不影响右键菜单文字。
+ internal void SetLabel(string text)
+ {
+ DisplayText = text.Length > 0 ? text : fallbackText;
+ if (label != null) label.Text = text;
+ if (menuItem != null) menuItem.Text = DisplayText;
+ }
+
+ ///
+ /// 添加一段只读文字(如“拍线:每2拍”)。
+ ///
+ /// 文字标识,供 更新用。
+ /// 初始文字。
+ internal ToolStripLabel AddLabel(string labelKey, string text)
+ {
+ ToolStripLabel item = new()
+ {
+ Name = "quickToggleValueLabel_" + labelKey,
+ Text = text,
+ AutoToolTip = false,
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ Alignment = ToolStripItemAlignment.Left,
+ Overflow = ToolStripItemOverflow.AsNeeded,
+ };
+ labels[labelKey] = item;
+ items.Add(item);
+ owner.NotifyGroupItemsChanged();
+ return item;
+ }
+
+ /// 更新功能区里的一段只读文字。
+ internal void SetLabelText(string labelKey, string text)
+ {
+ if (labels.TryGetValue(labelKey, out ToolStripLabel? item)) item.Text = text;
+ }
+
+ /// 功能区里的只读文字项,键为文字标识。
+ private readonly Dictionary labels = new(StringComparer.Ordinal);
+
+ /// 向功能区添加一个即时开关按钮。
+ internal ToolStripButton AddToggle(string key, string text, bool isChecked = false)
+ => owner.AddToggle(key, Key, text, isChecked);
+
+ ///
+ /// 添加一个“选择一个值”的下拉框(如拍线模式)。
+ ///
+ /// 值标识,用户改动时通过 上报。
+ /// 下拉项文本,下标即取值。
+ /// 初始选中项。
+ internal ComboBox AddCombo(string valueKey, IReadOnlyList optionTexts, int selectedIndex)
+ {
+ int width = 96;
+ foreach (string text in optionTexts)
+ {
+ width = Math.Max(width, TextRenderer.MeasureText(text, SystemFonts.DefaultFont).Width + 28);
+ }
+ width = Math.Min(width, 240);
+
+ ComboBox combo = new()
+ {
+ Name = "quickToggleCombo_" + valueKey,
+ DropDownStyle = ComboBoxStyle.DropDownList,
+ FlatStyle = FlatStyle.Flat,
+ IntegralHeight = false,
+ MaxDropDownItems = 16,
+ Width = width,
+ Height = 22,
+ };
+ foreach (string text in optionTexts) combo.Items.Add(text);
+ combo.SelectedIndex = Math.Clamp(selectedIndex, 0, Math.Max(combo.Items.Count - 1, 0));
+
+ combo.SelectedIndexChanged += (sender, e) =>
+ {
+ if (suppressedValues.Contains(valueKey) || combo.SelectedIndex < 0) return;
+ owner.GroupChanged?.Invoke(owner, new QuickToggleGroupChangedEventArgs(valueKey, combo.SelectedIndex));
+ };
+
+ ToolStripControlHost host = new(combo)
+ {
+ Name = "quickToggleHost_" + valueKey,
+ AutoSize = false,
+ Width = width,
+ Height = 22,
+ Margin = new Padding(2, 2, 4, 2),
+ };
+ AddValueHost(host);
+ ValueControls[valueKey] = combo;
+ owner.NotifyGroupItemsChanged();
+ return combo;
+ }
+
+ ///
+ /// 添加一个滑块(如拍线密度)。
+ ///
+ /// 值标识,用户拖动时通过 上报。
+ /// 最小值。
+ /// 最大值。
+ /// 初始值。
+ /// 刻度间隔(0 表示不画刻度)。
+ internal TrackBar AddSlider(string valueKey, int minimum, int maximum, int value, int tickFrequency)
+ {
+ TrackBar slider = new()
+ {
+ Name = "quickToggleSlider_" + valueKey,
+ Minimum = minimum,
+ Maximum = maximum,
+ TickFrequency = Math.Max(tickFrequency, 1),
+ SmallChange = 1,
+ LargeChange = Math.Max((maximum - minimum) / 10, 1),
+ AutoSize = false,
+ Width = 130,
+ Height = 24,
+ Value = Math.Clamp(value, minimum, maximum),
+ };
+
+ slider.ValueChanged += (sender, e) =>
+ {
+ if (suppressedValues.Contains(valueKey)) return;
+ owner.GroupChanged?.Invoke(owner, new QuickToggleGroupChangedEventArgs(valueKey, slider.Value));
+ };
+
+ ToolStripControlHost host = new(slider)
+ {
+ Name = "quickToggleHost_" + valueKey,
+ AutoSize = false,
+ Width = slider.Width,
+ Height = slider.Height,
+ Margin = new Padding(2, 1, 4, 1),
+ };
+ AddValueHost(host);
+ ValueControls[valueKey] = slider;
+ owner.NotifyGroupItemsChanged();
+ return slider;
+ }
+
+ /// 功能区里由外部控制的值控件(下拉框 / 滑块),键为值标识。
+ internal Dictionary ValueControls { get; } = new(StringComparer.Ordinal);
+
+ /// 正在被程序改写的值标识:期间的 不触发。
+ private readonly HashSet suppressedValues = new(StringComparer.Ordinal);
+
+ ///
+ /// 设置下拉框选中项(不触发 )。
+ ///
+ internal void SetComboIndex(string valueKey, int index)
+ {
+ if (!ValueControls.TryGetValue(valueKey, out Control? control) || control is not ComboBox combo) return;
+ int clamped = Math.Clamp(index, 0, Math.Max(combo.Items.Count - 1, 0));
+ if (combo.SelectedIndex == clamped) return;
+
+ suppressedValues.Add(valueKey);
+ try
+ {
+ combo.SelectedIndex = clamped;
+ }
+ finally
+ {
+ suppressedValues.Remove(valueKey);
+ }
+ }
+
+ ///
+ /// 设置滑块位置(不触发 )。
+ ///
+ internal void SetSliderValue(string valueKey, int value)
+ {
+ if (!ValueControls.TryGetValue(valueKey, out Control? control) || control is not TrackBar slider) return;
+ int clamped = Math.Clamp(value, slider.Minimum, slider.Maximum);
+ if (slider.Value == clamped) return;
+
+ suppressedValues.Add(valueKey);
+ try
+ {
+ slider.Value = clamped;
+ }
+ finally
+ {
+ suppressedValues.Remove(valueKey);
+ }
+ }
+
+ /// 读取下拉框选中项;控件不存在时返回 -1。
+ internal int GetComboIndex(string valueKey)
+ => ValueControls.TryGetValue(valueKey, out Control? control) && control is ComboBox combo ? combo.SelectedIndex : -1;
+
+ /// 读取滑块值;控件不存在时返回 0。
+ internal int GetSliderValue(string valueKey)
+ => ValueControls.TryGetValue(valueKey, out Control? control) && control is TrackBar slider ? slider.Value : 0;
+
+ private void AddValueHost(ToolStripControlHost host)
+ {
+ items.Add(host);
+ }
+
+ internal void AddItem(ToolStripItem item) => items.Add(item);
+
+ internal void RemoveItem(ToolStripItem item) => items.Remove(item);
+
+ /// 把功能区的项按顺序插入到条上(由条调用)。
+ internal void InsertItems(ToolStripItemCollection target)
+ {
+ foreach (ToolStripItem item in items) target.Add(item);
+ }
+ }
}
/// 开关状态变化事件参数。
@@ -361,6 +1049,22 @@ internal QuickToggleChangedEventArgs(string key, bool isChecked)
internal bool IsChecked { get; }
}
+ /// 功能区里的下拉框 / 滑块变化事件参数。
+ internal sealed class QuickToggleGroupChangedEventArgs : EventArgs
+ {
+ internal QuickToggleGroupChangedEventArgs(string key, int value)
+ {
+ Key = key;
+ Value = value;
+ }
+
+ /// 值标识(AddCombo / AddSlider 中传入的 key)。
+ internal string Key { get; }
+
+ /// 新的取值(下拉框为下标,滑块为滑块值)。
+ internal int Value { get; }
+ }
+
/// 拖动事件参数,坐标均为屏幕坐标。
internal sealed class QuickToggleDragEventArgs : EventArgs
{
diff --git a/osucatch-editor-realtimeviewer/QuickToggleDocking.cs b/osucatch-editor-realtimeviewer/QuickToggleDocking.cs
index d19f6f2..dc23217 100644
--- a/osucatch-editor-realtimeviewer/QuickToggleDocking.cs
+++ b/osucatch-editor-realtimeviewer/QuickToggleDocking.cs
@@ -41,9 +41,12 @@ protected override void OnLayout(LayoutEventArgs e)
base.OnLayout(e);
if (bar == null || bar.Parent != this) return;
- // 行高由条的首选高度决定;条本身不自动调整大小,宽度始终铺满窗口
- int height = Math.Max(bar.PreferredSize.Height, 1);
- bar.Bounds = new Rectangle(0, 0, Math.Max(Width, 1), height);
+ // 行高由条的首选高度决定;条本身不自动调整大小,宽度始终铺满窗口。
+ // 首选高度必须按“实际可用宽度”问条要:条用 Flow 布局换行排布,传一个很宽的宽度
+ // 只会得到“单行”的高度,行会被压成一行高,第二行往后的控件全被裁掉看不见。
+ int width = Math.Max(Width, 1);
+ int height = Math.Max(bar.GetPreferredSize(new Size(width, 0)).Height, 1);
+ bar.Bounds = new Rectangle(0, 0, width, height);
if (Height != height) Height = height;
}
}
@@ -164,6 +167,7 @@ internal QuickToggleDocking(Form owner, QuickToggleBar bar, QuickToggleDockRow d
bar.HideRequested += Bar_HideRequested;
bar.ContentChanged += Bar_ContentChanged;
bar.ToggleChanged += Bar_ToggleChanged;
+ bar.GroupsVisibilityChanged += Bar_GroupsVisibilityChanged;
dockRow.Controls.Add(bar);
}
@@ -262,6 +266,7 @@ internal void SaveState()
app.Default.QuickToggle_Float_Y = floatForm.Location.Y;
}
app.Default.QuickToggle_States = bar.GetCheckedStates();
+ app.Default.QuickToggle_HiddenGroups = bar.GetHiddenGroups();
}
#region 停靠 / 浮动切换
@@ -470,8 +475,21 @@ private void Bar_ContentChanged(object? sender, EventArgs e)
private void Bar_ToggleChanged(object? sender, QuickToggleChangedEventArgs e)
{
- // 即时开关:状态一变就写入设置,不需要等退出程序。
- // 写设置失败(配置文件只读/被占用等)不应让点开关变成崩溃,记录一条日志即可。
+ SaveAndFlush();
+ }
+
+ private void Bar_GroupsVisibilityChanged(object? sender, EventArgs e)
+ {
+ // 右键菜单隐藏/显示功能区:立即写入设置,下次启动保持
+ SaveAndFlush();
+ }
+
+ ///
+ /// 把开关条状态写入设置并立刻落盘。
+ /// 写设置失败(配置文件只读/被占用等)不应让点开关变成崩溃,记录一条日志即可。
+ ///
+ private void SaveAndFlush()
+ {
SaveState();
try
{
@@ -516,6 +534,7 @@ public void Dispose()
bar.HideRequested -= Bar_HideRequested;
bar.ContentChanged -= Bar_ContentChanged;
bar.ToggleChanged -= Bar_ToggleChanged;
+ bar.GroupsVisibilityChanged -= Bar_GroupsVisibilityChanged;
if (hintForm.Visible) hintForm.Hide();
hintForm.Dispose();
diff --git a/osucatch-editor-realtimeviewer/SettingsForm.Designer.cs b/osucatch-editor-realtimeviewer/SettingsForm.Designer.cs
index 08a0dc7..4e58805 100644
--- a/osucatch-editor-realtimeviewer/SettingsForm.Designer.cs
+++ b/osucatch-editor-realtimeviewer/SettingsForm.Designer.cs
@@ -1,4 +1,4 @@
-namespace osucatch_editor_realtimeviewer
+namespace osucatch_editor_realtimeviewer
{
partial class SettingsForm
{
@@ -85,7 +85,6 @@ private void InitializeComponent()
checkBox_UseBatchRendering = new CheckBox();
label_UseBatchRenderingWarning = new Label();
groupBox7 = new GroupBox();
- checkBox_BarLine_Show = new CheckBox();
checkBox_TimingLine_ShowGreen = new CheckBox();
checkBox_TimingLine_ShowRed = new CheckBox();
comboBox_BarLineSubdivide = new ComboBox();
@@ -532,7 +531,6 @@ private void InitializeComponent()
// groupBox7
//
resources.ApplyResources(groupBox7, "groupBox7");
- groupBox7.Controls.Add(checkBox_BarLine_Show);
groupBox7.Controls.Add(checkBox_TimingLine_ShowGreen);
groupBox7.Controls.Add(checkBox_TimingLine_ShowRed);
groupBox7.Controls.Add(comboBox_BarLineSubdivide);
@@ -544,12 +542,6 @@ private void InitializeComponent()
groupBox7.Name = "groupBox7";
groupBox7.TabStop = false;
//
- // checkBox_BarLine_Show
- //
- resources.ApplyResources(checkBox_BarLine_Show, "checkBox_BarLine_Show");
- checkBox_BarLine_Show.Name = "checkBox_BarLine_Show";
- checkBox_BarLine_Show.UseVisualStyleBackColor = true;
- //
// checkBox_TimingLine_ShowGreen
//
resources.ApplyResources(checkBox_TimingLine_ShowGreen, "checkBox_TimingLine_ShowGreen");
@@ -567,7 +559,18 @@ private void InitializeComponent()
resources.ApplyResources(comboBox_BarLineSubdivide, "comboBox_BarLineSubdivide");
comboBox_BarLineSubdivide.DropDownStyle = ComboBoxStyle.DropDownList;
comboBox_BarLineSubdivide.FormattingEnabled = true;
- comboBox_BarLineSubdivide.Items.AddRange(new object[] { resources.GetString("comboBox_BarLineSubdivide.Items"), resources.GetString("comboBox_BarLineSubdivide.Items1"), resources.GetString("comboBox_BarLineSubdivide.Items2") });
+ comboBox_BarLineSubdivide.Items.AddRange(new object[] {
+ resources.GetString("comboBox_BarLineSubdivide.Items"),
+ resources.GetString("comboBox_BarLineSubdivide.Items1"),
+ resources.GetString("comboBox_BarLineSubdivide.Items2"),
+ resources.GetString("comboBox_BarLineSubdivide.Items3"),
+ resources.GetString("comboBox_BarLineSubdivide.Items4"),
+ resources.GetString("comboBox_BarLineSubdivide.Items5"),
+ resources.GetString("comboBox_BarLineSubdivide.Items6"),
+ resources.GetString("comboBox_BarLineSubdivide.Items7"),
+ resources.GetString("comboBox_BarLineSubdivide.Items8"),
+ resources.GetString("comboBox_BarLineSubdivide.Items9"),
+ resources.GetString("comboBox_BarLineSubdivide.Items10")});
comboBox_BarLineSubdivide.Name = "comboBox_BarLineSubdivide";
//
// checkBox_ShowDistanceHelper
@@ -842,7 +845,6 @@ private void InitializeComponent()
private GroupBox groupBox7;
private CheckBox checkBox_TimingLine_ShowGreen;
private CheckBox checkBox_TimingLine_ShowRed;
- private CheckBox checkBox_BarLine_Show;
private CheckBox checkBox_ShowSelected;
private GroupBox groupBox8;
private CheckBox checkBox_FilterNearbyHitObjects;
diff --git a/osucatch-editor-realtimeviewer/SettingsForm.cs b/osucatch-editor-realtimeviewer/SettingsForm.cs
index 48095e4..d1562dc 100644
--- a/osucatch-editor-realtimeviewer/SettingsForm.cs
+++ b/osucatch-editor-realtimeviewer/SettingsForm.cs
@@ -1,4 +1,4 @@
-namespace osucatch_editor_realtimeviewer
+namespace osucatch_editor_realtimeviewer
{
public partial class SettingsForm : Form
{
@@ -7,6 +7,9 @@ public SettingsForm()
InitializeComponent();
}
+ /// 点了“应用”并写入设置后触发:宿主据此立刻把新设置同步到界面(如快捷开关栏上的拍线滑块)。
+ internal event Action? SettingsApplied;
+
private void SettingsForm_Load(object sender, EventArgs e)
{
numericUpDown_width.Value = app.Default.Window_Width;
@@ -39,10 +42,9 @@ private void SettingsForm_Load(object sender, EventArgs e)
checkBox_TimingLine_ShowRed.Checked = app.Default.TimingLine_ShowRed;
checkBox_TimingLine_ShowGreen.Checked = app.Default.TimingLine_ShowGreen;
- checkBox_BarLine_Show.Checked = app.Default.BarLine_Show;
- // 小节线细分选择(下拉项由 Designer 从 resx 填充)
- comboBox_BarLineSubdivide.SelectedIndex = Math.Clamp(app.Default.BarLine_Subdivide, 0, 2);
+ // 拍线细分(下拉项由 Designer 从 resx 填充,下标即 BarLineMode 的值)
+ comboBox_BarLineSubdivide.SelectedIndex = Math.Clamp((int)BarLineSettings.CurrentMode, 0, comboBox_BarLineSubdivide.Items.Count - 1);
// 距离辅助线开关
checkBox_ShowDistanceHelper.Checked = app.Default.Show_Distance_Helper;
@@ -145,8 +147,8 @@ private void button_apply_Click(object sender, EventArgs e)
app.Default.TimingLine_ShowRed = checkBox_TimingLine_ShowRed.Checked;
app.Default.TimingLine_ShowGreen = checkBox_TimingLine_ShowGreen.Checked;
- app.Default.BarLine_Show = checkBox_BarLine_Show.Checked;
- app.Default.BarLine_Subdivide = comboBox_BarLineSubdivide.SelectedIndex;
+ // 拍线模式:与快捷开关栏共用同一个设置项(CurrentMode 会一并刷新旧的兼容字段)
+ BarLineSettings.CurrentMode = BarLineSettings.Clamp(comboBox_BarLineSubdivide.SelectedIndex);
app.Default.Show_Distance_Helper = checkBox_ShowDistanceHelper.Checked;
app.Default.Distance_Helper_White_Speed = (double)numericUpDown_WhiteSpeed.Value;
app.Default.Distance_Helper_Red_Speed = (double)numericUpDown_RedSpeed.Value;
@@ -169,6 +171,7 @@ private void button_apply_Click(object sender, EventArgs e)
app.Default.Save();
Form1.NeedReapplySettings = true;
+ SettingsApplied?.Invoke();
this.Close();
}
diff --git a/osucatch-editor-realtimeviewer/SettingsForm.resx b/osucatch-editor-realtimeviewer/SettingsForm.resx
index ff4e562..325d045 100644
--- a/osucatch-editor-realtimeviewer/SettingsForm.resx
+++ b/osucatch-editor-realtimeviewer/SettingsForm.resx
@@ -1,4 +1,4 @@
-
+