Forge
csharpdeeb25a2
1#nullable enable
2using Avalonia.Input;
3using Avalonia.Threading;
4using CascadeIDE.Models.Shell;
5using CascadeIDE.Services;
6
7namespace CascadeIDE.Features.Shell.Application;
8
9/// <summary>
10/// Сессия аккорда ADR 0060: фаза, хвост мелодии, таймер и разбор клавиш до вызова исполнения команды.
11/// </summary>
12public sealed class CascadeChordIntentSession
13{
14 /// <summary>Таймаут ожидания следующей клавиши без Ctrl/Alt (секунды; лампа и оверлей).</summary>
15 public const double TimeoutSeconds = 8;
16
17 private enum Phase
18 {
19 Idle,
20 /// <summary>После корня — набор хвоста как у <c>c:</c> в палитре (включая параметрические <c>wai:</c>, <c>els:</c>…).</summary>
21 AwaitMelodyTail
22 }
23
24 private readonly Action _notifyOverlayProperties;
25 private readonly Func<string?> _getCurrentFilePath;
26 private readonly Func<string?> _getEditorText;
27 private readonly Func<string, string?, Task> _executeChordCommandAsync;
28
29 private Phase _phase;
30 /// <summary>Нормализованный хвост мелодии (нижний регистр), как после <c>c:</c>.</summary>
31 private string _melodyTail = "";
32 private DateTimeOffset _deadline = DateTimeOffset.MinValue;
33 private DispatcherTimer? _timer;
34
35 /// <summary>Пользователь закрыл выпадающий список (light dismiss).</summary>
36 private bool _dropdownUserDismissed;
37
38 public CascadeChordIntentSession(
39 Action notifyOverlayProperties,
40 Func<string?> getCurrentFilePath,
41 Func<string?> getEditorText,
42 Func<string, string?, Task> executeChordCommandAsync)
43 {
44 _notifyOverlayProperties = notifyOverlayProperties;
45 _getCurrentFilePath = getCurrentFilePath;
46 _getEditorText = getEditorText;
47 _executeChordCommandAsync = executeChordCommandAsync;
48 }
49
50 private const int MaxDropdownItems = 25;
51
52 public bool IsOverlayVisible => _phase != Phase.Idle;
53
54 public string OverlayHintText => BuildOverlayHint();
55
56 public IReadOnlyList<CascadeChordOverlaySuggestion> OverlaySuggestions =>
57 CascadeChordPresentationProjection.BuildSuggestionRows(
58 _phase == Phase.AwaitMelodyTail,
59 _melodyTail,
60 MaxDropdownItems);
61
62 public bool IsDropdownOpen =>
63 _phase == Phase.AwaitMelodyTail
64 && !_dropdownUserDismissed
65 && !string.IsNullOrEmpty(_melodyTail)
66 && (CascadeChordPresentationProjection.FilterEligibleMatches(_melodyTail).Count > 0
67 || OverlayNoMatches
68 || ParametricIntentMelody.IsParametricChordTailPrefix(_melodyTail));
69
70 public bool HasDropdownItems =>
71 _phase == Phase.AwaitMelodyTail && OverlaySuggestions.Count > 0;
72
73 public string HudWatermark =>
74 IsOverlayVisible
75 ? "мелодия (как после c:)"
76 : "Ctrl+K — команда по мелодии";
77
78 public string OverlayInputText =>
79 _phase == Phase.AwaitMelodyTail
80 ? _melodyTail
81 : "";
82
83 public string HudMelodyTextGet() =>
84 _phase == Phase.AwaitMelodyTail ? _melodyTail : "";
85
86 public string OverlayFindBarLine =>
87 _phase != Phase.AwaitMelodyTail
88 ? ""
89 : string.IsNullOrEmpty(_melodyTail)
90 ? "мелодия (как после c:) — полоска под тулбаром"
91 : _melodyTail;
92
93 public double OverlayFindBarOpacity =>
94 _phase == Phase.AwaitMelodyTail && string.IsNullOrEmpty(_melodyTail)
95 ? 0.72
96 : 1.0;
97
98 public string OverlayCompactFooter =>
99 "Esc — отмена · / — Command Line · таймаут " + (int)TimeoutSeconds + " с · Ctrl+Q — палитра";
100
101 public bool OverlayNoMatches =>
102 _phase == Phase.AwaitMelodyTail
103 && !string.IsNullOrEmpty(_melodyTail)
104 && CascadeChordPresentationProjection.FilterEligibleMatches(_melodyTail).Count == 0
105 && !ParametricIntentMelody.IsParametricChordTailPrefix(_melodyTail);
106
107 public string CommandArmedLampToolTip =>
108 !IsOverlayVisible
109 ? "Command: в покое. Ctrl+K — режим armed (CascadeChord)."
110 : "Command (armed) — ввод по аккорду; транспорт CascadeChord (Ctrl+K).\n\n" + BuildOverlayHint();
111
112 private string BuildOverlayHint()
113 {
114 var matches = CascadeChordPresentationProjection.FilterEligibleMatches(_melodyTail);
115 return CascadeChordPresentationProjection.BuildOverlayHint(
116 _phase == Phase.AwaitMelodyTail,
117 _melodyTail,
118 (int)TimeoutSeconds,
119 matches);
120 }
121
122 private void EnsureTimer()
123 {
124 if (_timer is not null)
125 return;
126 _timer = new DispatcherTimer
127 {
128 Interval = TimeSpan.FromSeconds(TimeoutSeconds)
129 };
130 _timer.Tick += (_, _) =>
131 {
132 _timer.Stop();
133 if (_phase == Phase.Idle)
134 return;
135 EndIdle();
136 };
137 }
138
139 private void RestartTimer()
140 {
141 EnsureTimer();
142 _timer!.Stop();
143 _timer.Start();
144 }
145
146 private void StopTimer() => _timer?.Stop();
147
148 public void NotifyDropdownDismissed()
149 {
150 _dropdownUserDismissed = true;
151 _notifyOverlayProperties();
152 }
153
154 public void PickSuggestion(CascadeChordOverlaySuggestion? item)
155 {
156 if (item is null || _phase != Phase.AwaitMelodyTail)
157 return;
158 ApplyMelodyTail(item.Alias);
159 }
160
161 public void Cancel() => EndIdle();
162
163 /// <summary>Enter из поля HUD (view).</summary>
164 public void OnHudEnterFromView() => CommitEnterFromMelodyInput();
165
166 private void CommitEnterFromMelodyInput()
167 {
168 if (_phase != Phase.AwaitMelodyTail)
169 return;
170
171 var melody = _melodyTail;
172
173 if (ParametricIntentMelody.TryResolveParametricExecution(
174 melody,
175 _getCurrentFilePath(),
176 _getEditorText() ?? "",
177 out var paramCmdId,
178 out var paramArgsJson,
179 out _))
180 _ = ExecuteCommandFireAndForgetAsync(paramCmdId, paramArgsJson);
181 else if (IntentMelodyAliases.TryResolveExactCommandId(melody) is { } plain &&
182 !ParametricIntentMelody.IsParametricMelodyBaseAlias(melody))
183 _ = ExecuteCommandFireAndForgetAsync(plain, null);
184
185 EndIdle();
186 }
187
188 private void ApplyMelodyTail(string newTail)
189 {
190 _dropdownUserDismissed = false;
191 var matches = CascadeChordPresentationProjection.FilterEligibleMatches(newTail);
192 var exact = matches.FirstOrDefault(m => string.Equals(m.Alias, newTail, StringComparison.Ordinal));
193 var hasLongerAlias = IntentMelodyAliases.HasStrictLongerAliasPrefix(newTail);
194 if (exact.CommandId != null &&
195 !hasLongerAlias &&
196 newTail.Length > 0 &&
197 !ParametricIntentMelody.ChordDefersInstantExecuteForExactAlias(exact.Alias))
198 {
199 _melodyTail = newTail;
200 _ = ExecuteCommandFireAndForgetAsync(exact.CommandId, null);
201 EndIdle();
202 return;
203 }
204
205 if (matches.Count == 0 && newTail.Length > 0)
206 {
207 if (!ParametricIntentMelody.IsParametricChordTailPrefix(newTail))
208 {
209 EndIdle();
210 return;
211 }
212
213 _melodyTail = newTail;
214 _deadline = DateTimeOffset.UtcNow.AddSeconds(TimeoutSeconds);
215 RestartTimer();
216 _notifyOverlayProperties();
217 return;
218 }
219
220 _melodyTail = newTail;
221 _deadline = DateTimeOffset.UtcNow.AddSeconds(TimeoutSeconds);
222 RestartTimer();
223 _notifyOverlayProperties();
224 }
225
226 /// <summary>Корень аккорда: фаза ожидания мелодии; ввод — через tunnel окна (зеркало HUD не забирает фокус).</summary>
227 public void BeginRoot()
228 {
229 _phase = Phase.AwaitMelodyTail;
230 _melodyTail = "";
231 _dropdownUserDismissed = false;
232 _deadline = DateTimeOffset.UtcNow.AddSeconds(TimeoutSeconds);
233 RestartTimer();
234 _notifyOverlayProperties();
235 }
236
237 /// <summary>
238 /// Обрабатывает аккорд Cascade: возвращает <see langword="true"/>, если событие поглощено (в т.ч. корень).
239 /// </summary>
240 public bool TryConsumeKeyDown(KeyEventArgs e)
241 {
242 var map = MainWindowHotkeyService.GetMergedMap();
243 var rootGesture = CascadeChordHotkey.ResolveRootGesture(map);
244
245 if (CascadeChordHotkey.RootGestureMatches(rootGesture, e))
246 {
247 BeginRoot();
248 e.Handled = true;
249 return true;
250 }
251
252 if (_phase == Phase.Idle)
253 return false;
254
255 if (DateTimeOffset.UtcNow > _deadline)
256 {
257 EndIdle();
258 return false;
259 }
260
261 if (e.Key == Key.Escape)
262 {
263 EndIdle();
264 e.Handled = true;
265 return true;
266 }
267
268 var mods = e.KeyModifiers;
269 if (mods.HasFlag(KeyModifiers.Control) || mods.HasFlag(KeyModifiers.Alt) || mods.HasFlag(KeyModifiers.Meta))
270 {
271 EndIdle();
272 return false;
273 }
274
275 if (_phase == Phase.AwaitMelodyTail)
276 return HandleMelodyKeyDown(e);
277
278 return false;
279 }
280
281 private bool HandleMelodyKeyDown(KeyEventArgs e)
282 {
283 if (e.Key == Key.Enter)
284 {
285 CommitEnterFromMelodyInput();
286 e.Handled = true;
287 return true;
288 }
289
290 if (e.Key == Key.Back)
291 {
292 if (_melodyTail.Length > 0)
293 ApplyMelodyTail(_melodyTail[..^1]);
294 else
295 EndIdle();
296 e.Handled = true;
297 return true;
298 }
299
300 if (!CascadeChordMelodyKeyMap.TryMapChordMelodyGlyph(e.Key, e.KeyModifiers, e.PhysicalKey, out var ch))
301 {
302 EndIdle();
303 e.Handled = true;
304 return true;
305 }
306
307 if (_melodyTail.Length == 0 && ch == '/')
308 {
309 _ = ExecuteCommandFireAndForgetAsync(IdeCommands.CockpitOpenCommandLine, """{"initial_text":"/"}""");
310 EndIdle();
311 e.Handled = true;
312 return true;
313 }
314
315 var newTail = _melodyTail + char.ToLowerInvariant(ch);
316 ApplyMelodyTail(newTail);
317 e.Handled = true;
318 return true;
319 }
320
321 public void EndIdle()
322 {
323 _phase = Phase.Idle;
324 _melodyTail = "";
325 _dropdownUserDismissed = false;
326 StopTimer();
327 _notifyOverlayProperties();
328 }
329
330 private async Task ExecuteCommandFireAndForgetAsync(string commandId, string? argsJson)
331 {
332 try
333 {
334 await _executeChordCommandAsync(commandId, argsJson).ConfigureAwait(false);
335 }
336 catch
337 {
338 // Исключения из хендлеров команд логируются внутри исполнителя; аккорд уже сброшен.
339 }
340 }
341}
342
View only · write via MCP/CIDE