Skip to content
Merged
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<PackageLicenseFile>LICENSE</PackageLicenseFile>

<!-- Pinned Terminal.Gui version. CI / release workflows can override via -p:TerminalGuiVersion=<x>. -->
<TerminalGuiVersion Condition="'$(TerminalGuiVersion)' == ''">2.1.0</TerminalGuiVersion>
<TerminalGuiVersion Condition="'$(TerminalGuiVersion)' == ''">2.1.1-develop.59</TerminalGuiVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
52 changes: 49 additions & 3 deletions src/Terminal.Gui.Editor/Editor.Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ public partial class Editor
[Command.Cut] = Bind.All (Key.X.WithCtrl),
[Command.Copy] = Bind.All (Key.C.WithCtrl),
[Command.Paste] = Bind.All (Key.V.WithCtrl),
[Command.Collapse] = Bind.All (Key.M.WithCtrl)
[Command.Collapse] = Bind.All (Key.M.WithCtrl),
[Command.InsertTab] = Bind.All (Key.Tab),
[Command.Unindent] = Bind.All (Key.Tab.WithShift),
[Command.FindNext] = Bind.All (Key.F3),
[Command.FindPrevious] = Bind.All (Key.F3.WithShift),
[Command.Find] = Bind.All (Key.F.WithCtrl),
[Command.Replace] = Bind.All (Key.H.WithCtrl)
};

private void CreateCommandsAndBindings ()
Expand Down Expand Up @@ -184,12 +190,24 @@ private void CreateCommandsAndBindings ()
// Folding
AddCommand (Command.Collapse, ToggleFoldUnderCaret);

// Indentation — InsertTab / Unindent return bool, wrapped for CommandImplementation (bool?).
AddCommand (Command.InsertTab, () => InsertTab ());
AddCommand (Command.Unindent, () => Unindent ());

// Find / Replace
AddCommand (Command.Find, InvokeFindRequested);
AddCommand (Command.Replace, InvokeReplaceRequested);
AddCommand (Command.FindNext, FindNextCommand);
AddCommand (Command.FindPrevious, FindPreviousCommand);

ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings);

// Reclaim Tab before the framework consumes it; the editor handles Tab / Shift+Tab
// in OnKeyDownNotHandled so indentation still works without a command binding.
// Reclaim Tab / Shift+Tab from the framework's default focus-cycling bindings so our
// InsertTab / Unindent commands fire instead.
KeyBindings.Remove (Key.Tab);
KeyBindings.Remove (Key.Tab.WithShift);
KeyBindings.Add (Key.Tab, Command.InsertTab);
KeyBindings.Add (Key.Tab.WithShift, Command.Unindent);

MouseBindings.Add (MouseFlags.WheeledUp, Command.ScrollUp);
MouseBindings.Add (MouseFlags.WheeledDown, Command.ScrollDown);
Expand Down Expand Up @@ -361,6 +379,34 @@ private void CreateCommandsAndBindings ()
return true;
}

private bool? InvokeFindRequested ()
{
FindRequested?.Invoke (this, EventArgs.Empty);

return true;
}

private bool? InvokeReplaceRequested ()
{
ReplaceRequested?.Invoke (this, EventArgs.Empty);

return true;
}

private bool? FindNextCommand ()
{
FindNext ();

return true;
}

private bool? FindPreviousCommand ()
{
FindPrevious ();

return true;
}

private bool? ToggleFoldUnderCaret ()
{
if (FoldingManager is not { } fm || _document is null)
Expand Down
39 changes: 0 additions & 39 deletions src/Terminal.Gui.Editor/Editor.Keyboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,45 +20,6 @@ protected override bool OnKeyDownNotHandled (Key key)
return true;
}

if (key == Key.Tab)
{
return InsertTab ();
}

if (key == Key.Tab.WithShift)
{
return Unindent ();
}

// Find/Replace keybindings — handled before the generic Ctrl/Alt guard.
if (key == Key.F3)
{
FindNext ();

return true;
}

if (key == Key.F3.WithShift)
{
FindPrevious ();

return true;
}

if (key == Key.F.WithCtrl)
{
FindRequested?.Invoke (this, EventArgs.Empty);

return true;
}

if (key == Key.H.WithCtrl)
{
ReplaceRequested?.Invoke (this, EventArgs.Empty);

return true;
}

if (key.IsCtrl || key.IsAlt)
{
return false;
Expand Down
204 changes: 204 additions & 0 deletions tests/Terminal.Gui.Editor.IntegrationTests/EditorKeyBindingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copilot - gpt-4.1

using Terminal.Gui.Document.Search;
using Terminal.Gui.Editor.IntegrationTests.Testing;
using Terminal.Gui.Input;
using Terminal.Gui.Testing;
using Xunit;

namespace Terminal.Gui.Editor.IntegrationTests;

/// <summary>
/// Tests that verify the six migrated key bindings (Tab, Shift+Tab, F3, Shift+F3, Ctrl+F, Ctrl+H)
/// are registered as proper <see cref="Command" /> bindings (not hardcoded in OnKeyDownNotHandled)
/// and respond correctly to both key injection and direct <see cref="Command" /> invocation.
/// </summary>
public class EditorKeyBindingTests
{
// ───────────────────── DefaultKeyBindings dictionary ─────────────────────

[Theory]
[InlineData (Command.InsertTab)]
[InlineData (Command.Unindent)]
[InlineData (Command.FindNext)]
[InlineData (Command.FindPrevious)]
[InlineData (Command.Find)]
[InlineData (Command.Replace)]
public void DefaultKeyBindings_Contains_Command (Command command)
{
Assert.NotNull (Editor.DefaultKeyBindings);
Assert.True (Editor.DefaultKeyBindings!.ContainsKey (command), $"DefaultKeyBindings missing {command}");
}

// ───────────────────── Command.InsertTab ─────────────────────

[Fact]
public async Task InsertTab_Command_Inserts_Tab ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

fx.Top.Editor.InvokeCommand (Command.InsertTab);

Assert.Equal ("\t", fx.Top.Editor.Document!.Text);
Assert.Equal (1, fx.Top.Editor.CaretOffset);
}

[Fact]
public async Task InsertTab_Command_ReadOnly_NoOp ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ("abc"));
fx.Top.Editor.SetFocus ();
fx.Top.Editor.ReadOnly = true;

