diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index fd535f21a6e..3df742125ad 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -59,6 +59,20 @@ public void Save() _storage.Save(); } + private bool _enableVimMode = false; + public bool EnableVimMode + { + get => _enableVimMode; + set + { + if (_enableVimMode != value) + { + _enableVimMode = value; + OnPropertyChanged(); + } + } + } + public string Hotkey { get; set; } = $"{KeyConstant.Alt} + {KeyConstant.Space}"; private string _openResultModifiers = KeyConstant.Alt; diff --git a/Flow.Launcher.Test/VimEngineTest.cs b/Flow.Launcher.Test/VimEngineTest.cs new file mode 100644 index 00000000000..062eed22887 --- /dev/null +++ b/Flow.Launcher.Test/VimEngineTest.cs @@ -0,0 +1,172 @@ +using NUnit.Framework; +using System.Collections.Generic; +using Flow.Launcher.VimMode; + +namespace Flow.Launcher.Test +{ + [TestFixture] + public class VimEngineTest + { + [Test] + public void DefaultModeIsInsert() + { + var engine = new VimEngine(); + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.Insert)); + } + + [Test] + public void SwitchToInsertFromNormalFiresEvent() + { + var engine = new VimEngine(); + engine.SwitchToNormal(); + var modes = new List(); + engine.ModeChanged += m => modes.Add(m); + + engine.SwitchToInsert(); + + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.Insert)); + Assert.That(modes, Is.EqualTo(new[] { VimModeType.Insert })); + } + + [Test] + public void SwitchToInsertWhenAlreadyInsertDoesNotFire() + { + var engine = new VimEngine(); + var fired = false; + engine.ModeChanged += _ => fired = true; + + engine.SwitchToInsert(); + + Assert.That(fired, Is.False); + } + + [Test] + public void SwitchToNormalFiresEvent() + { + var engine = new VimEngine(); + var modes = new List(); + engine.ModeChanged += m => modes.Add(m); + + engine.SwitchToNormal(); + + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.Normal)); + Assert.That(modes, Is.EqualTo(new[] { VimModeType.Normal })); + } + + [Test] + public void SwitchToNormalWhenAlreadyNormalDoesNotFire() + { + var engine = new VimEngine(); + engine.SwitchToNormal(); + var fired = false; + engine.ModeChanged += _ => fired = true; + + engine.SwitchToNormal(); + + Assert.That(fired, Is.False); + } + + [Test] + public void SwitchToVisualFiresEvent() + { + var engine = new VimEngine(); + engine.SwitchToNormal(); + var modes = new List(); + engine.ModeChanged += m => modes.Add(m); + + engine.SwitchToVisual(); + + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.Visual)); + Assert.That(modes, Is.EqualTo(new[] { VimModeType.Visual })); + } + + [Test] + public void SwitchToVisualWhenAlreadyVisualDoesNotFire() + { + var engine = new VimEngine(); + engine.SwitchToVisual(); + var fired = false; + engine.ModeChanged += _ => fired = true; + + engine.SwitchToVisual(); + + Assert.That(fired, Is.False); + } + + [Test] + public void SwitchToVisualLineFiresEvent() + { + var engine = new VimEngine(); + engine.SwitchToVisual(); + var modes = new List(); + engine.ModeChanged += m => modes.Add(m); + + engine.SwitchToVisualLine(); + + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.VisualLine)); + Assert.That(modes, Is.EqualTo(new[] { VimModeType.VisualLine })); + } + + [Test] + public void SwitchToVisualLineWhenAlreadyDoesNotFire() + { + var engine = new VimEngine(); + engine.SwitchToVisualLine(); + var fired = false; + engine.ModeChanged += _ => fired = true; + + engine.SwitchToVisualLine(); + + Assert.That(fired, Is.False); + } + + [Test] + public void FullModeCycleTest() + { + var engine = new VimEngine(); + var modes = new List(); + engine.ModeChanged += m => modes.Add(m); + + engine.SwitchToNormal(); + engine.SwitchToVisual(); + engine.SwitchToNormal(); + engine.SwitchToInsert(); + + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.Insert)); + Assert.That(modes, Is.EqualTo(new[] { + VimModeType.Normal, VimModeType.Visual, VimModeType.Normal, VimModeType.Insert + })); + } + + [Test] + public void VisualLineCycleTest() + { + var engine = new VimEngine(); + var modes = new List(); + engine.ModeChanged += m => modes.Add(m); + + engine.SwitchToNormal(); + engine.SwitchToVisualLine(); + engine.SwitchToVisual(); + engine.SwitchToVisualLine(); + engine.SwitchToNormal(); + engine.SwitchToInsert(); + + Assert.That(engine.CurrentMode, Is.EqualTo(VimModeType.Insert)); + Assert.That(modes, Is.EqualTo(new[] { + VimModeType.Normal, VimModeType.VisualLine, VimModeType.Visual, + VimModeType.VisualLine, VimModeType.Normal, VimModeType.Insert + })); + } + + [Test] + public void NoSubscriberDoesNotThrow() + { + var engine = new VimEngine(); + Assert.DoesNotThrow(() => engine.SwitchToNormal()); + Assert.DoesNotThrow(() => engine.SwitchToVisual()); + Assert.DoesNotThrow(() => engine.SwitchToVisualLine()); + Assert.DoesNotThrow(() => engine.SwitchToInsert()); + } + } +} diff --git a/Flow.Launcher.Test/VimMotionEngineTest.cs b/Flow.Launcher.Test/VimMotionEngineTest.cs new file mode 100644 index 00000000000..3b06b297fd2 --- /dev/null +++ b/Flow.Launcher.Test/VimMotionEngineTest.cs @@ -0,0 +1,227 @@ +using NUnit.Framework; +using Flow.Launcher.VimMode; + +namespace Flow.Launcher.Test +{ + [TestFixture] + public class VimMotionEngineTest + { + private const string Sample = "hello world_foo bar"; + + [Test] + public void MoveLeftTest() + { + Assert.That(VimMotionEngine.MoveLeft(5), Is.EqualTo(4)); + Assert.That(VimMotionEngine.MoveLeft(0), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MoveLeft(2), Is.EqualTo(1)); + } + + [Test] + public void MoveRightTest() + { + Assert.That(VimMotionEngine.MoveRight(5, Sample.Length), Is.EqualTo(6)); + Assert.That(VimMotionEngine.MoveRight(Sample.Length, Sample.Length), Is.EqualTo(Sample.Length)); + Assert.That(VimMotionEngine.MoveRight(Sample.Length - 1, Sample.Length), Is.EqualTo(Sample.Length)); + } + + [Test] + public void MoveStartOfLineTest() + { + Assert.That(VimMotionEngine.MoveStartOfLine(), Is.EqualTo(0)); + } + + [Test] + public void MoveEndOfLineTest() + { + Assert.That(VimMotionEngine.MoveEndOfLine(Sample.Length), Is.EqualTo(Sample.Length)); + Assert.That(VimMotionEngine.MoveEndOfLine(0), Is.EqualTo(0)); + } + + [Test] + public void MoveFirstNonBlankTest() + { + Assert.That(VimMotionEngine.MoveFirstNonBlank(" hello"), Is.EqualTo(3)); + Assert.That(VimMotionEngine.MoveFirstNonBlank("hello"), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MoveFirstNonBlank(" "), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MoveFirstNonBlank(""), Is.EqualTo(0)); + } + + [Test] + public void MoveNextWordTest() + { + Assert.That(VimMotionEngine.MoveNextWord(Sample, 0), Is.EqualTo(6)); + Assert.That(VimMotionEngine.MoveNextWord(Sample, 6), Is.EqualTo(16)); + Assert.That(VimMotionEngine.MoveNextWord(Sample, Sample.Length), Is.EqualTo(Sample.Length)); + Assert.That(VimMotionEngine.MoveNextWord(" abc", 0), Is.EqualTo(3)); + } + + [Test] + public void MovePrevWordTest() + { + Assert.That(VimMotionEngine.MovePrevWord(Sample, 6), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MovePrevWord("hello world", 8), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MovePrevWord(Sample, 0), Is.EqualTo(0)); + } + + [Test] + public void MoveEndWordTest() + { + Assert.That(VimMotionEngine.MoveEndWord(Sample, 0), Is.EqualTo(4)); + Assert.That(VimMotionEngine.MoveEndWord(Sample, 5), Is.EqualTo(14)); + Assert.That(VimMotionEngine.MoveEndWord(Sample, Sample.Length - 1), Is.EqualTo(Sample.Length - 1)); + } + + [Test] + public void FindCharForwardTest() + { + Assert.That(VimMotionEngine.FindCharForward(Sample, 0, 'o', false), Is.EqualTo(4)); + Assert.That(VimMotionEngine.FindCharForward(Sample, 0, 'o', till: true), Is.EqualTo(3)); + Assert.That(VimMotionEngine.FindCharForward(Sample, 0, 'z', false), Is.EqualTo(0)); + Assert.That(VimMotionEngine.FindCharForward(Sample, Sample.Length - 1, 'o', false), Is.EqualTo(Sample.Length - 1)); + Assert.That(VimMotionEngine.FindCharForward(Sample, 4, 'o', false), Is.EqualTo(7)); + } + + [Test] + public void FindCharBackwardTest() + { + Assert.That(VimMotionEngine.FindCharBackward(Sample, 7, 'o', false), Is.EqualTo(4)); + Assert.That(VimMotionEngine.FindCharBackward(Sample, 7, 'o', till: true), Is.EqualTo(5)); + Assert.That(VimMotionEngine.FindCharBackward(Sample, Sample.Length - 1, 'z', false), Is.EqualTo(Sample.Length - 1)); + Assert.That(VimMotionEngine.FindCharBackward(Sample, 0, 'o', false), Is.EqualTo(0)); + Assert.That(VimMotionEngine.FindCharBackward(Sample, 4, 'o', false), Is.EqualTo(4)); + } + + [Test] + public void MoveEndWordEmptyOrEndDoesNotReturnNegative() + { + Assert.That(VimMotionEngine.MoveEndWord("", 0), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MoveEndWordBig("", 0), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MoveEndWord("a", 0), Is.EqualTo(0)); + } + + [Test] + public void MoveLastNonBlankTest() + { + Assert.That(VimMotionEngine.MoveLastNonBlank("hello "), Is.EqualTo(5)); + Assert.That(VimMotionEngine.MoveLastNonBlank("hello"), Is.EqualTo(5)); + Assert.That(VimMotionEngine.MoveLastNonBlank(" "), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MoveLastNonBlank(""), Is.EqualTo(0)); + } + + [Test] + public void MoveNextWordBigTest() + { + Assert.That(VimMotionEngine.MoveNextWordBig("foo bar baz", 0), Is.EqualTo(4)); + Assert.That(VimMotionEngine.MoveNextWordBig("foo bar baz", 4), Is.EqualTo(8)); + // Punctuation is part of a BIG word (unlike w). + Assert.That(VimMotionEngine.MoveNextWordBig("foo.bar baz", 0), Is.EqualTo(8)); + Assert.That(VimMotionEngine.MoveNextWordBig("foo", 3), Is.EqualTo(3)); + } + + [Test] + public void MovePrevWordBigTest() + { + Assert.That(VimMotionEngine.MovePrevWordBig("foo bar baz", 8), Is.EqualTo(4)); + Assert.That(VimMotionEngine.MovePrevWordBig("foo bar", 4), Is.EqualTo(0)); + Assert.That(VimMotionEngine.MovePrevWordBig("foo", 0), Is.EqualTo(0)); + } + + [Test] + public void MoveEndWordBigTest() + { + Assert.That(VimMotionEngine.MoveEndWordBig("foo bar", 0), Is.EqualTo(2)); + Assert.That(VimMotionEngine.MoveEndWordBig("foo bar", 2), Is.EqualTo(6)); + } + + [Test] + public void MoveToMatchingBracketTest() + { + Assert.That(VimMotionEngine.MoveToMatchingBracket("a(bc)d", 1), Is.EqualTo(4)); + Assert.That(VimMotionEngine.MoveToMatchingBracket("a(bc)d", 4), Is.EqualTo(1)); + Assert.That(VimMotionEngine.MoveToMatchingBracket("(a(b)c)", 0), Is.EqualTo(6)); + // No bracket under the caret: stay put. + Assert.That(VimMotionEngine.MoveToMatchingBracket("abc", 1), Is.EqualTo(1)); + } + + [Test] + public void TextObjectWordTest() + { + Assert.That(VimMotionEngine.TextObjectWord("foo bar", 1, false), Is.EqualTo((0, 2))); + // aw includes the trailing whitespace. + Assert.That(VimMotionEngine.TextObjectWord("foo bar", 1, true), Is.EqualTo((0, 3))); + Assert.That(VimMotionEngine.TextObjectWord("foo bar", 5, false), Is.EqualTo((4, 6))); + // No trailing whitespace at end of string -> aw extends over leading whitespace instead. + Assert.That(VimMotionEngine.TextObjectWord("foo bar", 5, true), Is.EqualTo((3, 6))); + Assert.That(VimMotionEngine.TextObjectWord("", 0, false), Is.EqualTo((0, 0))); + } + + [Test] + public void TextObjectQuoteTest() + { + Assert.That(VimMotionEngine.TextObjectQuote("say \"hi\" now", 5, '"', false), Is.EqualTo((5, 6))); + Assert.That(VimMotionEngine.TextObjectQuote("say \"hi\" now", 5, '"', true), Is.EqualTo((4, 7))); + Assert.That(VimMotionEngine.TextObjectQuote("hello", 1, '"', false), Is.EqualTo((-1, -1))); + // caret past the last char (CaretIndex == text.Length) clamps onto the closing quote + Assert.That(VimMotionEngine.TextObjectQuote("say \"hi\"", 8, '"', false), Is.EqualTo((5, 6))); + Assert.That(VimMotionEngine.TextObjectQuote("say \"hi\"", 8, '"', true), Is.EqualTo((4, 7))); + Assert.That(VimMotionEngine.TextObjectQuote("", 0, '"', false), Is.EqualTo((-1, -1))); + } + + [Test] + public void ChangeNumberTest() + { + // Ctrl-A / Ctrl-X on the number at or to the right of the caret. + Assert.That(VimMotionEngine.ChangeNumber("port 8080", 0, 1), Is.EqualTo((true, "port 8081", 8))); + Assert.That(VimMotionEngine.ChangeNumber("v1", 0, 1), Is.EqualTo((true, "v2", 1))); + Assert.That(VimMotionEngine.ChangeNumber("9", 0, 1), Is.EqualTo((true, "10", 1))); + Assert.That(VimMotionEngine.ChangeNumber("5", 0, -1), Is.EqualTo((true, "4", 0))); + Assert.That(VimMotionEngine.ChangeNumber("count -5 x", 0, 1), Is.EqualTo((true, "count -4 x", 7))); + Assert.That(VimMotionEngine.ChangeNumber("abc", 0, 1).found, Is.False); + } + + [Test] + public void TextObjectDelimitedTest() + { + Assert.That(VimMotionEngine.TextObjectDelimited("f(a, b)", 3, '(', ')', false), Is.EqualTo((2, 5))); + Assert.That(VimMotionEngine.TextObjectDelimited("f(a, b)", 3, '(', ')', true), Is.EqualTo((1, 6))); + // Nested: caret inside the inner pair selects the inner pair. + Assert.That(VimMotionEngine.TextObjectDelimited("(a(b)c)", 3, '(', ')', false), Is.EqualTo((3, 3))); + Assert.That(VimMotionEngine.TextObjectDelimited("(a(b)c)", 3, '(', ')', true), Is.EqualTo((2, 4))); + Assert.That(VimMotionEngine.TextObjectDelimited("abc", 1, '(', ')', false), Is.EqualTo((-1, -1))); + } + + [Test] + public void OperatorRangeExclusiveTest() + { + // dw etc.: half-open, destination not included, in either direction. + Assert.That(VimMotionEngine.OperatorRange(0, 3, MotionInclusivity.Exclusive, 10), Is.EqualTo((0, 3))); + Assert.That(VimMotionEngine.OperatorRange(5, 2, MotionInclusivity.Exclusive, 10), Is.EqualTo((2, 5))); + } + + [Test] + public void OperatorRangeInclusiveForwardTest() + { + // de / df: forward motion consumes the destination character. + Assert.That(VimMotionEngine.OperatorRange(0, 3, MotionInclusivity.InclusiveForward, 10), Is.EqualTo((0, 4))); + // At the last index it still extends to the end of the text. + Assert.That(VimMotionEngine.OperatorRange(0, 9, MotionInclusivity.InclusiveForward, 10), Is.EqualTo((0, 10))); + // Never extends past the text length. + Assert.That(VimMotionEngine.OperatorRange(0, 10, MotionInclusivity.InclusiveForward, 10), Is.EqualTo((0, 10))); + // Backward find (F/T): the original caret char is NOT consumed. + Assert.That(VimMotionEngine.OperatorRange(5, 2, MotionInclusivity.InclusiveForward, 10), Is.EqualTo((2, 5))); + // Failed find (target == caret): empty range, nothing deleted. + Assert.That(VimMotionEngine.OperatorRange(5, 5, MotionInclusivity.InclusiveForward, 10), Is.EqualTo((5, 5))); + } + + [Test] + public void OperatorRangeInclusivePairTest() + { + // d% with caret on '(': forward, both brackets included. + Assert.That(VimMotionEngine.OperatorRange(1, 4, MotionInclusivity.InclusivePair, 6), Is.EqualTo((1, 5))); + // d% with caret on ')': backward, the far-end (the ')') is still included. + Assert.That(VimMotionEngine.OperatorRange(4, 0, MotionInclusivity.InclusivePair, 6), Is.EqualTo((0, 5))); + // No matching bracket (target == caret): empty range, nothing deleted. + Assert.That(VimMotionEngine.OperatorRange(3, 3, MotionInclusivity.InclusivePair, 10), Is.EqualTo((3, 3))); + } + } +} diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index dae6ee81b15..4df51e85b76 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -83,6 +83,8 @@ Hide Flow Launcher when focus is lost Show taskbar when Flow Launcher is opened Temporarily show the taskbar when Flow Launcher is opened, useful for auto-hidden taskbars. + Enable Advanced Vim Mode + Enables terminal-style inline Vim keybindings (Insert, Normal, Visual, and Visual Line modes, motions, and clipboard operations) directly within the search bar. Do not show new version notifications Search Window Location Remember Last Position diff --git a/Flow.Launcher/MainWindow.xaml b/Flow.Launcher/MainWindow.xaml index 2950d2f6555..0abdbff3a82 100644 --- a/Flow.Launcher/MainWindow.xaml +++ b/Flow.Launcher/MainWindow.xaml @@ -350,6 +350,34 @@ X2="0" Y1="0" Y2="0" /> + + + diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index 4866c0deae6..5d0809ea0e4 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -58,6 +58,7 @@ public partial class MainWindow : IDisposable // Window Context Menu private readonly ContextMenu _contextMenu = new(); private readonly MainViewModel _viewModel; + private readonly Flow.Launcher.VimMode.VimManager _vimManager; // Window Event: Key Event private bool _isArrowKeyPressed = false; @@ -96,6 +97,8 @@ public MainWindow() InitializeComponent(); UpdatePosition(); + _vimManager = new Flow.Launcher.VimMode.VimManager(this, _viewModel, QueryTextBox, VimBlockCaret, VimModeIndicator, _settings); + SyncSoundEffectsState(); RegisterSoundEffectsEvent(); DataObject.AddPastingHandler(QueryTextBox, QueryTextBox_OnPaste); @@ -439,6 +442,12 @@ private async void OnDeactivated(object sender, EventArgs e) private void OnKeyDown(object sender, KeyEventArgs e) { var specialKeyState = GlobalHotkey.CheckModifiers(); + + if (_vimManager != null && _vimManager.HandlePreviewKeyDown(e)) + { + return; + } + switch (e.Key) { case Key.Down: @@ -1421,7 +1430,7 @@ private void QueryTextBox_KeyUp(object sender, KeyEventArgs e) if (_viewModel.QueryText != QueryTextBox.Text) { BindingExpression be = QueryTextBox.GetBindingExpression(TextBox.TextProperty); - be.UpdateSource(); + be?.UpdateSource(); } } @@ -1546,6 +1555,7 @@ protected virtual void Dispose(bool disposing) UnregisterSoundEffectsEvent(); DisposeSoundEffects(); _viewModel.ActualApplicationThemeChanged -= ViewModel_ActualApplicationThemeChanged; + _vimManager?.Dispose(); } _disposed = true; diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index 5779071534f..ec8b5328383 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -82,6 +82,16 @@ OnContent="{DynamicResource enable}" /> + + + + .` re-deletes a word without re-typing `foo`. +- **Single line** — the query is a single line, so line-wise commands (`dd`, `cc`, `V`, `0`/`$`) act on the + whole query and `gg`/`G` are not bound. diff --git a/Flow.Launcher/VimMode/VimEngine.cs b/Flow.Launcher/VimMode/VimEngine.cs new file mode 100644 index 00000000000..80fde6274ad --- /dev/null +++ b/Flow.Launcher/VimMode/VimEngine.cs @@ -0,0 +1,59 @@ +using System; + +namespace Flow.Launcher.VimMode +{ + /// + /// Represents the different states of the Vim engine. + /// + public enum VimModeType + { + Insert, + Normal, + Visual, + VisualLine + } + + /// + /// Core state machine for managing Vim modes and transitions. + /// + public class VimEngine + { + /// + /// Gets the current Vim mode. + /// + public VimModeType CurrentMode { get; private set; } = VimModeType.Insert; + + /// + /// Event fired whenever the Vim mode changes. + /// + public event Action ModeChanged; + + private void SetMode(VimModeType newMode) + { + var oldMode = CurrentMode; + CurrentMode = newMode; + if (oldMode != newMode) + ModeChanged?.Invoke(CurrentMode); + } + + /// + /// Switches the engine to Insert mode. + /// + public void SwitchToInsert() => SetMode(VimModeType.Insert); + + /// + /// Switches the engine to Normal mode. + /// + public void SwitchToNormal() => SetMode(VimModeType.Normal); + + /// + /// Switches the engine to Visual mode. + /// + public void SwitchToVisual() => SetMode(VimModeType.Visual); + + /// + /// Switches the engine to Visual Line mode. + /// + public void SwitchToVisualLine() => SetMode(VimModeType.VisualLine); + } +} diff --git a/Flow.Launcher/VimMode/VimManager.cs b/Flow.Launcher/VimMode/VimManager.cs new file mode 100644 index 00000000000..64237c65f62 --- /dev/null +++ b/Flow.Launcher/VimMode/VimManager.cs @@ -0,0 +1,1606 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using Flow.Launcher.ViewModel; + +namespace Flow.Launcher.VimMode +{ + /// + /// Manages the integration of Vim keybindings and UI overlays into the application window. + /// + public class VimManager : IDisposable + { + private bool _disposed; + private readonly MainWindow _mainWindow; + private readonly MainViewModel _viewModel; + private readonly TextBox _queryTextBox; + + private static void SetClipboardText(string text) + { + if (!string.IsNullOrEmpty(text)) + { + try { Clipboard.SetText(text); } catch (Exception ex) { Flow.Launcher.Infrastructure.Logger.Log.Exception("VimManager", "Clipboard operation failed", ex); } + } + } + + private static System.Windows.Media.SolidColorBrush CreateBrush(byte r, byte g, byte b) + { + var brush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(r, g, b)); + brush.Freeze(); + return brush; + } + private readonly VimEngine _vimEngine; + private readonly System.Windows.Shapes.Rectangle _vimBlockCaret; + private readonly Border _vimModeIndicator; + private string _pendingCommand = ""; + private string _awaitingCharCommand = ""; + private string _lastFindCmd = ""; + private char _lastFindChar = '\0'; + private DateTime _lastEscapeTime = DateTime.MinValue; + private int _visualAnchor; + private int _visualCaret; + private int _count; + private bool _gPending; + private string _awaitingTextObject = ""; + private (int anchor, int caret)? _lastVisualRange; + private string _lastChange = ""; + private int _lastChangeLen = 0; // chars affected by last change (for . repeat) + private char _lastReplaceChar = '\0'; // char used in last r{char} (for . repeat) + private readonly Flow.Launcher.Infrastructure.UserSettings.Settings _settings; + + // Vim-style operation-level undo/redo stacks + private readonly System.Collections.Generic.Stack<(string text, int caretIndex)> _undoStack = new(); + private readonly System.Collections.Generic.Stack<(string text, int caretIndex)> _redoStack = new(); + + /// Snapshots the current text+caret state onto the undo stack and clears the redo stack. + private void PushUndo() + { + _undoStack.Push((_queryTextBox.Text, _queryTextBox.CaretIndex)); + _redoStack.Clear(); + } + + private void VimUndo() + { + if (_undoStack.Count == 0) return; + _redoStack.Push((_queryTextBox.Text, _queryTextBox.CaretIndex)); + var (text, caret) = _undoStack.Pop(); + _queryTextBox.SetCurrentValue(System.Windows.Controls.TextBox.TextProperty, text); + _queryTextBox.CaretIndex = Math.Min(caret, text.Length); + } + + private void VimRedo() + { + if (_redoStack.Count == 0) return; + _undoStack.Push((_queryTextBox.Text, _queryTextBox.CaretIndex)); + var (text, caret) = _redoStack.Pop(); + _queryTextBox.SetCurrentValue(System.Windows.Controls.TextBox.TextProperty, text); + _queryTextBox.CaretIndex = Math.Min(caret, text.Length); + } + + /// + /// Applies a text mutation: snapshots the current state onto the undo stack, + /// then sets the TextBox text. All text-mutating Vim commands must use this + /// instead of calling SetCurrentValue directly. + /// + private void SetText(string newText) + { + PushUndo(); + _queryTextBox.SetCurrentValue(System.Windows.Controls.TextBox.TextProperty, newText); + } + + + /// + /// Initializes a new instance of the VimManager class. + /// + public VimManager(MainWindow mainWindow, MainViewModel viewModel, TextBox queryTextBox, System.Windows.Shapes.Rectangle vimBlockCaret, Border vimModeIndicator, Flow.Launcher.Infrastructure.UserSettings.Settings settings) + { + _mainWindow = mainWindow; + _viewModel = viewModel; + _queryTextBox = queryTextBox; + _vimBlockCaret = vimBlockCaret; + _vimModeIndicator = vimModeIndicator; + _settings = settings; + + _vimEngine = new VimEngine(); + _vimEngine.ModeChanged += VimEngine_ModeChanged; + _queryTextBox.PreviewTextInput += QueryTextBox_PreviewTextInput; + _queryTextBox.SelectionChanged += QueryTextBox_SelectionChanged; + _queryTextBox.TextChanged += QueryTextBox_TextChanged; + _mainWindow.Loaded += MainWindow_Loaded; + _viewModel.PropertyChanged += ViewModel_PropertyChanged; + _settings.PropertyChanged += OnSettingsPropertyChanged; + } + + private void OnSettingsPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // When Vim mode is turned off, return to Insert so native typing is not blocked + // by QueryTextBox_PreviewTextInput and clear any lingering overlays/state. + if (e.PropertyName == nameof(Flow.Launcher.Infrastructure.UserSettings.Settings.EnableVimMode) && !_settings.EnableVimMode) + { + _mainWindow.Dispatcher.BeginInvoke(new Action(() => + { + _queryTextBox.SelectionLength = 0; + ResetPendingState(); + _vimEngine.SwitchToInsert(); + })); + } + } + + private void MainWindow_Loaded(object sender, RoutedEventArgs e) + { + // Initial state + UpdateIndicatorAsync(_vimEngine.CurrentMode); + } + + private void QueryTextBox_SelectionChanged(object sender, RoutedEventArgs e) + { + UpdateCaretPosition(); + } + + private void QueryTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + UpdateCaretPosition(); + } + + private void UpdateCaretPosition() + { + if (_vimEngine.CurrentMode != VimModeType.Insert && _vimBlockCaret != null) + { + try + { + int index = (_vimEngine.CurrentMode == VimModeType.Visual || _vimEngine.CurrentMode == VimModeType.VisualLine) + ? _visualCaret + : _queryTextBox.CaretIndex; + var rect = _queryTextBox.GetRectFromCharacterIndex(index); + if (!rect.IsEmpty) + { + var m = _queryTextBox.Margin; + _vimBlockCaret.Margin = new Thickness(rect.Left + m.Left, rect.Top + m.Top, 0, 0); + _vimBlockCaret.Width = Math.Max(rect.Width, 8); + _vimBlockCaret.Height = rect.Height; + } + } + catch (Exception ex) { Flow.Launcher.Infrastructure.Logger.Log.Exception("VimManager", "Layout exception in UpdateCaretPosition", ex); } + } + } + + private System.Windows.Controls.Canvas _vimYankFlash; + private int _yankFlashToken; + + /// + /// Briefly highlights a just-yanked text range (like Neovim's on-yank flash) so the user gets + /// visual confirmation the yank happened. Draws one accent rectangle per line of the range on the + /// VimYankFlash canvas overlay and fades it out. Purely visual — does not touch text, caret, or + /// selection. Only call this for pure yanks (y/Y/yy/visual y), never for cuts (x/d/c/s). + /// + private void FlashYank(int start, int length) + { + if (length <= 0) return; + if (_vimYankFlash == null) + _vimYankFlash = _mainWindow.FindName("VimYankFlash") as System.Windows.Controls.Canvas; + if (_vimYankFlash == null) return; + + try + { + string text = _queryTextBox.Text; + if (start < 0 || start >= text.Length) return; + int end = Math.Min(start + length, text.Length); // exclusive + + _vimYankFlash.Children.Clear(); + var m = _queryTextBox.Margin; + var fill = (System.Windows.Media.Brush)Application.Current.TryFindResource("BasicSystemAccentColor") + ?? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0, 120, 215)); + + // Split the range into per-line segments (at '\n') and draw a rectangle for each, so a + // multi-line yank highlights every line rather than a single oversized box. + int segStart = start; + for (int i = start; i <= end; i++) + { + bool atBreak = i == end || (i < text.Length && text[i] == '\n'); + if (!atBreak) continue; + if (i > segStart) AddFlashRect(segStart, i, m, fill); + segStart = i + 1; // skip the newline + } + if (_vimYankFlash.Children.Count == 0) return; + + _yankFlashToken++; + int token = _yankFlashToken; + _vimYankFlash.Visibility = Visibility.Visible; + var anim = new System.Windows.Media.Animation.DoubleAnimation(1.0, 0.0, new Duration(TimeSpan.FromMilliseconds(300))); + anim.Completed += (_, __) => + { + if (_yankFlashToken != token) return; // a newer flash superseded this one + _vimYankFlash.Children.Clear(); + _vimYankFlash.Visibility = Visibility.Collapsed; + }; + _vimYankFlash.BeginAnimation(UIElement.OpacityProperty, anim); + } + catch (Exception ex) { Flow.Launcher.Infrastructure.Logger.Log.Exception("VimManager", "FlashYank layout exception", ex); } + } + + private void AddFlashRect(int segStart, int segEnd, Thickness m, System.Windows.Media.Brush fill) + { + var r1 = _queryTextBox.GetRectFromCharacterIndex(segStart); + var r2 = _queryTextBox.GetRectFromCharacterIndex(segEnd); // leading edge of the char past the segment + if (r1.IsEmpty || r2.IsEmpty) return; + + double left = r1.Left + m.Left; + double top = Math.Min(r1.Top, r2.Top) + m.Top; + double width = Math.Max(r2.Left - r1.Left, 2); + double height = Math.Max(r1.Bottom, r2.Bottom) - Math.Min(r1.Top, r2.Top); + + var rect = new System.Windows.Shapes.Rectangle + { + Width = width, + Height = height, + Fill = fill, + Opacity = 0.35, + RadiusX = 2, + RadiusY = 2, + IsHitTestVisible = false, + }; + System.Windows.Controls.Canvas.SetLeft(rect, left); + System.Windows.Controls.Canvas.SetTop(rect, top); + _vimYankFlash.Children.Add(rect); + } + + private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainViewModel.MainWindowVisibilityStatus)) + { + if (_viewModel.MainWindowVisibilityStatus) + { + _mainWindow.Dispatcher.BeginInvoke(new Action(() => + { + if (_vimEngine.CurrentMode != VimModeType.Insert) + { + _vimEngine.SwitchToInsert(); + _queryTextBox.SelectAll(); // select the restored query so the first keystroke replaces it (match Insert) + } + })); + } + } + } + + private void VimEngine_ModeChanged(VimModeType mode) + { + UpdateIndicatorAsync(mode); + } + + private void UpdateIndicatorAsync(VimModeType mode) + { + if (_mainWindow.Dispatcher.CheckAccess()) + { + ApplyModeUI(mode); + } + else + { + _mainWindow.Dispatcher.BeginInvoke(new Action(() => ApplyModeUI(mode))); + } + } + + private void ApplyModeUI(VimModeType mode) + { + InputMethod.SetIsInputMethodSuspended(_queryTextBox, mode != VimModeType.Insert); + + if (_vimModeIndicator != null) + { + // The mode is shown as a small color-coded dot rather than a text label + // (Normal = accent, Visual = purple, Visual Line = orange). This keeps the + // indicator from overlapping the query text or otherwise altering Flow + // Launcher's search-bar layout; a text label can be added later if desired. + _vimModeIndicator.Visibility = _settings.EnableVimMode && mode != VimModeType.Insert ? Visibility.Visible : Visibility.Collapsed; + + _vimModeIndicator.Background = mode switch + { + VimModeType.Normal => (System.Windows.Media.Brush)Application.Current.FindResource("BasicSystemAccentColor") ?? CreateBrush(0, 120, 215), + VimModeType.Visual => CreateBrush(153, 50, 204), + VimModeType.VisualLine => CreateBrush(255, 140, 0), + _ => new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Transparent) + }; + } + + if (_vimBlockCaret == null) return; + + if (mode == VimModeType.Insert) + { + _vimBlockCaret.Visibility = Visibility.Collapsed; + _queryTextBox.ClearValue(System.Windows.Controls.TextBox.CaretBrushProperty); + } + else + { + _vimBlockCaret.Visibility = Visibility.Visible; + _queryTextBox.CaretBrush = System.Windows.Media.Brushes.Transparent; + UpdateCaretPosition(); + } + } + + /// + /// Intercepts and processes key presses before they reach the main window, applying Vim bindings if enabled. + /// + /// The key event arguments. + /// True if the key was handled by Vim mode, otherwise false. + public bool HandlePreviewKeyDown(KeyEventArgs e) + { + if (!_settings.EnableVimMode) + { + if (_vimBlockCaret != null && _vimBlockCaret.Visibility == Visibility.Visible) + { + _vimBlockCaret.Visibility = Visibility.Collapsed; + } + if (_vimModeIndicator != null && _vimModeIndicator.Visibility == Visibility.Visible) + { + _vimModeIndicator.Visibility = Visibility.Collapsed; + } + _queryTextBox.ClearValue(System.Windows.Controls.TextBox.CaretBrushProperty); + InputMethod.SetIsInputMethodSuspended(_queryTextBox, false); + return false; + } + + if (_vimBlockCaret != null && _vimBlockCaret.Visibility == Visibility.Collapsed && _vimEngine.CurrentMode != VimModeType.Insert) + { + _vimBlockCaret.Visibility = Visibility.Visible; + } + + var modifiers = e.KeyboardDevice.Modifiers; + + if (modifiers.HasFlag(ModifierKeys.Control) && e.Key == Key.R && _vimEngine.CurrentMode == VimModeType.Normal) + { + VimRedo(); + e.Handled = true; + return true; + } + + // Ctrl-A / Ctrl-X increment / decrement the number at or after the cursor (count-aware). + // Normal mode only, so Insert-mode Ctrl-A (select all) is unaffected. + if (modifiers.HasFlag(ModifierKeys.Control) && !modifiers.HasFlag(ModifierKeys.Alt) + && _vimEngine.CurrentMode == VimModeType.Normal && (e.Key == Key.A || e.Key == Key.X)) + { + int delta = (e.Key == Key.A ? 1 : -1) * Math.Max(1, GetCount()); + var (found, newText, newCaret) = VimMotionEngine.ChangeNumber(_queryTextBox.Text, _queryTextBox.CaretIndex, delta); + if (found) + { + SetText(newText); + _queryTextBox.CaretIndex = Math.Min(newCaret, newText.Length); + } + e.Handled = true; + return true; + } + + if (modifiers.HasFlag(ModifierKeys.Control) || modifiers.HasFlag(ModifierKeys.Alt)) + { + return false; + } + + if (e.Key == Key.Escape && e.KeyboardDevice.Modifiers == ModifierKeys.Shift) + { + _viewModel.Hide(); + e.Handled = true; + return true; + } + + if (_vimEngine.CurrentMode == VimModeType.Insert) + { + if (e.Key == Key.Escape) + { + _vimEngine.SwitchToNormal(); + _lastEscapeTime = DateTime.Now; + e.Handled = true; + return true; + } + } + else + { + if (_vimEngine.CurrentMode == VimModeType.Visual || _vimEngine.CurrentMode == VimModeType.VisualLine) + { + if (e.Key == Key.Escape) + { + SaveVisualRange(); + _queryTextBox.SelectionLength = 0; + ResetPendingState(); + _vimEngine.SwitchToNormal(); + e.Handled = true; + return true; + } + } + + if (e.Key == Key.Escape) + { + if ((DateTime.Now - _lastEscapeTime).TotalMilliseconds < 400) + { + _lastEscapeTime = DateTime.MinValue; + return false; + } + else + { + _lastEscapeTime = DateTime.Now; + ResetPendingState(); + e.Handled = true; + return true; + } + } + + if (HandleVimKey(e)) + { + e.Handled = true; + return true; + } + } + + return false; + } + + private bool HandleVimKey(KeyEventArgs e) + { + var modifiers = e.KeyboardDevice.Modifiers; + + if (_vimEngine.CurrentMode == VimModeType.Normal || _vimEngine.CurrentMode == VimModeType.Visual) + { + if (_gPending) + { + _gPending = false; + return HandleGKey(e, modifiers); + } + + if (string.IsNullOrEmpty(_awaitingCharCommand) && string.IsNullOrEmpty(_awaitingTextObject)) + { + if (e.Key >= Key.D1 && e.Key <= Key.D9 && !modifiers.HasFlag(ModifierKeys.Shift)) + { + _count = _count * 10 + (e.Key - Key.D0); + return true; + } + if (e.Key == Key.D0 && !modifiers.HasFlag(ModifierKeys.Shift) && _count > 0) + { + _count = _count * 10; + return true; + } + } + + if (string.IsNullOrEmpty(_awaitingCharCommand) && _awaitingTextObject.Length > 0) + { + return HandleTextObjectKey(e, modifiers); + } + } + + if (_vimEngine.CurrentMode == VimModeType.Normal || _vimEngine.CurrentMode == VimModeType.Visual || _vimEngine.CurrentMode == VimModeType.VisualLine) + { + if (!string.IsNullOrEmpty(_awaitingCharCommand)) + { + char c = GetCharFromKey(e.Key, modifiers); + if (c != '\0') + { + ExecuteCharCommand(_awaitingCharCommand, c); + } + else if (e.Key == Key.Escape) + { + _awaitingCharCommand = ""; // Cancel + } + e.Handled = true; + return true; + } + } + + switch (_vimEngine.CurrentMode) + { + case VimModeType.Normal: + switch (e.Key) + { + case Key.J: + _viewModel.SelectNextItemCommand.Execute(null); + return true; + case Key.K: + _viewModel.SelectPrevItemCommand.Execute(null); + return true; + case Key.H: + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MoveLeft(i))); + return true; + case Key.L: + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MoveRight(i, _queryTextBox.Text.Length))); + return true; + case Key.W when modifiers == ModifierKeys.None: + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MoveNextWord(_queryTextBox.Text, i))); + return true; + case Key.B when modifiers == ModifierKeys.None: + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MovePrevWord(_queryTextBox.Text, i))); + return true; + case Key.E when modifiers == ModifierKeys.None: + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MoveEndWord(_queryTextBox.Text, i)), MotionInclusivity.InclusiveForward); + return true; + case Key.W when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MoveNextWordBig(_queryTextBox.Text, i))); + return true; + case Key.B when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MovePrevWordBig(_queryTextBox.Text, i))); + return true; + case Key.E when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteMotion(ApplyCountMove(i => VimMotionEngine.MoveEndWordBig(_queryTextBox.Text, i)), MotionInclusivity.InclusiveForward); + return true; + case Key.D5 when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteMotion(VimMotionEngine.MoveToMatchingBracket(_queryTextBox.Text, _queryTextBox.CaretIndex), MotionInclusivity.InclusivePair); + return true; + case Key.G: + // 'g' is the prefix for multi-key commands (gu, gU, g~, gv, g_); 'G' jumps to the end. + if (modifiers.HasFlag(ModifierKeys.Shift)) + ExecuteMotion(VimMotionEngine.MoveEndOfLine(_queryTextBox.Text.Length)); + else + _gPending = true; + return true; + case Key.D0: + if (modifiers.HasFlag(ModifierKeys.Shift)) return false; // Handle ')' normally or ignore + ExecuteMotion(VimMotionEngine.MoveStartOfLine()); + return true; + case Key.D6: + if (modifiers.HasFlag(ModifierKeys.Shift)) // ^ + { + ExecuteMotion(VimMotionEngine.MoveFirstNonBlank(_queryTextBox.Text)); + return true; + } + return false; + case Key.D4: + if (modifiers.HasFlag(ModifierKeys.Shift)) // $ + { + ExecuteMotion(VimMotionEngine.MoveEndOfLine(_queryTextBox.Text.Length)); + return true; + } + return false; + case Key.F: + case Key.T: + _awaitingCharCommand = modifiers.HasFlag(ModifierKeys.Shift) ? e.Key.ToString() : e.Key.ToString().ToLower(); + return true; + case Key.R: + _awaitingCharCommand = "r"; + return true; + case Key.OemSemicolon: + if (!modifiers.HasFlag(ModifierKeys.Shift) && _lastFindChar != '\0') + { + ExecuteFindCommand(_lastFindCmd, _lastFindChar, GetCount()); + return true; + } + return false; + case Key.OemComma: + if (!modifiers.HasFlag(ModifierKeys.Shift) && _lastFindChar != '\0') + { + string reverseCmd = _lastFindCmd == "f" ? "F" : _lastFindCmd == "F" ? "f" : _lastFindCmd == "t" ? "T" : "t"; + ExecuteFindCommand(reverseCmd, _lastFindChar, GetCount()); + return true; + } + return false; + case Key.X: + if (modifiers.HasFlag(ModifierKeys.Shift)) // X + { + if (_queryTextBox.CaretIndex > 0) + { + int c = _queryTextBox.CaretIndex - 1; + SetClipboardText(_queryTextBox.Text.Substring(c, 1)); + SetText(_queryTextBox.Text.Remove(c, 1)); + _queryTextBox.CaretIndex = c; + } + _lastChange = "X"; + _lastChangeLen = 1; + } + else // x + { + int n = GetCount(); + if (_queryTextBox.CaretIndex < _queryTextBox.Text.Length) + { + int c = _queryTextBox.CaretIndex; + int len = Math.Min(n, _queryTextBox.Text.Length - c); + SetClipboardText(_queryTextBox.Text.Substring(c, len)); + SetText(_queryTextBox.Text.Remove(c, len)); + _queryTextBox.CaretIndex = c; + } + _lastChange = "x"; + _lastChangeLen = n; + } + return true; + case Key.S: + if (modifiers.HasFlag(ModifierKeys.Shift)) // S -> cc (substitute entire line) + { + _queryTextBox.CaretIndex = 0; + _pendingCommand = "c"; + ExecuteMotion(VimMotionEngine.MoveEndOfLine(_queryTextBox.Text.Length)); + } + else // s -> cl + { + if (_queryTextBox.CaretIndex < _queryTextBox.Text.Length) + { + int c = _queryTextBox.CaretIndex; + SetClipboardText(_queryTextBox.Text.Substring(c, 1)); + SetText(_queryTextBox.Text.Remove(c, 1)); + _queryTextBox.CaretIndex = c; + } + _lastChange = "s"; + _lastChangeLen = 1; + _vimEngine.SwitchToInsert(); + } + return true; + case Key.OemTilde: + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + int c = _queryTextBox.CaretIndex; + if (c < _queryTextBox.Text.Length) + { + char ch = _queryTextBox.Text[c]; + ch = char.IsUpper(ch) ? char.ToLower(ch) : char.ToUpper(ch); + SetText(_queryTextBox.Text.Remove(c, 1).Insert(c, ch.ToString())); + _queryTextBox.CaretIndex = Math.Min(_queryTextBox.Text.Length, c + 1); + } + _lastChange = "~"; + return true; + } + return false; + + case Key.P: + if (modifiers.HasFlag(ModifierKeys.Shift)) + PasteBeforeCursor(GetCount()); // P: paste before the cursor + else + PasteAfterCursor(GetCount()); // p: paste after the cursor + return true; + case Key.U: + VimUndo(); + return true; + case Key.D: + case Key.C: + case Key.Y: + string cmd = e.Key.ToString().ToLower(); + if (modifiers.HasFlag(ModifierKeys.Shift)) // D, C, Y + { + _pendingCommand = cmd; + ExecuteMotion(VimMotionEngine.MoveEndOfLine(_queryTextBox.Text.Length)); + _lastChange = cmd.ToUpper() + "_eol"; + return true; + } + + if (_pendingCommand == cmd) // dd, cc, yy + { + if (!string.IsNullOrEmpty(_queryTextBox.Text)) + { + SetClipboardText(_queryTextBox.Text); + if (cmd == "y") FlashYank(0, _queryTextBox.Text.Length); + } + if (cmd == "d" || cmd == "c") + { + SetText(""); + _queryTextBox.CaretIndex = 0; + } + if (cmd == "c") + _vimEngine.SwitchToInsert(); + _lastChange = cmd + cmd; + _pendingCommand = ""; + _count = 0; + } + else if (_pendingCommand != "" && _pendingCommand != cmd) + { + _pendingCommand = ""; + return true; + } + else + { + _pendingCommand = cmd; + } + return true; + case Key.A when _pendingCommand == "d" || _pendingCommand == "c" || _pendingCommand == "y": + _awaitingTextObject = "a"; + return true; + case Key.I when _pendingCommand == "d" || _pendingCommand == "c" || _pendingCommand == "y": + _awaitingTextObject = "i"; + return true; + case Key.I when modifiers == ModifierKeys.None && _pendingCommand == "": + PushUndo(); // snapshot pre-insert state so `u` reverts the whole insert session + _vimEngine.SwitchToInsert(); + return true; + case Key.I when modifiers.HasFlag(ModifierKeys.Shift): + PushUndo(); + _vimEngine.SwitchToInsert(); + _queryTextBox.CaretIndex = 0; + return true; + case Key.A when modifiers == ModifierKeys.None && _pendingCommand == "": + PushUndo(); + _vimEngine.SwitchToInsert(); + if (_queryTextBox.CaretIndex < _queryTextBox.Text.Length) + { + _queryTextBox.CaretIndex++; + } + return true; + case Key.A when modifiers.HasFlag(ModifierKeys.Shift): + PushUndo(); + _vimEngine.SwitchToInsert(); + _queryTextBox.CaretIndex = _queryTextBox.Text.Length; + return true; + case Key.V: + if (modifiers.HasFlag(ModifierKeys.Shift)) + EnterVisualLineMode(); + else + EnterVisualMode(); + return true; + case Key.OemPeriod: + if (!string.IsNullOrEmpty(_lastChange)) + RepeatLastChange(); + return true; + case Key.Escape: + return true; + default: + if (IsVimBlockedKey(e.Key)) + return true; + return false; + } + + case VimModeType.Visual: + switch (e.Key) + { + case Key.V: + if (modifiers.HasFlag(ModifierKeys.Shift)) + EnterVisualLineMode(); + else + { + _queryTextBox.SelectionLength = 0; + _vimEngine.SwitchToNormal(); + } + return true; + case Key.O when modifiers == ModifierKeys.None: + SwapVisualEnds(); + return true; + case Key.G when modifiers == ModifierKeys.None: + // 'g' prefix in Visual mode (e.g. gu / gU on the selection). + _gPending = true; + return true; + case Key.G when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteVisualMotion(VimMotionEngine.MoveEndOfLine(_queryTextBox.Text.Length)); + return true; + case Key.H: + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MoveLeft(i))); + return true; + case Key.L: + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MoveRight(i, _queryTextBox.Text.Length))); + return true; + case Key.W when modifiers == ModifierKeys.None: + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MoveNextWord(_queryTextBox.Text, i))); + return true; + case Key.B when modifiers == ModifierKeys.None: + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MovePrevWord(_queryTextBox.Text, i))); + return true; + case Key.E when modifiers == ModifierKeys.None: + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MoveEndWord(_queryTextBox.Text, i))); + return true; + case Key.W when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MoveNextWordBig(_queryTextBox.Text, i))); + return true; + case Key.B when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MovePrevWordBig(_queryTextBox.Text, i))); + return true; + case Key.E when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteVisualMotion(ApplyCountMove(i => VimMotionEngine.MoveEndWordBig(_queryTextBox.Text, i))); + return true; + case Key.D0: + if (modifiers.HasFlag(ModifierKeys.Shift)) return true; + ExecuteVisualMotion(VimMotionEngine.MoveStartOfLine()); + return true; + case Key.D5 when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteVisualMotion(VimMotionEngine.MoveToMatchingBracket(_queryTextBox.Text, _visualCaret)); + return true; + case Key.D6: + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + ExecuteVisualMotion(VimMotionEngine.MoveFirstNonBlank(_queryTextBox.Text)); + return true; + } + return true; + case Key.D4: + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + ExecuteVisualMotion(VimMotionEngine.MoveEndOfLine(_queryTextBox.Text.Length)); + return true; + } + return true; + case Key.F: + case Key.T: + _awaitingCharCommand = modifiers.HasFlag(ModifierKeys.Shift) ? e.Key.ToString() : e.Key.ToString().ToLower(); + return true; + case Key.R: + _awaitingCharCommand = "r"; + return true; + case Key.OemSemicolon: + if (!modifiers.HasFlag(ModifierKeys.Shift) && _lastFindChar != '\0') + { + ExecuteFindCommand(_lastFindCmd, _lastFindChar, GetCount()); + return true; + } + return true; + case Key.OemComma: + if (!modifiers.HasFlag(ModifierKeys.Shift) && _lastFindChar != '\0') + { + string reverseCmd = _lastFindCmd == "f" ? "F" : _lastFindCmd == "F" ? "f" : _lastFindCmd == "t" ? "T" : "t"; + ExecuteFindCommand(reverseCmd, _lastFindChar, GetCount()); + return true; + } + return true; + case Key.A when modifiers == ModifierKeys.None: + _awaitingTextObject = "a"; + return true; + case Key.I when modifiers == ModifierKeys.None: + _awaitingTextObject = "i"; + return true; + case Key.X: + case Key.D: + { + int selStart = _queryTextBox.SelectionStart; + int selLength = _queryTextBox.SelectionLength; + if (selLength > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(selStart, selLength)); + SetText(_queryTextBox.Text.Remove(selStart, selLength)); + _queryTextBox.CaretIndex = selStart; + } + _vimEngine.SwitchToNormal(); + } + return true; + case Key.Y: + { + int selStart = _queryTextBox.SelectionStart; + int selLength = _queryTextBox.SelectionLength; + if (selLength > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(selStart, selLength)); + FlashYank(selStart, selLength); + } + _queryTextBox.CaretIndex = selStart; + _queryTextBox.SelectionLength = 0; + _vimEngine.SwitchToNormal(); + } + return true; + case Key.C: + case Key.S: + { + int selStart = _queryTextBox.SelectionStart; + int selLength = _queryTextBox.SelectionLength; + if (selLength > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(selStart, selLength)); + SetText(_queryTextBox.Text.Remove(selStart, selLength)); + _queryTextBox.CaretIndex = selStart; + } + _vimEngine.SwitchToInsert(); + } + return true; + case Key.OemTilde: + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + int selStart = _queryTextBox.SelectionStart; + int selLength = _queryTextBox.SelectionLength; + if (selLength > 0) + { + char[] chars = _queryTextBox.Text.ToCharArray(); + for (int i = selStart; i < selStart + selLength && i < chars.Length; i++) + chars[i] = char.IsUpper(chars[i]) ? char.ToLower(chars[i]) : char.ToUpper(chars[i]); + SetText(new string(chars)); + _queryTextBox.CaretIndex = selStart; + _queryTextBox.SelectionLength = 0; + } + _vimEngine.SwitchToNormal(); + } + return true; + case Key.J: + _viewModel.SelectNextItemCommand.Execute(null); + return true; + case Key.K: + _viewModel.SelectPrevItemCommand.Execute(null); + return true; + default: + return true; + } + + case VimModeType.VisualLine: + switch (e.Key) + { + case Key.V: + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + _queryTextBox.SelectionLength = 0; + _vimEngine.SwitchToNormal(); + } + else + { + _vimEngine.SwitchToVisual(); + UpdateVisualSelection(); + } + return true; + case Key.X: + case Key.D: + SetClipboardText(_queryTextBox.Text); + SetText(""); + _queryTextBox.CaretIndex = 0; + _vimEngine.SwitchToNormal(); + return true; + case Key.Y: + SetClipboardText(_queryTextBox.Text); + FlashYank(0, _queryTextBox.Text.Length); + _queryTextBox.CaretIndex = 0; + _queryTextBox.SelectionLength = 0; + _vimEngine.SwitchToNormal(); + return true; + case Key.C: + case Key.S: + SetClipboardText(_queryTextBox.Text); + SetText(""); + _queryTextBox.CaretIndex = 0; + _vimEngine.SwitchToInsert(); + return true; + case Key.R: + _awaitingCharCommand = "r"; + return true; + case Key.OemTilde: + if (modifiers.HasFlag(ModifierKeys.Shift)) + { + if (_queryTextBox.Text.Length > 0) + { + char[] chars = _queryTextBox.Text.ToCharArray(); + for (int i = 0; i < chars.Length; i++) + chars[i] = char.IsUpper(chars[i]) ? char.ToLower(chars[i]) : char.ToUpper(chars[i]); + SetText(new string(chars)); + _queryTextBox.CaretIndex = 0; + _queryTextBox.SelectionLength = 0; + } + _vimEngine.SwitchToNormal(); + } + return true; + case Key.J: + _viewModel.SelectNextItemCommand.Execute(null); + return true; + case Key.K: + _viewModel.SelectPrevItemCommand.Execute(null); + return true; + default: + return true; + } + + default: + return false; + } + } + + private bool HandleGKey(KeyEventArgs e, ModifierKeys modifiers) + { + switch (_vimEngine.CurrentMode) + { + case VimModeType.Normal: + switch (e.Key) + { + case Key.G: + ExecuteMotion(VimMotionEngine.MoveStartOfLine()); // gg -> start of the query + return true; + case Key.OemMinus when modifiers.HasFlag(ModifierKeys.Shift): + ExecuteMotion(VimMotionEngine.MoveLastNonBlank(_queryTextBox.Text)); + return true; + case Key.T when modifiers.HasFlag(ModifierKeys.Shift): + _pendingCommand = "~"; + return true; + case Key.U when modifiers == ModifierKeys.None: + _pendingCommand = "gu"; + return true; + case Key.U when modifiers.HasFlag(ModifierKeys.Shift): + _pendingCommand = "gU"; + return true; + case Key.V: + if (_lastVisualRange != null) + { + _visualAnchor = _lastVisualRange.Value.anchor; + _visualCaret = _lastVisualRange.Value.caret; + _vimEngine.SwitchToVisual(); + UpdateVisualSelection(); + } + return true; + case Key.OemTilde: + _pendingCommand = "~"; + return true; + default: + return true; + } + case VimModeType.Visual: + switch (e.Key) + { + case Key.G: + ExecuteVisualMotion(VimMotionEngine.MoveStartOfLine()); // gg -> start of the query + return true; + case Key.U when modifiers == ModifierKeys.None: + ChangeSelectionCase(toUpper: false); + return true; + case Key.U when modifiers.HasFlag(ModifierKeys.Shift): + ChangeSelectionCase(toUpper: true); + return true; + case Key.V: + if (_lastVisualRange != null) + { + _visualAnchor = _lastVisualRange.Value.anchor; + _visualCaret = _lastVisualRange.Value.caret; + UpdateVisualSelection(); + } + return true; + default: + return true; + } + default: + return true; + } + } + + private bool HandleTextObjectKey(KeyEventArgs e, ModifierKeys modifiers) + { + string prefix = _awaitingTextObject; + _awaitingTextObject = ""; + + char delim = GetCharFromKey(e.Key, modifiers); + if (delim == '\0' && e.Key != Key.W && e.Key != Key.B) return true; + if (e.Key == Key.W) delim = 'w'; + // Vim block-object aliases: ib/ab == i(/a( and iB/aB == i{/a{ + if (e.Key == Key.B) delim = modifiers.HasFlag(ModifierKeys.Shift) ? 'B' : 'b'; + + string text = _queryTextBox.Text; + int caret = (_vimEngine.CurrentMode == VimModeType.Visual) ? _visualCaret : _queryTextBox.CaretIndex; + bool around = prefix == "a"; + (int start, int end) range = (0, 0); + + switch (delim) + { + case 'w': + range = VimMotionEngine.TextObjectWord(text, caret, around); + break; + case '"': + range = VimMotionEngine.TextObjectQuote(text, caret, '"', around); + break; + case '\'': + range = VimMotionEngine.TextObjectQuote(text, caret, '\'', around); + break; + case '(': + case ')': + case 'b': + range = VimMotionEngine.TextObjectDelimited(text, caret, '(', ')', around); + break; + case '[': + case ']': + range = VimMotionEngine.TextObjectDelimited(text, caret, '[', ']', around); + break; + case '{': + case '}': + case 'B': + range = VimMotionEngine.TextObjectDelimited(text, caret, '{', '}', around); + break; + default: + return true; + } + + if (range.start < 0) return true; + + // Counted word text objects (2aw / 3iw) extend the range through additional words. + int toCount = Math.Max(1, GetCount()); + if (toCount > 1 && delim == 'w') + { + int wEnd = range.end; + for (int k = 1; k < toCount; k++) + { + var next = VimMotionEngine.TextObjectWord(text, wEnd + 1, around); + if (next.start < 0 || next.end <= wEnd) break; + wEnd = next.end; + } + range.end = wEnd; + } + + if (_vimEngine.CurrentMode == VimModeType.Visual) + { + _visualAnchor = range.start; + _visualCaret = range.end; + UpdateVisualSelection(); + return true; + } + + if (_pendingCommand == "d" || _pendingCommand == "c" || _pendingCommand == "y") + { + int len = range.end - range.start + 1; + if (len > 0) + { + SetClipboardText(text.Substring(range.start, len)); + if (_pendingCommand != "y") + { + SetText(text.Remove(range.start, len)); + _queryTextBox.CaretIndex = range.start; + } + else FlashYank(range.start, len); + if (_pendingCommand == "c") + _vimEngine.SwitchToInsert(); + } + _pendingCommand = ""; + return true; + } + + return true; + } + + private int ApplyCountMove(Func move) + { + int target = (_vimEngine.CurrentMode == VimModeType.Visual) ? _visualCaret : _queryTextBox.CaretIndex; + int n = _count > 0 ? _count : 1; + for (int i = 0; i < n; i++) + target = move(target); + _count = 0; + return target; + } + + private int GetCount() + { + int c = _count > 0 ? _count : 1; + _count = 0; + return c; + } + + /// + /// Clears all transient command state (pending operator, awaited char/text-object, + /// the g-prefix flag, and the numeric count). Called on Escape and on mode entry + /// so partially-typed commands never leak into the next one. + /// + private void ResetPendingState() + { + _pendingCommand = ""; + _awaitingCharCommand = ""; + _awaitingTextObject = ""; + _gPending = false; + _count = 0; + } + + private void RepeatLastChange() + { + switch (_lastChange) + { + case "dd": + SetClipboardText(_queryTextBox.Text); + SetText(""); + _queryTextBox.CaretIndex = 0; + break; + case "cc": + SetClipboardText(_queryTextBox.Text); + SetText(""); + _queryTextBox.CaretIndex = 0; + _vimEngine.SwitchToInsert(); + break; + case "D_eol": + case "C_eol": + { + int c = _queryTextBox.CaretIndex; + if (c < _queryTextBox.Text.Length) + { + SetClipboardText(_queryTextBox.Text.Substring(c)); + SetText(_queryTextBox.Text.Remove(c)); + _queryTextBox.CaretIndex = c; + } + if (_lastChange == "C_eol") _vimEngine.SwitchToInsert(); + } + break; + case "Y_eol": + SetClipboardText(_queryTextBox.Text); + break; + case "p": + PasteAfterCursor(GetCount()); + break; + case "x": + { + int n = _count > 0 ? GetCount() : _lastChangeLen; // bare `.` repeats the original count + if (n < 1) n = 1; + int c = _queryTextBox.CaretIndex; + int len = Math.Min(n, _queryTextBox.Text.Length - c); + if (len > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(c, len)); + SetText(_queryTextBox.Text.Remove(c, len)); + _queryTextBox.CaretIndex = c; + } + } + break; + case "~": + { + int c = _queryTextBox.CaretIndex; + if (c < _queryTextBox.Text.Length) + { + char ch = _queryTextBox.Text[c]; + ch = char.IsUpper(ch) ? char.ToLower(ch) : char.ToUpper(ch); + SetText(_queryTextBox.Text.Remove(c, 1).Insert(c, ch.ToString())); + _queryTextBox.CaretIndex = Math.Min(_queryTextBox.Text.Length, c + 1); + } + } + break; + case "d_motion": + { + int c = _queryTextBox.CaretIndex; + int len = Math.Min(_lastChangeLen, _queryTextBox.Text.Length - c); + if (len > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(c, len)); + SetText(_queryTextBox.Text.Remove(c, len)); + _queryTextBox.CaretIndex = c; + } + } + break; + case "c_motion": + { + int c = _queryTextBox.CaretIndex; + int len = Math.Min(_lastChangeLen, _queryTextBox.Text.Length - c); + if (len > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(c, len)); + SetText(_queryTextBox.Text.Remove(c, len)); + _queryTextBox.CaretIndex = c; + } + _vimEngine.SwitchToInsert(); + } + break; + case "s": + case "X": + { + int c = _queryTextBox.CaretIndex; + if (_lastChange == "X") c = Math.Max(0, c - 1); + int len = Math.Min(_lastChangeLen, _queryTextBox.Text.Length - c); + if (len > 0) + { + SetClipboardText(_queryTextBox.Text.Substring(c, len)); + SetText(_queryTextBox.Text.Remove(c, len)); + _queryTextBox.CaretIndex = c; + } + if (_lastChange == "s") _vimEngine.SwitchToInsert(); + } + break; + case "r": + { + int n = _count > 0 ? GetCount() : _lastChangeLen; // bare `.` repeats the original count + if (n < 1) n = 1; + int c = _queryTextBox.CaretIndex; + if (_lastReplaceChar != '\0' && c < _queryTextBox.Text.Length) + { + int len = Math.Min(n, _queryTextBox.Text.Length - c); + char[] chars = _queryTextBox.Text.ToCharArray(); + for (int i = c; i < c + len; i++) chars[i] = _lastReplaceChar; + SetText(new string(chars)); + _queryTextBox.CaretIndex = c + len - 1; + } + } + break; + } + } + + /// + /// Pastes the clipboard text after the cursor (Vim 'p'), times, + /// leaving the caret on the last pasted character. Records the change for '.' repeat. + /// + private void PasteAfterCursor(int count) + { + try + { + string clip = Clipboard.GetText(); + if (string.IsNullOrEmpty(clip)) return; + if (count < 1) count = 1; + + string pasted = clip; + for (int i = 1; i < count; i++) pasted += clip; + + int c = _queryTextBox.CaretIndex; + if (c < _queryTextBox.Text.Length) c++; + SetText(_queryTextBox.Text.Insert(c, pasted)); + _queryTextBox.CaretIndex = Math.Min(c + pasted.Length - 1, Math.Max(0, _queryTextBox.Text.Length - 1)); + _lastChange = "p"; + } + catch (Exception ex) { Flow.Launcher.Infrastructure.Logger.Log.Exception("VimManager", "Clipboard paste operation failed", ex); } + } + + /// + /// Pastes the clipboard text before the cursor (Vim 'P'), times, + /// leaving the caret on the last pasted character. Records the change for '.' repeat. + /// + private void PasteBeforeCursor(int count) + { + try + { + string clip = Clipboard.GetText(); + if (string.IsNullOrEmpty(clip)) return; + if (count < 1) count = 1; + + string pasted = clip; + for (int i = 1; i < count; i++) pasted += clip; + + int c = _queryTextBox.CaretIndex; // P inserts at the caret (before it) + SetText(_queryTextBox.Text.Insert(c, pasted)); + _queryTextBox.CaretIndex = Math.Min(c + pasted.Length - 1, Math.Max(0, _queryTextBox.Text.Length - 1)); + _lastChange = "p"; + } + catch (Exception ex) { Flow.Launcher.Infrastructure.Logger.Log.Exception("VimManager", "Clipboard paste operation failed", ex); } + } + + private void ExecuteCharCommand(string cmd, char c) + { + _awaitingCharCommand = ""; + // Consume any count typed before the operator (e.g. 3rx, 2f,) so it never leaks + // into the next command. + int count = GetCount(); + string text = _queryTextBox.Text; + int caret = _queryTextBox.CaretIndex; + + if (cmd == "r") + { + if (_vimEngine.CurrentMode == VimModeType.Visual || _vimEngine.CurrentMode == VimModeType.VisualLine) + { + int selStart = _queryTextBox.SelectionStart; + int selLength = _queryTextBox.SelectionLength; + if (selLength > 0) + { + char[] chars = text.ToCharArray(); + for (int i = selStart; i < selStart + selLength && i < chars.Length; i++) + chars[i] = c; + SetText(new string(chars)); + _queryTextBox.CaretIndex = selStart; + _queryTextBox.SelectionLength = 0; + _vimEngine.SwitchToNormal(); + } + } + else if (caret < text.Length) + { + // r{char} with a count replaces that many characters (vim no-ops if fewer remain). + int len = Math.Min(count, text.Length - caret); + if (count <= text.Length - caret && len > 0) + { + char[] chars = text.ToCharArray(); + for (int i = caret; i < caret + len; i++) chars[i] = c; + SetText(new string(chars)); + _queryTextBox.CaretIndex = caret + len - 1; + _lastChange = "r"; + _lastChangeLen = len; // so a bare `.` repeats the counted replace + _lastReplaceChar = c; + } + } + } + else if (cmd == "f" || cmd == "F" || cmd == "t" || cmd == "T") + { + _lastFindCmd = cmd; + _lastFindChar = c; + ExecuteFindCommand(cmd, c, count); + } + } + + private void ExecuteFindCommand(string cmd, char c, int count = 1) + { + string text = _queryTextBox.Text; + int caret = (_vimEngine.CurrentMode == VimModeType.Visual || _vimEngine.CurrentMode == VimModeType.VisualLine) + ? _visualCaret + : _queryTextBox.CaretIndex; + + int target = caret; + for (int k = 0; k < (count < 1 ? 1 : count); k++) + { + int next = cmd switch + { + "f" => VimMotionEngine.FindCharForward(text, target, c, false), + "F" => VimMotionEngine.FindCharBackward(text, target, c, false), + "t" => VimMotionEngine.FindCharForward(text, target, c, true), + "T" => VimMotionEngine.FindCharBackward(text, target, c, true), + _ => target + }; + if (next == target) break; // not found / no further progress + target = next; + } + + if (_vimEngine.CurrentMode == VimModeType.Visual || _vimEngine.CurrentMode == VimModeType.VisualLine) + ExecuteVisualMotion(target); + else + ExecuteMotion(target, MotionInclusivity.InclusiveForward); + } + + private void ExecuteMotion(int targetCaret, MotionInclusivity inclusivity = MotionInclusivity.Exclusive) + { + if (_pendingCommand == "d" || _pendingCommand == "c" || _pendingCommand == "y") + { + var (start, end) = VimMotionEngine.OperatorRange(_queryTextBox.CaretIndex, targetCaret, inclusivity, _queryTextBox.Text.Length); + + if (end > start) + { + SetClipboardText(_queryTextBox.Text.Substring(start, end - start)); + if (_pendingCommand != "y") + { + SetText(_queryTextBox.Text.Remove(start, end - start)); + _queryTextBox.CaretIndex = start; + } + else FlashYank(start, end - start); + } + + if (_pendingCommand == "c") + _vimEngine.SwitchToInsert(); + _lastChange = _pendingCommand + "_motion"; + _lastChangeLen = end - start; + _pendingCommand = ""; + _count = 0; + } + else if (_pendingCommand == "~" || _pendingCommand == "gu" || _pendingCommand == "gU") + { + var (start, end) = VimMotionEngine.OperatorRange(_queryTextBox.CaretIndex, targetCaret, inclusivity, _queryTextBox.Text.Length); + + if (end > start) + { + char[] chars = _queryTextBox.Text.ToCharArray(); + for (int i = start; i < end && i < chars.Length; i++) + { + chars[i] = _pendingCommand == "gu" ? char.ToLower(chars[i]) + : _pendingCommand == "gU" ? char.ToUpper(chars[i]) + : char.IsUpper(chars[i]) ? char.ToLower(chars[i]) : char.ToUpper(chars[i]); + } + SetText(new string(chars)); + _queryTextBox.CaretIndex = Math.Min(start, _queryTextBox.Text.Length); + } + if (_pendingCommand == "~") _lastChange = "~"; + _pendingCommand = ""; + _count = 0; + } + else + { + _queryTextBox.CaretIndex = targetCaret; + } + } + + private void UpdateVisualSelection() + { + int start = Math.Min(_visualAnchor, _visualCaret); + int end = Math.Max(_visualAnchor, _visualCaret); + if (start < 0) start = 0; + int length = Math.Min(end - start + 1, _queryTextBox.Text.Length - start); + if (length < 0) length = 0; + _queryTextBox.Select(start, length); + UpdateCaretPosition(); + } + + private void ExecuteVisualMotion(int targetCaret) + { + _visualCaret = targetCaret; + UpdateVisualSelection(); + } + + private void EnterVisualMode() + { + if (_queryTextBox.Text.Length == 0) return; + if (_queryTextBox.CaretIndex >= _queryTextBox.Text.Length) + _queryTextBox.CaretIndex = _queryTextBox.Text.Length - 1; + _visualAnchor = _queryTextBox.CaretIndex; + _visualCaret = _queryTextBox.CaretIndex; + ResetPendingState(); + _vimEngine.SwitchToVisual(); + UpdateVisualSelection(); + } + + private void EnterVisualLineMode() + { + if (_queryTextBox.Text.Length == 0) return; + _visualAnchor = 0; + _visualCaret = _queryTextBox.Text.Length - 1; + ResetPendingState(); + _vimEngine.SwitchToVisualLine(); + _queryTextBox.Select(0, _queryTextBox.Text.Length); + UpdateCaretPosition(); + } + + private void SwapVisualEnds() + { + int temp = _visualAnchor; + _visualAnchor = _visualCaret; + _visualCaret = temp; + UpdateVisualSelection(); + } + + /// + /// Applies a case transform to the current Visual-mode selection (gu / gU) and + /// returns to Normal mode, mirroring the '~' selection handler. + /// + private void ChangeSelectionCase(bool toUpper) + { + int selStart = _queryTextBox.SelectionStart; + int selLength = _queryTextBox.SelectionLength; + if (selLength > 0) + { + char[] chars = _queryTextBox.Text.ToCharArray(); + for (int i = selStart; i < selStart + selLength && i < chars.Length; i++) + chars[i] = toUpper ? char.ToUpper(chars[i]) : char.ToLower(chars[i]); + SetText(new string(chars)); + _queryTextBox.CaretIndex = selStart; + _queryTextBox.SelectionLength = 0; + } + _vimEngine.SwitchToNormal(); + } + + private void SaveVisualRange() + { + _lastVisualRange = (_visualAnchor, _visualCaret); + } + + private static bool IsVimBlockedKey(Key key) + { + if (key >= Key.A && key <= Key.Z) return true; + if (key >= Key.D0 && key <= Key.D9) return true; + if (key >= Key.NumPad0 && key <= Key.NumPad9) return true; + if (key == Key.Space) return true; + if (key == Key.OemTilde) return true; + if (key == Key.OemMinus) return true; + if (key == Key.OemPlus) return true; + if (key >= Key.OemOpenBrackets && key <= Key.OemQuotes) return true; + if (key == Key.OemPeriod || key == Key.OemComma) return true; + if (key == Key.Back) return true; + return false; + } + + private static char GetCharFromKey(Key key, ModifierKeys modifiers) + { + bool shift = modifiers.HasFlag(ModifierKeys.Shift); + if (key >= Key.A && key <= Key.Z) + return shift ? key.ToString()[0] : key.ToString().ToLower()[0]; + if (key >= Key.D0 && key <= Key.D9) + { + if (!shift) return (char)('0' + (key - Key.D0)); + switch (key) + { + case Key.D1: return '!'; case Key.D2: return '@'; case Key.D3: return '#'; + case Key.D4: return '$'; case Key.D5: return '%'; case Key.D6: return '^'; + case Key.D7: return '&'; case Key.D8: return '*'; case Key.D9: return '('; + case Key.D0: return ')'; + } + } + switch (key) + { + case Key.Space: return ' '; + case Key.OemSemicolon: return shift ? ':' : ';'; + case Key.OemComma: return shift ? '<' : ','; + case Key.OemPeriod: return shift ? '>' : '.'; + case Key.OemQuestion: return shift ? '?' : '/'; + case Key.OemQuotes: return shift ? '"' : '\''; + case Key.OemOpenBrackets: return shift ? '{' : '['; + case Key.OemCloseBrackets: return shift ? '}' : ']'; + case Key.OemPipe: return shift ? '|' : '\\'; + case Key.OemMinus: return shift ? '_' : '-'; + case Key.OemPlus: return shift ? '+' : '='; + case Key.OemTilde: return shift ? '~' : '`'; + } + return '\0'; + } + + private void QueryTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e) + { + if (_settings.EnableVimMode && _vimEngine.CurrentMode != VimModeType.Insert) + { + e.Handled = true; + } + } + + /// + /// Gets a value indicating whether native text input is currently blocked by Vim mode. + /// + public bool IsInputBlocked => _vimEngine.CurrentMode != VimModeType.Insert; + + /// + /// Disposes the Vim manager and detaches from window events. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _vimEngine.ModeChanged -= VimEngine_ModeChanged; + _mainWindow.Loaded -= MainWindow_Loaded; + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + _settings.PropertyChanged -= OnSettingsPropertyChanged; + _queryTextBox.PreviewTextInput -= QueryTextBox_PreviewTextInput; + _queryTextBox.SelectionChanged -= QueryTextBox_SelectionChanged; + _queryTextBox.TextChanged -= QueryTextBox_TextChanged; + } + + _disposed = true; + } + } + } +} + diff --git a/Flow.Launcher/VimMode/VimMotionEngine.cs b/Flow.Launcher/VimMode/VimMotionEngine.cs new file mode 100644 index 00000000000..acbd8718fa8 --- /dev/null +++ b/Flow.Launcher/VimMode/VimMotionEngine.cs @@ -0,0 +1,518 @@ +using System; + +namespace Flow.Launcher.VimMode +{ + /// + /// Describes how an operator (d/c/y/gu/gU/g~) treats the character at the far end of a motion. + /// + public enum MotionInclusivity + { + /// Half-open range; the destination character is not affected (w, b, h, l, 0, ^, $). + Exclusive, + /// Forward motions that include the destination character (e, E, f, t). + InclusiveForward, + /// Paired motions that include the far-end character in either direction (%). + InclusivePair + } + + /// + /// Provides pure static methods for calculating caret movements and text object selections. + /// + public class VimMotionEngine + { + /// + /// Computes the character range [start, end) an operator should act on, given the caret, + /// the motion target, and the motion's inclusivity. Handles the Vim rule that inclusive + /// forward motions consume the destination character while backward find motions do not. + /// + public static (int start, int end) OperatorRange(int caret, int target, MotionInclusivity inclusivity, int textLength) + { + int start = Math.Max(0, Math.Min(caret, target)); + int end = Math.Max(0, Math.Max(caret, target)); + + // Inclusive-forward (e/f/t) consumes the destination only when the motion moved forward; + // a backward find (F/T) leaves the original caret char untouched. A paired motion (%) + // consumes the far-end char in either direction, but only when it actually spans a range. + bool extend = (inclusivity == MotionInclusivity.InclusiveForward && target > caret) + || (inclusivity == MotionInclusivity.InclusivePair && end > start); + + if (extend && end < textLength) + end++; + + return (start, end); + } + + /// + /// Calculates the new caret position after moving left one character. + /// + /// The current caret index. + /// The new caret index. + public static int MoveLeft(int caret) + { + return Math.Max(0, caret - 1); + } + + /// + /// Calculates the new caret position after moving right one character. + /// + /// The current caret index. + /// The total length of the text. + /// The new caret index. + public static int MoveRight(int caret, int length) + { + return Math.Min(length, caret + 1); + } + + /// + /// Calculates the new caret position at the start of the line. + /// + /// The start index (always 0). + public static int MoveStartOfLine() + { + return 0; + } + + /// + /// Calculates the new caret position at the end of the line. + /// + /// The total length of the text. + /// The end index. + public static int MoveEndOfLine(int length) + { + return length; + } + + /// + /// Calculates the index of the first non-blank character in the text. + /// + /// The text to search. + /// The index of the first non-blank character, or 0 if none. + public static int MoveFirstNonBlank(string text) + { + for (int i = 0; i < text.Length; i++) + { + if (!char.IsWhiteSpace(text[i])) return i; + } + return 0; + } + + /// + /// Calculates the index after the last non-blank character in the text. + /// + /// The text to search. + /// The target index. + public static int MoveLastNonBlank(string text) + { + for (int i = text.Length - 1; i >= 0; i--) + { + if (!char.IsWhiteSpace(text[i])) return Math.Min(i + 1, text.Length); + } + return 0; + } + + /// + /// Calculates the index of the start of the next word (w motion). + /// + /// The text to traverse. + /// The current caret index. + /// The new caret index. + public static int MoveNextWord(string text, int caret) + { + if (caret >= text.Length) return text.Length; + + bool startIsWord = IsWordChar(text[caret]); + int i = caret; + + while (i < text.Length && IsWordChar(text[i]) == startIsWord && !char.IsWhiteSpace(text[i])) + i++; + + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + + return Math.Min(i, text.Length); + } + + /// + /// Calculates the index of the start of the previous word (b motion). + /// + /// The text to traverse. + /// The current caret index. + /// The new caret index. + public static int MovePrevWord(string text, int caret) + { + if (caret <= 0) return 0; + + int i = caret - 1; + + while (i > 0 && char.IsWhiteSpace(text[i])) + i--; + + bool targetIsWord = IsWordChar(text[i]); + + while (i > 0 && IsWordChar(text[i - 1]) == targetIsWord && !char.IsWhiteSpace(text[i - 1])) + i--; + + return Math.Max(0, i); + } + + /// + /// Calculates the index of the end of the current or next word (e motion). + /// + /// The text to traverse. + /// The current caret index. + /// The new caret index. + public static int MoveEndWord(string text, int caret) + { + if (text.Length == 0) return 0; + if (caret >= text.Length - 1) return text.Length - 1; + + int i = caret + 1; + + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + + if (i >= text.Length) return caret; + + bool targetIsWord = IsWordChar(text[i]); + + while (i < text.Length - 1 && IsWordChar(text[i + 1]) == targetIsWord && !char.IsWhiteSpace(text[i + 1])) + i++; + + return Math.Min(i, text.Length - 1); + } + + /// + /// Calculates the index of the start of the next big word (W motion). + /// + /// The text to traverse. + /// The current caret index. + /// The new caret index. + public static int MoveNextWordBig(string text, int caret) + { + if (caret >= text.Length) return text.Length; + + int i = caret; + + while (i < text.Length && !char.IsWhiteSpace(text[i])) + i++; + + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + + return Math.Min(i, text.Length); + } + + /// + /// Calculates the index of the start of the previous big word (B motion). + /// + /// The text to traverse. + /// The current caret index. + /// The new caret index. + public static int MovePrevWordBig(string text, int caret) + { + if (caret <= 0) return 0; + + int i = caret - 1; + + while (i > 0 && char.IsWhiteSpace(text[i])) + i--; + + while (i > 0 && !char.IsWhiteSpace(text[i - 1])) + i--; + + return Math.Max(0, i); + } + + /// + /// Calculates the index of the end of the current or next big word (E motion). + /// + /// The text to traverse. + /// The current caret index. + /// The new caret index. + public static int MoveEndWordBig(string text, int caret) + { + if (text.Length == 0) return 0; + if (caret >= text.Length - 1) return text.Length - 1; + + int i = caret + 1; + + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + + if (i >= text.Length) return caret; + + while (i < text.Length - 1 && !char.IsWhiteSpace(text[i + 1])) + i++; + + return Math.Min(i, text.Length - 1); + } + + /// + /// Finds the index of the matching bracket (% motion). + /// + /// The text containing brackets. + /// The current caret index. + /// The index of the matching bracket, or the original caret if none. + public static int MoveToMatchingBracket(string text, int caret) + { + if (caret >= text.Length) return caret; + char c = text[caret]; + + char target; + bool forward; + switch (c) + { + case '(': target = ')'; forward = true; break; + case ')': target = '('; forward = false; break; + case '[': target = ']'; forward = true; break; + case ']': target = '['; forward = false; break; + case '{': target = '}'; forward = true; break; + case '}': target = '{'; forward = false; break; + default: return caret; + } + + int depth = 0; + char open = forward ? c : target; + char close = forward ? target : c; + + if (forward) + { + for (int i = caret; i < text.Length; i++) + { + if (text[i] == open) depth++; + else if (text[i] == close) { depth--; if (depth == 0) return i; } + } + } + else + { + for (int i = caret; i >= 0; i--) + { + if (text[i] == close) depth++; + else if (text[i] == open) { depth--; if (depth == 0) return i; } + } + } + + return caret; + } + + /// + /// Calculates the selection bounds for a word text object (iw / aw). + /// + /// The text. + /// The current caret index. + /// True for aw, false for iw. + /// A tuple containing the start and end indices of the selection. + public static (int start, int end) TextObjectWord(string text, int caret, bool around) + { + if (text.Length == 0) return (0, 0); + + int start, end; + int i = Math.Min(caret, text.Length - 1); + bool isWs = char.IsWhiteSpace(text[i]); + bool startIsWord = IsWordChar(text[i]); + + while (i > 0) + { + if (isWs) + { + if (!char.IsWhiteSpace(text[i - 1])) break; + } + else + { + if (IsWordChar(text[i - 1]) != startIsWord || char.IsWhiteSpace(text[i - 1])) break; + } + i--; + } + start = i; + + i = Math.Min(caret, text.Length - 1); + while (i < text.Length - 1) + { + if (isWs) + { + if (!char.IsWhiteSpace(text[i + 1])) break; + } + else + { + if (IsWordChar(text[i + 1]) != startIsWord || char.IsWhiteSpace(text[i + 1])) break; + } + i++; + } + end = i; + + if (around && !isWs) + { + if (end + 1 < text.Length && char.IsWhiteSpace(text[end + 1])) + { + while (end + 1 < text.Length && char.IsWhiteSpace(text[end + 1])) end++; + } + else if (start > 0 && char.IsWhiteSpace(text[start - 1])) + { + while (start > 0 && char.IsWhiteSpace(text[start - 1])) start--; + } + } + + return (start, end); + } + + /// + /// Calculates the selection bounds for a delimited text object, such as parentheses or brackets. + /// + /// The text. + /// The current caret index. + /// The opening delimiter. + /// The closing delimiter. + /// True to include the delimiters (a object), false to exclude (i object). + /// A tuple containing the start and end indices, or (-1, -1) if invalid. + public static (int start, int end) TextObjectDelimited(string text, int caret, char open, char close, bool around) + { + int openPos = -1; + int depth = 0; + for (int i = Math.Min(caret, text.Length - 1); i >= 0; i--) + { + if (text[i] == close) depth++; + else if (text[i] == open) + { + if (depth == 0) { openPos = i; break; } + depth--; + } + } + + if (openPos == -1) return (-1, -1); + + int closePos = -1; + depth = 0; + for (int i = openPos + 1; i < text.Length; i++) + { + if (text[i] == open) depth++; + else if (text[i] == close) + { + if (depth == 0) { closePos = i; break; } + depth--; + } + } + + if (closePos == -1) return (-1, -1); + + int start = around ? openPos : openPos + 1; + int end = around ? closePos : closePos - 1; + + if (start > end) return (start, start - 1); + + return (start, end); + } + + /// + /// Calculates the selection bounds for a quote text object. + /// + /// The text. + /// The current caret index. + /// The quote character. + /// True to include the quotes, false to exclude. + /// A tuple containing the start and end indices, or (-1, -1) if invalid. + public static (int start, int end) TextObjectQuote(string text, int caret, char quote, bool around) + { + if (text.Length == 0) return (-1, -1); + // CaretIndex can be text.Length (caret past the last char); clamp so end-of-query + // resolves onto the closing quote and ci"/di" still match the last quoted region. + caret = Math.Max(0, Math.Min(caret, text.Length - 1)); + int first = -1; + + for (int i = 0; i < text.Length; i++) + { + if (text[i] == quote) + { + if (first == -1) + { + first = i; + } + else + { + if (caret >= first && caret <= i) + { + int start = around ? first : first + 1; + int end = around ? i : i - 1; + if (start > end) return (start, start - 1); + return (start, end); + } + first = -1; + } + } + } + + return (-1, -1); + } + + + + private static bool IsWordChar(char c) + { + return char.IsLetterOrDigit(c) || c == '_'; + } + + /// + /// Finds the next occurrence of a character (f / t motion). + /// + /// The text. + /// The current caret index. + /// The character to find. + /// True for t (stops before), false for f (lands on). + /// The index of the target character, or the original caret if not found. + public static int FindCharForward(string text, int caret, char target, bool till = false) + { + if (caret >= text.Length - 1) return caret; + int start = caret + 1; + int index = text.IndexOf(target, start); + if (index == -1) return caret; + return till ? index - 1 : index; + } + + /// + /// Finds the previous occurrence of a character (F / T motion). + /// + /// The text. + /// The current caret index. + /// The character to find. + /// True for T (stops after), false for F (lands on). + /// The index of the target character, or the original caret if not found. + public static int FindCharBackward(string text, int caret, char target, bool till = false) + { + if (caret <= 0) return caret; + int start = caret - 1; + int index = text.LastIndexOf(target, start); + if (index == -1) return caret; + return till ? index + 1 : index; + } + + /// + /// Vim Ctrl-A / Ctrl-X: finds the (decimal, optionally negative) number at or to the right of the + /// caret and adds to it. Pure: returns whether a number was found, the new + /// text, and the caret position (on the last digit of the result), matching Vim's behaviour. + /// + public static (bool found, string newText, int newCaret) ChangeNumber(string text, int caret, int delta) + { + if (string.IsNullOrEmpty(text)) return (false, text, caret); + int n = text.Length; + int p = Math.Min(Math.Max(caret, 0), n); + + // Scan right (within the current line) for the first digit. + while (p < n && text[p] != '\n' && !char.IsDigit(text[p])) p++; + if (p >= n || text[p] == '\n') return (false, text, caret); + + // Expand to the full digit run, then absorb a leading '-' sign. + int start = p; + while (start > 0 && char.IsDigit(text[start - 1])) start--; + int end = p; + while (end < n && char.IsDigit(text[end])) end++; + if (start > 0 && text[start - 1] == '-') start--; + + if (!long.TryParse(text.Substring(start, end - start), out long value)) + return (false, text, caret); + + string replacement = (value + delta).ToString(); + string newText = text.Substring(0, start) + replacement + text.Substring(end); + int newCaret = start + replacement.Length - 1; // cursor lands on the last digit + return (true, newText, newCaret); + } + } +}