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
+