diff --git a/documentation/vim-mode.md b/documentation/vim-mode.md new file mode 100644 index 000000000..82bcd2544 --- /dev/null +++ b/documentation/vim-mode.md @@ -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. diff --git a/example/lib/pages/desktop_editor.dart b/example/lib/pages/desktop_editor.dart index 4371d9c39..384fd109c 100644 --- a/example/lib/pages/desktop_editor.dart +++ b/example/lib/pages/desktop_editor.dart @@ -87,6 +87,7 @@ class _DesktopEditorState extends State { child: Directionality( textDirection: widget.textDirection, child: AppFlowyEditor( + vimMode: true, editorState: editorState, editorScrollController: editorScrollController, blockComponentBuilders: blockComponentBuilders, diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index 8508a1b22..114db53d6 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -92,17 +92,18 @@ class _BlockSelectionAreaState extends State { builder: ((context, value, child) { final sizedBox = child ?? const SizedBox.shrink(); final selection = value?.normalized; - + final editorState = context.watch(); if (selection == null) { return sizedBox; } final path = widget.node.path; + if (!path.inSelection(selection)) { return sizedBox; } - final editorState = context.read(); + //final editorState = context.read(); if (editorState.selectionType == SelectionType.block) { if (!widget.supportTypes.contains(BlockSelectionType.block) || !path.inSelection(selection, isSameDepth: true) || @@ -125,8 +126,10 @@ class _BlockSelectionAreaState extends State { ), ); } + // 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; @@ -146,6 +149,25 @@ class _BlockSelectionAreaState extends State { // force to show the cursor cursorKey.currentState?.unwrapOrNull()?.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()?.show(); + return cursor; } else { // show the selection area when the selection is not collapsed if (!widget.supportTypes.contains(BlockSelectionType.selection) || diff --git a/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart b/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart index 76bae2ce0..512184258 100644 --- a/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart +++ b/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart @@ -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, diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index ae689a946..7d5a046ab 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -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(); @@ -153,5 +154,6 @@ final List standardCommandShortcutEvents = [ // copy paste and cut copyCommand, ...pasteCommands, + // ...vimKeyModes, cutCommand, ]; diff --git a/lib/src/editor/editor_component/service/editor.dart b/lib/src/editor/editor_component/service/editor.dart index 1101140bc..42d050151 100644 --- a/lib/src/editor/editor_component/service/editor.dart +++ b/lib/src/editor/editor_component/service/editor.dart @@ -29,6 +29,7 @@ class AppFlowyEditor extends StatefulWidget { List>? contextMenuItems, this.contentInsertionConfiguration, this.editable = true, + this.vimMode = false, this.autoFocus = false, this.focusedSelection, this.shrinkWrap = false, @@ -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; @@ -253,6 +260,8 @@ class _AppFlowyEditorState extends State { _updateValues(); editorState.renderer = _renderer; + editorState.editable = widget.editable; + editorState.vimMode = widget.vimMode; // auto focus WidgetsBinding.instance.addPostFrameCallback((timeStamp) { @@ -274,6 +283,9 @@ class _AppFlowyEditorState extends State { 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) { diff --git a/lib/src/editor/editor_component/service/keyboard_service_widget.dart b/lib/src/editor/editor_component/service/keyboard_service_widget.dart index fe7366e20..645218f35 100644 --- a/lib/src/editor/editor_component/service/keyboard_service_widget.dart +++ b/lib/src/editor/editor_component/service/keyboard_service_widget.dart @@ -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'; @@ -168,6 +169,17 @@ class KeyboardServiceWidgetState extends State 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) { @@ -235,6 +247,7 @@ class KeyboardServiceWidgetState extends State AppFlowyEditorLog.editor.debug( 'keyboard service - attach text input service: $textEditingValue', ); + if (textEditingValue != null) { textInputService.attach( textEditingValue, @@ -258,6 +271,10 @@ class KeyboardServiceWidgetState extends State // 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) diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index 76e51abe5..985e69c59 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -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); diff --git a/lib/src/editor/editor_component/service/shortcut_events.dart b/lib/src/editor/editor_component/service/shortcut_events.dart index c4d125a39..dde3ccfdc 100644 --- a/lib/src/editor/editor_component/service/shortcut_events.dart +++ b/lib/src/editor/editor_component/service/shortcut_events.dart @@ -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'; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart index 73a5bd804..fceec7fa5 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart @@ -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, @@ -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; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart b/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart index 53f7cd8f6..220f63446 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart @@ -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; } diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart new file mode 100644 index 000000000..291e5d966 --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -0,0 +1,258 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; +import 'dart:math'; + +final List vimKeyModes = [ + ///Insert Methods + + ///Vim Movements + /* + jumpUpCommand, + jumpDownCommand, + jumpLeftCommand, + jumpRightCommand, +*/ + + ///Vim Jump to line + //BUG: Won't work properly keyboard shortcut fails + // vimJumpToLineCommand, + + ///Page Movements + //NOTE: Conflicts with ctrl+b key + vimPageUpCommand, + vimHalfPageDownCommand, + vimPageDownCommand, + //BUG: Not working for some reason + //vimHalfPageUpCommand, + + ///Undo Commands + //BUG: These commands won't work not sure why but + //The undoManager doesnt work in normal mode + /* + There is an issue with transactions. + When the editor is in Normal mode, transactions are closed. What ever happens + then + well wont work. Problem is undo & redo needs that transaction window open. + Unless something is implemented to ignore all other keys unless they match + a VIM key? + */ + // vimUndoCommand, + // vimRedoCommand, + + ///Navigate line Commands + // vimMoveCursorToStartCommand, + //vimMoveCursorToEndCommand, + //BUG: Selection doesnt show up to user + // vimSelectLineCommand, + + ///Text operations + //BUG: Deleting at the end of text will cause the widget tree to panic + //NOTE: Probably try using the 'delete' button instead + // vimDeleteUnderCursorCommand, +]; + +//BUG: Selection does not show up in normal mode +final CommandShortcutEvent vimSelectLineCommand = CommandShortcutEvent( + key: 'enter insert mode from previous selection', + command: 'shift+v', + handler: _vimSelectLineCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdLineSelect, +); +/* +What we probably need is to follow the `select all command` +Except that we want to get the cursor position or line. +When we get line/position then select the whole line. +*/ +CommandShortcutEventHandler _vimSelectLineCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection == null || editorState.prevSelection != null) { + //BUG: Throwing issue on PropertyValueNotifier + final selection = editorState.selection; + editorState.selectionService.updateSelection(selection); + editorState.prevSelection = null; + final nodes = editorState.getNodesInSelection(selection!); + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + var end = selection.end; + final position = isRTL(editorState) + ? nodes.last.selectable?.end() + : nodes.last.selectable?.start(); + if (position != null) { + end = position; + } + editorState.updateSelectionWithReason( + selection.copyWith(end: end), + reason: SelectionUpdateReason.uiEvent, + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimPageDownCommand = CommandShortcutEvent( + key: 'scroll one page down in normal mode', + command: 'ctrl+f', + handler: _vimPageDownCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpPageDown, +); + +CommandShortcutEventHandler _vimPageDownCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = max(0, scrollService.dy); + if (scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + dy + scrollHeight, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; +//NOTE: Move the cursor as well when moving the page +final CommandShortcutEvent vimHalfPageDownCommand = CommandShortcutEvent( + key: 'scroll half page down in normal mode', + command: 'ctrl+d', + handler: _vimHalfPageDownCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpHalfPageDown, +); + +CommandShortcutEventHandler _vimHalfPageDownCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = max(0, scrollService.dy); + if (scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + (dy + scrollHeight) / 2, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimPageUpCommand = CommandShortcutEvent( + key: 'scroll one page up in normal mode', + command: 'ctrl+b', + handler: _vimPageUpCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpPageUp, +); + +CommandShortcutEventHandler _vimPageUpCommandHandler = (editorState) { + if (PlatformExtension.isMobile) { + assert(false, 'pageUpCommand is not supported on mobile platform.'); + return KeyEventResult.ignored; + } + + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = scrollService.dy; + if (dy <= 0 || scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + dy - scrollHeight, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimHalfPageUpCommand = CommandShortcutEvent( + key: 'scroll one page up in normal mode', + command: 'ctrl+u', + handler: _vimHalfPageUpCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpPageUp, +); + +CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { + if (PlatformExtension.isMobile) { + assert(false, 'pageUpCommand is not supported on mobile platform.'); + return KeyEventResult.ignored; + } + + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = scrollService.dy; + if (dy <= 0 || scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + dy - scrollHeight, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart index 92a1a6239..b7dae3d2e 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart @@ -100,6 +100,10 @@ class CommandShortcutEvent { this.command = command; matched = true; } + /*NOTE: This is the main thing to look at for multiple key binds + Though the problem is it has to listen out for anything. + So defining the keys before hand well might just not work + */ if (matched) { _keybindings = diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart new file mode 100644 index 000000000..24ef36100 --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart @@ -0,0 +1,295 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; +// import 'package:appflowy_editor/src/editor_state.dart'; + +void customMoveCursor( + EditorState editorState, + SelectionMoveDirection direction, [ + SelectionMoveRange range = SelectionMoveRange.character, +]) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + + // If the selection is not collapsed, then we want to collapse the selection + if (!selection.isCollapsed && range != SelectionMoveRange.line) { + // move the cursor to the start or end of the selection + editorState.selection = selection.collapse( + atStart: direction == SelectionMoveDirection.forward, + ); + return; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return; + } + + // Originally, I want to make this function as pure as possible, + // but I have to import the selectable here to compute the selection. + final start = node.selectable?.start(); + final end = node.selectable?.end(); + final offset = direction == SelectionMoveDirection.forward + ? selection.startIndex + : selection.endIndex; + { + // the cursor is at the start of the node + // move the cursor to the end of the previous node + if (direction == SelectionMoveDirection.forward && + start != null && + start.offset >= offset) { + final previousEnd = node + .previousNodeWhere((element) => element.selectable != null) + ?.selectable + ?.end(); + if (previousEnd != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(previousEnd), + reason: SelectionUpdateReason.uiEvent, + ); + } + return; + } + // the cursor is at the end of the node + // move the cursor to the start of the next node + else if (direction == SelectionMoveDirection.backward && + end != null && + end.offset <= offset) { + //Would have to handle if text has reached document end + if (end.offset == offset) { + editorState.insertText(end.offset, ' ', node: node); + final pos = Position(path: node.path, offset: end.offset + 1); + if (node.selectable?.end() != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(pos), + reason: SelectionUpdateReason.uiEvent, + ); + } + } + + return; + } + } + + //NOTE: Possibly support jumping to the next whitespace with "Shift+W" + final delta = node.delta; + switch (range) { + case SelectionMoveRange.character: + if (delta != null) { + // move the cursor to the left or right by one character + editorState.updateSelectionWithReason( + Selection.collapsed( + selection.start.copyWith( + offset: direction == SelectionMoveDirection.forward + ? delta.prevRunePosition(offset) + : delta.nextRunePosition(offset), + ), + ), + reason: SelectionUpdateReason.uiEvent, + ); + } else { + throw UnimplementedError(); + } + break; + default: + throw UnimplementedError(); + } +} + +class VimCursor { + static Position? processMotionKeys( + String key, + EditorState editorState, + Selection selection, + int count, + ) { + //final int docLength = editorState.document.root.children.length; + switch (key) { + case 'j': + { + //print('Here is the doc length!, $docLength'); + int tmpPos = count + selection.end.path.first; + //print('counter: $count'); + + Selection bottomLevel = Selection( + start: Position( + path: editorState.document.root.children.last.path, + offset: 0, + ), + end: Position( + path: editorState.document.root.children.last.path, + offset: 0, + ), + ); + + if (editorState.selection == bottomLevel) { + return Position( + path: editorState.document.root.children.last.path, + offset: 0, + ); + } + if (count > editorState.document.root.children.length) { + //print('Found a value out of range!, $count'); + /*print( + 'Here is the doc length!, ${editorState.document.root.children.length}'); + */ + return Position( + path: editorState.document.root.children.last.path, + offset: 0, + ); + } + if (tmpPos < editorState.document.root.children.length) { + //BUG: This causes editor to say null value on places where offset is empty + // Position(path: [tmpPos], offset: selection.end.offset ?? 0); + return Position(path: [tmpPos], offset: 0); + } + //newPosition = Position(path: [count+selection.end.path.first]); + } + + case 'k': + { + int tmpPos = selection.end.path.first - count; + Selection topLevel = Selection( + start: Position(path: [0], offset: 0), + end: Position( + path: [0], + offset: 0, + ), + ); + + if (editorState.selection == topLevel) { + return Position(path: [0], offset: 0); + } + + if (count > editorState.document.root.children.length) { + return Position(path: [0], offset: 0); + } + if (tmpPos < editorState.document.root.children.length) { + //BUG: This causes editor to say null value on places where offset is empty + // Position(path: [tmpPos], offset: selection.end.offset ?? 0); + return Position(path: [tmpPos], offset: 0); + } + } + case 'h': + return moveHorizontalMultiple( + editorState, + selection.end, + forward: true, + count: count, + ); + + case 'l': + return moveHorizontalMultiple( + editorState, + selection.end, + forward: false, + count: count, + ); + case 'i': + { + editorState.editable = true; + editorState.mode = VimModes.insertMode; + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'a': + { + editorState.editable = true; + editorState.mode = VimModes.insertMode; + editorState.selection = editorState.selection; + + customMoveCursor(editorState, SelectionMoveDirection.backward); + + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'o': + { + editorState.editable = true; + editorState.mode = VimModes.insertMode; + editorState.selection = editorState.selection; + final transaction = editorState.transaction; + final nextPosition = Position( + path: [editorState.selection!.end.path.first + 1], + offset: 0, + ); + final Node blankLine = paragraphNode(text: ''); + transaction.insertNode(nextPosition.path, blankLine); + transaction.afterSelection = Selection.collapsed(nextPosition); + transaction.customSelectionType = SelectionType.inline; + editorState.apply(transaction).then((value) => {}); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + case 'w': + { + editorState.selection = editorState.selection; + + editorState.moveCursor( + SelectionMoveDirection.backward, + SelectionMoveRange.word, + ); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'b': + { + editorState.selection = editorState.selection; + + editorState.moveCursor( + SelectionMoveDirection.forward, + SelectionMoveRange.word, + ); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'u': + { + editorState.undoManager.undo(); + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + default: + return null; + } + return null; + } +} diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart new file mode 100644 index 000000000..9033f4c0a --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; +import './vim_cursor.dart'; + +/* List of keys to implement +- 'ctrl+f' scroll one page down +- 'ctrl+d' scroll half page down +- 'ctrl+u' scroll one page up +*/ + +const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o', 'w', 'b', 'u']; + +void vimMoveCursorToStartHandler(EditorState editorState) { + if (!editorState.vimMode) {} + if (editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + if (selection == null) {} + if (isRTL(editorState)) { + editorState.moveCursorBackward(SelectionMoveRange.line); + } else { + editorState.moveCursorForward(SelectionMoveRange.line); + } + } +} + +void vimMoveCursorToEndHandler(EditorState editorState) { + if (!editorState.vimMode) {} + if (editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + if (selection == null) {} + if (isRTL(editorState)) { + editorState.moveCursorForward(SelectionMoveRange.line); + } else { + editorState.moveCursorBackward(SelectionMoveRange.line); + } + } +} + +Position deleteCurrentLine(EditorState editorState, int count) { + final selection = editorState.selection; + if (selection == null) return Position(path: [0], offset: 0); + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.delta == null) return Position(path: [0], offset: 0); + final startPath = selection.start.path.first; + final endPath = startPath + count - 1; + final tmpPosition = Position(path: [startPath], offset: 0); + final delta = node.delta; + for (int i = startPath; i <= endPath; i++) { + if (delta != null) { + final deletionSelection = Selection( + start: Position(path: selection.start.path, offset: 0), + end: Position(path: selection.end.path, offset: delta.length)); + editorState.selection = deletionSelection; + final transaction = editorState.transaction; + transaction.deleteNodesAtPath(editorState.selection!.start.path); + + editorState + .apply(transaction) + .then((value) => {editorState.selectionType = null}); + editorState.updateSelectionWithReason( + Selection(start: tmpPosition, end: tmpPosition), + reason: SelectionUpdateReason.uiEvent, + ); + } + } + return tmpPosition; +} + +/// Vim state to manage the buffers in between key events +class VimState { + String commandBuffer = ''; + String deleteBuffer = ''; + Timer? resetTimer; + void reset() { + commandBuffer = ''; + deleteBuffer = ''; + resetTimer?.cancel(); + } + + void scheduleReset() { + resetTimer?.cancel(); + resetTimer = Timer(const Duration(milliseconds: 500), reset); + } +} + +/// Vim State Machine, this class holds all the vim logic +class VimFSM { + static final VimFSM _instance = VimFSM._internal(); + factory VimFSM() => _instance; + VimFSM._internal(); + final _state = VimState(); + + KeyEventResult processKey(KeyEvent event, EditorState editorState) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + _state.scheduleReset(); + + final key = event.logicalKey.keyLabel.toLowerCase(); + if (baseKeys.contains(key)) { + final count = _parseCount(_state.commandBuffer); + final selection = editorState.selection; + if (selection == null) return KeyEventResult.ignored; + + Position? newPosition = + VimCursor.processMotionKeys(key, editorState, selection, count); + + if (newPosition != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(newPosition), + reason: SelectionUpdateReason.uiEvent, + ); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command: $key', + ); + return KeyEventResult.handled; + } + } + String keySequence = _buildKeySequence(event); + _state.commandBuffer += keySequence; + if (_handleSpecialCommands(editorState)) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + int _parseCount(String buffer) { + final match = RegExp(r'^(\d+)').firstMatch(buffer); + if (match != null) { + return int.tryParse(match.group(1) ?? '') ?? 1; + } + return 1; + } + + String _buildKeySequence(KeyEvent event) { + final key = event.logicalKey.keyLabel.toLowerCase(); + if (key.startsWith('shift')) { + return key; + } + return key; + } + +//TODO: Handle other keys from vim.dart + bool _handleSpecialCommands(EditorState editorState) { + final buffer = _state.commandBuffer; + if (buffer == 'shift left4' || buffer == 'shift right4') { + vimMoveCursorToEndHandler(editorState); + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); + _state.reset(); + return true; + } + + if (buffer == '0') { + vimMoveCursorToStartHandler(editorState); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); + _state.reset(); + return true; + } + //NOTE: Does not handle blocks other than text yet. + //BUG: Breaks tables + if (buffer.endsWith('dd')) { + final count = _parseCount(buffer.substring(0, buffer.length - 2)); + final position = deleteCurrentLine(editorState, count); + editorState.selection = Selection(start: position, end: position); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); + _state.reset(); + return true; + } + if (buffer == 'control leftr' || buffer == 'control rightr') { + final prevSelection = editorState.selection; + //NOTE: Currently the redo stack can only go one level. undo_manager.dart + editorState.undoManager.redo(); + editorState.selection = prevSelection; + editorState.selectionService.updateSelection(prevSelection); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); + _state.reset(); + return true; + } + return false; + } +} diff --git a/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart new file mode 100644 index 000000000..13595c51f --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart @@ -0,0 +1,21 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart'; +import 'package:flutter/widgets.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; + } +} diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 149190a7b..f1cfc431f 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -62,6 +62,12 @@ enum SelectionUpdateReason { searchHighlight, // Highlighting search results } +//Enum for VIM Mode +enum VimModes { + insertMode, + normalMode, +} + enum SelectionType { inline, block, @@ -141,9 +147,21 @@ class EditorState { final PropertyValueNotifier selectionNotifier = PropertyValueNotifier(null); + /// The previous selection notifier of the editor. + final PropertyValueNotifier prevSelectionNotifier = + PropertyValueNotifier(null); + /// The selection of the editor. Selection? get selection => selectionNotifier.value; + /// The previous selection of the editor. + Selection? get prevSelection => prevSelectionNotifier.value; + + var mode = VimModes.insertMode; + + /// Whether Vim mode is enabled or not + bool vimMode = false; + /// Remote selection is the selection from other users. final PropertyValueNotifier> remoteSelections = PropertyValueNotifier>([]); @@ -161,6 +179,12 @@ class EditorState { selectionNotifier.value = value; } + /// Sets the previous selection of the editor. + set prevSelection(Selection? value) { + prevSelectionNotifier.value = value; + } + + //SelectionType? selectionType; SelectionType? _selectionType; set selectionType(SelectionType? value) { @@ -400,10 +424,6 @@ class EditorState { bool withUpdateSelection = true, bool skipHistoryDebounce = false, }) async { - if (!editable || isDisposed) { - return; - } - // it's a time consuming task, only enable it if necessary. if (_enableCheckIntegrity) { document.root.checkDocumentIntegrity(); diff --git a/lib/src/extensions/vim_shortcut_extensions.dart b/lib/src/extensions/vim_shortcut_extensions.dart new file mode 100644 index 000000000..ed3fa2635 --- /dev/null +++ b/lib/src/extensions/vim_shortcut_extensions.dart @@ -0,0 +1,29 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +Position? moveVerticalMultiple( + EditorState editorState, + Position startPosition, { + required bool upwards, + required int count, +}) { + Position? current = startPosition; + for (int i = 0; i < count; i++) { + current = current?.moveVertical(editorState, upwards: upwards); + if (current == null) break; + } + return current; +} + +Position? moveHorizontalMultiple( + EditorState editorState, + Position startPosition, { + required bool forward, + required int count, +}) { + Position? current = startPosition; + for (int i = 0; i < count; i++) { + current = current?.moveHorizontal(editorState, forward: forward); + if (current == null) break; + } + return current; +} diff --git a/lib/src/l10n/l10n.dart b/lib/src/l10n/l10n.dart index a2a7a01db..4972f8822 100644 --- a/lib/src/l10n/l10n.dart +++ b/lib/src/l10n/l10n.dart @@ -1880,6 +1880,116 @@ class AppFlowyEditorLocalizations { args: [], ); } + /// `vim insert below` + String get cmdInsertBelow { + return Intl.message('insert below', + name: 'cmdInsertBelow', desc: '', args: []); + } + + /// `vim insert at current position` + String get cmdInsertCurrentPos { + return Intl.message('insert at current position', + name: 'cmdInsertCurrentPos', desc: '', args: []); + } + + /// `vim insert after current position` + String get cmdInsertNextPos { + return Intl.message('insert at next position', + name: 'cmdInsertNextPos', desc: '', args: []); + } + + /// `vim jump down` + String get cmdJumpDown { + return Intl.message('navigate down', + name: 'cmdJumpDown', desc: '', args: []); + } + + /// `vim jump up` + String get cmdJumpUp { + return Intl.message('navigate up', name: 'cmdJumpUp', desc: '', args: []); + } + + /// `vim jump left` + String get cmdJumpLeft { + return Intl.message('navigate left', + name: 'cmdJumpLeft', desc: '', args: []); + } + + /// `vim jump right` + String get cmdJumpRight { + return Intl.message('navigate right', + name: 'cmdJumpRight', desc: '', args: []); + } + + /// `vim line select` + String get cmdLineSelect { + return Intl.message('line select', + name: 'cmdLineSelect', desc: '', args: []); + } + + /// `vim undo command` + String get cmdVimUndo { + return Intl.message('undo', name: 'cmdVimUndo', desc: '', args: []); + } + + /// `vim redo command` + String get cmdVimRedo { + return Intl.message('redo', name: 'cmdVimRedo', desc: '', args: []); + } + + /// `vim jump down one page command` + String get cmdVimJumpPageDown { + return Intl.message('jump one page down', + name: 'cmdVimJumpPageDown', desc: '', args: []); + } + + /// `vim jump down half page command` + String get cmdVimJumpHalfPageDown { + return Intl.message('jump half a page down', + name: 'cmdVimJumpHalfPageDown', desc: '', args: []); + } + + /// `vim jump up page command` + String get cmdVimJumpPageUp { + return Intl.message('jump a page up', + name: 'cmdVimJumpPageUp', desc: '', args: []); + } + + /// `vim jump to start of line command` + String get cmdVimJumpFirstChar { + return Intl.message('jump to beginning of the line', + name: 'cmdVimJumpFirstChar', desc: '', args: []); + } + + /// `vim jump to end of line command` + String get cmdVimJumpEndChar { + return Intl.message('jump to end of the line', + name: 'cmdVimJumpEndChar', desc: '', args: []); + } + + /// `vim move left by word command` + String get cmdVimBackWordJump { + return Intl.message('jump to start of each word but backwards', + name: 'cmdVimBackWordJump', desc: '', args: []); + } + + /// `vim move right by word command` + String get cmdVimForwardWordJump { + return Intl.message('jump to start of each word but forwards', + name: 'cmdVimForwardWordJump', desc: '', args: []); + } + + /// `vim delete character under cursor command` + String get cmdVimDeleteCharCursor { + return Intl.message('delete character under cursor', + name: 'cmdVimDeleteCharCursor', desc: '', args: []); + } + + /// `vim jump around number command` + String get cmdVimNumJumper { + return Intl.message('jump around with numbers', + name: 'cmdVimNumJumper', desc: '', args: []); +} } class AppLocalizationDelegate diff --git a/lib/src/render/selection/cursor.dart b/lib/src/render/selection/cursor.dart index e4c6c4dc4..8cbbc988e 100644 --- a/lib/src/render/selection/cursor.dart +++ b/lib/src/render/selection/cursor.dart @@ -82,6 +82,12 @@ class CursorState extends State { border: Border.all(color: color, width: 2), ), ); + case CursorStyle.block: + return Container( + decoration: BoxDecoration( + border: Border.all(color: color, width: 4), + ), + ); case CursorStyle.cover: final size = widget.rect.size; return Container( diff --git a/lib/src/render/selection/selectable.dart b/lib/src/render/selection/selectable.dart index b78e1e9cb..bbd335103 100644 --- a/lib/src/render/selection/selectable.dart +++ b/lib/src/render/selection/selectable.dart @@ -2,11 +2,7 @@ import 'package:appflowy_editor/src/core/location/position.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:flutter/material.dart'; -enum CursorStyle { - verticalLine, - borderLine, - cover, -} +enum CursorStyle { verticalLine, borderLine, cover, block } /// [SelectableMixin] is used for the editor to calculate the position /// and size of the selection. @@ -89,6 +85,7 @@ mixin SelectableMixin on State { bool get shouldCursorBlink => true; CursorStyle get cursorStyle => CursorStyle.verticalLine; + CursorStyle get blockCursorStyle => CursorStyle.block; Rect transformRectToGlobal( Rect r, { diff --git a/test/service/shortcut_event/vim/movement_keys_test.dart b/test/service/shortcut_event/vim/movement_keys_test.dart new file mode 100644 index 000000000..66d24b64b --- /dev/null +++ b/test/service/shortcut_event/vim/movement_keys_test.dart @@ -0,0 +1,340 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../new/infra/testable_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('vim_fsm.dart', () { + testWidgets('vim normal mode [j, k] horizontal keys (up/down)', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [1], startOffset: text2.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.keyK); + expect( + editor.selection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressKey(key: LogicalKeyboardKey.keyJ); + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode horizontal keys left to right [h -> l]', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + for (var i = 0; i < text1.length; i++) { + await editor.pressKey(key: LogicalKeyboardKey.keyL); + + if (i == text1.length - 1) { + // Wrap to next node if the cursor is at the end of the current node. + expect( + editor.selection, + Selection.single( + path: [1], + startOffset: 0, + ), + ); + } else { + final delta = editor.nodeAtPath([0])!.delta!; + expect( + editor.selection, + Selection.single( + path: [0], + startOffset: delta.nextRunePosition(i), + ), + ); + } + } + await editor.dispose(); + }); + + testWidgets('vim normal mode horizontal keys right to left [l -> h]', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + for (var i = 0; i < text1.length; i++) { + await editor.pressKey(key: LogicalKeyboardKey.keyL); + + if (i == text1.length - 1) { + // Wrap to next node if the cursor is at the end of the current node. + expect( + editor.selection, + Selection.single( + path: [1], + startOffset: 0, + ), + ); + } else { + final delta = editor.nodeAtPath([0])!.delta!; + expect( + editor.selection, + Selection.single( + path: [0], + startOffset: delta.nextRunePosition(i), + ), + ); + } + } + await editor.dispose(); + }); + + testWidgets('vim normal mode move cursor to start', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [1], startOffset: text.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode move cursor to end', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [1], startOffset: text.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit4, isShiftPressed: true); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: text.length), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode jump 400 lines down out of bounds', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(60, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit4); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyJ); + expect( + editor.selection, + Selection.single(path: [59], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode jump 40 lines down', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(100, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit4); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyJ); + expect( + editor.selection, + Selection.single(path: [40], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode jump 40 lines up', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(60, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [60], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit4); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyK); + expect( + editor.selection, + Selection.single(path: [20], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets( + 'vim normal mode jump 10 characters to the right on current line', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit1); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyL); + expect( + editor.selection, + Selection.single(path: [0], startOffset: 10), + ); + + await editor.dispose(); + }); + testWidgets('vim normal mode navigate by word', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.keyW); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: 7), + ); + + await editor.dispose(); + }); + + testWidgets( + 'vim normal mode jump 10 characters to the left on current line', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: text.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit1); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyH); + expect( + editor.selection, + Selection.single(path: [0], startOffset: 11), + ); + + await editor.dispose(); + }); + }); +} diff --git a/test/service/shortcut_event/vim/shortcut_keys_test.dart b/test/service/shortcut_event/vim/shortcut_keys_test.dart new file mode 100644 index 000000000..3128728da --- /dev/null +++ b/test/service/shortcut_event/vim/shortcut_keys_test.dart @@ -0,0 +1,197 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'dart:io' show Platform; + +import '../../../new/infra/testable_editor.dart'; +import '../../../new/util/util.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + group('vim.dart', () { + testWidgets('vim normal mode move cursor to start', (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [1], offset: text2.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit0, + ); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + testWidgets('vim normal mode move cursor to end from start', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.collapsed(Position(path: [1], offset: 0)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit4, + isShiftPressed: true, + ); + if (Platform.isWindows || Platform.isLinux) { + expect( + editor.selection, + Selection.single(path: [1], startOffset: text2.length), + ); + } + + await editor.dispose(); + }); + + testWidgets('vim normal mode delete 2 lines (2dd)', (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor..addParagraphs(6, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [1], offset: text2.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit2, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + expect(editor.document.root.children.length, equals(4)); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + testWidgets('vim normal mode delete 1 line (dd)', (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor..addParagraphs(6, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [1], offset: text2.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + expect(editor.document.root.children.length, equals(5)); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode delete then perform undo and redo', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor..addParagraphs(6, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [0], offset: text1.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + await tester.pumpAndSettle(); + + expect(editor.document.root.children.length, equals(5)); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressKey(key: LogicalKeyboardKey.keyU); + await tester.pumpAndSettle(); + expect(editor.document.root.children.length, equals(6)); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: text1.length), + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyR, isControlPressed: true); + await tester.pumpAndSettle(); + + expect(editor.document.root.children.length, equals(5)); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: text1.length), + ); + + await editor.dispose(); + }); + }); +}