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 @@ - +