Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
67b76eb
feat: vim mode key binding
rileyhawk1417 Sep 28, 2023
b86387e
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Oct 7, 2023
afc4445
feat: enable insert on new line in vim mode
rileyhawk1417 Oct 14, 2023
aa78f50
feat: add more insert modes
rileyhawk1417 Oct 17, 2023
8493a17
chore: remove previous selection after assigning selection
rileyhawk1417 Oct 18, 2023
f149c60
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Oct 19, 2023
b77a100
feat: add block cursor style
rileyhawk1417 Oct 19, 2023
d81cc79
feat: add Vim Modes enum
rileyhawk1417 Oct 19, 2023
bb6e5bc
feat: enable block cursor & movement keys in normal mode
rileyhawk1417 Oct 24, 2023
daf4946
fix: change vim mode to be insert by default
rileyhawk1417 Oct 24, 2023
df8c894
feat: add in extra vim movements
rileyhawk1417 Oct 29, 2023
1d0ecc0
docs: add some comments for future work
rileyhawk1417 Oct 29, 2023
f597249
feat: add jump word forward & backward
rileyhawk1417 Oct 31, 2023
9eb8997
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Oct 31, 2023
433d986
doc: add some comments from keyboard shortcut
rileyhawk1417 Nov 2, 2023
c8becbc
fix: reduce cursor size to increase visibility
rileyhawk1417 Nov 2, 2023
03f893c
refactor: display selection in vim mode
rileyhawk1417 Nov 2, 2023
edccb11
feat: add global mode for vim keybindings
rileyhawk1417 Nov 2, 2023
993740c
fix: add extra conditions to ensure modes don't conclict with each other
rileyhawk1417 Nov 3, 2023
c6ae21a
chore: add more checks for vim mode, enable vim mode by default
rileyhawk1417 Nov 10, 2023
881d6f0
doc: add some comments for reference
rileyhawk1417 Nov 29, 2023
693cb43
fix: Merge branch 'main' into feat_vim_mode
rileyhawk1417 Nov 29, 2023
e117aa9
refactor: update file import path
rileyhawk1417 Dec 1, 2023
12d7795
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Dec 26, 2023
7c8f768
Merge remote-tracking branch 'origin' into feat_vim_mode
rileyhawk1417 Jan 12, 2024
e623fda
fix: disable other keys affecting Vim Keys.
rileyhawk1417 Jan 14, 2024
d1fbd5d
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Nov 6, 2024
8c71f99
chore: resolve conflicts with remote.
rileyhawk1417 Nov 16, 2024
199600f
chore: update `h`, `l` keys to follow arrow keys
rileyhawk1417 Nov 20, 2024
e8ccce1
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Dec 27, 2024
e695d17
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Feb 22, 2025
0f01cd0
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Mar 11, 2025
130e5fb
feat: use vimFSM to catch key shortcuts
rileyhawk1417 Mar 11, 2025
5e3d425
feat: enable line jumping up & down the document
rileyhawk1417 Mar 11, 2025
3eab25d
chore: add note comment to handle transactions
rileyhawk1417 Mar 11, 2025
995e8f7
feat: add 'dd' regex to delete whole line
rileyhawk1417 Mar 11, 2025
cbbf0a5
fix: remove conflicting keys in vim shortcuts
rileyhawk1417 Mar 15, 2025
669bfbb
chore: prune comments & unused packages
rileyhawk1417 Mar 15, 2025
94723fc
test: commit test for (j, k) keys and one shortcut
rileyhawk1417 Mar 15, 2025
58d73e0
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Mar 17, 2025
5f902ac
fix: handle '0' key behavior and move it to vim_fsm.dart
rileyhawk1417 Mar 17, 2025
d66fae0
test: commit test for '0' key
rileyhawk1417 Mar 17, 2025
a9f66c5
refactor: remove guardrails blocking accidental edits
rileyhawk1417 Mar 18, 2025
7f26dd6
chore: remove vim keys from standard_block_components
rileyhawk1417 Mar 19, 2025
51295e2
fix: ensure pressing escape doesnt make the cursor disappear
rileyhawk1417 Mar 19, 2025
0632d85
chore: move some keys from vim.dart to vim_cursor.dart
rileyhawk1417 Mar 19, 2025
8aea597
chore: remove guard rails for editor transactions
rileyhawk1417 Mar 19, 2025
057b49b
test: write test for '0' & '$' keys
rileyhawk1417 Mar 19, 2025
3836765
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Mar 19, 2025
5733313
fix: ensure if vim mode is NormalMode dont attach input service
rileyhawk1417 Mar 27, 2025
e15af89
fix: ensure escape key does nothing if in normal mode
rileyhawk1417 Mar 27, 2025
0540036
fix: handle cases where user wants to go beyond doc length
rileyhawk1417 Mar 27, 2025
9bea128
chore: add print statements before testing with AppFlowy
rileyhawk1417 Mar 27, 2025
5bbba40
chore: add notes & comments about buffer reset logic
rileyhawk1417 Mar 27, 2025
f6b9865
test: commit working tests for basic movements in vim
rileyhawk1417 Mar 27, 2025
5eb397c
feat: handle extra vim keys
rileyhawk1417 Mar 31, 2025
9e413d3
refactor: use a vim state class to manage the buffer
rileyhawk1417 Mar 31, 2025
64512c7
test: add test for extra vim keys
rileyhawk1417 Mar 31, 2025
1f5cc31
feat: implement undo & redo in vim
rileyhawk1417 Apr 12, 2025
ba41870
test: add test for undo & redo vim command
rileyhawk1417 Apr 12, 2025
352ecce
doc: add technical doc for vim mode
rileyhawk1417 Apr 12, 2025
01531c1
Merge branch 'main' into feat_vim_mode
rileyhawk1417 Apr 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions documentation/vim-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Vim Mode

