diff --git a/Fuyutsui/Fuyutsui.toc b/Fuyutsui/Fuyutsui.toc index 57ce2980..3155ef5d 100644 --- a/Fuyutsui/Fuyutsui.toc +++ b/Fuyutsui/Fuyutsui.toc @@ -1,7 +1,7 @@ ## Interface: 120100 ## Title: Fuyutsui -## Version: 1.2.1.12 +## Version: 1.2.1.13 ## Author: Wayne Bian ## IconTexture: Interface\AddOns\Fuyutsui\core\Spell_Misc_EmotionHappy.blp ## OptionalDeps: LibRangeCheck-3.0 diff --git a/Infrastructure/ClassBlocksStore.cs b/Infrastructure/ClassBlocksStore.cs index a5fe55f8..d19f30db 100644 --- a/Infrastructure/ClassBlocksStore.cs +++ b/Infrastructure/ClassBlocksStore.cs @@ -38,6 +38,7 @@ internal static class ClassBlocksStore public int TableEndExclusive { get; set; } public Dictionary Specs { get; set; } = new(); public List SpellsList { get; set; } = new(); + public HashSet DeletedSpellsListOriginalIds { get; } = new(); public bool IsModernFormat { get; set; } } @@ -199,7 +200,10 @@ internal static class ClassBlocksStore throw new InvalidOperationException("当前文件仍是旧版稀疏索引 ClassBlocks,无法用图形编辑器保存。"); } - var updated = UpdateSpellsListEntries(document.SourceText, document.SpellsList); + var updated = UpdateSpellsListEntries( + document.SourceText, + document.SpellsList, + document.DeletedSpellsListOriginalIds); if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var classBlocksStart, out var classBlocksEnd)) { throw new InvalidOperationException("保存前无法重新定位 ClassBlocks 表。"); @@ -225,9 +229,14 @@ internal static class ClassBlocksStore spell.OriginalIndex = spell.Index; spell.OriginalName = spell.Name; } + + document.DeletedSpellsListOriginalIds.Clear(); } - private static string UpdateSpellsListEntries(string source, IReadOnlyList entries) + private static string UpdateSpellsListEntries( + string source, + IReadOnlyList entries, + IReadOnlySet deletedOriginalIds) { var newEntries = entries.Where(entry => entry.OriginalSpellId == 0).ToArray(); var changedEntries = entries @@ -236,7 +245,7 @@ internal static class ClassBlocksStore || entry.Index != entry.OriginalIndex || !string.Equals(entry.Name, entry.OriginalName, StringComparison.Ordinal))) .ToDictionary(entry => entry.OriginalSpellId); - if (changedEntries.Count == 0 && newEntries.Length == 0) + if (changedEntries.Count == 0 && newEntries.Length == 0 && deletedOriginalIds.Count == 0) { return source; } @@ -248,14 +257,26 @@ internal static class ClassBlocksStore var tableText = source[tableStart..tableEnd]; var updatedOriginalIds = new HashSet(); + var deletedIdsFound = new HashSet(); var pattern = new Regex( - """^(?[ \t]*\[[ \t]*)(?\d+)(?[ \t]*\][ \t]*=[ \t]*\{[ \t]*index[ \t]*=[ \t]*)(?\d+)(?[ \t]*,[ \t]*name[ \t]*=[ \t]*)(?"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')(?[^\n]*)$""", + """^(?[ \t]*\[[ \t]*)(?\d+)(?[ \t]*\][ \t]*=[ \t]*\{[ \t]*index[ \t]*=[ \t]*)(?\d+)(?[ \t]*,[ \t]*name[ \t]*=[ \t]*)(?"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')(?[^\r\n]*)(?\r?\n|$)""", RegexOptions.Multiline | RegexOptions.CultureInvariant); var updatedTable = pattern.Replace(tableText, match => { - if (!long.TryParse(match.Groups["spellId"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var originalSpellId) - || !changedEntries.TryGetValue(originalSpellId, out var entry)) + if (!long.TryParse(match.Groups["spellId"].Value, NumberStyles.None, + CultureInfo.InvariantCulture, out var originalSpellId)) + { + return match.Value; + } + + if (deletedOriginalIds.Contains(originalSpellId)) + { + deletedIdsFound.Add(originalSpellId); + return string.Empty; + } + + if (!changedEntries.TryGetValue(originalSpellId, out var entry)) { return match.Value; } @@ -271,7 +292,8 @@ internal static class ClassBlocksStore + quote + EscapeLuaString(entry.Name, quote) + quote - + match.Groups["suffix"].Value; + + match.Groups["suffix"].Value + + match.Groups["lineEnd"].Value; }); var missing = changedEntries.Keys.Where(id => !updatedOriginalIds.Contains(id)).ToArray(); @@ -281,6 +303,13 @@ internal static class ClassBlocksStore $"无法在 {SpellsListAssignmentName} 中定位法术 ID {string.Join(", ", missing)} 的原始条目。"); } + var missingDeleted = deletedOriginalIds.Where(id => !deletedIdsFound.Contains(id)).ToArray(); + if (missingDeleted.Length > 0) + { + throw new InvalidOperationException( + $"无法在 {SpellsListAssignmentName} 中定位待删除的法术 ID {string.Join(", ", missingDeleted)}。"); + } + if (newEntries.Length > 0) { var newline = source.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; diff --git a/Shigure.csproj b/Shigure.csproj index c37b6a21..5ac15689 100644 --- a/Shigure.csproj +++ b/Shigure.csproj @@ -9,9 +9,9 @@ Shigure Shigure Arasaka Corporation - 1.2.1.12 - 1.2.1.12 - 1.2.1.12 + 1.2.1.13 + 1.2.1.13 + 1.2.1.13 Assets\arasaka-icon.ico PerMonitorV2 diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index 45412eb6..0373e085 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -29,6 +29,8 @@ public sealed class ClassConfigEditorControl : UserControl private readonly TextBox _spellsListSearchBox = new(); private readonly TextBox _newSpellIdBox = new(); private readonly TextBox _newSpellNameBox = new(); + private ToolStripDropDown? _spellSuggestionDropDown; + private bool _suppressSpellSuggestions; private readonly NumericUpDown _groupNumBox = new(); private readonly NumericUpDown _groupHealthBox = new(); private readonly NumericUpDown _groupRoleBox = new(); @@ -83,6 +85,7 @@ public sealed class ClassConfigEditorControl : UserControl if (disposing) { CloseStateComboDropDown(); + CloseSpellSuggestions(); SpellIconCatalog.IconAvailable -= OnSpellIconAvailable; } @@ -213,42 +216,13 @@ public sealed class ClassConfigEditorControl : UserControl Dock = DockStyle.Fill, BackColor = UiTheme.Surface, ColumnCount = 1, - RowCount = 3, + RowCount = 2, Margin = new Padding(0) }; - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 68)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 80)); - var header = new UiCardPanel - { - Dock = DockStyle.Fill, - ColumnCount = 2, - RowCount = 2, - Padding = new Padding(UiTheme.CardPadding, 12, UiTheme.CardPadding, 12), - Margin = new Padding(0, 0, 0, UiTheme.PageGap) - }; - header.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56)); - header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - - header.Controls.Add(CreateFieldCaption("路径"), 0, 0); - ConfigureInfoLabel(_pathLabel, UiTheme.Text); - _pathLabel.Text = "未加载"; - _pathLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); - _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); - header.Controls.Add(_pathLabel, 1, 0); - - header.Controls.Add(CreateFieldCaption("状态"), 0, 1); - ConfigureInfoLabel(_statusLabel, UiTheme.Muted); - _statusLabel.Text = "点击刷新以加载项目 Fuyutsui\\class"; - _statusLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); - _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); - header.Controls.Add(_statusLabel, 1, 1); - root.Controls.Add(header, 0, 0); - - root.Controls.Add(BuildSectionTabs(), 0, 1); + root.Controls.Add(BuildSectionTabs(), 0, 0); var actionRow = new UiCardPanel { @@ -277,11 +251,43 @@ public sealed class ClassConfigEditorControl : UserControl _saveButton.Click += async (_, _) => await SaveAndUpdateAsync(); actions.Controls.Add(_reloadButton); actions.Controls.Add(_saveButton); + actionRow.Controls.Add(BuildFooterInfo(), 0, 0); actionRow.Controls.Add(actions, 1, 0); - root.Controls.Add(actionRow, 0, 2); + root.Controls.Add(actionRow, 0, 1); return root; } + private Control BuildFooterInfo() + { + var info = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = Color.Transparent, + ColumnCount = 2, + RowCount = 2, + Margin = new Padding(0) + }; + info.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56)); + info.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + info.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + info.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + + info.Controls.Add(CreateFieldCaption("状态"), 0, 0); + ConfigureInfoLabel(_statusLabel, UiTheme.Muted); + _statusLabel.Text = "点击刷新以加载项目 Fuyutsui\\class"; + _statusLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); + _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); + info.Controls.Add(_statusLabel, 1, 0); + + info.Controls.Add(CreateFieldCaption("路径"), 0, 1); + ConfigureInfoLabel(_pathLabel, UiTheme.Text); + _pathLabel.Text = "未加载"; + _pathLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); + _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); + info.Controls.Add(_pathLabel, 1, 1); + return info; + } + private Control BuildSectionTabs() { var root = new TableLayoutPanel @@ -355,6 +361,11 @@ public sealed class ClassConfigEditorControl : UserControl return; } + if (index != 4) + { + CloseSpellSuggestions(); + } + selectedIndex = index; for (var i = 0; i < tabs.Length; i++) { @@ -740,6 +751,15 @@ public sealed class ClassConfigEditorControl : UserControl _newSpellNameBox.PlaceholderText = "名称"; addCard.Controls.Add(_newSpellNameBox, 2, 0); + _newSpellIdBox.TextChanged += (_, _) => UpdateSpellSuggestions(addCard); + _newSpellIdBox.VisibleChanged += (_, _) => + { + if (!_newSpellIdBox.Visible) + { + CloseSpellSuggestions(); + } + }; + var addControl = new UiPillTab("添加") { Selected = true, @@ -754,6 +774,14 @@ public sealed class ClassConfigEditorControl : UserControl void HandleAddOnEnter(object? _, KeyEventArgs e) { + if (e.KeyCode == Keys.Escape && _spellSuggestionDropDown is not null) + { + e.Handled = true; + e.SuppressKeyPress = true; + CloseSpellSuggestions(); + return; + } + if (e.KeyCode != Keys.Enter) { return; @@ -774,6 +802,7 @@ public sealed class ClassConfigEditorControl : UserControl ConfigureGrid(_spellsListGrid, "class-config-spells-list"); _spellsListGrid.AllowUserToAddRows = false; + _spellsListGrid.CellContentClick += HandleSpellsListDeleteClick; _spellsListGrid.CellValueChanged += (_, e) => { MarkDirty(); @@ -804,6 +833,7 @@ public sealed class ClassConfigEditorControl : UserControl AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, SortMode = DataGridViewColumnSortMode.NotSortable }); + _spellsListGrid.Columns.Add(CreateDeleteColumn()); panel.Controls.Add(_spellsListGrid, 0, 2); return panel; } @@ -1513,8 +1543,13 @@ public sealed class ClassConfigEditorControl : UserControl { CloseStateComboDropDown(); if (rowIndex < 0 - || rowIndex >= _statesGrid.Rows.Count - || _statesGrid.Rows[rowIndex].Cells[columnIndex] is not DataGridViewComboBoxCell cell) + || rowIndex >= _statesGrid.Rows.Count) + { + return; + } + + var row = _statesGrid.Rows[rowIndex]; + if (row.Cells[columnIndex] is not DataGridViewComboBoxCell cell) { return; } @@ -1545,8 +1580,21 @@ public sealed class ClassConfigEditorControl : UserControl current, selected => { - cell.Value = selected.Value?.ToString() ?? string.Empty; - _statesGrid.InvalidateCell(cell); + var selectedValue = selected.Value?.ToString() ?? string.Empty; + if (row.IsNewRow) + { + var addedRowIndex = _statesGrid.Rows.Add(selectedValue, "×"); + var addedCell = _statesGrid.Rows[addedRowIndex].Cells[columnIndex]; + _statesGrid.CurrentCell = addedCell; + _statesGrid.InvalidateCell(addedCell); + } + else + { + cell.Value = selectedValue; + _statesGrid.InvalidateCell(cell); + } + + MarkDirty(); }, closed: () => { @@ -1771,6 +1819,94 @@ public sealed class ClassConfigEditorControl : UserControl .Select(columnName => row.Cells[columnName].Value?.ToString() ?? string.Empty) .Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase)); + private void UpdateSpellSuggestions(Control owner) + { + if (_suppressSpellSuggestions) + { + return; + } + + CloseSpellSuggestions(); + if (!_newSpellIdBox.Focused) + { + return; + } + + var suggestions = SpellIconCatalog.SearchByIdPrefix(_newSpellIdBox.Text, 8); + if (suggestions.Count == 0) + { + return; + } + + var selectionStart = _newSpellIdBox.SelectionStart; + var selectionLength = _newSpellIdBox.SelectionLength; + var anchorBounds = Rectangle.Union(_newSpellIdBox.Bounds, _newSpellNameBox.Bounds); + ToolStripDropDown? dropDown = null; + dropDown = SpellSuggestionPopup.Show( + owner, + anchorBounds, + suggestions, + ApplySpellSuggestion, + closed: () => + { + if (ReferenceEquals(_spellSuggestionDropDown, dropDown)) + { + _spellSuggestionDropDown = null; + } + }); + _spellSuggestionDropDown = dropDown; + if (dropDown is not null) + { + _newSpellIdBox.Focus(); + _newSpellIdBox.SelectionStart = Math.Min(selectionStart, _newSpellIdBox.TextLength); + _newSpellIdBox.SelectionLength = Math.Min( + selectionLength, + _newSpellIdBox.TextLength - _newSpellIdBox.SelectionStart); + BeginInvoke((Action)(() => + { + if (IsDisposed + || Disposing + || !ReferenceEquals(_spellSuggestionDropDown, dropDown) + || dropDown.IsDisposed) + { + return; + } + + _newSpellIdBox.Focus(); + _newSpellIdBox.SelectionStart = Math.Min(selectionStart, _newSpellIdBox.TextLength); + _newSpellIdBox.SelectionLength = Math.Min( + selectionLength, + _newSpellIdBox.TextLength - _newSpellIdBox.SelectionStart); + })); + } + } + + private void ApplySpellSuggestion(SpellSuggestion suggestion) + { + _suppressSpellSuggestions = true; + try + { + _newSpellIdBox.Text = suggestion.SpellId.ToString(CultureInfo.InvariantCulture); + _newSpellNameBox.Text = suggestion.Name; + } + finally + { + _suppressSpellSuggestions = false; + } + + CloseSpellSuggestions(); + _newSpellNameBox.Focus(); + _newSpellNameBox.SelectionStart = _newSpellNameBox.TextLength; + _newSpellNameBox.SelectionLength = 0; + } + + private void CloseSpellSuggestions() + { + var dropDown = _spellSuggestionDropDown; + _spellSuggestionDropDown = null; + SpellSuggestionPopup.Dismiss(dropDown); + } + private void FillGroupEditors() { _groupAurasGrid.Rows.Clear(); @@ -2342,6 +2478,11 @@ public sealed class ClassConfigEditorControl : UserControl } _statusLabel.Text = "已保存并更新配置"; + MessageBox.Show( + "请在游戏内重载界面, /reload", + "保存成功", + MessageBoxButtons.OK, + MessageBoxIcon.Information); } catch (Exception ex) { @@ -2433,6 +2574,36 @@ public sealed class ClassConfigEditorControl : UserControl MarkDirty(); } + private void HandleSpellsListDeleteClick(object? sender, DataGridViewCellEventArgs e) + { + if (sender is not DataGridView grid + || e.RowIndex < 0 + || e.ColumnIndex < 0 + || grid.Columns[e.ColumnIndex].Name != "Delete") + { + return; + } + + var row = grid.Rows[e.RowIndex]; + if (row.IsNewRow || row.Tag is not ClassBlocksStore.SpellsListEntry entry) + { + return; + } + + if (_currentDocument is not null) + { + if (entry.OriginalSpellId > 0) + { + _currentDocument.DeletedSpellsListOriginalIds.Add(entry.OriginalSpellId); + } + + _currentDocument.SpellsList.Remove(entry); + } + + grid.Rows.RemoveAt(e.RowIndex); + MarkDirty(); + } + private void MoveSelectedRow(DataGridView grid, int delta) { if (grid.CurrentRow is null || grid.CurrentRow.IsNewRow) diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs index 2416b6c9..35d8d09f 100644 --- a/UI/ClassMacrosEditorControl.cs +++ b/UI/ClassMacrosEditorControl.cs @@ -126,41 +126,12 @@ public sealed class ClassMacrosEditorControl : UserControl Dock = DockStyle.Fill, BackColor = UiTheme.Surface, ColumnCount = 1, - RowCount = 4, + RowCount = 3, Margin = new Padding(0) }; - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 36)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 68)); - - var header = new UiCardPanel - { - Dock = DockStyle.Fill, - ColumnCount = 2, - RowCount = 2, - Padding = new Padding(UiTheme.CardPadding, 12, UiTheme.CardPadding, 12), - Margin = new Padding(0, 0, 0, UiTheme.PageGap) - }; - header.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56)); - header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - - header.Controls.Add(CreateFieldCaption("路径"), 0, 0); - ConfigureInfoLabel(_pathLabel, UiTheme.Text); - _pathLabel.Text = "未加载"; - _pathLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); - _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); - header.Controls.Add(_pathLabel, 1, 0); - - header.Controls.Add(CreateFieldCaption("状态"), 0, 1); - ConfigureInfoLabel(_statusLabel, UiTheme.Muted); - _statusLabel.Text = "点击刷新以加载项目 Fuyutsui\\core\\classmacros.lua"; - _statusLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); - _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); - header.Controls.Add(_statusLabel, 1, 1); - root.Controls.Add(header, 0, 0); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 80)); _offsetLabel.Dock = DockStyle.Fill; _offsetLabel.AutoSize = false; @@ -171,9 +142,9 @@ public sealed class ClassMacrosEditorControl : UserControl _offsetLabel.Margin = new Padding(0); _offsetLabel.AutoEllipsis = true; _offsetLabel.Text = "创建顺序:动态宏(每项 30 槽)→ 静态宏 → 特殊宏;空字符串保留槽位"; - root.Controls.Add(_offsetLabel, 0, 1); + root.Controls.Add(_offsetLabel, 0, 0); - root.Controls.Add(BuildSectionTabs(), 0, 2); + root.Controls.Add(BuildSectionTabs(), 0, 1); var actionRow = new UiCardPanel { @@ -202,11 +173,43 @@ public sealed class ClassMacrosEditorControl : UserControl _saveButton.Click += async (_, _) => await SaveAndUpdateAsync(); actions.Controls.Add(_reloadButton); actions.Controls.Add(_saveButton); + actionRow.Controls.Add(BuildFooterInfo(), 0, 0); actionRow.Controls.Add(actions, 1, 0); - root.Controls.Add(actionRow, 0, 3); + root.Controls.Add(actionRow, 0, 2); return root; } + private Control BuildFooterInfo() + { + var info = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = Color.Transparent, + ColumnCount = 2, + RowCount = 2, + Margin = new Padding(0) + }; + info.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56)); + info.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + info.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + info.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + + info.Controls.Add(CreateFieldCaption("状态"), 0, 0); + ConfigureInfoLabel(_statusLabel, UiTheme.Muted); + _statusLabel.Text = "点击刷新以加载项目 Fuyutsui\\core\\classmacros.lua"; + _statusLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); + _toolTip.SetToolTip(_statusLabel, _statusLabel.Text); + info.Controls.Add(_statusLabel, 1, 0); + + info.Controls.Add(CreateFieldCaption("路径"), 0, 1); + ConfigureInfoLabel(_pathLabel, UiTheme.Text); + _pathLabel.Text = "未加载"; + _pathLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); + _toolTip.SetToolTip(_pathLabel, _pathLabel.Text); + info.Controls.Add(_pathLabel, 1, 1); + return info; + } + private Control BuildSectionTabs() { var root = new TableLayoutPanel @@ -997,6 +1000,11 @@ public sealed class ClassMacrosEditorControl : UserControl _statusLabel.Text = "已保存并更新配置"; UpdateOffsetHint(); + MessageBox.Show( + "请在游戏内重载界面, /reload", + "保存成功", + MessageBoxButtons.OK, + MessageBoxIcon.Information); } catch (Exception ex) { diff --git a/UI/MainForm.cs b/UI/MainForm.cs index dd760f2d..fa9daed3 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -18,6 +18,7 @@ public sealed class MainForm : Form, IMessageFilter private const int ResizeGripSize = 8; private const int RoundedCornerResizeDebounceMs = 80; + private const int WowProcessMonitorIntervalMs = 10_000; private const string HeaderIconResourcePath = "Assets.arasaka-icon-transparent.png"; private const string ModuleWebsiteUrl = "https://www.shigure.club"; private static readonly Color DefaultHeaderIconColor = Color.White; @@ -89,6 +90,7 @@ public sealed class MainForm : Form, IMessageFilter private readonly AppOptions _initialOptions; private readonly UiCacheState _uiCache; private readonly System.Windows.Forms.Timer _roundedCornerResizeTimer; + private readonly System.Windows.Forms.Timer _wowProcessMonitorTimer; private RenderSnapshot? _lastSnapshot; private string? _lastLoggedStep; private string? _lastLoggedStepDetails; @@ -103,6 +105,7 @@ public sealed class MainForm : Form, IMessageFilter private bool _exitRequested; private bool _shutdownStarted; private bool _shutdownCompleted; + private bool _wasWowProcessWindowAvailable; private sealed record ProjectConfigUpdateResult( FuyutsuiConfigConverter.UpdateResult Config, @@ -140,6 +143,13 @@ public sealed class MainForm : Form, IMessageFilter UiTheme.ApplyFallbackRoundedCorners(this); } }; + _wasWowProcessWindowAvailable = _processLocator.FindFrontmostWindow() != 0; + _wowProcessMonitorTimer = new System.Windows.Forms.Timer + { + Interval = WowProcessMonitorIntervalMs + }; + _wowProcessMonitorTimer.Tick += HandleWowProcessMonitorTick; + _wowProcessMonitorTimer.Start(); Application.AddMessageFilter(this); InitializeComponent(); TryApplyApplicationIcon(); @@ -366,6 +376,7 @@ public sealed class MainForm : Form, IMessageFilter _shutdownStarted = true; SaveUiCache(); _roundedCornerResizeTimer.Stop(); + _wowProcessMonitorTimer.Stop(); Application.RemoveMessageFilter(this); _ = CompleteShutdownAsync(); } @@ -385,9 +396,43 @@ public sealed class MainForm : Form, IMessageFilter _trayDefaultIcon?.Dispose(); _trayEnabledIcon?.Dispose(); _roundedCornerResizeTimer.Dispose(); + _wowProcessMonitorTimer.Dispose(); base.OnFormClosed(e); } + private async void HandleWowProcessMonitorTick(object? sender, EventArgs e) + { + var isAvailable = _processLocator.FindFrontmostWindow() != 0; + var justOpened = !_wasWowProcessWindowAvailable && isAvailable; + _wasWowProcessWindowAvailable = isAvailable; + + if (!justOpened || _shutdownStarted) + { + return; + } + + AppendLog("检测到目标游戏进程已打开,正在自动更新配置"); + try + { + await QueueProjectConfigUpdateAsync(savedAddonFilePath: null); + if (!_shutdownStarted) + { + AppendLog("目标游戏进程启动后的配置更新已完成"); + } + } + catch (OperationCanceledException) when (_shutdownStarted) + { + // 关闭流程会等待队列收尾,无需再记录失败。 + } + catch (Exception ex) + { + if (!_shutdownStarted) + { + AppendLog($"目标游戏进程启动后的配置更新失败: {ex.Message}"); + } + } + } + private async Task CompleteShutdownAsync() { _runtimeSession.SnapshotUpdated -= HandleSnapshotUpdated; diff --git a/UI/SpellIconCatalog.cs b/UI/SpellIconCatalog.cs index 384623cb..9f1a3492 100644 --- a/UI/SpellIconCatalog.cs +++ b/UI/SpellIconCatalog.cs @@ -4,6 +4,8 @@ using System.Text.Json; namespace Shigure; +internal sealed record SpellSuggestion(long SpellId, string Name); + /// /// 技能名称/ID 到技能图标的目录。优先读取自定义嵌入资源和发布数据包,开发环境 /// 回退到 Assets/Spell;未知 ID 会在后台从 Wowhead 下载并按图标资源名缓存。 @@ -18,6 +20,7 @@ internal static class SpellIconCatalog private static readonly SpellIconPackage? PackagedCatalog = SpellIconPackage.TryOpen(); private static readonly CatalogData Catalog = LoadCatalog(); private static readonly Dictionary SpellIdsByName = LoadSpellIdsByName(); + private static readonly SpellSuggestion[] SuggestionsBySpellId = LoadSpellSuggestions(); private static readonly Dictionary IconTargetsBySpellId = Catalog.TargetsBySpellId; private static readonly string RuntimeCacheDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -129,6 +132,111 @@ internal static class SpellIconCatalog } } + internal static IReadOnlyList SearchByIdPrefix(string? prefix, int limit) + { + var normalized = prefix; + if (limit <= 0 + || string.IsNullOrEmpty(normalized) + || normalized.Length > 19 + || normalized[0] == '0' + || SuggestionsBySpellId.Length == 0) + { + return Array.Empty(); + } + + long prefixValue = 0; + foreach (var character in normalized) + { + if (character is < '0' or > '9') + { + return Array.Empty(); + } + + var digit = character - '0'; + if (prefixValue > (long.MaxValue - digit) / 10) + { + return Array.Empty(); + } + + prefixValue = prefixValue * 10 + digit; + } + + if (prefixValue <= 0) + { + return Array.Empty(); + } + + var maximumSpellId = SuggestionsBySpellId[^1].SpellId; + var maximumDigits = 1; + for (var remaining = maximumSpellId; remaining >= 10; remaining /= 10) + { + maximumDigits++; + } + + var maximumSuffixDigits = maximumDigits - normalized.Length; + if (maximumSuffixDigits < 0) + { + return Array.Empty(); + } + + var matches = new List(Math.Min(limit, 8)); + long scale = 1; + for (var suffixDigits = 0; suffixDigits <= maximumSuffixDigits; suffixDigits++) + { + if (prefixValue > long.MaxValue / scale) + { + break; + } + + var start = prefixValue * scale; + var intervalLength = scale - 1; + var end = intervalLength > long.MaxValue - start + ? long.MaxValue + : start + intervalLength; + var index = LowerBoundSuggestion(start); + while (index < SuggestionsBySpellId.Length + && SuggestionsBySpellId[index].SpellId <= end) + { + matches.Add(SuggestionsBySpellId[index]); + if (matches.Count >= limit) + { + return matches; + } + + index++; + } + + if (scale > long.MaxValue / 10) + { + break; + } + + scale *= 10; + } + + return matches; + } + + private static int LowerBoundSuggestion(long spellId) + { + var low = 0; + var high = SuggestionsBySpellId.Length; + while (low < high) + { + var middle = low + (high - low) / 2; + if (SuggestionsBySpellId[middle].SpellId < spellId) + { + low = middle + 1; + } + else + { + high = middle; + } + } + + return low; + } + public static Image? GetLastRuleRowIcon() => GetNamedIcon("last-rule-row", LastRuleRowIconResource); @@ -398,6 +506,24 @@ internal static class SpellIconCatalog return result; } + private static SpellSuggestion[] LoadSpellSuggestions() + { + var namesBySpellId = new Dictionary(Catalog.SpellNamesById); + if (PackagedCatalog is not null) + { + foreach (var (spellId, name) in PackagedCatalog.SpellNamesById) + { + namesBySpellId.TryAdd(spellId, name); + } + } + + return namesBySpellId + .Where(pair => pair.Key > 0 && !string.IsNullOrWhiteSpace(pair.Value)) + .OrderBy(pair => pair.Key) + .Select(pair => new SpellSuggestion(pair.Key, pair.Value)) + .ToArray(); + } + private static CatalogData LoadCatalog() { var result = new CatalogData(); @@ -427,6 +553,11 @@ internal static class SpellIconCatalog { result.SpellIdsByName[name] = id; } + + if (!string.IsNullOrWhiteSpace(name)) + { + result.SpellNamesById[id] = name; + } } if (spell.TryGetProperty("target", out var targetElement)) @@ -450,6 +581,7 @@ internal static class SpellIconCatalog private sealed class CatalogData { public Dictionary SpellIdsByName { get; } = new(StringComparer.Ordinal); + public Dictionary SpellNamesById { get; } = new(); public Dictionary TargetsBySpellId { get; } = new(); } @@ -538,6 +670,7 @@ internal static class SpellIconCatalog } SpellIdsByName = new Dictionary(StringComparer.Ordinal); + SpellNamesById = new Dictionary(); _stream.Position = nameIndexOffset; for (var index = 0; index < nameCount; index++) { @@ -554,6 +687,7 @@ internal static class SpellIconCatalog if (!string.IsNullOrWhiteSpace(name)) { SpellIdsByName.TryAdd(name, spellId); + SpellNamesById.TryAdd(spellId, name); } } @@ -570,6 +704,7 @@ internal static class SpellIconCatalog } public Dictionary SpellIdsByName { get; } + public Dictionary SpellNamesById { get; } public static SpellIconPackage? TryOpen() { diff --git a/UI/SpellSuggestionPopup.cs b/UI/SpellSuggestionPopup.cs new file mode 100644 index 00000000..eb12fc75 --- /dev/null +++ b/UI/SpellSuggestionPopup.cs @@ -0,0 +1,335 @@ +namespace Shigure; + +/// 在输入框下方显示带图标的 spellId 候选,不抢占文本框焦点。 +internal static class SpellSuggestionPopup +{ + public static ToolStripDropDown? Show( + Control owner, + Rectangle anchorBounds, + IReadOnlyList items, + Action applySelection, + Action? closed = null) + { + if (items.Count == 0 || owner.IsDisposed || !owner.IsHandleCreated) + { + return null; + } + + var rowHeight = Math.Max(UiTheme.Scale(owner, 44), owner.Font.Height + UiTheme.Scale(owner, 16)); + var workingArea = Screen.FromControl(owner).WorkingArea; + var availableWidth = Math.Max(1, workingArea.Width - UiTheme.Scale(owner, 20)); + var popupWidth = Math.Clamp(anchorBounds.Width, 1, availableWidth); + var listSize = new Size(Math.Max(1, popupWidth - 2), rowHeight * items.Count); + + ToolStripDropDown? dropDown = null; + var list = new SpellSuggestionList( + items, + rowHeight, + suggestion => + { + applySelection(suggestion); + Dismiss(dropDown, ToolStripDropDownCloseReason.ItemClicked); + }) + { + Size = listSize, + Font = owner.Font + }; + var host = new ToolStripControlHost(list) + { + AutoSize = false, + Margin = Padding.Empty, + Padding = Padding.Empty, + Size = listSize + }; + dropDown = new NonActivatingToolStripDropDown + { + AutoSize = false, + AutoClose = false, + BackColor = UiTheme.Border, + DropShadowEnabled = true, + Margin = Padding.Empty, + Padding = new Padding(1), + Size = new Size(popupWidth, listSize.Height + 2) + }; + dropDown.Items.Add(host); + dropDown.Closed += (_, _) => closed?.Invoke(); + + var screenAnchor = owner.RectangleToScreen(anchorBounds); + var screenLocation = new Point(screenAnchor.Left, screenAnchor.Bottom); + if (screenLocation.X + dropDown.Width > workingArea.Right) + { + screenLocation.X = Math.Max(workingArea.Left, workingArea.Right - dropDown.Width); + } + + if (screenLocation.Y + dropDown.Height > workingArea.Bottom) + { + screenLocation.Y = Math.Max(workingArea.Top, screenAnchor.Top - dropDown.Height); + } + + dropDown.Show(owner, owner.PointToClient(screenLocation), ToolStripDropDownDirection.BelowRight); + return dropDown; + } + + public static void Dismiss( + ToolStripDropDown? dropDown, + ToolStripDropDownCloseReason reason = ToolStripDropDownCloseReason.AppClicked) + { + if (dropDown is null || dropDown.IsDisposed) + { + return; + } + + if (dropDown is NonActivatingToolStripDropDown nonActivating) + { + nonActivating.Dismiss(reason); + return; + } + + dropDown.Close(reason); + if (dropDown.Visible) + { + dropDown.Hide(); + } + } + + private sealed class NonActivatingToolStripDropDown : ToolStripDropDown, IMessageFilter + { + private const int WsExNoActivate = 0x08000000; + private const int WmMouseActivate = 0x0021; + private const int WmActivateApp = 0x001C; + private const int WmLeftButtonDown = 0x0201; + private const int WmRightButtonDown = 0x0204; + private const int WmMiddleButtonDown = 0x0207; + private const int WmNonClientLeftButtonDown = 0x00A1; + private static readonly IntPtr MaNoActivate = new(3); + private bool _messageFilterInstalled; + + protected override CreateParams CreateParams + { + get + { + var createParams = base.CreateParams; + createParams.ExStyle |= WsExNoActivate; + return createParams; + } + } + + protected override void WndProc(ref Message message) + { + if (message.Msg == WmMouseActivate) + { + message.Result = MaNoActivate; + return; + } + + base.WndProc(ref message); + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + if (!_messageFilterInstalled) + { + Application.AddMessageFilter(this); + _messageFilterInstalled = true; + } + } + + protected override void OnClosed(ToolStripDropDownClosedEventArgs e) + { + RemoveMessageFilter(); + base.OnClosed(e); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + RemoveMessageFilter(); + } + + base.Dispose(disposing); + } + + public bool PreFilterMessage(ref Message message) + { + if (message.Msg == WmActivateApp && message.WParam == IntPtr.Zero) + { + Dismiss(ToolStripDropDownCloseReason.AppFocusChange); + } + else if (message.Msg is WmLeftButtonDown or WmRightButtonDown + or WmMiddleButtonDown or WmNonClientLeftButtonDown + && !Bounds.Contains(Control.MousePosition)) + { + Dismiss(ToolStripDropDownCloseReason.AppClicked); + } + + return false; + } + + public void Dismiss(ToolStripDropDownCloseReason reason) + { + if (IsDisposed) + { + return; + } + + RemoveMessageFilter(); + AutoClose = true; + Close(reason); + if (Visible) + { + Hide(); + } + } + + private void RemoveMessageFilter() + { + if (!_messageFilterInstalled) + { + return; + } + + Application.RemoveMessageFilter(this); + _messageFilterInstalled = false; + } + } + + private sealed class SpellSuggestionList : Control + { + private readonly IReadOnlyList _items; + private readonly int _rowHeight; + private readonly Action _applySelection; + private int _hoveredIndex = -1; + + public SpellSuggestionList( + IReadOnlyList items, + int rowHeight, + Action applySelection) + { + _items = items; + _rowHeight = rowHeight; + _applySelection = applySelection; + SetStyle( + ControlStyles.AllPaintingInWmPaint + | ControlStyles.OptimizedDoubleBuffer + | ControlStyles.ResizeRedraw + | ControlStyles.UserPaint, + true); + SetStyle(ControlStyles.Selectable, false); + BackColor = UiTheme.Surface; + ForeColor = UiTheme.Text; + Cursor = Cursors.Hand; + TabStop = false; + AccessibleRole = AccessibleRole.List; + AccessibleName = "技能候选"; + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + e.Graphics.Clear(UiTheme.Surface); + var iconSize = Math.Min(UiTheme.Scale(this, 32), _rowHeight - UiTheme.Scale(this, 8)); + var horizontalPadding = UiTheme.Scale(this, 10); + var iconGap = UiTheme.Scale(this, 10); + var idWidth = UiTheme.Scale(this, 104); + + using var separator = new Pen(UiTheme.Border); + using var hoveredBrush = new SolidBrush(UiTheme.AccentSoft); + for (var index = 0; index < _items.Count; index++) + { + var rowBounds = new Rectangle(0, index * _rowHeight, ClientSize.Width, _rowHeight); + if (index == _hoveredIndex) + { + e.Graphics.FillRectangle(hoveredBrush, rowBounds); + } + + var iconBounds = new Rectangle( + horizontalPadding, + rowBounds.Top + (rowBounds.Height - iconSize) / 2, + iconSize, + iconSize); + var suggestion = _items[index]; + var icon = SpellIconCatalog.Get(suggestion.SpellId); + if (icon is not null) + { + e.Graphics.DrawImage(icon, iconBounds); + } + else + { + e.Graphics.DrawRectangle(separator, iconBounds); + } + + var idBounds = new Rectangle( + Math.Max(iconBounds.Right + iconGap, rowBounds.Right - idWidth - horizontalPadding), + rowBounds.Top, + idWidth, + rowBounds.Height); + var nameBounds = new Rectangle( + iconBounds.Right + iconGap, + rowBounds.Top, + Math.Max(0, idBounds.Left - iconBounds.Right - iconGap), + rowBounds.Height); + var textColor = index == _hoveredIndex ? UiTheme.Accent : ForeColor; + TextRenderer.DrawText( + e.Graphics, + suggestion.Name, + Font, + nameBounds, + textColor, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine + | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + TextRenderer.DrawText( + e.Graphics, + suggestion.SpellId.ToString(), + Font, + idBounds, + index == _hoveredIndex ? UiTheme.Accent : UiTheme.Muted, + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine + | TextFormatFlags.NoPrefix); + + if (index < _items.Count - 1) + { + e.Graphics.DrawLine(separator, rowBounds.Left, rowBounds.Bottom - 1, rowBounds.Right, rowBounds.Bottom - 1); + } + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + var index = e.Y >= 0 ? e.Y / _rowHeight : -1; + index = index >= 0 && index < _items.Count ? index : -1; + if (_hoveredIndex != index) + { + _hoveredIndex = index; + Invalidate(); + } + } + + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + if (_hoveredIndex >= 0) + { + _hoveredIndex = -1; + Invalidate(); + } + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left) + { + return; + } + + var index = e.Y >= 0 ? e.Y / _rowHeight : -1; + if (index >= 0 && index < _items.Count) + { + _applySelection(_items[index]); + } + } + } +} diff --git a/一键打包.py b/一键打包.py index 4e0bac54..aeeb173d 100644 --- a/一键打包.py +++ b/一键打包.py @@ -345,15 +345,13 @@ def build_spell_icon_package(build_root: Path) -> Path: raise ValueError(f"无效或缺失的法术图标: {target}") icon_paths.append(icon_path) - seen_names: set[str] = set() name_records: list[tuple[int, bytes]] = [] for spell_id, _, name in spells: - if not name or name in seen_names: + if not name: continue encoded = name.encode("utf-8") if len(encoded) > 4096: raise ValueError(f"法术名称异常过长: {spell_id}") - seen_names.add(name) name_records.append((spell_id, encoded)) spell_map_offset = SPELL_ICON_PACKAGE_HEADER.size