fx.Top.Editor.InvokeCommand (Command.InsertTab);

Assert.Equal ("abc", fx.Top.Editor.Document!.Text);
}

// ───────────────────── Command.Unindent ─────────────────────

[Fact]
public async Task Unindent_Command_Removes_Leading_Whitespace ()
{
await using AppFixture<EditorTestHost> fx = new (() => new (" alpha"));
fx.Top.Editor.SetFocus ();
fx.Top.Editor.CaretOffset = 4;

fx.Top.Editor.InvokeCommand (Command.Unindent);

Assert.Equal ("alpha", fx.Top.Editor.Document!.Text);
Assert.Equal (0, fx.Top.Editor.CaretOffset);
}

[Fact]
public async Task Unindent_Command_ReadOnly_NoOp ()
{
await using AppFixture<EditorTestHost> fx = new (() => new (" abc"));
fx.Top.Editor.SetFocus ();
fx.Top.Editor.ReadOnly = true;
fx.Top.Editor.CaretOffset = 4;

fx.Top.Editor.InvokeCommand (Command.Unindent);

Assert.Equal (" abc", fx.Top.Editor.Document!.Text);
}

// ───────────────────── Command.FindNext ─────────────────────

[Fact]
public async Task FindNext_Command_Selects_Next_Match ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ("hello world hello"));
fx.Top.Editor.SetFocus ();
fx.Top.Editor.SearchStrategy = SearchStrategyFactory.Create ("hello", false, false, SearchMode.Normal);
fx.Top.Editor.CaretOffset = 0;

fx.Top.Editor.InvokeCommand (Command.FindNext);

Assert.Equal (5, fx.Top.Editor.CaretOffset);
Assert.True (fx.Top.Editor.HasSelection);
}

// ───────────────────── Command.FindPrevious ─────────────────────

[Fact]
public async Task FindPrevious_Command_Selects_Previous_Match ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ("hello world hello"));
fx.Top.Editor.SetFocus ();
fx.Top.Editor.SearchStrategy = SearchStrategyFactory.Create ("hello", false, false, SearchMode.Normal);
fx.Top.Editor.CaretOffset = 17;

fx.Top.Editor.InvokeCommand (Command.FindPrevious);

Assert.True (fx.Top.Editor.HasSelection);
Assert.Equal (12, fx.Top.Editor.SelectionStart);
}

// ───────────────────── Command.Find ─────────────────────

[Fact]
public async Task Find_Command_Raises_FindRequested ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ("test"));
fx.Top.Editor.SetFocus ();
var fired = false;
fx.Top.Editor.FindRequested += (_, _) => fired = true;

fx.Top.Editor.InvokeCommand (Command.Find);

Assert.True (fired);
}

// ───────────────────── Command.Replace ─────────────────────

[Fact]
public async Task Replace_Command_Raises_ReplaceRequested ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ("test"));
fx.Top.Editor.SetFocus ();
var fired = false;
fx.Top.Editor.ReplaceRequested += (_, _) => fired = true;

fx.Top.Editor.InvokeCommand (Command.Replace);

Assert.True (fired);
}

// ───────────────────── Key binding wire-up via InjectKey ─────────────────────

[Fact]
public async Task Tab_Key_Bound_To_InsertTab_Command ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

Assert.Contains (Command.InsertTab, fx.Top.Editor.KeyBindings.GetCommands (Key.Tab));
}

[Fact]
public async Task ShiftTab_Key_Bound_To_Unindent_Command ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

Assert.Contains (Command.Unindent, fx.Top.Editor.KeyBindings.GetCommands (Key.Tab.WithShift));
}

[Fact]
public async Task F3_Key_Bound_To_FindNext_Command ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

Assert.Contains (Command.FindNext, fx.Top.Editor.KeyBindings.GetCommands (Key.F3));
}

[Fact]
public async Task ShiftF3_Key_Bound_To_FindPrevious_Command ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

Assert.Contains (Command.FindPrevious, fx.Top.Editor.KeyBindings.GetCommands (Key.F3.WithShift));
}

[Fact]
public async Task CtrlF_Key_Bound_To_Find_Command ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

Assert.Contains (Command.Find, fx.Top.Editor.KeyBindings.GetCommands (Key.F.WithCtrl));
}

[Fact]
public async Task CtrlH_Key_Bound_To_Replace_Command ()
{
await using AppFixture<EditorTestHost> fx = new (() => new ());
fx.Top.Editor.SetFocus ();

Assert.Contains (Command.Replace, fx.Top.Editor.KeyBindings.GetCommands (Key.H.WithCtrl));
}
}
Loading