Vim mode is not meant to emulate all the vim functionality but instead provide partial emulation.
The editor was not built to have vim mode from the beginning. However this mode allows some functionality to be used.

## Why not leverage on the existing shortcuts system?

Leveraging on the shortcuts system was the inital solution however that showed its limitations.
In some cases if the vim shortcut did not recognize the key input as a vim command, it will treat it as regular input this would result in printing letters onto the editor.
Which is not meant to be the case with Vim. 'Normal Mode' does not allow any text input besides the known shortcuts/commands.
The proposed idea was to instead capture keyboard events directly without modifying the core editor itself.


### So how does it work?

Inside the `keyboard_service_widget.dart` is where the keyboard capture is happening. Below is the snippet that is used specifically for vim mode.

```dart
if (editorState.vimMode) {
if (editorState.mode == VimModes.normalMode) {
final VimCommandShortcutEvent vimCommandShortcutEvent =
VimCommandShortcutEvent();
final vimResult = vimCommandShortcutEvent.handleKey(event, editorState);
if (vimResult == KeyEventResult.handled) {
return KeyEventResult.handled;
}
}
}
```

We only execute the block inside the if statement if the Vim Mode was enabled from the beginning. Vim Mode can be activated from the main AppFlowy constructor.

```dart
AppFlowyEditor(
vimMode: true,
editorState: editorState,
// Other AppFlowy Commands
...
)
```

## Technical Details

The `VimCommandShortcutEvent` value being used in `keyboard_service_widget.dart` does extend the CommandShortcut class but its not entirely used.
Instead its more of a wrapper for the actual vim handler. This was done so the vim keys can be registered in AppFlowy and not break anything else in the process.


```dart
class VimCommandShortcutEvent extends CommandShortcutEvent {
final VimFSM vimFSM = VimFSM();
VimCommandShortcutEvent()
: super(
key: 'Vim FSM Handler',
command: '',
handler: _dummyHandler,
getDescription: () => 'Handles multi-key vim commands using an FSM',
);
KeyEventResult handleKey(KeyEvent event, EditorState editorState) {
return vimFSM.processKey(event, editorState);
}

static KeyEventResult _dummyHandler(EditorState editorState) {
return KeyEventResult.ignored;
}
}
```

### Vim Shortcuts handling

There are known keys for Vim to at least get around the document, such as `[h, j, k, l]` any other keys would be either extensions of the mentioned keys or new ones entirely.
Currently there are hard-coded keys which the Vim keyboard handler will check with for every key press. If the pressed keys are within the list of known keys they will trigger an action.
Any other key that is not known will just be ignored.


