diff --git a/osucatch-editor-realtimeviewer/BarLineSettings.cs b/osucatch-editor-realtimeviewer/BarLineSettings.cs index 2abc0f5..9687ce6 100644 --- a/osucatch-editor-realtimeviewer/BarLineSettings.cs +++ b/osucatch-editor-realtimeviewer/BarLineSettings.cs @@ -173,29 +173,36 @@ internal static SubdivisionTier GetSubdivisionTier(BarLineMode mode) }; } - /// 模式对应的“拍线”下拉项 / 滑块提示文本。 + /// + /// 模式对应的档位文字,用在快捷开关栏滑块下方的状态标签上。 + /// 英文刻意写短(1/8 beat 而不是 Every 1/8 beat):这段文字直接决定 + /// 吸附在左 / 右侧时那一列的宽度,长句子会把整列撑得很宽;缩略写法配合功能区的 + /// “Bar Lines” 标题一样看得懂。设置窗口里的下拉项另有更详细的文案(含配色说明)。 + /// + /// 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", + BarLineMode.Every4Beats => chinese ? "每4拍" : "4 beats", + BarLineMode.Every2Beats => chinese ? "每2拍" : "2 beats", + BarLineMode.EveryBeat => chinese ? "每拍" : "1 beat", + BarLineMode.EveryHalfBeat => chinese ? "每1/2拍" : "1/2 beat", + BarLineMode.EveryThirdBeat => chinese ? "每1/3拍" : "1/3 beat", + BarLineMode.EveryQuarterBeat => chinese ? "每1/4拍" : "1/4 beat", + BarLineMode.EveryFifthBeat => chinese ? "每1/5拍" : "1/5 beat", + BarLineMode.EverySixthBeat => chinese ? "每1/6拍" : "1/6 beat", + BarLineMode.EverySeventhBeat => chinese ? "每1/7拍" : "1/7 beat", + BarLineMode.EveryEighthBeat => chinese ? "每1/8拍" : "1/8 beat", + _ => chinese ? "不显示" : "Off", }; } /// - /// 拍线状态文字里的显示名称:拍线:{当前拍线显示模式}。 - /// 只写档位、不写配色——线上什么颜色用户看一眼就知道,写进文字反而啰嗦。 + /// 拍线状态文字里的显示名称:只写当前档位(如“每2拍”)。 + /// 只写档位、不写配色——线上什么颜色用户看一眼就知道,写进文字反而啰嗦; + /// 也不带“拍线:”这类前缀:所在功能区本身就叫“拍线”,吸附在左 / 右侧竖排时前缀还会把整列撑宽。 /// internal static string GetOptionName(BarLineMode mode) => GetDisplayName(mode); diff --git a/osucatch-editor-realtimeviewer/Form1.cs b/osucatch-editor-realtimeviewer/Form1.cs index 71e5de0..faba769 100644 --- a/osucatch-editor-realtimeviewer/Form1.cs +++ b/osucatch-editor-realtimeviewer/Form1.cs @@ -31,11 +31,15 @@ public partial class Form1 : Form private ToolStripMenuItem? unloadTemplateStripMenuItem; private TemplateBeatmapData? templateData; - // 快捷开关条:可吸附在菜单栏下方的工具栏行里,也可拖出为浮动小窗口 + // 快捷开关条:可吸附在菜单栏下方(横排)或画布左 / 右侧(竖排),也可拖出为浮动小窗口 private QuickToggleBar? quickToggleBar; private QuickToggleDockRow? quickToggleDockRow; private QuickToggleDocking? quickToggleDocking; private ToolStripMenuItem? quickToggleStripMenuItem; + private ToolStripMenuItem? quickToggleDockSideToolStripMenuItem; + private ToolStripMenuItem? quickToggleDockTopToolStripMenuItem; + private ToolStripMenuItem? quickToggleDockLeftToolStripMenuItem; + private ToolStripMenuItem? quickToggleDockRightToolStripMenuItem; #region 快捷开关条:开关标识与功能区 @@ -312,7 +316,7 @@ private void Form1_Load(object sender, EventArgs e) ReapplyBookmarkStyles(); - // 快捷开关栏:恢复功能区显示状态、开关状态与停靠 / 浮动状态(浮窗要等主窗口显示出来后再弹出) + // 快捷开关栏:恢复功能区显示状态、开关状态与吸附位置 / 浮动状态(浮窗要等主窗口显示出来后再弹出) quickToggleBar?.ApplyHiddenGroups(app.Default.QuickToggle_HiddenGroups); quickToggleBar?.ApplyCheckedStates(app.Default.QuickToggle_States); // ApplyCheckedStates 不触发事件,这里手动把各开关的内部状态同步过来 @@ -320,8 +324,12 @@ private void Form1_Load(object sender, EventArgs e) SyncModToggleFromMenu(); SyncLabelToggleFromMenu(); ApplyBarLineMode(BarLineSettings.CurrentMode, persist: false); - quickToggleDocking?.ApplyStartupState(app.Default.QuickToggle_Visible, app.Default.QuickToggle_Floating); + quickToggleDocking?.ApplyStartupState( + app.Default.QuickToggle_Visible, + app.Default.QuickToggle_Floating, + QuickToggleDockSideHelper.ParseDockSide(app.Default.QuickToggle_DockSide)); if (quickToggleStripMenuItem != null) quickToggleStripMenuItem.Checked = app.Default.QuickToggle_Visible; + SyncQuickToggleDockSideMenu(); // osu path if (app.Default.osu_path == "") @@ -1478,7 +1486,7 @@ private void RestoreTemplateMenuText() } /// - /// 创建快捷开关条(菜单栏正下方的工具栏行):吸附在工具栏与拖出为浮动小窗口之间切换。 + /// 创建快捷开关条:可吸附在菜单栏下方(横排)或画布左 / 右侧(竖排),也可以拖出为浮动小窗口。 /// private void CreateQuickToggleBar() { @@ -1489,20 +1497,21 @@ private void CreateQuickToggleBar() quickToggleBar.GroupChanged += quickToggleBar_GroupChanged; quickToggleDockRow = new QuickToggleDockRow(); - quickToggleDocking = new QuickToggleDocking(this, quickToggleBar, quickToggleDockRow, menuStrip1); + quickToggleDocking = new QuickToggleDocking(this, quickToggleBar, quickToggleDockRow, menuStrip1, statusStrip1); QuickToggleDocking docking = quickToggleDocking; docking.StateChanged += (sender, e) => { if (quickToggleStripMenuItem != null) quickToggleStripMenuItem.Checked = docking.BarVisible; + SyncQuickToggleDockSideMenu(); }; - // 停靠行要排在菜单栏之后、画布之前参与停靠布局: - // WinForms 按控件集合的倒序布局停靠控件(集合末尾的控件最先被安排), - // 把行插到菜单栏原来的位置上,它才会正好占据菜单栏下方的一行, - // 而画布(Dock=Fill)在其下方填满剩余空间;否则行会盖在画布上而不是挤开画布。 + // 停靠行要参与主窗口的停靠布局。它在控件集合里的位置决定它落在哪: + // 顶部吸附时排在画布之后、菜单栏之前(正好占据菜单栏下方的一行,把画布挤下去), + // 左 / 右侧吸附时排在最前面(正好夹在菜单栏与状态栏之间的左 / 右边)。 + // 具体位置由停靠管理器按吸附位置安排(见 QuickToggleDocking.PlaceDockRow)。 Controls.Add(quickToggleDockRow); - Controls.SetChildIndex(quickToggleDockRow, Controls.GetChildIndex(menuStrip1)); + docking.ApplyDockSide(); CreateQuickToggleMenu(); } @@ -1523,6 +1532,8 @@ private void CreateQuickToggles() (string freezeText, string freezeToolTip) = FreezePreviewTimeText(previewTimeFrozen); quickToggleBar.AddStandaloneToggle(FreezePreviewTimeKey, freezeText, false); quickToggleBar.SetToggleText(FreezePreviewTimeKey, freezeText, freezeToolTip); + // 条上这个按钮只显示图标,右键菜单里给它一个和功能区一样的“是否显示”勾选项 + quickToggleBar.AddStandaloneToggleToMenu(FreezePreviewTimeKey, FreezePreviewTimeMenuText()); // ---- MOD:三个按钮单选,当前 MOD 对应的按钮保持按下(NM/EZ/HR 一看便知,不加标题) ---- QuickToggleBar.QuickToggleGroup modGroup = quickToggleBar.AddGroup(ModGroupKey, ""); @@ -1637,6 +1648,18 @@ private static (string Text, string ToolTip) FreezePreviewTimeText(bool frozen) return (text, toolTip); } + /// + /// 右键菜单里“跟随模式”勾选项的文字:和 Mod / HitObjectLabel / 拍线 一样, + /// 勾选表示这个 ⏸️ / ▶️ 按钮在快捷开关栏上显示。 + /// 文字必须单独给:条上这个按钮只显示图标,没法拿它的 Text 当菜单项。 + /// + /// + private static string FreezePreviewTimeMenuText() + { + bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; + return chinese ? "跟随模式" : "Follow mode"; + } + /// MOD 快捷按钮在当前语言下的文本(三种 MOD 名称各语言都相同)。 private static string ModToggleText(ModMode mode) { @@ -1684,12 +1707,11 @@ private static string LabelToggleToolTip(QuickLabelMode mode) }; } - /// “拍线:{当前拍线显示模式}”文字。 - private static string BarLineStatusText() - { - bool chinese = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "zh"; - return (chinese ? "拍线:" : "Bar lines: ") + BarLineSettings.GetOptionName(BarLineSettings.CurrentMode); - } + /// + /// 功能区里显示当前拍线模式的文字:只写档位(如“每2拍”),不再带“拍线:”前缀。 + /// 前缀对横排只是占地方,竖排(吸附在左 / 右侧)时更会把整列撑宽,而所在功能区本身就叫“拍线”。 + /// + private static string BarLineStatusText() => BarLineSettings.GetOptionName(BarLineSettings.CurrentMode); @@ -1878,7 +1900,8 @@ private void ApplyEditorTime(int editorTime) } /// - /// 程序内创建“快捷开关栏”菜单项(显示 / 隐藏整行)。 + /// 程序内创建“快捷开关栏”菜单项(显示 / 隐藏整行)与“快捷开关栏位置”子菜单 + /// (顶部横排 / 左侧竖排 / 右侧竖排),文本由语言资源按控件名应用。 /// private void CreateQuickToggleMenu() { @@ -1889,10 +1912,63 @@ private void CreateQuickToggleMenu() }; quickToggleStripMenuItem.Click += quickToggleStripMenuItem_Click; + quickToggleDockTopToolStripMenuItem = CreateQuickToggleDockSideMenuItem( + "quickToggleDockTopToolStripMenuItem", QuickToggleDockSide.Top); + quickToggleDockLeftToolStripMenuItem = CreateQuickToggleDockSideMenuItem( + "quickToggleDockLeftToolStripMenuItem", QuickToggleDockSide.Left); + quickToggleDockRightToolStripMenuItem = CreateQuickToggleDockSideMenuItem( + "quickToggleDockRightToolStripMenuItem", QuickToggleDockSide.Right); + + quickToggleDockSideToolStripMenuItem = new ToolStripMenuItem + { + Name = "quickToggleDockSideToolStripMenuItem", + }; + quickToggleDockSideToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] + { + quickToggleDockTopToolStripMenuItem, + quickToggleDockLeftToolStripMenuItem, + quickToggleDockRightToolStripMenuItem, + }); + // 归入 Viewer 菜单的显示选项分组(分隔线之前的最后一项) int insertIndex = viewerToolStripMenuItem.DropDownItems.IndexOf(toolStripSeparator1); if (insertIndex < 0) insertIndex = viewerToolStripMenuItem.DropDownItems.Count; viewerToolStripMenuItem.DropDownItems.Insert(insertIndex, quickToggleStripMenuItem); + viewerToolStripMenuItem.DropDownItems.Insert(insertIndex + 1, quickToggleDockSideToolStripMenuItem); + + SyncQuickToggleDockSideMenu(); + } + + private ToolStripMenuItem CreateQuickToggleDockSideMenuItem(string name, QuickToggleDockSide side) + { + ToolStripMenuItem item = new() + { + Name = name, + CheckOnClick = false, + }; + item.Click += (sender, e) => SetQuickToggleDockSide(side); + return item; + } + + /// 选择吸附位置:立即吸附过去(正在浮动时一并吸附回主窗口)并保存。 + private void SetQuickToggleDockSide(QuickToggleDockSide side) + { + if (quickToggleDocking == null) return; + + quickToggleDocking.DockTo(side); + SyncQuickToggleDockSideMenu(); + app.Default.Save(); + } + + /// 把当前吸附位置同步到 Viewer 菜单里的三个勾选项(单选)。 + private void SyncQuickToggleDockSideMenu() + { + if (quickToggleDocking == null) return; + + QuickToggleDockSide side = quickToggleDocking.DockSide; + if (quickToggleDockTopToolStripMenuItem != null) quickToggleDockTopToolStripMenuItem.Checked = side == QuickToggleDockSide.Top; + if (quickToggleDockLeftToolStripMenuItem != null) quickToggleDockLeftToolStripMenuItem.Checked = side == QuickToggleDockSide.Left; + if (quickToggleDockRightToolStripMenuItem != null) quickToggleDockRightToolStripMenuItem.Checked = side == QuickToggleDockSide.Right; } private void quickToggleStripMenuItem_Click(object? sender, EventArgs e) @@ -1916,6 +1992,7 @@ private void ApplyQuickToggleLanguage() (string text, string toolTip) = FreezePreviewTimeText(previewTimeFrozen); quickToggleBar.SetToggleText(FreezePreviewTimeKey, text, toolTip); + quickToggleBar.SetStandaloneToggleMenuText(FreezePreviewTimeKey, FreezePreviewTimeMenuText()); // 功能区标题(AddGroup 对已存在的功能区只更新标题) quickToggleBar.AddGroup(ModGroupKey, ""); diff --git a/osucatch-editor-realtimeviewer/Form1.resx b/osucatch-editor-realtimeviewer/Form1.resx index 739f53b..8d7110f 100644 --- a/osucatch-editor-realtimeviewer/Form1.resx +++ b/osucatch-editor-realtimeviewer/Form1.resx @@ -1009,4 +1009,16 @@ &Quick Toggle Bar + + Quick Toggle Bar &Position + + + &Top of the viewer (horizontal) + + + &Left of the viewer (vertical) + + + &Right of the viewer (vertical) + diff --git a/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx b/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx index 3a2a084..f4b19c7 100644 --- a/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx +++ b/osucatch-editor-realtimeviewer/Form1.zh-Hans.resx @@ -445,4 +445,16 @@ 快捷开关栏 (&Q) + + 快捷开关栏位置 (&P) + + + 吸附在 Viewer 顶部(横排)(&T) + + + 吸附在 Viewer 左侧(竖排)(&L) + + + 吸附在 Viewer 右侧(竖排)(&R) + diff --git a/osucatch-editor-realtimeviewer/QuickToggleBar.cs b/osucatch-editor-realtimeviewer/QuickToggleBar.cs index a199613..d6baa36 100644 --- a/osucatch-editor-realtimeviewer/QuickToggleBar.cs +++ b/osucatch-editor-realtimeviewer/QuickToggleBar.cs @@ -4,10 +4,12 @@ namespace osucatch_editor_realtimeviewer { /// /// 快捷开关条:类似 的工具条控件。 - /// 它既能吸附在主窗口菜单栏下方的一行里,也能拖出来变成浮动小窗口: - /// 按住左侧手柄(或条上的空白处)拖动即可拖出 / 拖回,双击手柄或点击右端按钮同样可以切换。 + /// 它既能吸附在主窗口菜单栏下方的一行里,也能吸附在预览画布左 / 右侧成为一列, + /// 还能拖出来变成浮动小窗口:按住手柄(或条上的空白处)拖动即可拖出 / 拖回, + /// 拖到窗口顶部或左 / 右边缘松手即吸附到那一侧;双击手柄或点击末端按钮同样可以切换浮动 / 吸附。 + /// 左 / 右侧吸附时条上的控件竖排(滑块也是竖向的)。 /// 条上的内容按“功能区”组织:每个功能区有一段文字标题和若干按钮 / 下拉框 / 滑块, - /// 功能区之间用分隔线隔开;在条上点右键可以在右键菜单里勾选要显示哪些功能区。 + /// 功能区之间用分隔线隔开;在条上点右键可以在右键菜单里勾选要显示哪些功能区、选择吸附位置。 /// /// /// 典型用法: @@ -51,9 +53,19 @@ internal sealed class QuickToggleBar : ToolStrip private readonly ContextMenuStrip barContextMenu = new(); private readonly ToolStripMenuItem floatMenuItem = new(); + private readonly ToolStripMenuItem dockSideMenuItem = new(); + private readonly ToolStripMenuItem dockTopMenuItem = new(); + private readonly ToolStripMenuItem dockLeftMenuItem = new(); + private readonly ToolStripMenuItem dockRightMenuItem = new(); private readonly ToolStripMenuItem hideMenuItem = new(); private readonly ToolStripSeparator contextMenuSeparator = new(); + /// 右键菜单里独立开关的勾选项(勾选 = 在条上显示),键为开关标识。 + private readonly Dictionary standaloneMenuItems = new(StringComparer.Ordinal); + + /// 被用户在右键菜单里取消勾选、因而不显示在条上的独立开关标识。 + private readonly HashSet hiddenStandaloneKeys = new(StringComparer.Ordinal); + private readonly System.Windows.Forms.Timer dragTimer = new() { Interval = DragPollIntervalMs }; private Point dragStartCursor; @@ -64,6 +76,12 @@ internal sealed class QuickToggleBar : ToolStrip private Point lastDragAreaClickPoint; private bool suppressToggleEvents; private bool rebuilding; + private QuickToggleDockSide dockSide = QuickToggleDockSide.Top; + + /// 上次在某个吸附位置停靠时测得的“厚度”(横排 = 行高,竖排 = 列宽)。 + private int dockedHorizontalThickness; + private int dockedVerticalThickness; + private string title = "Quick Toggles"; internal QuickToggleBar() @@ -91,8 +109,26 @@ internal QuickToggleBar() floatMenuItem.Click += (sender, e) => FloatingToggleRequested?.Invoke(this, EventArgs.Empty); hideMenuItem.Name = "quickToggleHideMenuItem"; hideMenuItem.Click += (sender, e) => HideRequested?.Invoke(this, EventArgs.Empty); + + // 吸附位置:顶部横排 / 左、右侧竖排由用户在右键菜单里选择 + dockSideMenuItem.Name = "quickToggleDockSideMenuItem"; + dockTopMenuItem.Name = "quickToggleDockTopMenuItem"; + dockLeftMenuItem.Name = "quickToggleDockLeftMenuItem"; + dockRightMenuItem.Name = "quickToggleDockRightMenuItem"; + dockTopMenuItem.Click += (sender, e) => RequestDockSide(QuickToggleDockSide.Top); + dockLeftMenuItem.Click += (sender, e) => RequestDockSide(QuickToggleDockSide.Left); + dockRightMenuItem.Click += (sender, e) => RequestDockSide(QuickToggleDockSide.Right); + dockSideMenuItem.DropDownItems.AddRange(new ToolStripItem[] + { + dockTopMenuItem, + dockLeftMenuItem, + dockRightMenuItem, + }); + barContextMenu.Items.Add(floatMenuItem); + barContextMenu.Items.Add(dockSideMenuItem); barContextMenu.Items.Add(hideMenuItem); + // 分隔线之后是各个勾选项:独立开关与功能区的“是否显示”都排在这里 barContextMenu.Items.Add(contextMenuSeparator); ContextMenuStrip = barContextMenu; @@ -112,6 +148,12 @@ internal QuickToggleBar() /// 显示的功能区增删导致内容变化时触发:宿主据此重新计算停靠行高度 / 浮窗尺寸。 internal event EventHandler? ContentChanged; + /// 用户在右键菜单里选了新的吸附位置(顶部 / 左侧 / 右侧)时触发。 + internal event EventHandler? DockSideRequested; + + private void RequestDockSide(QuickToggleDockSide side) + => DockSideRequested?.Invoke(this, new QuickToggleDockSideEventArgs(side)); + #endregion #region 功能区 @@ -140,13 +182,13 @@ internal QuickToggleGroup AddGroup(string key, string label) groups[key] = group; groupOrder.Add(group); RebuildItems(); - UpdateGroupMenuChecks(); + UpdateVisibilityMenuChecks(); ContentChanged?.Invoke(this, EventArgs.Empty); return group; } - /// 功能区的显示状态发生变化(右键菜单勾选)时触发。 - internal event EventHandler? GroupsVisibilityChanged; + /// 条上项的显示状态发生变化(右键菜单勾选功能区或独立开关)时触发,宿主据此立即落盘。 + internal event EventHandler? VisibilityChanged; /// 功能区是否存在。 internal bool HasGroup(string key) => groups.ContainsKey(key); @@ -162,24 +204,38 @@ internal void SetGroupVisible(string key, bool visible) group.Visible = visible; RebuildItems(); - UpdateGroupMenuChecks(); + UpdateVisibilityMenuChecks(); 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); + AppendKey(builder, group.Key); + } + foreach (ToolStripButton standalone in standaloneToggles) + { + if (standalone.Name is not string key || !IsStandaloneHidden(standalone)) continue; + AppendKey(builder, key); } return builder.ToString(); + + static void AppendKey(StringBuilder builder, string key) + { + if (builder.Length > 0) builder.Append(';'); + builder.Append(key); + } } - /// 从设置字符串恢复功能区显示状态(不触发 )。 + /// 从设置字符串恢复功能区与独立开关的显示状态(不触发 )。 internal void ApplyHiddenGroups(string? hidden) { HashSet hiddenKeys = new(StringComparer.Ordinal); @@ -202,9 +258,16 @@ internal void ApplyHiddenGroups(string? hidden) changed = true; } + foreach (ToolStripButton standalone in standaloneToggles) + { + if (standalone.Name is not string key) continue; + bool visible = firstRun || !hiddenKeys.Contains(key); + if (visible ? hiddenStandaloneKeys.Remove(key) : hiddenStandaloneKeys.Add(key)) changed = true; + } + if (!changed) return; RebuildItems(); - UpdateGroupMenuChecks(); + UpdateVisibilityMenuChecks(); ContentChanged?.Invoke(this, EventArgs.Empty); } @@ -225,16 +288,23 @@ private void RebuildItems() if (item is ToolStripSeparator separator) separator.Dispose(); } - // 最左边先放不归属任何功能区的固定开关,再按顺序排各功能区 - foreach (ToolStripButton standalone in standaloneToggles) Items.Add(standalone); + // 最左边先放不归属任何功能区的固定开关,再按顺序排各功能区。 + // 被用户隐藏的独立开关不加进来;独立开关与第一个功能区之间同样要有分隔线: + // 例如“固定预览时刻”按钮紧跟 MOD 的 NM / EZ / HR 时,没有线就会被当成同一组按钮。 + bool needSeparator = false; + foreach (ToolStripButton standalone in standaloneToggles) + { + if (IsStandaloneHidden(standalone)) continue; + Items.Add(standalone); + needSeparator = true; + } - bool anyVisible = false; foreach (QuickToggleGroup group in groupOrder) { if (!group.Visible) continue; - if (anyVisible) Items.Add(new ToolStripSeparator()); + if (needSeparator) Items.Add(new ToolStripSeparator()); group.InsertItems(Items); - anyVisible = true; + needSeparator = true; } } finally @@ -249,8 +319,8 @@ private void RebuildItems() /// /// 添加一个不属于任何功能区、固定排在条最左边的即时开关(如“固定预览时刻”)。 - /// 它没有功能区标题,也不会出现在右键菜单的功能区勾选里——功能区开关是用来 - /// 按需收起的,而这种独立的模式切换按钮必须一直看得见。 + /// 它没有功能区标题,但和功能区一样可以在右键菜单里按需收起—— + /// 用 给它挂一个“是否显示”的勾选项(默认显示)。 /// /// /// 开关标识,用于读写状态与持久化,需保持稳定。 @@ -318,11 +388,86 @@ internal void RemoveToggle(string key) if (!toggles.Remove(key, out ToolStripButton? item)) return; RemoveItemFromGroup(item); standaloneToggles.Remove(item); + + // 右键菜单里的可见性勾选项一并撤掉,避免留下一个点了没反应的死项 + if (standaloneMenuItems.Remove(key, out ToolStripMenuItem? menuItem)) + { + hiddenStandaloneKeys.Remove(key); + barContextMenu.Items.Remove(menuItem); + menuItem.Dispose(); + UpdateContextMenuSections(); + } + RebuildItems(); item.Dispose(); ContentChanged?.Invoke(this, EventArgs.Empty); } + #region 右键菜单:独立开关的显示 / 隐藏 + + /// + /// 给一个独立开关加一个右键菜单勾选项(勾选 = 在条上显示),和功能区的勾选项排在一起。 + /// 菜单文字必须由调用方给:条上的按钮可能只显示图标(如“固定预览时刻”用 ⏸️ / ▶️), + /// 那种文本没法直接当菜单项用。 + /// + /// + /// 开关标识( 里用过的那个)。 + /// 菜单项文字(通常是这个开关的名字)。 + internal void AddStandaloneToggleToMenu(string key, string menuText) + { + if (standaloneMenuItems.ContainsKey(key)) return; + if (!toggles.ContainsKey(key)) return; + + ToolStripMenuItem item = new() + { + Name = "quickToggleStandalone_" + key, + Text = menuText, + CheckOnClick = true, + Checked = !hiddenStandaloneKeys.Contains(key), + }; + // CheckOnClick 已经翻转了勾选状态,这里只负责应用 + item.Click += (sender, e) => + { + SetStandaloneToggleVisible(key, item.Checked); + VisibilityChanged?.Invoke(this, EventArgs.Empty); + }; + standaloneMenuItems[key] = item; + + // 排在分隔线之后的勾选项里最前面(与条上独立开关靠左的位置一致),功能区勾选依次后移 + int separatorIndex = barContextMenu.Items.IndexOf(contextMenuSeparator); + barContextMenu.Items.Insert(separatorIndex + standaloneMenuItems.Count, item); + UpdateContextMenuSections(); + } + + /// 更新独立开关菜单项的文字(语言切换时调用)。 + internal void SetStandaloneToggleMenuText(string key, string menuText) + { + if (standaloneMenuItems.TryGetValue(key, out ToolStripMenuItem? item)) item.Text = menuText; + } + + /// 显示 / 隐藏一个独立开关(右键菜单勾选,立即生效;下次启动按设置恢复)。 + internal void SetStandaloneToggleVisible(string key, bool visible) + { + if (!toggles.ContainsKey(key)) return; + + bool changed = visible ? hiddenStandaloneKeys.Remove(key) : hiddenStandaloneKeys.Add(key); + if (!changed) return; + + RebuildItems(); + UpdateVisibilityMenuChecks(); + ContentChanged?.Invoke(this, EventArgs.Empty); + } + + /// 该独立开关是否被用户隐藏。 + private bool IsStandaloneHidden(ToolStripButton toggle) + => toggle.Name is string key && hiddenStandaloneKeys.Contains(key); + + /// 按菜单里实际有哪些勾选项决定分隔线是否显示。 + private void UpdateContextMenuSections() + => contextMenuSeparator.Visible = groupOrder.Count > 0 || standaloneMenuItems.Count > 0; + + #endregion + #region 按钮图标 /// @@ -590,18 +735,80 @@ internal void SetGroupLabelText(string groupKey, string labelKey, string text) /// 当前是否处于浮动小窗口状态,由停靠管理器维护。 internal bool IsFloating { get; set; } + /// + /// 当前吸附位置,由停靠管理器维护。左 / 右侧时条上的控件改为竖排(滑块也变成竖向的)。 + /// + internal QuickToggleDockSide DockSide + { + get => dockSide; + set + { + if (dockSide == value) return; + dockSide = value; + ApplyOrientation(); + } + } + + /// 当前是否竖排显示(吸附在左 / 右侧)。 + internal bool IsVertical => dockSide.IsVertical(); + + /// 按当前的吸附位置切换排布方向:竖排时功能区里的下拉框 / 滑块等宿主控件也要跟着转。 + private void ApplyOrientation() + { + bool vertical = IsVertical; + LayoutStyle = vertical + ? ToolStripLayoutStyle.VerticalStackWithOverflow + : ToolStripLayoutStyle.HorizontalStackWithOverflow; + + foreach (QuickToggleGroup group in groupOrder) group.ApplyOrientation(vertical); + + UpdateDockSideMenuChecks(); + ContentChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// 条在某个吸附位置下的“厚度”:横排是行高,竖排是列宽。 + /// 拖动时的吸附提示框要贴着目标边缘画,必须知道条贴上去之后有多厚。 + /// 与当前排布一致时直接量实际尺寸;另一种排布用上次在该侧停靠时记下的值, + /// 没记过(例如从没吸附到过左 / 右侧)就按字体与图标估一个,只影响提示框的粗细。 + /// + /// + internal int PreferredThickness(bool vertical) + { + if (vertical == IsVertical) return Math.Max(vertical ? Width : Height, 1); + + int recorded = vertical ? dockedVerticalThickness : dockedHorizontalThickness; + return recorded > 0 ? recorded : Math.Max(ImageScalingSize.Height + 8, Font.Height + 6); + } + + /// 停靠行 / 浮窗布局完成后回填实际厚度,供另一种排布的吸附提示使用。 + internal void NoteDockedThickness(bool vertical, int thickness) + { + if (thickness <= 0) return; + if (vertical) dockedVerticalThickness = thickness; + else dockedHorizontalThickness = thickness; + } + /// 浮窗标题 / 提示用的名称。 internal string BarTitle => title; /// /// 浮窗尺寸:按内容计算,并限制在工作区之内。 - /// 宽度先夹到工作区,再按这个宽度问首选高度:条是换行排布的(Flow), + /// 横排时宽度先夹到工作区,再按这个宽度问首选高度:条是换行排布的(Flow), /// 用一个很宽的宽度去问只会拿到“单行”的高度,窄屏上内容换行后就会被裁掉。 + /// 竖排时反过来:高度先夹到工作区,再按这个高度问列宽。 /// /// internal Size PreferredFloatSize() { Rectangle workingArea = Screen.FromControl(this).WorkingArea; + if (IsVertical) + { + int columnHeight = Math.Max(96, Math.Min(GetPreferredSize(new Size(0, 4096)).Height, workingArea.Height)); + int columnWidth = Math.Max(48, Math.Min(GetPreferredSize(new Size(0, columnHeight)).Width, workingArea.Width)); + return new Size(columnWidth, columnHeight); + } + 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); @@ -618,13 +825,18 @@ internal void ApplyLanguage() title = chinese ? "快捷开关栏" : "Quick Toggle Bar"; floatingButton.Text = IsFloating ? (chinese ? "吸附" : "Dock") : (chinese ? "浮动" : "Float"); floatingButton.ToolTipText = chinese - ? "浮动为小窗口 / 吸附回工具栏(也可拖动左侧手柄或双击)" - : "Float as a window / dock back to the toolbar (or drag the grip)"; + ? "浮动为小窗口 / 吸附回工具栏(也可拖动手柄,或将条拖到窗口顶部或左 / 右边缘吸附)" + : "Float as a window / dock back to the toolbar (or drag the grip to the top, left or right edge)"; floatMenuItem.Text = IsFloating ? (chinese ? "吸附到工具栏" : "Dock to toolbar") : (chinese ? "浮动为小窗口" : "Float as window"); + dockSideMenuItem.Text = chinese ? "吸附位置" : "Dock position"; + dockTopMenuItem.Text = chinese ? "吸附在顶部(横排)" : "Dock on top (horizontal)"; + dockLeftMenuItem.Text = chinese ? "吸附在左侧(竖排)" : "Dock on left (vertical)"; + dockRightMenuItem.Text = chinese ? "吸附在右侧(竖排)" : "Dock on right (vertical)"; + UpdateDockSideMenuChecks(); hideMenuItem.Text = chinese ? "隐藏快捷开关栏" : "Hide quick toggle bar"; - contextMenuSeparator.Visible = groupOrder.Count > 0; + UpdateContextMenuSections(); for (int i = 0; i < groupOrder.Count; i++) { @@ -640,12 +852,25 @@ internal void ApplyLanguage() #region 右键菜单:功能区勾选 + /// 把当前的吸附位置同步到右键菜单的勾选项(子项按“单选”呈现)。 + private void UpdateDockSideMenuChecks() + { + dockTopMenuItem.Checked = dockSide == QuickToggleDockSide.Top; + dockLeftMenuItem.Checked = dockSide == QuickToggleDockSide.Left; + dockRightMenuItem.Checked = dockSide == QuickToggleDockSide.Right; + } + /// - /// 把功能区显示状态同步到右键菜单的勾选项。 - /// 菜单项按功能区加入顺序排列,取消勾选即隐藏对应功能区。 + /// 把功能区与独立开关的显示状态同步到右键菜单的勾选项(勾选 = 在条上显示)。 + /// 功能区的菜单项按加入顺序创建,排在独立开关的勾选项之后。 /// - private void UpdateGroupMenuChecks() + private void UpdateVisibilityMenuChecks() { + foreach (KeyValuePair pair in standaloneMenuItems) + { + pair.Value.Checked = !hiddenStandaloneKeys.Contains(pair.Key); + } + foreach (QuickToggleGroup group in groupOrder) { if (group.MenuItem == null) @@ -660,7 +885,7 @@ private void UpdateGroupMenuChecks() { // CheckOnClick 已经翻转了勾选状态,这里只负责应用 SetGroupVisible(captured.Key, item.Checked); - GroupsVisibilityChanged?.Invoke(this, EventArgs.Empty); + VisibilityChanged?.Invoke(this, EventArgs.Empty); }; group.MenuItem = item; barContextMenu.Items.Add(item); @@ -669,6 +894,8 @@ private void UpdateGroupMenuChecks() group.MenuItem.Text = group.DisplayText; group.MenuItem.Checked = group.Visible; } + + UpdateContextMenuSections(); } #endregion @@ -912,14 +1139,14 @@ internal ComboBox AddCombo(string valueKey, IReadOnlyList optionTexts, i Height = 22, Margin = new Padding(2, 2, 4, 2), }; - AddValueHost(host); + AddValueHost(host, combo, new Size(width, 22), new Size(width, 22), new Padding(2, 2, 4, 2), new Padding(2, 2, 2, 2)); ValueControls[valueKey] = combo; owner.NotifyGroupItemsChanged(); return combo; } /// - /// 添加一个滑块(如拍线密度)。 + /// 添加一个滑块(如拍线密度)。吸附在左 / 右侧时滑块自动变成竖向的。 /// /// 值标识,用户拖动时通过 上报。 /// 最小值。 @@ -956,7 +1183,8 @@ internal TrackBar AddSlider(string valueKey, int minimum, int maximum, int value Height = slider.Height, Margin = new Padding(2, 1, 4, 1), }; - AddValueHost(host); + // 竖排时滑块转 90°:长边放到高度上;宽度留够刻度与滑块本身的绘制空间 + AddValueHost(host, slider, new Size(130, 24), new Size(30, 130), new Padding(2, 1, 4, 1), new Padding(1, 2, 1, 4)); ValueControls[valueKey] = slider; owner.NotifyGroupItemsChanged(); return slider; @@ -1016,9 +1244,63 @@ internal int GetComboIndex(string valueKey) internal int GetSliderValue(string valueKey) => ValueControls.TryGetValue(valueKey, out Control? control) && control is TrackBar slider ? slider.Value : 0; - private void AddValueHost(ToolStripControlHost host) + /// + /// 功能区里“跟着排布方向走”的宿主控件:横排与竖排各有一份尺寸与间距, + /// 切换吸附位置时由 统一改写(滑块还要转动方向)。 + /// + private sealed class ValueHost + { + internal required ToolStripControlHost Host { get; init; } + + internal required Control Control { get; init; } + + internal Size HorizontalSize { get; init; } + + internal Size VerticalSize { get; init; } + + internal Padding HorizontalMargin { get; init; } + + internal Padding VerticalMargin { get; init; } + } + + private readonly List valueHosts = new(); + + /// + /// 按条的排布方向调整功能区里的值控件:竖排时滑块转成竖向,宿主尺寸与间距一并交换。 + /// + internal void ApplyOrientation(bool vertical) + { + foreach (ValueHost info in valueHosts) + { + if (info.Control is TrackBar slider) + { + Orientation orientation = vertical ? Orientation.Vertical : Orientation.Horizontal; + if (slider.Orientation != orientation) slider.Orientation = orientation; + } + + Size size = vertical ? info.VerticalSize : info.HorizontalSize; + if (info.Control.Size != size) info.Control.Size = size; + info.Host.AutoSize = false; + info.Host.Margin = vertical ? info.VerticalMargin : info.HorizontalMargin; + if (info.Host.Size != size) info.Host.Size = size; + } + } + + private void AddValueHost(ToolStripControlHost host, Control control, Size horizontalSize, Size verticalSize, Padding horizontalMargin, Padding verticalMargin) { items.Add(host); + valueHosts.Add(new ValueHost + { + Host = host, + Control = control, + HorizontalSize = horizontalSize, + VerticalSize = verticalSize, + HorizontalMargin = horizontalMargin, + VerticalMargin = verticalMargin, + }); + + // 条已经吸附在左 / 右侧时,新加的控件要立刻按竖排摆好,而不是等下一次切换 + if (owner.IsVertical) ApplyOrientation(vertical: true); } internal void AddItem(ToolStripItem item) => items.Add(item); @@ -1065,6 +1347,18 @@ internal QuickToggleGroupChangedEventArgs(string key, int value) internal int Value { get; } } + /// 吸附位置变化事件参数。 + internal sealed class QuickToggleDockSideEventArgs : EventArgs + { + internal QuickToggleDockSideEventArgs(QuickToggleDockSide side) + { + Side = side; + } + + /// 用户选择的吸附位置。 + internal QuickToggleDockSide Side { get; } + } + /// 拖动事件参数,坐标均为屏幕坐标。 internal sealed class QuickToggleDragEventArgs : EventArgs { diff --git a/osucatch-editor-realtimeviewer/QuickToggleDockSide.cs b/osucatch-editor-realtimeviewer/QuickToggleDockSide.cs new file mode 100644 index 0000000..f9a2982 --- /dev/null +++ b/osucatch-editor-realtimeviewer/QuickToggleDockSide.cs @@ -0,0 +1,36 @@ +namespace osucatch_editor_realtimeviewer +{ + /// + /// 快捷开关栏的吸附位置:菜单栏下方的顶部横条,或者预览画布左 / 右侧的竖列。 + /// + internal enum QuickToggleDockSide + { + /// 吸附在菜单栏下方,条上的控件横排。 + Top, + + /// 吸附在预览画布左侧,条上的控件竖排(滑块也是竖向的)。 + Left, + + /// 吸附在预览画布右侧,条上的控件竖排(滑块也是竖向的)。 + Right, + } + + internal static class QuickToggleDockSideHelper + { + /// 左 / 右侧吸附时为 true:条上的控件竖排。 + internal static bool IsVertical(this QuickToggleDockSide side) => side != QuickToggleDockSide.Top; + + /// 解析设置里保存的吸附位置;缺失或非法值一律回退为顶部。 + internal static QuickToggleDockSide ParseDockSide(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return QuickToggleDockSide.Top; + + return Enum.TryParse(value.Trim(), ignoreCase: true, out QuickToggleDockSide side) && Enum.IsDefined(side) + ? side + : QuickToggleDockSide.Top; + } + + /// 写成可保存到设置的字符串。 + internal static string ToSettingValue(this QuickToggleDockSide side) => side.ToString(); + } +} diff --git a/osucatch-editor-realtimeviewer/QuickToggleDocking.cs b/osucatch-editor-realtimeviewer/QuickToggleDocking.cs index dc23217..9924c77 100644 --- a/osucatch-editor-realtimeviewer/QuickToggleDocking.cs +++ b/osucatch-editor-realtimeviewer/QuickToggleDocking.cs @@ -1,7 +1,8 @@ namespace osucatch_editor_realtimeviewer { /// - /// 快捷开关条的停靠行:吸附时占据菜单栏正下方的一行;浮动或隐藏时高度归零, + /// 快捷开关条的停靠行:吸附在顶部时占据菜单栏正下方的一行, + /// 吸附在左 / 右侧时占据画布旁边的一列;浮动或隐藏时尺寸归零, /// 配合 = false 完全不占用画布空间。 /// internal sealed class QuickToggleDockRow : Panel @@ -14,6 +15,8 @@ internal QuickToggleDockRow() Dock = DockStyle.Top; AutoSize = false; Height = 0; + Width = 0; + Visible = false; Margin = Padding.Empty; Padding = Padding.Empty; TabStop = false; @@ -21,6 +24,9 @@ internal QuickToggleDockRow() internal bool HasBar => bar != null && bar.Parent == this; + /// 当前是否竖排(吸附在左 / 右侧)。 + internal bool IsVertical => Dock is DockStyle.Left or DockStyle.Right; + protected override void OnControlAdded(ControlEventArgs e) { base.OnControlAdded(e); @@ -33,7 +39,14 @@ protected override void OnControlRemoved(ControlEventArgs e) base.OnControlRemoved(e); if (e.Control != bar) return; bar = null; - if (Height != 0) Height = 0; + if (IsVertical) + { + if (Width != 0) Width = 0; + } + else if (Height != 0) + { + Height = 0; + } } protected override void OnLayout(LayoutEventArgs e) @@ -41,13 +54,25 @@ protected override void OnLayout(LayoutEventArgs e) base.OnLayout(e); if (bar == null || bar.Parent != this) return; + if (IsVertical) + { + // 列宽由条的竖排宽度决定;条本身不自动调整大小,高度始终铺满可用区域 + int height = Math.Max(Height, 1); + int width = Math.Max(bar.GetPreferredSize(new Size(0, height)).Width, 1); + bar.Bounds = new Rectangle(0, 0, width, height); + bar.NoteDockedThickness(vertical: true, width); + if (Width != width) Width = width; + return; + } + // 行高由条的首选高度决定;条本身不自动调整大小,宽度始终铺满窗口。 // 首选高度必须按“实际可用宽度”问条要:条用 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; + int rowWidth = Math.Max(Width, 1); + int rowHeight = Math.Max(bar.GetPreferredSize(new Size(rowWidth, 0)).Height, 1); + bar.Bounds = new Rectangle(0, 0, rowWidth, rowHeight); + bar.NoteDockedThickness(vertical: false, rowHeight); + if (Height != rowHeight) Height = rowHeight; } } @@ -118,26 +143,37 @@ protected override CreateParams CreateParams } /// - /// 快捷开关条的停靠 / 浮动管理器:在“菜单栏下方的停靠行”和“浮动小窗口”之间搬运开关条, - /// 处理拖出、拖回时的吸附判定,并把停靠状态与开关状态持久化到用户设置。 + /// 快捷开关条的停靠 / 浮动管理器:在“菜单栏下方的停靠行”、“画布左 / 右侧的停靠列” + /// 和“浮动小窗口”之间搬运开关条,处理拖出、拖回时的吸附判定(顶部 / 左侧 / 右侧), + /// 并把停靠位置与开关状态持久化到用户设置。 /// internal sealed class QuickToggleDocking : IDisposable { - /// 吸附判定距离(屏幕像素):拖到停靠行 / 菜单栏附近这么多像素内松手即吸附回去。 + /// 吸附判定距离(屏幕像素):拖到停靠区附近这么多像素内松手即吸附过去。 private const int SnapDistance = 32; - /// 吸附提示条高度(屏幕像素)。 + /// 吸附提示条厚度(屏幕像素)。 private const int HintHeight = 6; + /// 三个可吸附的位置,按“顶部 → 左侧 → 右侧”的顺序判定。 + private static readonly QuickToggleDockSide[] DockSides = + { + QuickToggleDockSide.Top, + QuickToggleDockSide.Left, + QuickToggleDockSide.Right, + }; + private readonly Form owner; private readonly QuickToggleBar bar; private readonly QuickToggleDockRow dockRow; private readonly Control? snapAnchor; + private readonly Control? bottomAnchor; private readonly QuickToggleFloatForm floatForm; private readonly QuickToggleDropHintForm hintForm; private bool barVisible = true; private bool floating; + private QuickToggleDockSide dockSide = QuickToggleDockSide.Top; private bool dragging; private bool disposed; private bool suppressReDock; @@ -145,12 +181,13 @@ internal sealed class QuickToggleDocking : IDisposable private Point dragOriginCursor; private Point dragOriginFormLocation; - internal QuickToggleDocking(Form owner, QuickToggleBar bar, QuickToggleDockRow dockRow, Control? snapAnchor) + internal QuickToggleDocking(Form owner, QuickToggleBar bar, QuickToggleDockRow dockRow, Control? snapAnchor, Control? bottomAnchor = null) { this.owner = owner; this.bar = bar; this.dockRow = dockRow; this.snapAnchor = snapAnchor; + this.bottomAnchor = bottomAnchor; floatForm = new QuickToggleFloatForm(bar); hintForm = new QuickToggleDropHintForm(); @@ -165,19 +202,45 @@ internal QuickToggleDocking(Form owner, QuickToggleBar bar, QuickToggleDockRow d bar.DragEnded += Bar_DragEnded; bar.FloatingToggleRequested += Bar_FloatingToggleRequested; bar.HideRequested += Bar_HideRequested; + bar.DockSideRequested += Bar_DockSideRequested; bar.ContentChanged += Bar_ContentChanged; bar.ToggleChanged += Bar_ToggleChanged; - bar.GroupsVisibilityChanged += Bar_GroupsVisibilityChanged; + bar.VisibilityChanged += Bar_VisibilityChanged; dockRow.Controls.Add(bar); } - /// 停靠状态(停靠 / 浮动 / 显示 / 隐藏)发生变化时触发。 + /// 停靠状态(停靠位置 / 浮动 / 显示 / 隐藏)发生变化时触发。 internal event EventHandler? StateChanged; /// 当前是否浮动为小窗口。 internal bool IsFloating => floating; + /// + /// 当前吸附位置:顶部(菜单栏下方一行,控件横排)或左 / 右侧(画布旁边一列,控件竖排)。 + /// 写入即生效并持久化;正在浮动时只改变浮动窗口的排布方向,不会自动吸附回去。 + /// + internal QuickToggleDockSide DockSide + { + get => dockSide; + set + { + if (disposed || dockSide == value) return; + dockSide = value; + ApplyDockSide(); + SaveState(); + RaiseStateChanged(); + } + } + + /// 吸附到指定位置;正在浮动时一并吸附回主窗口。 + internal void DockTo(QuickToggleDockSide side) + { + DockSide = side; + if (disposed) return; + Dock(); + } + /// 快捷开关栏是否显示(隐藏时停靠行与浮窗都不占屏幕空间)。 internal bool BarVisible { @@ -196,10 +259,13 @@ internal bool BarVisible /// 按用户设置恢复启动状态。 为 true 时等主窗口显示出来之后再弹出浮窗, /// 避免启动过程中浮窗出现在错误的位置。 /// - internal void ApplyStartupState(bool visible, bool floatMode) + internal void ApplyStartupState(bool visible, bool floatMode, QuickToggleDockSide side) { if (disposed) return; + dockSide = side; + ApplyDockSide(); + barVisible = visible; if (!visible || !floatMode) { @@ -214,6 +280,29 @@ internal void ApplyStartupState(bool visible, bool floatMode) })); } + /// + /// 把停靠行挂进主窗口的停靠布局:由宿主在把停靠行加入 Controls 之后调用。 + /// 行在控件集合里的位置取决于吸附位置,见 。 + /// + internal void ApplyDockSide() + { + if (disposed) return; + + dockRow.Dock = dockSide switch + { + QuickToggleDockSide.Left => DockStyle.Left, + QuickToggleDockSide.Right => DockStyle.Right, + _ => DockStyle.Top, + }; + + bar.DockSide = dockSide; + PlaceDockRow(); + + if (floating) floatForm.Refit(); + else dockRow.PerformLayout(); + owner.PerformLayout(); + } + /// 吸附回工具栏。 internal void Dock() { @@ -260,6 +349,7 @@ internal void SaveState() app.Default.QuickToggle_Visible = barVisible; app.Default.QuickToggle_Floating = floating; + app.Default.QuickToggle_DockSide = dockSide.ToSettingValue(); if (floating && floatForm.Visible) { app.Default.QuickToggle_Float_X = floatForm.Location.X; @@ -271,6 +361,56 @@ internal void SaveState() #region 停靠 / 浮动切换 + /// + /// 安排停靠行的位置。WinForms 按控件集合的倒序停靠控件(下标大的先被安排), + /// 因此集合顺序直接决定了行与菜单栏 / 状态栏 / 画布的关系: + /// + /// 顶部吸附:[状态栏, 画布, 停靠行, 菜单栏] —— 与既有布局完全一致: + /// 行先于画布被安排,正好把画布挤到菜单栏下方; + /// 左 / 右吸附:[画布, 停靠行, 状态栏, 菜单栏] —— 画布先让出一列宽度给行, + /// 行夹在菜单栏与状态栏之间,画布也不会有一块被行盖住。 + /// + /// + private void PlaceDockRow() + { + Control.ControlCollection controls = owner.Controls; + if (!controls.Contains(dockRow)) return; + + Control? canvas = FindFillControl(); + Control?[] order = dockSide == QuickToggleDockSide.Top + ? new[] { bottomAnchor, canvas, dockRow, snapAnchor } + : new[] { canvas, dockRow, bottomAnchor, snapAnchor }; + + // SetChildIndex 是“先摘下来、再插到指定下标”,按目标下标从小到大逐个摆放即可 + // 得到想要的顺序(前几个位置在摆放过程中不会被后面的操作打乱) + owner.SuspendLayout(); + try + { + int index = 0; + foreach (Control? control in order) + { + if (control == null || !controls.Contains(control)) continue; + if (controls.GetChildIndex(control) != index) controls.SetChildIndex(control, index); + index++; + } + } + finally + { + owner.ResumeLayout(performLayout: false); + } + } + + /// 找出铺满剩余区域的画布控件():停靠行要相对它摆位。 + private Control? FindFillControl() + { + foreach (Control control in owner.Controls) + { + if (control == dockRow || control == snapAnchor || control == bottomAnchor) continue; + if (control.Dock == DockStyle.Fill) return control; + } + return null; + } + private void DockCore() { MoveBar(dockRow); @@ -318,16 +458,23 @@ private void ApplyVisibility() private void MoveBar(Control parent) { if (bar.Parent == parent) return; - // 先摘再挂:停靠行会在移除时把高度归零,加入时按条的首选高度重新撑开 + // 先摘再挂:停靠行会在移除时把行高 / 列宽归零,加入时按条的首选尺寸重新撑开 bar.Parent?.Controls.Remove(bar); parent.Controls.Add(bar); parent.PerformLayout(); } + /// 浮窗的默认位置:贴着条原来在屏幕上的位置弹出来,横排弹到下方、竖排弹到侧面。 private Point DefaultFloatLocation(Rectangle barScreenBefore) { if (barScreenBefore.IsEmpty) return floatForm.Location; - return new Point(barScreenBefore.X, barScreenBefore.Bottom + 4); + + return dockSide switch + { + QuickToggleDockSide.Left => new Point(barScreenBefore.Right + 4, barScreenBefore.Y), + QuickToggleDockSide.Right => new Point(barScreenBefore.Left - floatForm.Width - 4, barScreenBefore.Y), + _ => new Point(barScreenBefore.X, barScreenBefore.Bottom + 4), + }; } private static Point ClampToScreen(Point location, Size size) @@ -384,7 +531,7 @@ private void Bar_DragStarted(object? sender, QuickToggleDragEventArgs e) dragOriginFormLocation = floatForm.Location; } - UpdateHint(IsInSnapZone(e.CursorScreen)); + UpdateHint(TryGetSnapSide(e.CursorScreen, out QuickToggleDockSide side) ? side : null); } private void Bar_DragMoved(object? sender, QuickToggleDragEventArgs e) @@ -396,18 +543,19 @@ private void Bar_DragMoved(object? sender, QuickToggleDragEventArgs e) dragOriginFormLocation.Y + e.CursorScreen.Y - dragOriginCursor.Y); if (floatForm.Location != location) floatForm.Location = location; - UpdateHint(IsInSnapZone(e.CursorScreen)); + UpdateHint(TryGetSnapSide(e.CursorScreen, out QuickToggleDockSide side) ? side : null); } private void Bar_DragEnded(object? sender, QuickToggleDragEventArgs e) { if (disposed) return; dragging = false; - UpdateHint(false); + UpdateHint(null); - if (IsInSnapZone(e.CursorScreen)) + if (TryGetSnapSide(e.CursorScreen, out QuickToggleDockSide side)) { - Dock(); + // 拖到哪一侧就吸附到哪一侧(顶部 / 左侧 / 右侧),并顺带切换横排 / 竖排 + DockTo(side); return; } @@ -416,39 +564,91 @@ private void Bar_DragEnded(object? sender, QuickToggleDragEventArgs e) SaveState(); } - /// 拖到停靠行所在的一行(或菜单栏上)即视为要吸附回去。 - private bool IsInSnapZone(Point cursorScreen) + /// + /// 判断松手位置要吸附到哪一侧:拖到某个吸附区附近(或菜单栏上)即命中, + /// 同时命中多个时取最近的一个,已经吸附着的那一侧略有优先,避免抖动时来回跳。 + /// + private bool TryGetSnapSide(Point cursorScreen, out QuickToggleDockSide side) { - Rectangle target = DockTargetScreenRect(); - target.Inflate(0, SnapDistance); - if (target.Contains(cursorScreen)) return true; + side = QuickToggleDockSide.Top; + // 菜单栏本身也算吸附区:拖到菜单栏上松手即吸附回顶部 Control? anchor = snapAnchor; - return anchor != null && anchor.IsHandleCreated && - anchor.RectangleToScreen(anchor.ClientRectangle).Contains(cursorScreen); + if (anchor != null && anchor.IsHandleCreated && + anchor.RectangleToScreen(anchor.ClientRectangle).Contains(cursorScreen)) + { + return true; + } + + int best = int.MaxValue; + foreach (QuickToggleDockSide candidate in DockSides) + { + int distance = DistanceToRect(DockTargetScreenRect(candidate), cursorScreen); + if (candidate == dockSide) distance -= SnapDistance / 2; + if (distance > SnapDistance || distance >= best) continue; + + best = distance; + side = candidate; + } + + return best != int.MaxValue; } - /// 吸附目标矩形:菜单栏正下方、与主窗口同宽的一行(屏幕坐标)。 - private Rectangle DockTargetScreenRect() + /// 点到矩形的距离:在矩形内为 0,取两个方向上偏移量的较大值(靠近边缘即算命中)。 + private static int DistanceToRect(Rectangle rect, Point point) { - int menuBottom = snapAnchor != null ? snapAnchor.Bottom : 0; - Point topLeft = owner.PointToScreen(new Point(0, menuBottom)); - int height = Math.Max(bar.PreferredSize.Height, 1); - return new Rectangle(topLeft, new Size(owner.ClientSize.Width, height)); + int dx = Math.Max(Math.Max(rect.Left - point.X, 0), point.X - rect.Right); + int dy = Math.Max(Math.Max(rect.Top - point.Y, 0), point.Y - rect.Bottom); + return Math.Max(dx, dy); } - private void UpdateHint(bool show) + /// + /// 某个吸附位置对应的目标矩形(屏幕坐标): + /// 顶部是菜单栏正下方、与主窗口同宽的一行;左 / 右是菜单栏与状态栏之间的一个竖条。 + /// + private Rectangle DockTargetScreenRect(QuickToggleDockSide side) { - if (show) + Point origin = owner.PointToScreen(Point.Empty); + int top = origin.Y + (snapAnchor != null ? snapAnchor.Bottom : 0); + int bottom = bottomAnchor != null && bottomAnchor.IsHandleCreated + ? origin.Y + bottomAnchor.Top + : origin.Y + owner.ClientSize.Height; + int height = Math.Max(bottom - top, 1); + + if (side == QuickToggleDockSide.Top) { - Rectangle target = DockTargetScreenRect(); - hintForm.Bounds = new Rectangle(target.X, target.Y, Math.Max(target.Width, 1), HintHeight); - if (!hintForm.Visible) hintForm.Show(); + return new Rectangle( + origin.X, + top, + Math.Max(owner.ClientSize.Width, 1), + Math.Max(bar.PreferredThickness(vertical: false), 1)); } - else if (hintForm.Visible) + + int thickness = Math.Max(bar.PreferredThickness(vertical: true), 1); + return side == QuickToggleDockSide.Left + ? new Rectangle(origin.X, top, thickness, height) + : new Rectangle(origin.X + Math.Max(owner.ClientSize.Width - thickness, 0), top, thickness, height); + } + + /// 显示 / 隐藏吸附提示:顶部是一条横线,左 / 右是一条竖线。 + private void UpdateHint(QuickToggleDockSide? side) + { + if (!side.HasValue) { - hintForm.Hide(); + if (hintForm.Visible) hintForm.Hide(); + return; } + + Rectangle target = DockTargetScreenRect(side.Value); + Rectangle bounds = side.Value switch + { + QuickToggleDockSide.Left => new Rectangle(target.X, target.Y, HintHeight, Math.Max(target.Height, 1)), + QuickToggleDockSide.Right => new Rectangle(target.Right - HintHeight, target.Y, HintHeight, Math.Max(target.Height, 1)), + _ => new Rectangle(target.X, target.Y, Math.Max(target.Width, 1), HintHeight), + }; + + if (hintForm.Bounds != bounds) hintForm.Bounds = bounds; + if (!hintForm.Visible) hintForm.Show(); } #endregion @@ -465,6 +665,12 @@ private void Bar_HideRequested(object? sender, EventArgs e) BarVisible = false; } + /// 右键菜单里选了新的吸附位置:切过去并吸附回主窗口。 + private void Bar_DockSideRequested(object? sender, QuickToggleDockSideEventArgs e) + { + DockTo(e.Side); + } + private void Bar_ContentChanged(object? sender, EventArgs e) { // 条不自动调整大小,内容变化后由宿主重新计算行高 / 浮窗尺寸 @@ -478,9 +684,9 @@ private void Bar_ToggleChanged(object? sender, QuickToggleChangedEventArgs e) SaveAndFlush(); } - private void Bar_GroupsVisibilityChanged(object? sender, EventArgs e) + private void Bar_VisibilityChanged(object? sender, EventArgs e) { - // 右键菜单隐藏/显示功能区:立即写入设置,下次启动保持 + // 右键菜单隐藏 / 显示功能区或独立开关:立即写入设置,下次启动保持 SaveAndFlush(); } @@ -532,9 +738,10 @@ public void Dispose() bar.DragEnded -= Bar_DragEnded; bar.FloatingToggleRequested -= Bar_FloatingToggleRequested; bar.HideRequested -= Bar_HideRequested; + bar.DockSideRequested -= Bar_DockSideRequested; bar.ContentChanged -= Bar_ContentChanged; bar.ToggleChanged -= Bar_ToggleChanged; - bar.GroupsVisibilityChanged -= Bar_GroupsVisibilityChanged; + bar.VisibilityChanged -= Bar_VisibilityChanged; if (hintForm.Visible) hintForm.Hide(); hintForm.Dispose(); diff --git a/osucatch-editor-realtimeviewer/app.Designer.cs b/osucatch-editor-realtimeviewer/app.Designer.cs index 4b5ffd5..47804d6 100644 --- a/osucatch-editor-realtimeviewer/app.Designer.cs +++ b/osucatch-editor-realtimeviewer/app.Designer.cs @@ -1091,5 +1091,17 @@ public string QuickToggle_HiddenGroups { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("Top")] + public string QuickToggle_DockSide { + get { + return ((string)(this["QuickToggle_DockSide"])); + } + set { + this["QuickToggle_DockSide"] = value; + } + } + } } diff --git a/osucatch-editor-realtimeviewer/app.settings b/osucatch-editor-realtimeviewer/app.settings index 44013da..e57d8d7 100644 --- a/osucatch-editor-realtimeviewer/app.settings +++ b/osucatch-editor-realtimeviewer/app.settings @@ -266,6 +266,9 @@ + + Top +