In Vim there is the concept of doing key combinations or awaiting key presses. For example `4j` means jump 4 lines down from the current position.
Normally `4` and `j` are registered as separate keys, however with VimMode we hold the keys in a buffer before we release them.
So if `4j` matches then an action is triggered however if we try `10m` that will not trigger anything because `m` is not a known key, so the buffer will be cleared.
1 change: 1 addition & 0 deletions example/lib/pages/desktop_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ class _DesktopEditorState extends State<DesktopEditor> {
child: Directionality(
textDirection: widget.textDirection,
child: AppFlowyEditor(
vimMode: true,
editorState: editorState,
editorScrollController: editorScrollController,
blockComponentBuilders: blockComponentBuilders,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,18 @@ class _BlockSelectionAreaState extends State<BlockSelectionArea> {
builder: ((context, value, child) {
final sizedBox = child ?? const SizedBox.shrink();
final selection = value?.normalized;

final editorState = context.watch<EditorState>();
if (selection == null) {
return sizedBox;
}

final path = widget.node.path;

if (!path.inSelection(selection)) {
return sizedBox;
}

final editorState = context.read<EditorState>();
//final editorState = context.read<EditorState>();
if (editorState.selectionType == SelectionType.block) {
if (!widget.supportTypes.contains(BlockSelectionType.block) ||
!path.inSelection(selection, isSameDepth: true) ||
Expand All @@ -125,8 +126,10 @@ class _BlockSelectionAreaState extends State<BlockSelectionArea> {
),
);
}

// show the cursor when the selection is collapsed
else if (selection.isCollapsed) {
else if (selection.isCollapsed &&
(editorState.mode == VimModes.insertMode || !editorState.vimMode)) {
if (!widget.supportTypes.contains(BlockSelectionType.cursor) ||
prevCursorRect == null) {
return sizedBox;
Expand All @@ -146,6 +149,25 @@ class _BlockSelectionAreaState extends State<BlockSelectionArea> {
// force to show the cursor
cursorKey.currentState?.unwrapOrNull<CursorState>()?.show();
return cursor;
} else if (selection.isCollapsed &&
editorState.mode == VimModes.normalMode &&
editorState.vimMode) {
if (!widget.supportTypes.contains(BlockSelectionType.selection) ||
prevSelectionRects == null) {
return sizedBox;
}

final rect = widget.delegate.getCursorRectInPosition(selection.start);
//NOTE: Hard coded cursor style
final cursor = Cursor(
key: cursorKey,
rect: rect ?? prevCursorRect!,
shouldBlink: false,
cursorStyle: CursorStyle.block,
color: Colors.blue,
);
cursorKey.currentState?.unwrapOrNull<CursorState>()?.show();
return cursor;
} else {
// show the selection area when the selection is not collapsed
if (!widget.supportTypes.contains(BlockSelectionType.selection) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ class _ParagraphBlockComponentWidgetState
mainAxisSize: MainAxisSize.min,
textDirection: textDirection,
children: [
/*
NOTE:
One can combine AppFlowyRichText
With a row then stick in the line number.
Only problem is the container doesnt maintain
its size
*/
AppFlowyRichText(
key: forwardKey,
delegate: this,
Expand Down
2 changes: 2 additions & 0 deletions lib/src/editor/block_component/standard_block_components.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:appflowy_editor/src/editor/block_component/heading_block_compone
import 'package:appflowy_editor/src/editor/util/platform_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command/vim.dart';

const standardBlockComponentConfiguration = BlockComponentConfiguration();

Expand Down Expand Up @@ -153,5 +154,6 @@ final List<CommandShortcutEvent> standardCommandShortcutEvents = [
// copy paste and cut
copyCommand,
...pasteCommands,
// ...vimKeyModes,
cutCommand,
];
12 changes: 12 additions & 0 deletions lib/src/editor/editor_component/service/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class AppFlowyEditor extends StatefulWidget {
List<List<ContextMenuItem>>? contextMenuItems,
this.contentInsertionConfiguration,
this.editable = true,
this.vimMode = false,
this.autoFocus = false,
this.focusedSelection,
this.shrinkWrap = false,
Expand Down Expand Up @@ -151,6 +152,12 @@ class AppFlowyEditor extends StatefulWidget {
/// If you want to disable the scroll service, you can set [disableScrollService] to true.
final bool editable;

/// Set the value to false to disable Vim Mode.
///
/// if false, the editor will work normally like any other
/// without the vim motion features and keybindings.
final bool vimMode;

/// Set the value to true to focus the editor on the start of the document.
final bool autoFocus;

Expand Down Expand Up @@ -253,6 +260,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {

_updateValues();
editorState.renderer = _renderer;
editorState.editable = widget.editable;
editorState.vimMode = widget.vimMode;

// auto focus
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
Expand All @@ -274,6 +283,9 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
void didUpdateWidget(covariant AppFlowyEditor oldWidget) {
super.didUpdateWidget(oldWidget);

editorState.editorStyle = widget.editorStyle;
editorState.editable = widget.editable;
editorState.vimMode = widget.vimMode;
_updateValues();

if (editorState.service != oldWidget.editorState.service) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/ime/delta_input_on_floating_cursor_update.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart';
import 'package:appflowy_editor/src/editor/util/platform_extension.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -168,6 +169,17 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
return KeyEventResult.ignored;
}

if (editorState.vimMode) {
if (editorState.mode == VimModes.normalMode) {
final VimCommandShortcutEvent vimCommandShortcutEvent =
VimCommandShortcutEvent();
final vimResult = vimCommandShortcutEvent.handleKey(event, editorState);
if (vimResult == KeyEventResult.handled) {
return KeyEventResult.handled;
}
}
}

if ((event is! KeyDownEvent && event is! KeyRepeatEvent) ||
!enableIMEShortcuts) {
if (textInputService.composingTextRange != TextRange.empty) {
Expand Down Expand Up @@ -235,6 +247,7 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
AppFlowyEditorLog.editor.debug(
'keyboard service - attach text input service: $textEditingValue',
);

if (textEditingValue != null) {
textInputService.attach(
textEditingValue,
Expand All @@ -258,6 +271,10 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
// This function is used to get the current text editing value of the editor
// based on the given selection.
TextEditingValue? _getCurrentTextEditingValue(Selection selection) {
// This is to avoid the attachment service from capturing some input
if (editorState.vimMode && editorState.mode != VimModes.insertMode) {
return null;
}
// Get all the editable nodes in the selection.
final editableNodes = editorState
.getNodesInSelection(selection)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,25 @@ class _DesktopSelectionServiceWidgetState
if (HardwareKeyboard.instance.isShiftPressed && _panStartPosition != null) {
selection = Selection(start: _panStartPosition!, end: position);
} else {

if (editorState.mode == VimModes.normalMode && editorState.vimMode){
//NOTE: It throws a transaction error when I mimic the else statement for selection
//NOTE: So settled for a single selection
selection = Selection.collapsed(position);
editorState.prevSelection = selection;

// Reset old start offset
_panStartOffset = offset;
} else {
selection = selectable.cursorStyle == CursorStyle.verticalLine
? Selection.collapsed(position)
: Selection(start: selectable.start(), end: selectable.end());

// Reset old start offset
_panStartPosition = position;
_panStartOffset = offset;

}

}

updateSelection(selection);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export 'shortcuts/character/character_shortcut_events.dart';
export 'shortcuts/character_shortcut_event.dart';
export 'shortcuts/command/command_shortcut_events.dart';
export 'shortcuts/command_shortcut_event.dart';
export 'shortcuts/vim_shortcut_event.dart';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
/// - desktop
/// - web
///
//TODO: Allow custom escape key
final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent(
key: 'exit the editing mode',
getDescription: () => AppFlowyEditorL10n.current.cmdExitEditing,
Expand All @@ -15,6 +16,20 @@ final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent(
);

CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) {
if (editorState.mode == VimModes.normalMode) {
return KeyEventResult.handled;
}
if (editorState.vimMode == true &&
editorState.editable == true &&
editorState.mode == VimModes.insertMode) {
editorState.prevSelection = editorState.selection;
editorState.selection = null;
editorState.mode = VimModes.normalMode;
editorState.editable = false;
editorState.selection = editorState.prevSelection;
editorState.service.keyboardService?.closeKeyboard();
return KeyEventResult.handled;
}
editorState.selection = null;
editorState.service.keyboardService?.closeKeyboard();
return KeyEventResult.handled;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,16 @@ KeyEventResult _toggleAttribute(
EditorState editorState,
String key,
) {
final selection = editorState.selection;
if (selection == null) {
return KeyEventResult.ignored;
}
//NOTE: This is a fix for vim mode. Some keys are conflicting
if (editorState.mode == VimModes.insertMode || editorState.vimMode == false) {
final selection = editorState.selection;
if (selection == null) {
return KeyEventResult.ignored;
}

editorState.toggleAttribute(key);
editorState.toggleAttribute(key);

return KeyEventResult.handled;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Loading
Loading