diff --git a/lib/base.coffee b/lib/base.coffee deleted file mode 100644 index 1e7aad70a..000000000 --- a/lib/base.coffee +++ /dev/null @@ -1,389 +0,0 @@ -# To avoid loading underscore-plus and depending underscore on startup -__plus = null -_plus = -> - __plus ?= require 'underscore-plus' - -Delegato = require 'delegato' -settings = require './settings' - -[ - CSON - path - selectList - getEditorState # set by Base.init() -] = [] # set null - -VMP_LOADING_FILE = null -VMP_LOADED_FILES = [] - -loadVmpOperationFile = (filename) -> - # Call to loadVmpOperationFile can be nested. - # 1. require("./operator-transform-string") - # 2. in operator-transform-string.coffee call Base.getClass("Operator") cause operator.coffee required. - # So we have to save original VMP_LOADING_FILE and restore it after require finished. - vmpLoadingFileOriginal = VMP_LOADING_FILE - VMP_LOADING_FILE = filename - require(filename) - VMP_LOADING_FILE = vmpLoadingFileOriginal - - VMP_LOADED_FILES.push(filename) - -OperationAbortedError = null - -vimStateMethods = [ - "onDidChangeSearch" - "onDidConfirmSearch" - "onDidCancelSearch" - "onDidCommandSearch" - - # Life cycle of operationStack - "onDidSetTarget", "emitDidSetTarget" - "onWillSelectTarget", "emitWillSelectTarget" - "onDidSelectTarget", "emitDidSelectTarget" - "onDidFailSelectTarget", "emitDidFailSelectTarget" - - "onWillFinishMutation", "emitWillFinishMutation" - "onDidFinishMutation", "emitDidFinishMutation" - "onDidFinishOperation" - "onDidResetOperationStack" - - "onDidSetOperatorModifier" - - "onWillActivateMode" - "onDidActivateMode" - "preemptWillDeactivateMode" - "onWillDeactivateMode" - "onDidDeactivateMode" - - "onDidCancelSelectList" - "subscribe" - "isMode" - "getBlockwiseSelections" - "getLastBlockwiseSelection" - "addToClassList" - "getConfig" -] - -class Base - Delegato.includeInto(this) - @delegatesMethods(vimStateMethods..., toProperty: 'vimState') - @delegatesProperty('mode', 'submode', 'swrap', 'utils', toProperty: 'vimState') - - constructor: (@vimState, properties=null) -> - {@editor, @editorElement, @globalState, @swrap} = @vimState - @name = @constructor.name - Object.assign(this, properties) if properties? - - # To override - initialize: -> - resetState: -> - - # Operation processor execute only when isComplete() return true. - # If false, operation processor postpone its execution. - isComplete: -> - if @requireInput and not @input? - false - else if @requireTarget - # When this function is called in Base::constructor - # tagert is still string like `MoveToRight`, in this case isComplete - # is not available. - @target?.isComplete?() - else - true - - requireTarget: false - requireInput: false - recordable: false - repeated: false - target: null # Set in Operator - operator: null # Set in operator's target( Motion or TextObject ) - - isAsTargetExceptSelectInVisualMode: -> - @operator? and not @operator.instanceof('SelectInVisualMode') - - abort: -> - OperationAbortedError ?= require './errors' - throw new OperationAbortedError('aborted') - - # Count - # ------------------------- - count: null - defaultCount: 1 - getCount: (offset=0) -> - @count ?= @vimState.getCount() ? @defaultCount - @count + offset - - resetCount: -> - @count = null - - isDefaultCount: -> - @count is @defaultCount - - # Misc - # ------------------------- - countTimes: (last, fn) -> - return if last < 1 - - stopped = false - stop = -> stopped = true - for count in [1..last] - isFinal = count is last - fn({count, isFinal, stop}) - break if stopped - - activateMode: (mode, submode) -> - @onDidFinishOperation => - @vimState.activate(mode, submode) - - activateModeIfNecessary: (mode, submode) -> - unless @vimState.isMode(mode, submode) - @activateMode(mode, submode) - - new: (name, properties) -> - klass = Base.getClass(name) - new klass(@vimState, properties) - - # FIXME: This is used to clone Motion::Search to support `n` and `N` - # But manual reseting and overriding property is bug prone. - # Should extract as search spec object and use it by - # creating clean instance of Search. - clone: (vimState) -> - properties = {} - excludeProperties = ['editor', 'editorElement', 'globalState', 'vimState', 'operator'] - for own key, value of this when key not in excludeProperties - properties[key] = value - klass = this.constructor - new klass(vimState, properties) - - cancelOperation: -> - @vimState.operationStack.cancel(this) - - processOperation: -> - @vimState.operationStack.process() - - focusSelectList: (options={}) -> - @onDidCancelSelectList => - @cancelOperation() - selectList ?= new (require './select-list') - selectList.show(@vimState, options) - - input: null - focusInput: (options = {}) -> - options.onConfirm ?= (@input) => @processOperation() - options.onCancel ?= => @cancelOperation() - options.onChange ?= (input) => @vimState.hover.set(input) - @vimState.focusInput(options) - - readChar: -> - @vimState.readChar - onConfirm: (@input) => @processOperation() - onCancel: => @cancelOperation() - - getVimEofBufferPosition: -> - @utils.getVimEofBufferPosition(@editor) - - getVimLastBufferRow: -> - @utils.getVimLastBufferRow(@editor) - - getVimLastScreenRow: -> - @utils.getVimLastScreenRow(@editor) - - getWordBufferRangeAndKindAtBufferPosition: (point, options) -> - @utils.getWordBufferRangeAndKindAtBufferPosition(@editor, point, options) - - getFirstCharacterPositionForBufferRow: (row) -> - @utils.getFirstCharacterPositionForBufferRow(@editor, row) - - getBufferRangeForRowRange: (rowRange) -> - @utils.getBufferRangeForRowRange(@editor, rowRange) - - getIndentLevelForBufferRow: (row) -> - @utils.getIndentLevelForBufferRow(@editor, row) - - scanForward: (args...) -> - @utils.scanEditorInDirection(@editor, 'forward', args...) - - scanBackward: (args...) -> - @utils.scanEditorInDirection(@editor, 'backward', args...) - - getFoldEndRowForRow: (args...) -> - @utils.getFoldEndRowForRow(@editor, args...) - - instanceof: (klassName) -> - this instanceof Base.getClass(klassName) - - is: (klassName) -> - this.constructor is Base.getClass(klassName) - - isOperator: -> - @constructor.operationKind is 'operator' - - isMotion: -> - @constructor.operationKind is 'motion' - - isTextObject: -> - @constructor.operationKind is 'text-object' - - getCursorBufferPosition: -> - if @mode is 'visual' - @getCursorPositionForSelection(@editor.getLastSelection()) - else - @editor.getCursorBufferPosition() - - getCursorBufferPositions: -> - if @mode is 'visual' - @editor.getSelections().map(@getCursorPositionForSelection.bind(this)) - else - @editor.getCursorBufferPositions() - - getBufferPositionForCursor: (cursor) -> - if @mode is 'visual' - @getCursorPositionForSelection(cursor.selection) - else - cursor.getBufferPosition() - - getCursorPositionForSelection: (selection) -> - @swrap(selection).getBufferPositionFor('head', from: ['property', 'selection']) - - toString: -> - str = @name - if @target? - str += ", target=#{@target.name}, target.wise=#{@target.wise} " - else if @operator? - str += ", wise=#{@wise} , operator=#{@operator.name}" - else - str - - # Class methods - # ------------------------- - @writeCommandTableOnDisk: -> - commandTable = @generateCommandTableByEagerLoad() - _ = _plus() - if _.isEqual(@commandTable, commandTable) - atom.notifications.addInfo("No change commandTable", dismissable: true) - return - - CSON ?= require 'season' - path ?= require('path') - - loadableCSONText = """ - # This file is auto generated by `vim-mode-plus:write-command-table-on-disk` command. - # DONT edit manually. - module.exports = - #{CSON.stringify(commandTable)}\n - """ - commandTablePath = path.join(__dirname, "command-table.coffee") - atom.workspace.open(commandTablePath).then (editor) -> - editor.setText(loadableCSONText) - editor.save() - atom.notifications.addInfo("Updated commandTable", dismissable: true) - - @generateCommandTableByEagerLoad: -> - # NOTE: changing order affects output of lib/command-table.coffee - filesToLoad = [ - './operator', './operator-insert', './operator-transform-string', - './motion', './motion-search', './text-object', './misc-command' - ] - filesToLoad.forEach(loadVmpOperationFile) - _ = _plus() - klasses = _.values(@getClassRegistry()) - klassesGroupedByFile = _.groupBy(klasses, (klass) -> klass.VMP_LOADING_FILE) - - commandTable = {} - for file in filesToLoad - for klass in klassesGroupedByFile[file] - commandTable[klass.name] = klass.getSpec() - commandTable - - @commandTable: null - @init: (_getEditorState) -> - getEditorState = _getEditorState - @commandTable = require('./command-table') - subscriptions = [] - for name, spec of @commandTable when spec.commandName? - subscriptions.push(@registerCommandFromSpec(name, spec)) - return subscriptions - - classRegistry = {Base} - @extend: (@command=true) -> - @VMP_LOADING_FILE = VMP_LOADING_FILE - if @name of classRegistry - console.warn("Duplicate constructor #{@name}") - classRegistry[@name] = this - - @initClass = @extend - - @getSpec: -> - if @isCommand() - file: @VMP_LOADING_FILE - commandName: @getCommandName() - commandScope: @getCommandScope() - else - file: @VMP_LOADING_FILE - - @getClass: (name) -> - return klass if (klass = classRegistry[name]) - - fileToLoad = @commandTable[name].file - if fileToLoad not in VMP_LOADED_FILES - if atom.inDevMode() and settings.get('debug') - console.log "lazy-require: #{fileToLoad} for #{name}" - loadVmpOperationFile(fileToLoad) - return klass if (klass = classRegistry[name]) - - throw new Error("class '#{name}' not found") - - @getClassRegistry: -> - classRegistry - - @isCommand: -> - @command - - @commandPrefix: 'vim-mode-plus' - @getCommandName: -> - @commandPrefix + ':' + _plus().dasherize(@name) - - @getCommandNameWithoutPrefix: -> - _plus().dasherize(@name) - - @commandScope: 'atom-text-editor' - @getCommandScope: -> - @commandScope - - @getDesctiption: -> - if @hasOwnProperty("description") - @description - else - null - - @registerCommand: -> - klass = this - atom.commands.add @getCommandScope(), @getCommandName(), (event) -> - vimState = getEditorState(@getModel()) ? getEditorState(atom.workspace.getActiveTextEditor()) - if vimState? # Possibly undefined See #85 - vimState.operationStack.run(klass) - event.stopPropagation() - - @registerCommandFromSpec: (name, spec) -> - {commandScope, commandPrefix, commandName, getClass} = spec - commandScope ?= 'atom-text-editor' - commandName ?= (commandPrefix ? 'vim-mode-plus') + ':' + _plus().dasherize(name) - atom.commands.add commandScope, commandName, (event) -> - vimState = getEditorState(@getModel()) ? getEditorState(atom.workspace.getActiveTextEditor()) - if vimState? # Possibly undefined See #85 - if getClass? - vimState.operationStack.run(getClass(name)) - else - vimState.operationStack.run(name) - event.stopPropagation() - - # For demo-mode pkg integration - @operationKind: null - @getKindForCommandName: (command) -> - command = command.replace(/^vim-mode-plus:/, "") - _ = _plus() - name = _.capitalize(_.camelize(command)) - if name of classRegistry - classRegistry[name].operationKind - -module.exports = Base diff --git a/lib/base.js b/lib/base.js new file mode 100644 index 000000000..adf0ea506 --- /dev/null +++ b/lib/base.js @@ -0,0 +1,454 @@ +"use babel" + +const settings = require("./settings") + +let CSON, path, selectList, OperationAbortedError, __plus +const CLASS_REGISTRY = {} + +function _plus() { + return __plus || (__plus = require("underscore-plus")) +} + +let VMP_LOADING_FILE +function loadVmpOperationFile(filename) { + // Call to loadVmpOperationFile can be nested. + // 1. require("./operator-transform-string") + // 2. in operator-transform-string.coffee call Base.getClass("Operator") cause operator.coffee required. + // So we have to save original VMP_LOADING_FILE and restore it after require finished. + const preserved = VMP_LOADING_FILE + VMP_LOADING_FILE = filename + require(filename) + VMP_LOADING_FILE = preserved +} + +class Base { + static commandTable = null + static commandPrefix = "vim-mode-plus" + static commandScope = "atom-text-editor" + static operationKind = null + static getEditorState = null // set through init() + + requireTarget = false + requireInput = false + recordable = false + repeated = false + target = null + operator = null + count = null + defaultCount = 1 + input = null + + get name() { + return this.constructor.name + } + + constructor(vimState) { + this.vimState = vimState + } + + // NOTE: initialize() must return `this` + initialize() { + return this + } + + // Called both on cancel and success + resetState() {} + + // Operation processor execute only when isComplete() return true. + // If false, operation processor postpone its execution. + isComplete() { + if (this.requireInput && this.input == null) { + return false + } else if (this.requireTarget) { + // When this function is called in Base::constructor + // tagert is still string like `MoveToRight`, in this case isComplete + // is not available. + return !!this.target && this.target.isComplete() + } else { + return true // Set in operator's target( Motion or TextObject ) + } + } + + isAsTargetExceptSelectInVisualMode() { + return this.operator && !this.operator.instanceof("SelectInVisualMode") + } + + abort() { + if (!OperationAbortedError) OperationAbortedError = require("./errors") + throw new OperationAbortedError("aborted") + } + + getCount(offset = 0) { + if (this.count == null) { + this.count = this.vimState.hasCount() ? this.vimState.getCount() : this.defaultCount + } + return this.count + offset + } + + resetCount() { + this.count = null + } + + countTimes(last, fn) { + if (last < 1) return + + let stopped = false + const stop = () => (stopped = true) + for (let count = 1; count <= last; count++) { + fn({count, isFinal: count === last, stop}) + if (stopped) break + } + } + + activateMode(mode, submode) { + this.onDidFinishOperation(() => this.vimState.activate(mode, submode)) + } + + activateModeIfNecessary(mode, submode) { + if (!this.vimState.isMode(mode, submode)) { + this.activateMode(mode, submode) + } + } + + getInstance(name, properties) { + return this.constructor.getInstance(this.vimState, name, properties) + } + + cancelOperation() { + this.vimState.operationStack.cancel(this) + } + + processOperation() { + this.vimState.operationStack.process() + } + + focusSelectList(options = {}) { + this.onDidCancelSelectList(() => this.cancelOperation()) + if (!selectList) { + selectList = new (require("./select-list"))() + } + selectList.show(this.vimState, options) + } + + focusInput(options = {}) { + if (!options.onConfirm) { + options.onConfirm = input => { + this.input = input + this.processOperation() + } + } + if (!options.onCancel) { + options.onCancel = () => this.cancelOperation() + } + if (!options.onChange) { + options.onChange = input => this.vimState.hover.set(input) + } + this.vimState.focusInput(options) + } + + readChar() { + this.vimState.readChar({ + onConfirm: input => { + this.input = input + this.processOperation() + }, + onCancel: () => this.cancelOperation(), + }) + } + + getVimEofBufferPosition() { + return this.utils.getVimEofBufferPosition(this.editor) + } + + getVimLastBufferRow() { + return this.utils.getVimLastBufferRow(this.editor) + } + + getVimLastScreenRow() { + return this.utils.getVimLastScreenRow(this.editor) + } + + getWordBufferRangeAndKindAtBufferPosition(point, options) { + return this.utils.getWordBufferRangeAndKindAtBufferPosition(this.editor, point, options) + } + + getFirstCharacterPositionForBufferRow(row) { + return this.utils.getFirstCharacterPositionForBufferRow(this.editor, row) + } + + getBufferRangeForRowRange(rowRange) { + return this.utils.getBufferRangeForRowRange(this.editor, rowRange) + } + + scanForward(...args) { + return this.utils.scanEditorInDirection(this.editor, "forward", ...args) + } + + scanBackward(...args) { + return this.utils.scanEditorInDirection(this.editor, "backward", ...args) + } + + getFoldStartRowForRow(...args) { + return this.utils.getFoldStartRowForRow(this.editor, ...args) + } + + getFoldEndRowForRow(...args) { + return this.utils.getFoldEndRowForRow(this.editor, ...args) + } + + instanceof(klassName) { + return this instanceof Base.getClass(klassName) + } + + is(klassName) { + return this.constructor === Base.getClass(klassName) + } + + isOperator() { + // Don't use `instanceof` to postpone require for faster activation. + return this.constructor.operationKind === "operator" + } + + isMotion() { + // Don't use `instanceof` to postpone require for faster activation. + return this.constructor.operationKind === "motion" + } + + isTextObject() { + // Don't use `instanceof` to postpone require for faster activation. + return this.constructor.operationKind === "text-object" + } + + getCursorBufferPosition() { + return this.mode === "visual" + ? this.getCursorPositionForSelection(this.editor.getLastSelection()) + : this.editor.getCursorBufferPosition() + } + + getCursorBufferPositions() { + return this.mode === "visual" + ? this.editor.getSelections().map(this.getCursorPositionForSelection.bind(this)) + : this.editor.getCursorBufferPositions() + } + + getBufferPositionForCursor(cursor) { + return this.mode === "visual" ? this.getCursorPositionForSelection(cursor.selection) : cursor.getBufferPosition() + } + + getCursorPositionForSelection(selection) { + return this.swrap(selection).getBufferPositionFor("head", {from: ["property", "selection"]}) + } + + toString() { + if (this.target) { + return `${this.name}, target=${this.target.name}, target.wise=${this.target.wise} ` + } else if (this.operator) { + return `${this.name}, wise=${this.wise} , operator=${this.operator.name}` + } else { + return this.name + } + } + + getCommandName() { + return this.constructor.getCommandName() + } + + getCommandNameWithoutPrefix() { + return this.constructor.getCommandNameWithoutPrefix() + } + + static writeCommandTableOnDisk() { + const commandTable = this.generateCommandTableByEagerLoad() + const _ = _plus() + if (_.isEqual(this.commandTable, commandTable)) { + atom.notifications.addInfo("No changes in commandTable", {dismissable: true}) + return + } + + if (!CSON) CSON = require("season") + if (!path) path = require("path") + + let loadableCSONText = "# This file is auto generated by `vim-mode-plus:write-command-table-on-disk` command.\n" + loadableCSONText += "# DONT edit manually.\n" + loadableCSONText += CSON.stringify(commandTable) + "\n" + + const commandTablePath = path.join(__dirname, "command-table.coffee") + atom.workspace.open(commandTablePath).then(editor => { + editor.setText(loadableCSONText) + editor.save() + atom.notifications.addInfo("Updated commandTable", {dismissable: true}) + }) + } + + static generateCommandTableByEagerLoad() { + // NOTE: changing order affects output of lib/command-table.coffee + const filesToLoad = [ + "./operator", + "./operator-insert", + "./operator-transform-string", + "./motion", + "./motion-search", + "./text-object", + "./misc-command", + ] + filesToLoad.forEach(loadVmpOperationFile) + const _ = _plus() + const klassesGroupedByFile = _.groupBy(_.values(CLASS_REGISTRY), klass => klass.file) + + const commandTable = {} + for (const file of filesToLoad) { + for (const klass of klassesGroupedByFile[file]) { + commandTable[klass.name] = klass.isCommand() + ? {file: klass.file, commandName: klass.getCommandName(), commandScope: klass.getCommandScope()} + : {file: klass.file} + } + } + return commandTable + } + + static init(getEditorState) { + this.getEditorState = getEditorState + + this.commandTable = require("./command-table") + const subscriptions = [] + for (const name in this.commandTable) { + const spec = this.commandTable[name] + if (spec.commandName) { + subscriptions.push(this.registerCommandFromSpec(name, spec)) + } + } + return subscriptions + } + + static register(command = true) { + this.command = command + this.file = VMP_LOADING_FILE + if (this.name in CLASS_REGISTRY) { + console.warn(`Duplicate constructor ${this.name}`) + } + CLASS_REGISTRY[this.name] = this + } + + static extend(...args) { + console.error("calling deprecated Base.extend(), use Base.register instead!") + this.register(...args) + } + + static getClass(name) { + if (name in CLASS_REGISTRY) return CLASS_REGISTRY[name] + + const fileToLoad = this.commandTable[name].file + if (atom.inDevMode() && settings.get("debug")) { + console.log(`lazy-require: ${fileToLoad} for ${name}`) + } + loadVmpOperationFile(fileToLoad) + if (name in CLASS_REGISTRY) return CLASS_REGISTRY[name] + + throw new Error(`class '${name}' not found`) + } + + static getInstance(vimState, klassOrName, properties) { + const klass = typeof klassOrName === "function" ? klassOrName : Base.getClass(klassOrName) + const instance = new klass(vimState) + if (properties) Object.assign(instance, properties) + return instance.initialize() // initialize must return instance. + } + + static getClassRegistry() { + return CLASS_REGISTRY + } + + static isCommand() { + return this.command + } + + static getCommandName() { + return this.commandPrefix + ":" + _plus().dasherize(this.name) + } + + static getCommandNameWithoutPrefix() { + return _plus().dasherize(this.name) + } + + static getCommandScope() { + return this.commandScope + } + + static registerCommand() { + return this.registerCommandFromSpec(this.name, { + commandScope: this.getCommandScope(), + commandName: this.getCommandName(), + getClass: () => this, + }) + } + + static registerCommandFromSpec(name, spec) { + let {commandScope = "atom-text-editor", commandPrefix = "vim-mode-plus", commandName, getClass} = spec + if (!commandName) commandName = commandPrefix + ":" + _plus().dasherize(name) + if (!getClass) getClass = name => this.getClass(name) + + const getEditorState = this.getEditorState + return atom.commands.add(commandScope, commandName, function(event) { + const vimState = getEditorState(this.getModel()) || getEditorState(atom.workspace.getActiveTextEditor()) + if (vimState) vimState.operationStack.run(getClass(name)) // vimState possibly be undefined See #85 + event.stopPropagation() + }) + } + + static getKindForCommandName(command) { + const commandWithoutPrefix = command.replace(/^vim-mode-plus:/, "") + const {capitalize, camelize} = _plus() + const commandClassName = capitalize(camelize(commandWithoutPrefix)) + if (commandClassName in CLASS_REGISTRY) { + return CLASS_REGISTRY[commandClassName].operationKind + } + } + + // Proxy propperties and methods + //=========================================================================== + get mode() { return this.vimState.mode } // prettier-ignore + get submode() { return this.vimState.submode } // prettier-ignore + get swrap() { return this.vimState.swrap } // prettier-ignore + get utils() { return this.vimState.utils } // prettier-ignore + get editor() { return this.vimState.editor } // prettier-ignore + get editorElement() { return this.vimState.editorElement } // prettier-ignore + get globalState() { return this.vimState.globalState } // prettier-ignore + get mutationManager() { return this.vimState.mutationManager } // prettier-ignore + get occurrenceManager() { return this.vimState.occurrenceManager } // prettier-ignore + get persistentSelection() { return this.vimState.persistentSelection } // prettier-ignore + + onDidChangeSearch(...args) { return this.vimState.onDidChangeSearch(...args) } // prettier-ignore + onDidConfirmSearch(...args) { return this.vimState.onDidConfirmSearch(...args) } // prettier-ignore + onDidCancelSearch(...args) { return this.vimState.onDidCancelSearch(...args) } // prettier-ignore + onDidCommandSearch(...args) { return this.vimState.onDidCommandSearch(...args) } // prettier-ignore + onDidSetTarget(...args) { return this.vimState.onDidSetTarget(...args) } // prettier-ignore + emitDidSetTarget(...args) { return this.vimState.emitDidSetTarget(...args) } // prettier-ignore + onWillSelectTarget(...args) { return this.vimState.onWillSelectTarget(...args) } // prettier-ignore + emitWillSelectTarget(...args) { return this.vimState.emitWillSelectTarget(...args) } // prettier-ignore + onDidSelectTarget(...args) { return this.vimState.onDidSelectTarget(...args) } // prettier-ignore + emitDidSelectTarget(...args) { return this.vimState.emitDidSelectTarget(...args) } // prettier-ignore + onDidFailSelectTarget(...args) { return this.vimState.onDidFailSelectTarget(...args) } // prettier-ignore + emitDidFailSelectTarget(...args) { return this.vimState.emitDidFailSelectTarget(...args) } // prettier-ignore + onWillFinishMutation(...args) { return this.vimState.onWillFinishMutation(...args) } // prettier-ignore + emitWillFinishMutation(...args) { return this.vimState.emitWillFinishMutation(...args) } // prettier-ignore + onDidFinishMutation(...args) { return this.vimState.onDidFinishMutation(...args) } // prettier-ignore + emitDidFinishMutation(...args) { return this.vimState.emitDidFinishMutation(...args) } // prettier-ignore + onDidFinishOperation(...args) { return this.vimState.onDidFinishOperation(...args) } // prettier-ignore + onDidResetOperationStack(...args) { return this.vimState.onDidResetOperationStack(...args) } // prettier-ignore + onDidSetOperatorModifier(...args) { return this.vimState.onDidSetOperatorModifier(...args) } // prettier-ignore + onWillActivateMode(...args) { return this.vimState.onWillActivateMode(...args) } // prettier-ignore + onDidActivateMode(...args) { return this.vimState.onDidActivateMode(...args) } // prettier-ignore + preemptWillDeactivateMode(...args) { return this.vimState.preemptWillDeactivateMode(...args) } // prettier-ignore + onWillDeactivateMode(...args) { return this.vimState.onWillDeactivateMode(...args) } // prettier-ignore + onDidDeactivateMode(...args) { return this.vimState.onDidDeactivateMode(...args) } // prettier-ignore + onDidCancelSelectList(...args) { return this.vimState.onDidCancelSelectList(...args) } // prettier-ignore + subscribe(...args) { return this.vimState.subscribe(...args) } // prettier-ignore + isMode(...args) { return this.vimState.isMode(...args) } // prettier-ignore + getBlockwiseSelections(...args) { return this.vimState.getBlockwiseSelections(...args) } // prettier-ignore + getLastBlockwiseSelection(...args) { return this.vimState.getLastBlockwiseSelection(...args) } // prettier-ignore + addToClassList(...args) { return this.vimState.addToClassList(...args) } // prettier-ignore + getConfig(...args) { return this.vimState.getConfig(...args) } // prettier-ignore +} +Base.register(false) + +module.exports = Base diff --git a/lib/blockwise-selection.js b/lib/blockwise-selection.js index 17c516e2d..0797dd3e8 100644 --- a/lib/blockwise-selection.js +++ b/lib/blockwise-selection.js @@ -1,12 +1,12 @@ const _ = require("underscore-plus") -const {sortRanges, assertWithException, trimRange, getRange} = require("./utils") +const {sortRanges, assertWithException, trimRange, getList} = require("./utils") const settings = require("./settings") const blockwiseSelectionsByEditor = new Map() let __swrap function swrap(...args) { - if (__swrap == null) __swrap = require("./selection-wrapper") + if (!__swrap) __swrap = require("./selection-wrapper") return __swrap(...args) } @@ -80,7 +80,7 @@ module.exports = class BlockwiseSelection { endColumn = end.column + 1 } - const ranges = getRange(start.row, end.row).map(row => [[row, startColumn], [row, endColumn]]) + const ranges = getList(start.row, end.row).map(row => [[row, startColumn], [row, endColumn]]) selection.setBufferRange(ranges.shift(), {reversed}) this.selections = [selection, ...ranges.map(range => this.editor.addSelectionForBufferRange(range, {reversed}))] } diff --git a/lib/command-table.coffee b/lib/command-table.coffee index ce58dfac8..9786c6f1c 100644 --- a/lib/command-table.coffee +++ b/lib/command-table.coffee @@ -673,7 +673,7 @@ MoveToLineByPercent: commandScope: "atom-text-editor" MoveToRelativeLine: file: "./motion" -MoveToRelativeLineMinimumOne: +MoveToRelativeLineMinimumTwo: file: "./motion" MoveToTopOfScreen: file: "./motion" diff --git a/lib/developer.js b/lib/developer.js index 12ffbab89..237a65e1d 100644 --- a/lib/developer.js +++ b/lib/developer.js @@ -208,7 +208,7 @@ module.exports = class Developer { ancestors.pop() const kind = ancestors.pop().name const commandName = klass.getCommandName() - const description = klass.getDesctiption() ? klass.getDesctiption().replace(/\n/g, "
") : undefined + // const description = klass.getDesctiption() ? klass.getDesctiption().replace(/\n/g, "
") : undefined const keymaps = getKeyBindingForCommand(commandName, {packageName: "vim-mode-plus"}) const keymap = keymaps diff --git a/lib/main.js b/lib/main.js index 3493010e5..58c0a7414 100644 --- a/lib/main.js +++ b/lib/main.js @@ -21,6 +21,7 @@ module.exports = { activate() { this.emitter = new Emitter() settings.notifyDeprecatedParams() + settings.notifyCoffeeScriptNoLongerSupportedToExtendVMP() settings.migrateRenamedParams() if (atom.inSpecMode()) settings.set("strictAssertion", true) @@ -249,7 +250,7 @@ module.exports = { provideVimModePlus() { return { Base: Base, - registerCommandFromSpec: Base.registerCommandFromSpec, + registerCommandFromSpec: Base.registerCommandFromSpec.bind(Base), getGlobalState: this.getGlobalState, getEditorState: this.getEditorState, observeVimStates: this.observeVimStates.bind(this), diff --git a/lib/mark-manager.js b/lib/mark-manager.js index 3e1abf2d8..edd71a5c1 100644 --- a/lib/mark-manager.js +++ b/lib/mark-manager.js @@ -40,7 +40,7 @@ class MarkManager { const {editor} = this.vimState point = editor.clipBufferPosition(point) this.marks[name] = this.markerLayer.markBufferPosition(point, {invalidate: "never"}) - this.vimState.emitter.emit("did-set-mark", {name, point, editor}) + this.vimState.emitter.emit("did-set-mark", {name, bufferPosition: point, editor}) } } diff --git a/lib/misc-command.coffee b/lib/misc-command.coffee deleted file mode 100644 index 15e09a4ce..000000000 --- a/lib/misc-command.coffee +++ /dev/null @@ -1,503 +0,0 @@ -{Range, Point} = require 'atom' -Base = require './base' -_ = require 'underscore-plus' - -{ - moveCursorRight - isLinewiseRange - setBufferRow - sortRanges - findRangeContainsPoint - isSingleLineRange - isLeadingWhiteSpaceRange - humanizeBufferRange - getFoldInfoByKind - limitNumber - getFoldRowRangesContainedByFoldStartsAtRow -} = require './utils' - -class MiscCommand extends Base - @extend(false) - @operationKind: 'misc-command' - constructor: -> - super - @initialize() - -class Mark extends MiscCommand - @extend() - requireInput: true - initialize: -> - @readChar() - super - - execute: -> - @vimState.mark.set(@input, @editor.getCursorBufferPosition()) - @activateMode('normal') - -class ReverseSelections extends MiscCommand - @extend() - execute: -> - @swrap.setReversedState(@editor, not @editor.getLastSelection().isReversed()) - if @isMode('visual', 'blockwise') - @getLastBlockwiseSelection().autoscroll() - -class BlockwiseOtherEnd extends ReverseSelections - @extend() - execute: -> - for blockwiseSelection in @getBlockwiseSelections() - blockwiseSelection.reverse() - super - -class Undo extends MiscCommand - @extend() - - setCursorPosition: ({newRanges, oldRanges, strategy}) -> - lastCursor = @editor.getLastCursor() # This is restored cursor - - if strategy is 'smart' - changedRange = findRangeContainsPoint(newRanges, lastCursor.getBufferPosition()) - else - changedRange = sortRanges(newRanges.concat(oldRanges))[0] - - if changedRange? - if isLinewiseRange(changedRange) - setBufferRow(lastCursor, changedRange.start.row) - else - lastCursor.setBufferPosition(changedRange.start) - - mutateWithTrackChanges: -> - newRanges = [] - oldRanges = [] - - # Collect changed range while mutating text-state by fn callback. - disposable = @editor.getBuffer().onDidChange ({newRange, oldRange}) -> - if newRange.isEmpty() - oldRanges.push(oldRange) # Remove only - else - newRanges.push(newRange) - - @mutate() - - disposable.dispose() - {newRanges, oldRanges} - - flashChanges: ({newRanges, oldRanges}) -> - isMultipleSingleLineRanges = (ranges) -> - ranges.length > 1 and ranges.every(isSingleLineRange) - - if newRanges.length > 0 - return if @isMultipleAndAllRangeHaveSameColumnAndConsecutiveRows(newRanges) - newRanges = newRanges.map (range) => humanizeBufferRange(@editor, range) - newRanges = @filterNonLeadingWhiteSpaceRange(newRanges) - - if isMultipleSingleLineRanges(newRanges) - @flash(newRanges, type: 'undo-redo-multiple-changes') - else - @flash(newRanges, type: 'undo-redo') - else - return if @isMultipleAndAllRangeHaveSameColumnAndConsecutiveRows(oldRanges) - - if isMultipleSingleLineRanges(oldRanges) - oldRanges = @filterNonLeadingWhiteSpaceRange(oldRanges) - @flash(oldRanges, type: 'undo-redo-multiple-delete') - - filterNonLeadingWhiteSpaceRange: (ranges) -> - ranges.filter (range) => - not isLeadingWhiteSpaceRange(@editor, range) - - # [TODO] Improve further by checking oldText, newText? - # [Purpose of this is function] - # Suppress flash when undo/redoing toggle-comment while flashing undo/redo of occurrence operation. - # This huristic approach never be perfect. - # Ultimately cannnot distinguish occurrence operation. - isMultipleAndAllRangeHaveSameColumnAndConsecutiveRows: (ranges) -> - return false if ranges.length <= 1 - - {start: {column: startColumn}, end: {column: endColumn}} = ranges[0] - previousRow = null - for range in ranges - {start, end} = range - unless ((start.column is startColumn) and (end.column is endColumn)) - return false - - if previousRow? and (previousRow + 1 isnt start.row) - return false - previousRow = start.row - return true - - ranges.every ({start, end}) -> - (start.column is startColumn) and (end.column is endColumn) - - flash: (flashRanges, options) -> - options.timeout ?= 500 - @onDidFinishOperation => - @vimState.flash(flashRanges, options) - - execute: -> - {newRanges, oldRanges} = @mutateWithTrackChanges() - - for selection in @editor.getSelections() - selection.clear() - - if @getConfig('setCursorToStartOfChangeOnUndoRedo') - strategy = @getConfig('setCursorToStartOfChangeOnUndoRedoStrategy') - @setCursorPosition({newRanges, oldRanges, strategy}) - @vimState.clearSelections() - - if @getConfig('flashOnUndoRedo') - @flashChanges({newRanges, oldRanges}) - - @activateMode('normal') - - mutate: -> - @editor.undo() - -class Redo extends Undo - @extend() - mutate: -> - @editor.redo() - -# zc -class FoldCurrentRow extends MiscCommand - @extend() - execute: -> - for selection in @editor.getSelections() - {row} = @getCursorPositionForSelection(selection) - @editor.foldBufferRow(row) - -# zo -class UnfoldCurrentRow extends MiscCommand - @extend() - execute: -> - for selection in @editor.getSelections() - {row} = @getCursorPositionForSelection(selection) - @editor.unfoldBufferRow(row) - -# za -class ToggleFold extends MiscCommand - @extend() - execute: -> - point = @editor.getCursorBufferPosition() - @editor.toggleFoldAtBufferRow(point.row) - -# Base of zC, zO, zA -class FoldCurrentRowRecursivelyBase extends MiscCommand - @extend(false) - - foldRecursively: (row) -> - rowRanges = getFoldRowRangesContainedByFoldStartsAtRow(@editor, row) - if rowRanges? - startRows = rowRanges.map (rowRange) -> rowRange[0] - for row in startRows.reverse() when not @editor.isFoldedAtBufferRow(row) - @editor.foldBufferRow(row) - - unfoldRecursively: (row) -> - rowRanges = getFoldRowRangesContainedByFoldStartsAtRow(@editor, row) - if rowRanges? - startRows = rowRanges.map (rowRange) -> rowRange[0] - for row in startRows when @editor.isFoldedAtBufferRow(row) - @editor.unfoldBufferRow(row) - - foldRecursivelyForAllSelections: -> - for selection in @editor.getSelectionsOrderedByBufferPosition().reverse() - @foldRecursively(@getCursorPositionForSelection(selection).row) - - unfoldRecursivelyForAllSelections: -> - for selection in @editor.getSelectionsOrderedByBufferPosition() - @unfoldRecursively(@getCursorPositionForSelection(selection).row) - -# zC -class FoldCurrentRowRecursively extends FoldCurrentRowRecursivelyBase - @extend() - execute: -> - @foldRecursivelyForAllSelections() - -# zO -class UnfoldCurrentRowRecursively extends FoldCurrentRowRecursivelyBase - @extend() - execute: -> - @unfoldRecursivelyForAllSelections() - -# zA -class ToggleFoldRecursively extends FoldCurrentRowRecursivelyBase - @extend() - execute: -> - row = @getCursorPositionForSelection(@editor.getLastSelection()).row - if @editor.isFoldedAtBufferRow(row) - @unfoldRecursivelyForAllSelections() - else - @foldRecursivelyForAllSelections() - -# zR -class UnfoldAll extends MiscCommand - @extend() - execute: -> - @editor.unfoldAll() - -# zM -class FoldAll extends MiscCommand - @extend() - execute: -> - {allFold} = getFoldInfoByKind(@editor) - if allFold? - @editor.unfoldAll() - for {indent, startRow, endRow} in allFold.rowRangesWithIndent - if indent <= @getConfig('maxFoldableIndentLevel') - @editor.foldBufferRowRange(startRow, endRow) - -# zr -class UnfoldNextIndentLevel extends MiscCommand - @extend() - execute: -> - {folded} = getFoldInfoByKind(@editor) - if folded? - {minIndent, rowRangesWithIndent} = folded - count = limitNumber(@getCount() - 1, min: 0) - targetIndents = [minIndent..(minIndent + count)] - for {indent, startRow} in rowRangesWithIndent - if indent in targetIndents - @editor.unfoldBufferRow(startRow) - -# zm -class FoldNextIndentLevel extends MiscCommand - @extend() - execute: -> - {unfolded, allFold} = getFoldInfoByKind(@editor) - if unfolded? - # FIXME: Why I need unfoldAll()? Why can't I just fold non-folded-fold only? - # Unless unfoldAll() here, @editor.unfoldAll() delete foldMarker but fail - # to render unfolded rows correctly. - # I believe this is bug of text-buffer's markerLayer which assume folds are - # created **in-order** from top-row to bottom-row. - @editor.unfoldAll() - - maxFoldable = @getConfig('maxFoldableIndentLevel') - fromLevel = Math.min(unfolded.maxIndent, maxFoldable) - count = limitNumber(@getCount() - 1, min: 0) - fromLevel = limitNumber(fromLevel - count, min: 0) - targetIndents = [fromLevel..maxFoldable] - - for {indent, startRow, endRow} in allFold.rowRangesWithIndent - if indent in targetIndents - @editor.foldBufferRowRange(startRow, endRow) - -class ReplaceModeBackspace extends MiscCommand - @commandScope: 'atom-text-editor.vim-mode-plus.insert-mode.replace' - @extend() - execute: -> - for selection in @editor.getSelections() - # char might be empty. - char = @vimState.modeManager.getReplacedCharForSelection(selection) - if char? - selection.selectLeft() - unless selection.insertText(char).isEmpty() - selection.cursor.moveLeft() - -class ScrollWithoutChangingCursorPosition extends MiscCommand - @extend(false) - scrolloff: 2 # atom default. Better to use editor.getVerticalScrollMargin()? - cursorPixel: null - - getFirstVisibleScreenRow: -> - @editorElement.getFirstVisibleScreenRow() - - getLastVisibleScreenRow: -> - @editorElement.getLastVisibleScreenRow() - - getLastScreenRow: -> - @editor.getLastScreenRow() - - getCursorPixel: -> - point = @editor.getCursorScreenPosition() - @editorElement.pixelPositionForScreenPosition(point) - -# ctrl-e scroll lines downwards -class ScrollDown extends ScrollWithoutChangingCursorPosition - @extend() - - execute: -> - count = @getCount() - oldFirstRow = @editor.getFirstVisibleScreenRow() - @editor.setFirstVisibleScreenRow(oldFirstRow + count) - newFirstRow = @editor.getFirstVisibleScreenRow() - - offset = 2 - {row, column} = @editor.getCursorScreenPosition() - if row < (newFirstRow + offset) - newPoint = [row + count, column] - @editor.setCursorScreenPosition(newPoint, autoscroll: false) - -# ctrl-y scroll lines upwards -class ScrollUp extends ScrollWithoutChangingCursorPosition - @extend() - - execute: -> - count = @getCount() - oldFirstRow = @editor.getFirstVisibleScreenRow() - @editor.setFirstVisibleScreenRow(oldFirstRow - count) - newLastRow = @editor.getLastVisibleScreenRow() - - offset = 2 - {row, column} = @editor.getCursorScreenPosition() - if row >= (newLastRow - offset) - newPoint = [row - count, column] - @editor.setCursorScreenPosition(newPoint, autoscroll: false) - -# ScrollWithoutChangingCursorPosition without Cursor Position change. -# ------------------------- -class ScrollCursor extends ScrollWithoutChangingCursorPosition - @extend(false) - execute: -> - @moveToFirstCharacterOfLine?() - if @isScrollable() - @editorElement.setScrollTop @getScrollTop() - - moveToFirstCharacterOfLine: -> - @editor.moveToFirstCharacterOfLine() - - getOffSetPixelHeight: (lineDelta=0) -> - @editor.getLineHeightInPixels() * (@scrolloff + lineDelta) - -# z enter -class ScrollCursorToTop extends ScrollCursor - @extend() - isScrollable: -> - @getLastVisibleScreenRow() isnt @getLastScreenRow() - - getScrollTop: -> - @getCursorPixel().top - @getOffSetPixelHeight() - -# zt -class ScrollCursorToTopLeave extends ScrollCursorToTop - @extend() - moveToFirstCharacterOfLine: null - -# z- -class ScrollCursorToBottom extends ScrollCursor - @extend() - isScrollable: -> - @getFirstVisibleScreenRow() isnt 0 - - getScrollTop: -> - @getCursorPixel().top - (@editorElement.getHeight() - @getOffSetPixelHeight(1)) - -# zb -class ScrollCursorToBottomLeave extends ScrollCursorToBottom - @extend() - moveToFirstCharacterOfLine: null - -# z. -class ScrollCursorToMiddle extends ScrollCursor - @extend() - isScrollable: -> - true - - getScrollTop: -> - @getCursorPixel().top - (@editorElement.getHeight() / 2) - -# zz -class ScrollCursorToMiddleLeave extends ScrollCursorToMiddle - @extend() - moveToFirstCharacterOfLine: null - -# Horizontal ScrollWithoutChangingCursorPosition -# ------------------------- -# zs -class ScrollCursorToLeft extends ScrollWithoutChangingCursorPosition - @extend() - - execute: -> - @editorElement.setScrollLeft(@getCursorPixel().left) - -# ze -class ScrollCursorToRight extends ScrollCursorToLeft - @extend() - - execute: -> - @editorElement.setScrollRight(@getCursorPixel().left) - -# insert-mode specific commands -# ------------------------- -class InsertMode extends MiscCommand - @commandScope: 'atom-text-editor.vim-mode-plus.insert-mode' - -class ActivateNormalModeOnce extends InsertMode - @extend() - thisCommandName: @getCommandName() - - execute: -> - cursorsToMoveRight = @editor.getCursors().filter (cursor) -> not cursor.isAtBeginningOfLine() - @vimState.activate('normal') - moveCursorRight(cursor) for cursor in cursorsToMoveRight - disposable = atom.commands.onDidDispatch ({type}) => - return if type is @thisCommandName - disposable.dispose() - disposable = null - @vimState.activate('insert') - -class InsertRegister extends InsertMode - @extend() - requireInput: true - - initialize: -> - super - @readChar() - - execute: -> - @editor.transact => - for selection in @editor.getSelections() - text = @vimState.register.getText(@input, selection) - selection.insertText(text) - -class InsertLastInserted extends InsertMode - @extend() - @description: """ - Insert text inserted in latest insert-mode. - Equivalent to *i_CTRL-A* of pure Vim - """ - execute: -> - text = @vimState.register.getText('.') - @editor.insertText(text) - -class CopyFromLineAbove extends InsertMode - @extend() - @description: """ - Insert character of same-column of above line. - Equivalent to *i_CTRL-Y* of pure Vim - """ - rowDelta: -1 - - execute: -> - translation = [@rowDelta, 0] - @editor.transact => - for selection in @editor.getSelections() - point = selection.cursor.getBufferPosition().translate(translation) - continue if point.row < 0 - range = Range.fromPointWithDelta(point, 0, 1) - if text = @editor.getTextInBufferRange(range) - selection.insertText(text) - -class CopyFromLineBelow extends CopyFromLineAbove - @extend() - @description: """ - Insert character of same-column of above line. - Equivalent to *i_CTRL-E* of pure Vim - """ - rowDelta: +1 - -class NextTab extends MiscCommand - @extend() - defaultCount: 0 - execute: -> - count = @getCount() - pane = atom.workspace.paneForItem(@editor) - if count - pane.activateItemAtIndex(count - 1) - else - pane.activateNextItem() - -class PreviousTab extends MiscCommand - @extend() - execute: -> - pane = atom.workspace.paneForItem(@editor) - pane.activatePreviousItem() diff --git a/lib/misc-command.js b/lib/misc-command.js new file mode 100644 index 000000000..1677d354e --- /dev/null +++ b/lib/misc-command.js @@ -0,0 +1,591 @@ +"use babel" + +const {Range} = require("atom") +const Base = require("./base") +const { + moveCursorRight, + isLinewiseRange, + setBufferRow, + sortRanges, + findRangeContainsPoint, + isSingleLineRange, + isLeadingWhiteSpaceRange, + humanizeBufferRange, + getFoldInfoByKind, + limitNumber, + getFoldRowRangesContainedByFoldStartsAtRow, + getList, +} = require("./utils") + +class MiscCommand extends Base { + static operationKind = "misc-command" +} +MiscCommand.register(false) + +class Mark extends MiscCommand { + requireInput = true + initialize() { + this.readChar() + return super.initialize() + } + + execute() { + this.vimState.mark.set(this.input, this.editor.getCursorBufferPosition()) + this.activateMode("normal") + } +} +Mark.register() + +class ReverseSelections extends MiscCommand { + execute() { + this.swrap.setReversedState(this.editor, !this.editor.getLastSelection().isReversed()) + if (this.isMode("visual", "blockwise")) { + this.getLastBlockwiseSelection().autoscroll() + } + } +} +ReverseSelections.register() + +class BlockwiseOtherEnd extends ReverseSelections { + execute() { + for (const blockwiseSelection of this.getBlockwiseSelections()) { + blockwiseSelection.reverse() + } + super.execute() + } +} +BlockwiseOtherEnd.register() + +class Undo extends MiscCommand { + setCursorPosition({newRanges, oldRanges, strategy}) { + const lastCursor = this.editor.getLastCursor() // This is restored cursor + + const changedRange = + strategy === "smart" + ? findRangeContainsPoint(newRanges, lastCursor.getBufferPosition()) + : sortRanges(newRanges.concat(oldRanges))[0] + + if (changedRange) { + if (isLinewiseRange(changedRange)) setBufferRow(lastCursor, changedRange.start.row) + else lastCursor.setBufferPosition(changedRange.start) + } + } + + mutateWithTrackChanges() { + const newRanges = [] + const oldRanges = [] + + // Collect changed range while mutating text-state by fn callback. + const disposable = this.editor.getBuffer().onDidChange(({newRange, oldRange}) => { + if (newRange.isEmpty()) { + oldRanges.push(oldRange) // Remove only + } else { + newRanges.push(newRange) + } + }) + + this.mutate() + disposable.dispose() + return {newRanges, oldRanges} + } + + flashChanges({newRanges, oldRanges}) { + const isMultipleSingleLineRanges = ranges => ranges.length > 1 && ranges.every(isSingleLineRange) + + if (newRanges.length > 0) { + if (this.isMultipleAndAllRangeHaveSameColumnAndConsecutiveRows(newRanges)) return + + newRanges = newRanges.map(range => humanizeBufferRange(this.editor, range)) + newRanges = this.filterNonLeadingWhiteSpaceRange(newRanges) + + const type = isMultipleSingleLineRanges(newRanges) ? "undo-redo-multiple-changes" : "undo-redo" + this.flash(newRanges, {type}) + } else { + if (this.isMultipleAndAllRangeHaveSameColumnAndConsecutiveRows(oldRanges)) return + + if (isMultipleSingleLineRanges(oldRanges)) { + oldRanges = this.filterNonLeadingWhiteSpaceRange(oldRanges) + this.flash(oldRanges, {type: "undo-redo-multiple-delete"}) + } + } + } + + filterNonLeadingWhiteSpaceRange(ranges) { + return ranges.filter(range => !isLeadingWhiteSpaceRange(this.editor, range)) + } + + // [TODO] Improve further by checking oldText, newText? + // [Purpose of this function] + // Suppress flash when undo/redoing toggle-comment while flashing undo/redo of occurrence operation. + // This huristic approach never be perfect. + // Ultimately cannnot distinguish occurrence operation. + isMultipleAndAllRangeHaveSameColumnAndConsecutiveRows(ranges) { + if (ranges.length <= 1) { + return false + } + + const {start: {column: startColumn}, end: {column: endColumn}} = ranges[0] + let previousRow + + for (const range of ranges) { + const {start, end} = range + if (start.column !== startColumn || end.column !== endColumn) return false + if (previousRow != null && previousRow + 1 !== start.row) return false + previousRow = start.row + } + return true + } + + flash(ranges, options) { + if (options.timeout == null) options.timeout = 500 + this.onDidFinishOperation(() => this.vimState.flash(ranges, options)) + } + + execute() { + const {newRanges, oldRanges} = this.mutateWithTrackChanges() + + for (const selection of this.editor.getSelections()) { + selection.clear() + } + + if (this.getConfig("setCursorToStartOfChangeOnUndoRedo")) { + const strategy = this.getConfig("setCursorToStartOfChangeOnUndoRedoStrategy") + this.setCursorPosition({newRanges, oldRanges, strategy}) + this.vimState.clearSelections() + } + + if (this.getConfig("flashOnUndoRedo")) this.flashChanges({newRanges, oldRanges}) + this.activateMode("normal") + } + + mutate() { + this.editor.undo() + } +} +Undo.register() + +class Redo extends Undo { + mutate() { + this.editor.redo() + } +} +Redo.register() + +// zc +class FoldCurrentRow extends MiscCommand { + execute() { + for (const selection of this.editor.getSelections()) { + this.editor.foldBufferRow(this.getCursorPositionForSelection(selection).row) + } + } +} +FoldCurrentRow.register() + +// zo +class UnfoldCurrentRow extends MiscCommand { + execute() { + for (const selection of this.editor.getSelections()) { + this.editor.unfoldBufferRow(this.getCursorPositionForSelection(selection).row) + } + } +} +UnfoldCurrentRow.register() + +// za +class ToggleFold extends MiscCommand { + execute() { + this.editor.toggleFoldAtBufferRow(this.editor.getCursorBufferPosition().row) + } +} +ToggleFold.register() + +// Base of zC, zO, zA +class FoldCurrentRowRecursivelyBase extends MiscCommand { + foldRecursively(row) { + const rowRanges = getFoldRowRangesContainedByFoldStartsAtRow(this.editor, row) + if (!rowRanges) return + const startRows = rowRanges.map(rowRange => rowRange[0]) + for (const row of startRows.reverse()) { + if (!this.editor.isFoldedAtBufferRow(row)) { + this.editor.foldBufferRow(row) + } + } + } + + unfoldRecursively(row) { + const rowRanges = getFoldRowRangesContainedByFoldStartsAtRow(this.editor, row) + if (!rowRanges) return + const startRows = rowRanges.map(rowRange => rowRange[0]) + for (row of startRows) { + if (this.editor.isFoldedAtBufferRow(row)) { + this.editor.unfoldBufferRow(row) + } + } + } + + foldRecursivelyForAllSelections() { + for (const selection of this.editor.getSelectionsOrderedByBufferPosition().reverse()) { + this.foldRecursively(this.getCursorPositionForSelection(selection).row) + } + } + + unfoldRecursivelyForAllSelections() { + for (const selection of this.editor.getSelectionsOrderedByBufferPosition()) { + this.unfoldRecursively(this.getCursorPositionForSelection(selection).row) + } + } +} +FoldCurrentRowRecursivelyBase.register(false) + +// zC +class FoldCurrentRowRecursively extends FoldCurrentRowRecursivelyBase { + execute() { + this.foldRecursivelyForAllSelections() + } +} +FoldCurrentRowRecursively.register() + +// zO +class UnfoldCurrentRowRecursively extends FoldCurrentRowRecursivelyBase { + execute() { + this.unfoldRecursivelyForAllSelections() + } +} +UnfoldCurrentRowRecursively.register() + +// zA +class ToggleFoldRecursively extends FoldCurrentRowRecursivelyBase { + execute() { + const {row} = this.getCursorPositionForSelection(this.editor.getLastSelection()) + if (this.editor.isFoldedAtBufferRow(row)) { + this.unfoldRecursivelyForAllSelections() + } else { + this.foldRecursivelyForAllSelections() + } + } +} +ToggleFoldRecursively.register() + +// zR +class UnfoldAll extends MiscCommand { + execute() { + this.editor.unfoldAll() + } +} +UnfoldAll.register() + +// zM +class FoldAll extends MiscCommand { + execute() { + const {allFold} = getFoldInfoByKind(this.editor) + if (!allFold) return + + this.editor.unfoldAll() + for (const {indent, startRow, endRow} of allFold.rowRangesWithIndent) { + if (indent <= this.getConfig("maxFoldableIndentLevel")) { + this.editor.foldBufferRowRange(startRow, endRow) + } + } + } +} +FoldAll.register() + +// zr +class UnfoldNextIndentLevel extends MiscCommand { + execute() { + const {folded} = getFoldInfoByKind(this.editor) + if (!folded) return + const {minIndent, rowRangesWithIndent} = folded + const count = limitNumber(this.getCount() - 1, {min: 0}) + const targetIndents = getList(minIndent, minIndent + count) + for (const {indent, startRow} of rowRangesWithIndent) { + if (targetIndents.includes(indent)) { + this.editor.unfoldBufferRow(startRow) + } + } + } +} +UnfoldNextIndentLevel.register() + +// zm +class FoldNextIndentLevel extends MiscCommand { + execute() { + const {unfolded, allFold} = getFoldInfoByKind(this.editor) + if (!unfolded) return + // FIXME: Why I need unfoldAll()? Why can't I just fold non-folded-fold only? + // Unless unfoldAll() here, @editor.unfoldAll() delete foldMarker but fail + // to render unfolded rows correctly. + // I believe this is bug of text-buffer's markerLayer which assume folds are + // created **in-order** from top-row to bottom-row. + this.editor.unfoldAll() + + const maxFoldable = this.getConfig("maxFoldableIndentLevel") + let fromLevel = Math.min(unfolded.maxIndent, maxFoldable) + const count = limitNumber(this.getCount() - 1, {min: 0}) + fromLevel = limitNumber(fromLevel - count, {min: 0}) + const targetIndents = getList(fromLevel, maxFoldable) + for (const {indent, startRow, endRow} of allFold.rowRangesWithIndent) { + if (targetIndents.includes(indent)) { + this.editor.foldBufferRowRange(startRow, endRow) + } + } + } +} +FoldNextIndentLevel.register() + +class ReplaceModeBackspace extends MiscCommand { + static commandScope = "atom-text-editor.vim-mode-plus.insert-mode.replace" + + execute() { + for (const selection of this.editor.getSelections()) { + // char might be empty. + const char = this.vimState.modeManager.getReplacedCharForSelection(selection) + if (char != null) { + selection.selectLeft() + if (!selection.insertText(char).isEmpty()) selection.cursor.moveLeft() + } + } + } +} +ReplaceModeBackspace.register() + +class ScrollWithoutChangingCursorPosition extends MiscCommand { + scrolloff = 2 // atom default. Better to use editor.getVerticalScrollMargin()? + cursorPixel = null + + getFirstVisibleScreenRow() { + return this.editorElement.getFirstVisibleScreenRow() + } + + getLastVisibleScreenRow() { + return this.editorElement.getLastVisibleScreenRow() + } + + getLastScreenRow() { + return this.editor.getLastScreenRow() + } + + getCursorPixel() { + const point = this.editor.getCursorScreenPosition() + return this.editorElement.pixelPositionForScreenPosition(point) + } +} +ScrollWithoutChangingCursorPosition.register(false) + +// ctrl-e scroll lines downwards +class ScrollDown extends ScrollWithoutChangingCursorPosition { + execute() { + const count = this.getCount() + const oldFirstRow = this.editor.getFirstVisibleScreenRow() + this.editor.setFirstVisibleScreenRow(oldFirstRow + count) + const newFirstRow = this.editor.getFirstVisibleScreenRow() + + const offset = 2 + const {row, column} = this.editor.getCursorScreenPosition() + if (row < newFirstRow + offset) { + const newPoint = [row + count, column] + this.editor.setCursorScreenPosition(newPoint, {autoscroll: false}) + } + } +} +ScrollDown.register() + +// ctrl-y scroll lines upwards +class ScrollUp extends ScrollWithoutChangingCursorPosition { + execute() { + const count = this.getCount() + const oldFirstRow = this.editor.getFirstVisibleScreenRow() + this.editor.setFirstVisibleScreenRow(oldFirstRow - count) + const newLastRow = this.editor.getLastVisibleScreenRow() + + const offset = 2 + const {row, column} = this.editor.getCursorScreenPosition() + if (row >= newLastRow - offset) { + const newPoint = [row - count, column] + this.editor.setCursorScreenPosition(newPoint, {autoscroll: false}) + } + } +} +ScrollUp.register() + +// ScrollWithoutChangingCursorPosition without Cursor Position change. +// ------------------------- +class ScrollCursor extends ScrollWithoutChangingCursorPosition { + moveToFirstCharacterOfLine = true + + execute() { + if (this.moveToFirstCharacterOfLine) this.editor.moveToFirstCharacterOfLine() + if (this.isScrollable()) this.editorElement.setScrollTop(this.getScrollTop()) + } + + getOffSetPixelHeight(lineDelta = 0) { + return this.editor.getLineHeightInPixels() * (this.scrolloff + lineDelta) + } +} +ScrollCursor.register(false) + +// z enter +class ScrollCursorToTop extends ScrollCursor { + isScrollable() { + return this.getLastVisibleScreenRow() !== this.getLastScreenRow() + } + + getScrollTop() { + return this.getCursorPixel().top - this.getOffSetPixelHeight() + } +} +ScrollCursorToTop.register() + +// zt +class ScrollCursorToTopLeave extends ScrollCursorToTop { + moveToFirstCharacterOfLine = false +} +ScrollCursorToTopLeave.register() + +// z- +class ScrollCursorToBottom extends ScrollCursor { + isScrollable() { + return this.getFirstVisibleScreenRow() !== 0 + } + + getScrollTop() { + return this.getCursorPixel().top - (this.editorElement.getHeight() - this.getOffSetPixelHeight(1)) + } +} +ScrollCursorToBottom.register() + +// zb +class ScrollCursorToBottomLeave extends ScrollCursorToBottom { + moveToFirstCharacterOfLine = false +} +ScrollCursorToBottomLeave.register() + +// z. +class ScrollCursorToMiddle extends ScrollCursor { + isScrollable() { + return true + } + + getScrollTop() { + return this.getCursorPixel().top - this.editorElement.getHeight() / 2 + } +} +ScrollCursorToMiddle.register() + +// zz +class ScrollCursorToMiddleLeave extends ScrollCursorToMiddle { + moveToFirstCharacterOfLine = false +} +ScrollCursorToMiddleLeave.register() + +// Horizontal ScrollWithoutChangingCursorPosition +// ------------------------- +// zs +class ScrollCursorToLeft extends ScrollWithoutChangingCursorPosition { + execute() { + this.editorElement.setScrollLeft(this.getCursorPixel().left) + } +} +ScrollCursorToLeft.register() + +// ze +class ScrollCursorToRight extends ScrollCursorToLeft { + execute() { + this.editorElement.setScrollRight(this.getCursorPixel().left) + } +} +ScrollCursorToRight.register() + +// insert-mode specific commands +// ------------------------- +class InsertMode extends MiscCommand {} +InsertMode.commandScope = "atom-text-editor.vim-mode-plus.insert-mode" + +class ActivateNormalModeOnce extends InsertMode { + execute() { + const cursorsToMoveRight = this.editor.getCursors().filter(cursor => !cursor.isAtBeginningOfLine()) + this.vimState.activate("normal") + for (const cursor of cursorsToMoveRight) { + moveCursorRight(cursor) + } + + let disposable = atom.commands.onDidDispatch(event => { + if (event.type === this.getCommandName()) return + + disposable.dispose() + disposable = null + this.vimState.activate("insert") + }) + } +} +ActivateNormalModeOnce.register() + +class InsertRegister extends InsertMode { + requireInput = true + initialize() { + this.readChar() + return super.initialize() + } + + execute() { + this.editor.transact(() => { + for (const selection of this.editor.getSelections()) { + const text = this.vimState.register.getText(this.input, selection) + selection.insertText(text) + } + }) + } +} +InsertRegister.register() + +class InsertLastInserted extends InsertMode { + execute() { + const text = this.vimState.register.getText(".") + this.editor.insertText(text) + } +} +InsertLastInserted.register() + +class CopyFromLineAbove extends InsertMode { + rowDelta = -1 + + execute() { + const translation = [this.rowDelta, 0] + this.editor.transact(() => { + for (let selection of this.editor.getSelections()) { + const point = selection.cursor.getBufferPosition().translate(translation) + if (point.row < 0) continue + + const range = Range.fromPointWithDelta(point, 0, 1) + const text = this.editor.getTextInBufferRange(range) + if (text) selection.insertText(text) + } + }) + } +} +CopyFromLineAbove.register() + +class CopyFromLineBelow extends CopyFromLineAbove { + rowDelta = +1 +} +CopyFromLineBelow.register() + +class NextTab extends MiscCommand { + defaultCount = 0 + + execute() { + const count = this.getCount() + const pane = atom.workspace.paneForItem(this.editor) + + if (count) pane.activateItemAtIndex(count - 1) + else pane.activateNextItem() + } +} +NextTab.register() + +class PreviousTab extends MiscCommand { + execute() { + atom.workspace.paneForItem(this.editor).activatePreviousItem() + } +} +PreviousTab.register() diff --git a/lib/motion-search.coffee b/lib/motion-search.coffee deleted file mode 100644 index fa69ca9e1..000000000 --- a/lib/motion-search.coffee +++ /dev/null @@ -1,232 +0,0 @@ -_ = require 'underscore-plus' - -{saveEditorState, getNonWordCharactersForCursor, searchByProjectFind} = require './utils' -SearchModel = require './search-model' -Motion = require('./base').getClass('Motion') - -class SearchBase extends Motion - @extend(false) - jump: true - backwards: false - useRegexp: true - caseSensitivityKind: null - landingPoint: null # ['start' or 'end'] - defaultLandingPoint: 'start' # ['start' or 'end'] - relativeIndex: null - updatelastSearchPattern: true - - isBackwards: -> - @backwards - - isIncrementalSearch: -> - @instanceof('Search') and not @repeated and @getConfig('incrementalSearch') - - initialize: -> - super - @onDidFinishOperation => - @finish() - - getCount: -> - count = super - if @isBackwards() - -count - else - count - - finish: -> - if @isIncrementalSearch() and @getConfig('showHoverSearchCounter') - @vimState.hoverSearchCounter.reset() - @relativeIndex = null - @searchModel?.destroy() - @searchModel = null - - getLandingPoint: -> - @landingPoint ?= @defaultLandingPoint - - getPoint: (cursor) -> - if @searchModel? - @relativeIndex = @getCount() + @searchModel.getRelativeIndex() - else - @relativeIndex ?= @getCount() - - if range = @search(cursor, @input, @relativeIndex) - point = range[@getLandingPoint()] - - @searchModel.destroy() - @searchModel = null - - point - - moveCursor: (cursor) -> - input = @input - return unless input - - if point = @getPoint(cursor) - cursor.setBufferPosition(point, autoscroll: false) - - unless @repeated - @globalState.set('currentSearch', this) - @vimState.searchHistory.save(input) - - if @updatelastSearchPattern - @globalState.set('lastSearchPattern', @getPattern(input)) - - getSearchModel: -> - @searchModel ?= new SearchModel(@vimState, incrementalSearch: @isIncrementalSearch()) - - search: (cursor, input, relativeIndex) -> - searchModel = @getSearchModel() - if input - fromPoint = @getBufferPositionForCursor(cursor) - return searchModel.search(fromPoint, @getPattern(input), relativeIndex) - else - @vimState.hoverSearchCounter.reset() - searchModel.clearMarkers() - -# /, ? -# ------------------------- -class Search extends SearchBase - @extend() - caseSensitivityKind: "Search" - requireInput: true - - initialize: -> - super - return if @isComplete() # When repeated, no need to get user input - - if @isIncrementalSearch() - @restoreEditorState = saveEditorState(@editor) - @onDidCommandSearch(@handleCommandEvent.bind(this)) - - @onDidConfirmSearch(@handleConfirmSearch.bind(this)) - @onDidCancelSearch(@handleCancelSearch.bind(this)) - @onDidChangeSearch(@handleChangeSearch.bind(this)) - - @focusSearchInputEditor() - - focusSearchInputEditor: -> - classList = [] - classList.push('backwards') if @backwards - @vimState.searchInput.focus({classList}) - - handleCommandEvent: (commandEvent) -> - return unless commandEvent.input - switch commandEvent.name - when 'visit' - {direction} = commandEvent - if @isBackwards() and @getConfig('incrementalSearchVisitDirection') is 'relative' - direction = switch direction - when 'next' then 'prev' - when 'prev' then 'next' - - switch direction - when 'next' then @getSearchModel().visit(+1) - when 'prev' then @getSearchModel().visit(-1) - - when 'occurrence' - {operation, input} = commandEvent - @vimState.occurrenceManager.addPattern(@getPattern(input), reset: operation?) - @vimState.occurrenceManager.saveLastPattern() - - @vimState.searchHistory.save(input) - @vimState.searchInput.cancel() - - @vimState.operationStack.run(operation) if operation? - - when 'project-find' - {input} = commandEvent - @vimState.searchHistory.save(input) - @vimState.searchInput.cancel() - searchByProjectFind(@editor, input) - - handleCancelSearch: -> - @vimState.resetNormalMode() unless @mode in ['visual', 'insert'] - @restoreEditorState?() - @vimState.reset() - @finish() - - isSearchRepeatCharacter: (char) -> - if @isIncrementalSearch() - char is '' - else - searchChar = if @isBackwards() then '?' else '/' - char in ['', searchChar] - - handleConfirmSearch: ({@input, @landingPoint}) => - if @isSearchRepeatCharacter(@input) - @input = @vimState.searchHistory.get('prev') - atom.beep() unless @input - @processOperation() - - handleChangeSearch: (input) -> - # If input starts with space, remove first space and disable useRegexp. - if input.startsWith(' ') - input = input.replace(/^ /, '') - @useRegexp = false - @vimState.searchInput.updateOptionSettings({@useRegexp}) - - if @isIncrementalSearch() - @search(@editor.getLastCursor(), input, @getCount()) - - getPattern: (term) -> - modifiers = if @isCaseSensitive(term) then 'g' else 'gi' - # FIXME this prevent search \\c itself. - # DONT thinklessly mimic pure Vim. Instead, provide ignorecase button and shortcut. - if term.indexOf('\\c') >= 0 - term = term.replace('\\c', '') - modifiers += 'i' unless 'i' in modifiers - - if @useRegexp - try - return new RegExp(term, modifiers) - catch - null - - new RegExp(_.escapeRegExp(term), modifiers) - -class SearchBackwards extends Search - @extend() - backwards: true - -# *, # -# ------------------------- -class SearchCurrentWord extends SearchBase - @extend() - caseSensitivityKind: "SearchCurrentWord" - - moveCursor: (cursor) -> - @input ?= ( - wordRange = @getCurrentWordBufferRange() - if wordRange? - @editor.setCursorBufferPosition(wordRange.start) - @editor.getTextInBufferRange(wordRange) - else - '' - ) - super - - getPattern: (term) -> - modifiers = if @isCaseSensitive(term) then 'g' else 'gi' - pattern = _.escapeRegExp(term) - if /\W/.test(term) - new RegExp("#{pattern}\\b", modifiers) - else - new RegExp("\\b#{pattern}\\b", modifiers) - - getCurrentWordBufferRange: -> - cursor = @editor.getLastCursor() - point = cursor.getBufferPosition() - - nonWordCharacters = getNonWordCharactersForCursor(cursor) - wordRegex = new RegExp("[^\\s#{_.escapeRegExp(nonWordCharacters)}]+", 'g') - - found = null - @scanForward wordRegex, {from: [point.row, 0], allowNextLine: false}, ({range, stop}) -> - if range.end.isGreaterThan(point) - found = range - stop() - found - -class SearchCurrentWordBackwards extends SearchCurrentWord - @extend() - backwards: true diff --git a/lib/motion-search.js b/lib/motion-search.js new file mode 100644 index 000000000..d7a8af55f --- /dev/null +++ b/lib/motion-search.js @@ -0,0 +1,265 @@ +"use babel" + +const _ = require("underscore-plus") + +const {saveEditorState, getNonWordCharactersForCursor, searchByProjectFind} = require("./utils") +const SearchModel = require("./search-model") +const Motion = require("./base").getClass("Motion") + +class SearchBase extends Motion { + jump = true + backwards = false + useRegexp = true + caseSensitivityKind = null + landingPoint = null // ['start' or 'end'] + defaultLandingPoint = "start" // ['start' or 'end'] + relativeIndex = null + updatelastSearchPattern = true + + isBackwards() { + return this.backwards + } + + resetState() { + super.resetState() + this.relativeIndex = null + } + + isIncrementalSearch() { + return this.instanceof("Search") && !this.repeated && this.getConfig("incrementalSearch") + } + + initialize() { + this.onDidFinishOperation(() => this.finish()) + return super.initialize() + } + + getCount(...args) { + return super.getCount(...args) * (this.isBackwards() ? -1 : 1) + } + + finish() { + if (this.isIncrementalSearch() && this.getConfig("showHoverSearchCounter")) { + this.vimState.hoverSearchCounter.reset() + } + if (this.searchModel) this.searchModel.destroy() + + this.relativeIndex = null + this.searchModel = null + } + + getLandingPoint() { + if (!this.landingPoint) this.landingPoint = this.defaultLandingPoint + return this.landingPoint + } + + getPoint(cursor) { + if (this.searchModel) { + this.relativeIndex = this.getCount() + this.searchModel.getRelativeIndex() + } else if (this.relativeIndex == null) { + this.relativeIndex = this.getCount() + } + + const range = this.search(cursor, this.input, this.relativeIndex) + + this.searchModel.destroy() + this.searchModel = null + + if (range) return range[this.getLandingPoint()] + } + + moveCursor(cursor) { + if (!this.input) return + const point = this.getPoint(cursor) + + if (point) cursor.setBufferPosition(point, {autoscroll: false}) + + if (!this.repeated) { + this.globalState.set("currentSearch", this) + this.vimState.searchHistory.save(this.input) + } + + if (this.updatelastSearchPattern) { + this.globalState.set("lastSearchPattern", this.getPattern(this.input)) + } + } + + getSearchModel() { + if (!this.searchModel) { + this.searchModel = new SearchModel(this.vimState, {incrementalSearch: this.isIncrementalSearch()}) + } + return this.searchModel + } + + search(cursor, input, relativeIndex) { + const searchModel = this.getSearchModel() + if (input) { + const fromPoint = this.getBufferPositionForCursor(cursor) + return searchModel.search(fromPoint, this.getPattern(input), relativeIndex) + } + this.vimState.hoverSearchCounter.reset() + searchModel.clearMarkers() + } +} +SearchBase.register(false) + +// /, ? +// ------------------------- +class Search extends SearchBase { + caseSensitivityKind = "Search" + requireInput = true + + initialize() { + if (!this.isComplete()) { + if (this.isIncrementalSearch()) { + this.restoreEditorState = saveEditorState(this.editor) + this.onDidCommandSearch(this.handleCommandEvent.bind(this)) + } + + this.onDidConfirmSearch(this.handleConfirmSearch.bind(this)) + this.onDidCancelSearch(this.handleCancelSearch.bind(this)) + this.onDidChangeSearch(this.handleChangeSearch.bind(this)) + + this.focusSearchInputEditor() + } + + return super.initialize() + } + + focusSearchInputEditor() { + const classList = this.isBackwards() ? ["backwards"] : [] + this.vimState.searchInput.focus({classList}) + } + + handleCommandEvent(event) { + if (!event.input) return + + if (event.name === "visit") { + let {direction} = event + if (this.isBackwards() && this.getConfig("incrementalSearchVisitDirection") === "relative") { + direction = direction === "next" ? "prev" : "next" + } + this.getSearchModel().visit(direction === "next" ? +1 : -1) + } else if (event.name === "occurrence") { + const {operation, input} = event + this.occurrenceManager.addPattern(this.getPattern(input), {reset: operation != null}) + this.occurrenceManager.saveLastPattern() + + this.vimState.searchHistory.save(input) + this.vimState.searchInput.cancel() + if (operation != null) this.vimState.operationStack.run(operation) + } else if (event.name === "project-find") { + this.vimState.searchHistory.save(event.input) + this.vimState.searchInput.cancel() + searchByProjectFind(this.editor, event.input) + } + } + + handleCancelSearch() { + if (!["visual", "insert"].includes(this.mode)) this.vimState.resetNormalMode() + + if (this.restoreEditorState) this.restoreEditorState() + this.vimState.reset() + this.finish() + } + + isSearchRepeatCharacter(char) { + return this.isIncrementalSearch() ? char === "" : ["", this.isBackwards() ? "?" : "/"].includes(char) // empty confirm or invoking-char + } + + handleConfirmSearch({input, landingPoint}) { + this.input = input + this.landingPoint = landingPoint + if (this.isSearchRepeatCharacter(this.input)) { + this.input = this.vimState.searchHistory.get("prev") + if (!this.input) atom.beep() + } + this.processOperation() + } + + handleChangeSearch(input) { + // If input starts with space, remove first space and disable useRegexp. + if (input.startsWith(" ")) { + // FIXME: Sould I remove this unknown hack and implement visible button to togle regexp? + input = input.replace(/^ /, "") + this.useRegexp = false + } + this.vimState.searchInput.updateOptionSettings({useRegexp: this.useRegexp}) + + if (this.isIncrementalSearch()) { + this.search(this.editor.getLastCursor(), input, this.getCount()) + } + } + + getPattern(term) { + let modifiers = this.isCaseSensitive(term) ? "g" : "gi" + // FIXME this prevent search \\c itself. + // DONT thinklessly mimic pure Vim. Instead, provide ignorecase button and shortcut. + if (term.indexOf("\\c") >= 0) { + term = term.replace("\\c", "") + if (!modifiers.includes("i")) modifiers += "i" + } + + if (this.useRegexp) { + try { + return new RegExp(term, modifiers) + } catch (error) {} + } + return new RegExp(_.escapeRegExp(term), modifiers) + } +} +Search.register() + +class SearchBackwards extends Search { + backwards = true +} +SearchBackwards.register() + +// *, # +// ------------------------- +class SearchCurrentWord extends SearchBase { + caseSensitivityKind = "SearchCurrentWord" + + moveCursor(cursor) { + if (this.input == null) { + const wordRange = this.getCurrentWordBufferRange() + if (wordRange) { + this.editor.setCursorBufferPosition(wordRange.start) + this.input = this.editor.getTextInBufferRange(wordRange) + } else { + this.input = "" + } + } + + super.moveCursor(cursor) + } + + getPattern(term) { + const escaped = _.escapeRegExp(term) + const source = /\W/.test(term) ? `${escaped}\\b` : `\\b${escaped}\\b` + return new RegExp(source, this.isCaseSensitive(term) ? "g" : "gi") + } + + getCurrentWordBufferRange() { + const cursor = this.editor.getLastCursor() + const point = cursor.getBufferPosition() + + const nonWordCharacters = getNonWordCharactersForCursor(cursor) + const wordRegex = new RegExp(`[^\\s${_.escapeRegExp(nonWordCharacters)}]+`, "g") + + let foundRange + this.scanForward(wordRegex, {from: [point.row, 0], allowNextLine: false}, ({range, stop}) => { + if (range.end.isGreaterThan(point)) { + foundRange = range + stop() + } + }) + return foundRange + } +} +SearchCurrentWord.register() + +class SearchCurrentWordBackwards extends SearchCurrentWord { + backwards = true +} +SearchCurrentWordBackwards.register() diff --git a/lib/motion.coffee b/lib/motion.coffee deleted file mode 100644 index 0d316e2fa..000000000 --- a/lib/motion.coffee +++ /dev/null @@ -1,1265 +0,0 @@ -_ = require 'underscore-plus' -{Point, Range} = require 'atom' - -{ - moveCursorLeft, moveCursorRight - moveCursorUpScreen, moveCursorDownScreen - pointIsAtVimEndOfFile - getFirstVisibleScreenRow, getLastVisibleScreenRow - getValidVimScreenRow, getValidVimBufferRow - moveCursorToFirstCharacterAtRow - sortRanges - pointIsOnWhiteSpace - moveCursorToNextNonWhitespace - isEmptyRow - getCodeFoldRowRanges - getLargestFoldRangeContainsBufferRow - isIncludeFunctionScopeForRow - detectScopeStartPositionForScope - getBufferRows - getTextInScreenRange - setBufferRow - setBufferColumn - limitNumber - getIndex - smartScrollToBufferPosition - pointIsAtEndOfLineAtNonEmptyRow - getEndOfLineForBufferRow - findRangeInBufferRow - saveEditorState -} = require './utils' - -Base = require './base' - -class Motion extends Base - @extend(false) - @operationKind: 'motion' - inclusive: false - wise: 'characterwise' - jump: false - verticalMotion: false - moveSucceeded: null - moveSuccessOnLinewise: false - selectSucceeded: false - - constructor: -> - super - - if @mode is 'visual' - @wise = @submode - @initialize() - - isLinewise: -> @wise is 'linewise' - isBlockwise: -> @wise is 'blockwise' - - forceWise: (wise) -> - if wise is 'characterwise' - if @wise is 'linewise' - @inclusive = false - else - @inclusive = not @inclusive - @wise = wise - - resetState: -> - @selectSucceeded = false - - setBufferPositionSafely: (cursor, point) -> - cursor.setBufferPosition(point) if point? - - setScreenPositionSafely: (cursor, point) -> - cursor.setScreenPosition(point) if point? - - moveWithSaveJump: (cursor) -> - if cursor.isLastCursor() and @jump - cursorPosition = cursor.getBufferPosition() - - @moveCursor(cursor) - - if cursorPosition? and not cursorPosition.isEqual(cursor.getBufferPosition()) - @vimState.mark.set('`', cursorPosition) - @vimState.mark.set("'", cursorPosition) - - execute: -> - if @operator? - @select() - else - @moveWithSaveJump(cursor) for cursor in @editor.getCursors() - @editor.mergeCursors() - @editor.mergeIntersectingSelections() - - # NOTE: Modify selection by modtion, selection is already "normalized" before this function is called. - select: -> - isOrWasVisual = @operator?.instanceof('SelectBase') or @is('CurrentSelection') # need to care was visual for `.` repeated. - for selection in @editor.getSelections() - selection.modifySelection => - @moveWithSaveJump(selection.cursor) - - selectSucceeded = @moveSucceeded ? not selection.isEmpty() or (@isLinewise() and @moveSuccessOnLinewise) - @selectSucceeded or= selectSucceeded - - if isOrWasVisual or (selectSucceeded and (@inclusive or @isLinewise())) - $selection = @swrap(selection) - $selection.saveProperties(true) # save property of "already-normalized-selection" - $selection.applyWise(@wise) - - @vimState.getLastBlockwiseSelection().autoscroll() if @wise is 'blockwise' - - setCursorBufferRow: (cursor, row, options) -> - if @verticalMotion and not @getConfig('stayOnVerticalMotion') - cursor.setBufferPosition(@getFirstCharacterPositionForBufferRow(row), options) - else - setBufferRow(cursor, row, options) - - # [NOTE] - # Since this function checks cursor position change, a cursor position MUST be - # updated IN callback(=fn) - # Updating point only in callback is wrong-use of this funciton, - # since it stops immediately because of not cursor position change. - moveCursorCountTimes: (cursor, fn) -> - oldPosition = cursor.getBufferPosition() - @countTimes @getCount(), (state) -> - fn(state) - if (newPosition = cursor.getBufferPosition()).isEqual(oldPosition) - state.stop() - oldPosition = newPosition - - isCaseSensitive: (term) -> - if @getConfig("useSmartcaseFor#{@caseSensitivityKind}") - term.search(/[A-Z]/) isnt -1 - else - not @getConfig("ignoreCaseFor#{@caseSensitivityKind}") - -# Used as operator's target in visual-mode. -class CurrentSelection extends Motion - @extend(false) - selectionExtent: null - blockwiseSelectionExtent: null - inclusive: true - - initialize: -> - super - @pointInfoByCursor = new Map - - moveCursor: (cursor) -> - if @mode is 'visual' - if @isBlockwise() - @blockwiseSelectionExtent = @swrap(cursor.selection).getBlockwiseSelectionExtent() - else - @selectionExtent = @editor.getSelectedBufferRange().getExtent() - else - # `.` repeat case - point = cursor.getBufferPosition() - - if @blockwiseSelectionExtent? - cursor.setBufferPosition(point.translate(@blockwiseSelectionExtent)) - else - cursor.setBufferPosition(point.traverse(@selectionExtent)) - - select: -> - if @mode is 'visual' - super - else - for cursor in @editor.getCursors() when pointInfo = @pointInfoByCursor.get(cursor) - {cursorPosition, startOfSelection} = pointInfo - if cursorPosition.isEqual(cursor.getBufferPosition()) - cursor.setBufferPosition(startOfSelection) - super - - # * Purpose of pointInfoByCursor? see #235 for detail. - # When stayOnTransformString is enabled, cursor pos is not set on start of - # of selected range. - # But I want following behavior, so need to preserve position info. - # 1. `vj>.` -> indent same two rows regardless of current cursor's row. - # 2. `vj>j.` -> indent two rows from cursor's row. - for cursor in @editor.getCursors() - startOfSelection = cursor.selection.getBufferRange().start - @onDidFinishOperation => - cursorPosition = cursor.getBufferPosition() - @pointInfoByCursor.set(cursor, {startOfSelection, cursorPosition}) - -class MoveLeft extends Motion - @extend() - moveCursor: (cursor) -> - allowWrap = @getConfig('wrapLeftRightMotion') - @moveCursorCountTimes cursor, -> - moveCursorLeft(cursor, {allowWrap}) - -class MoveRight extends Motion - @extend() - canWrapToNextLine: (cursor) -> - if @isAsTargetExceptSelectInVisualMode() and not cursor.isAtEndOfLine() - false - else - @getConfig('wrapLeftRightMotion') - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - cursorPosition = cursor.getBufferPosition() - @editor.unfoldBufferRow(cursorPosition.row) - allowWrap = @canWrapToNextLine(cursor) - moveCursorRight(cursor) - if cursor.isAtEndOfLine() and allowWrap and not pointIsAtVimEndOfFile(@editor, cursorPosition) - moveCursorRight(cursor, {allowWrap}) - -class MoveRightBufferColumn extends Motion - @extend(false) - - moveCursor: (cursor) -> - setBufferColumn(cursor, cursor.getBufferColumn() + @getCount()) - -class MoveUp extends Motion - @extend() - wise: 'linewise' - wrap: false - - getBufferRow: (row) -> - row = @getNextRow(row) - if @editor.isFoldedAtBufferRow(row) - getLargestFoldRangeContainsBufferRow(@editor, row).start.row - else - row - - getNextRow: (row) -> - min = 0 - if @wrap and row is min - @getVimLastBufferRow() - else - limitNumber(row - 1, {min}) - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - setBufferRow(cursor, @getBufferRow(cursor.getBufferRow())) - -class MoveUpWrap extends MoveUp - @extend() - wrap: true - -class MoveDown extends MoveUp - @extend() - wise: 'linewise' - wrap: false - - getBufferRow: (row) -> - if @editor.isFoldedAtBufferRow(row) - row = getLargestFoldRangeContainsBufferRow(@editor, row).end.row - @getNextRow(row) - - getNextRow: (row) -> - max = @getVimLastBufferRow() - if @wrap and row >= max - 0 - else - limitNumber(row + 1, {max}) - -class MoveDownWrap extends MoveDown - @extend() - wrap: true - -class MoveUpScreen extends Motion - @extend() - wise: 'linewise' - direction: 'up' - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, -> - moveCursorUpScreen(cursor) - -class MoveDownScreen extends MoveUpScreen - @extend() - wise: 'linewise' - direction: 'down' - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, -> - moveCursorDownScreen(cursor) - -# Move down/up to Edge -# ------------------------- -# See t9md/atom-vim-mode-plus#236 -# At least v1.7.0. bufferPosition and screenPosition cannot convert accurately -# when row is folded. -class MoveUpToEdge extends Motion - @extend() - wise: 'linewise' - jump: true - direction: 'up' - @description: "Move cursor up to **edge** char at same-column" - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - @setScreenPositionSafely(cursor, @getPoint(cursor.getScreenPosition())) - - getPoint: (fromPoint) -> - column = fromPoint.column - for row in @getScanRows(fromPoint) when @isEdge(point = new Point(row, column)) - return point - - getScanRows: ({row}) -> - validRow = getValidVimScreenRow.bind(null, @editor) - switch @direction - when 'up' then [validRow(row - 1)..0] - when 'down' then [validRow(row + 1)..@getVimLastScreenRow()] - - isEdge: (point) -> - if @isStoppablePoint(point) - # If one of above/below point was not stoppable, it's Edge! - above = point.translate([-1, 0]) - below = point.translate([+1, 0]) - (not @isStoppablePoint(above)) or (not @isStoppablePoint(below)) - else - false - - isStoppablePoint: (point) -> - if @isNonWhiteSpacePoint(point) or @isFirstRowOrLastRowAndStoppable(point) - true - else - leftPoint = point.translate([0, -1]) - rightPoint = point.translate([0, +1]) - @isNonWhiteSpacePoint(leftPoint) and @isNonWhiteSpacePoint(rightPoint) - - isNonWhiteSpacePoint: (point) -> - char = getTextInScreenRange(@editor, Range.fromPointWithDelta(point, 0, 1)) - char? and /\S/.test(char) - - isFirstRowOrLastRowAndStoppable: (point) -> - # In normal-mode we adjust cursor by moving-left if cursor at EOL of non-blank row. - # So explicitly guard to not answer it stoppable. - if @isMode('normal') and pointIsAtEndOfLineAtNonEmptyRow(@editor, point) - false - else - point.isEqual(@editor.clipScreenPosition(point)) and - ((point.row is 0) or (point.row is @getVimLastScreenRow())) - -class MoveDownToEdge extends MoveUpToEdge - @extend() - @description: "Move cursor down to **edge** char at same-column" - direction: 'down' - -# word -# ------------------------- -class MoveToNextWord extends Motion - @extend() - wordRegex: null - - getPoint: (pattern, from) -> - wordRange = null - found = false - vimEOF = @getVimEofBufferPosition(@editor) - - @scanForward pattern, {from}, ({range, matchText, stop}) -> - wordRange = range - # Ignore 'empty line' matches between '\r' and '\n' - return if matchText is '' and range.start.column isnt 0 - if range.start.isGreaterThan(from) - found = true - stop() - - if found - point = wordRange.start - if pointIsAtEndOfLineAtNonEmptyRow(@editor, point) and not point.isEqual(vimEOF) - point.traverse([1, 0]) - else - point - else - wordRange?.end ? from - - # Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is - # on a non-blank. This is because "cw" is interpreted as change-word, and a - # word does not include the following white space. {Vi: "cw" when on a blank - # followed by other blanks changes only the first blank; this is probably a - # bug, because "dw" deletes all the blanks} - # - # Another special case: When using the "w" motion in combination with an - # operator and the last word moved over is at the end of a line, the end of - # that word becomes the end of the operated text, not the first word in the - # next line. - moveCursor: (cursor) -> - cursorPosition = cursor.getBufferPosition() - return if pointIsAtVimEndOfFile(@editor, cursorPosition) - wasOnWhiteSpace = pointIsOnWhiteSpace(@editor, cursorPosition) - - isAsTargetExceptSelectInVisualMode = @isAsTargetExceptSelectInVisualMode() - @moveCursorCountTimes cursor, ({isFinal}) => - cursorPosition = cursor.getBufferPosition() - if isEmptyRow(@editor, cursorPosition.row) and isAsTargetExceptSelectInVisualMode - point = cursorPosition.traverse([1, 0]) - else - pattern = @wordRegex ? cursor.wordRegExp() - point = @getPoint(pattern, cursorPosition) - if isFinal and isAsTargetExceptSelectInVisualMode - if @operator.is('Change') and (not wasOnWhiteSpace) - point = cursor.getEndOfCurrentWordBufferPosition({@wordRegex}) - else - point = Point.min(point, getEndOfLineForBufferRow(@editor, cursorPosition.row)) - cursor.setBufferPosition(point) - -# b -class MoveToPreviousWord extends Motion - @extend() - wordRegex: null - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - point = cursor.getBeginningOfCurrentWordBufferPosition({@wordRegex}) - cursor.setBufferPosition(point) - -class MoveToEndOfWord extends Motion - @extend() - wordRegex: null - inclusive: true - - moveToNextEndOfWord: (cursor) -> - moveCursorToNextNonWhitespace(cursor) - point = cursor.getEndOfCurrentWordBufferPosition({@wordRegex}).translate([0, -1]) - point = Point.min(point, @getVimEofBufferPosition()) - cursor.setBufferPosition(point) - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - originalPoint = cursor.getBufferPosition() - @moveToNextEndOfWord(cursor) - if originalPoint.isEqual(cursor.getBufferPosition()) - # Retry from right column if cursor was already on EndOfWord - cursor.moveRight() - @moveToNextEndOfWord(cursor) - -# [TODO: Improve, accuracy] -class MoveToPreviousEndOfWord extends MoveToPreviousWord - @extend() - inclusive: true - - moveCursor: (cursor) -> - times = @getCount() - wordRange = cursor.getCurrentWordBufferRange() - cursorPosition = cursor.getBufferPosition() - - # if we're in the middle of a word then we need to move to its start - if cursorPosition.isGreaterThan(wordRange.start) and cursorPosition.isLessThan(wordRange.end) - times += 1 - - for [1..times] - point = cursor.getBeginningOfCurrentWordBufferPosition({@wordRegex}) - cursor.setBufferPosition(point) - - @moveToNextEndOfWord(cursor) - if cursor.getBufferPosition().isGreaterThanOrEqual(cursorPosition) - cursor.setBufferPosition([0, 0]) - - moveToNextEndOfWord: (cursor) -> - point = cursor.getEndOfCurrentWordBufferPosition({@wordRegex}).translate([0, -1]) - point = Point.min(point, @getVimEofBufferPosition()) - cursor.setBufferPosition(point) - -# Whole word -# ------------------------- -class MoveToNextWholeWord extends MoveToNextWord - @extend() - wordRegex: /^$|\S+/g - -class MoveToPreviousWholeWord extends MoveToPreviousWord - @extend() - wordRegex: /^$|\S+/g - -class MoveToEndOfWholeWord extends MoveToEndOfWord - @extend() - wordRegex: /\S+/ - -# [TODO: Improve, accuracy] -class MoveToPreviousEndOfWholeWord extends MoveToPreviousEndOfWord - @extend() - wordRegex: /\S+/ - -# Alphanumeric word [Experimental] -# ------------------------- -class MoveToNextAlphanumericWord extends MoveToNextWord - @extend() - @description: "Move to next alphanumeric(`/\w+/`) word" - wordRegex: /\w+/g - -class MoveToPreviousAlphanumericWord extends MoveToPreviousWord - @extend() - @description: "Move to previous alphanumeric(`/\w+/`) word" - wordRegex: /\w+/ - -class MoveToEndOfAlphanumericWord extends MoveToEndOfWord - @extend() - @description: "Move to end of alphanumeric(`/\w+/`) word" - wordRegex: /\w+/ - -# Alphanumeric word [Experimental] -# ------------------------- -class MoveToNextSmartWord extends MoveToNextWord - @extend() - @description: "Move to next smart word (`/[\w-]+/`) word" - wordRegex: /[\w-]+/g - -class MoveToPreviousSmartWord extends MoveToPreviousWord - @extend() - @description: "Move to previous smart word (`/[\w-]+/`) word" - wordRegex: /[\w-]+/ - -class MoveToEndOfSmartWord extends MoveToEndOfWord - @extend() - @description: "Move to end of smart word (`/[\w-]+/`) word" - wordRegex: /[\w-]+/ - -# Subword -# ------------------------- -class MoveToNextSubword extends MoveToNextWord - @extend() - moveCursor: (cursor) -> - @wordRegex = cursor.subwordRegExp() - super - -class MoveToPreviousSubword extends MoveToPreviousWord - @extend() - moveCursor: (cursor) -> - @wordRegex = cursor.subwordRegExp() - super - -class MoveToEndOfSubword extends MoveToEndOfWord - @extend() - moveCursor: (cursor) -> - @wordRegex = cursor.subwordRegExp() - super - -# Sentence -# ------------------------- -# Sentence is defined as below -# - end with ['.', '!', '?'] -# - optionally followed by [')', ']', '"', "'"] -# - followed by ['$', ' ', '\t'] -# - paragraph boundary is also sentence boundary -# - section boundary is also sentence boundary(ignore) -class MoveToNextSentence extends Motion - @extend() - jump: true - sentenceRegex: ///(?:[\.!\?][\)\]"']*\s+)|(\n|\r\n)///g - direction: 'next' - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - @setBufferPositionSafely(cursor, @getPoint(cursor.getBufferPosition())) - - getPoint: (fromPoint) -> - if @direction is 'next' - @getNextStartOfSentence(fromPoint) - else if @direction is 'previous' - @getPreviousStartOfSentence(fromPoint) - - isBlankRow: (row) -> - @editor.isBufferRowBlank(row) - - getNextStartOfSentence: (from) -> - foundPoint = null - @scanForward @sentenceRegex, {from}, ({range, matchText, match, stop}) => - if match[1]? - [startRow, endRow] = [range.start.row, range.end.row] - return if @skipBlankRow and @isBlankRow(endRow) - if @isBlankRow(startRow) isnt @isBlankRow(endRow) - foundPoint = @getFirstCharacterPositionForBufferRow(endRow) - else - foundPoint = range.end - stop() if foundPoint? - foundPoint ? @getVimEofBufferPosition() - - getPreviousStartOfSentence: (from) -> - foundPoint = null - @scanBackward @sentenceRegex, {from}, ({range, match, stop, matchText}) => - if match[1]? - [startRow, endRow] = [range.start.row, range.end.row] - if not @isBlankRow(endRow) and @isBlankRow(startRow) - point = @getFirstCharacterPositionForBufferRow(endRow) - if point.isLessThan(from) - foundPoint = point - else - return if @skipBlankRow - foundPoint = @getFirstCharacterPositionForBufferRow(startRow) - else - if range.end.isLessThan(from) - foundPoint = range.end - stop() if foundPoint? - foundPoint ? [0, 0] - -class MoveToPreviousSentence extends MoveToNextSentence - @extend() - direction: 'previous' - -class MoveToNextSentenceSkipBlankRow extends MoveToNextSentence - @extend() - skipBlankRow: true - -class MoveToPreviousSentenceSkipBlankRow extends MoveToPreviousSentence - @extend() - skipBlankRow: true - -# Paragraph -# ------------------------- -class MoveToNextParagraph extends Motion - @extend() - jump: true - direction: 'next' - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - @setBufferPositionSafely(cursor, @getPoint(cursor.getBufferPosition())) - - getPoint: (fromPoint) -> - startRow = fromPoint.row - wasAtNonBlankRow = not @editor.isBufferRowBlank(startRow) - for row in getBufferRows(@editor, {startRow, @direction}) - if @editor.isBufferRowBlank(row) - return new Point(row, 0) if wasAtNonBlankRow - else - wasAtNonBlankRow = true - - # fallback - switch @direction - when 'previous' then new Point(0, 0) - when 'next' then @getVimEofBufferPosition() - -class MoveToPreviousParagraph extends MoveToNextParagraph - @extend() - direction: 'previous' - -# ------------------------- -# keymap: 0 -class MoveToBeginningOfLine extends Motion - @extend() - - moveCursor: (cursor) -> - setBufferColumn(cursor, 0) - -class MoveToColumn extends Motion - @extend() - - moveCursor: (cursor) -> - setBufferColumn(cursor, @getCount(-1)) - -class MoveToLastCharacterOfLine extends Motion - @extend() - - moveCursor: (cursor) -> - row = getValidVimBufferRow(@editor, cursor.getBufferRow() + @getCount(-1)) - cursor.setBufferPosition([row, Infinity]) - cursor.goalColumn = Infinity - -class MoveToLastNonblankCharacterOfLineAndDown extends Motion - @extend() - inclusive: true - - moveCursor: (cursor) -> - point = @getPoint(cursor.getBufferPosition()) - cursor.setBufferPosition(point) - - getPoint: ({row}) -> - row = limitNumber(row + @getCount(-1), max: @getVimLastBufferRow()) - range = findRangeInBufferRow(@editor, /\S|^/, row, direction: 'backward') - range?.start ? new Point(row, 0) - -# MoveToFirstCharacterOfLine faimily -# ------------------------------------ -# ^ -class MoveToFirstCharacterOfLine extends Motion - @extend() - moveCursor: (cursor) -> - point = @getFirstCharacterPositionForBufferRow(cursor.getBufferRow()) - @setBufferPositionSafely(cursor, point) - -class MoveToFirstCharacterOfLineUp extends MoveToFirstCharacterOfLine - @extend() - wise: 'linewise' - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, -> - point = cursor.getBufferPosition() - unless point.row is 0 - cursor.setBufferPosition(point.translate([-1, 0])) - super - -class MoveToFirstCharacterOfLineDown extends MoveToFirstCharacterOfLine - @extend() - wise: 'linewise' - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - point = cursor.getBufferPosition() - unless @getVimLastBufferRow() is point.row - cursor.setBufferPosition(point.translate([+1, 0])) - super - -class MoveToFirstCharacterOfLineAndDown extends MoveToFirstCharacterOfLineDown - @extend() - defaultCount: 0 - getCount: -> super - 1 - -# keymap: g g -class MoveToFirstLine extends Motion - @extend() - wise: 'linewise' - jump: true - verticalMotion: true - moveSuccessOnLinewise: true - - moveCursor: (cursor) -> - @setCursorBufferRow(cursor, getValidVimBufferRow(@editor, @getRow())) - cursor.autoscroll(center: true) - - getRow: -> - @getCount(-1) - -class MoveToScreenColumn extends Motion - @extend(false) - moveCursor: (cursor) -> - allowOffScreenPosition = @getConfig("allowMoveToOffScreenColumnOnScreenLineMotion") - point = @vimState.utils.getScreenPositionForScreenRow(@editor, cursor.getScreenRow(), @which, {allowOffScreenPosition}) - @setScreenPositionSafely(cursor, point) - -# keymap: g 0 -class MoveToBeginningOfScreenLine extends MoveToScreenColumn - @extend() - which: "beginning" - -# g ^: `move-to-first-character-of-screen-line` -class MoveToFirstCharacterOfScreenLine extends MoveToScreenColumn - @extend() - which: "first-character" - -# keymap: g $ -class MoveToLastCharacterOfScreenLine extends MoveToScreenColumn - @extend() - which: "last-character" - -# keymap: G -class MoveToLastLine extends MoveToFirstLine - @extend() - defaultCount: Infinity - -# keymap: N% e.g. 10% -class MoveToLineByPercent extends MoveToFirstLine - @extend() - - getRow: -> - percent = limitNumber(@getCount(), max: 100) - Math.floor((@editor.getLineCount() - 1) * (percent / 100)) - -class MoveToRelativeLine extends Motion - @extend(false) - wise: 'linewise' - moveSuccessOnLinewise: true - - moveCursor: (cursor) -> - row = @getFoldEndRowForRow(cursor.getBufferRow()) - - count = @getCount(-1) - while (count > 0) - row = @getFoldEndRowForRow(row + 1) - count-- - - setBufferRow(cursor, row) - -class MoveToRelativeLineMinimumOne extends MoveToRelativeLine - @extend(false) - - getCount: -> - limitNumber(super, min: 1) - -# Position cursor without scrolling., H, M, L -# ------------------------- -# keymap: H -class MoveToTopOfScreen extends Motion - @extend() - wise: 'linewise' - jump: true - scrolloff: 2 - defaultCount: 0 - verticalMotion: true - - moveCursor: (cursor) -> - bufferRow = @editor.bufferRowForScreenRow(@getScreenRow()) - @setCursorBufferRow(cursor, bufferRow) - - getScrolloff: -> - if @isAsTargetExceptSelectInVisualMode() - 0 - else - @scrolloff - - getScreenRow: -> - firstRow = getFirstVisibleScreenRow(@editor) - offset = @getScrolloff() - offset = 0 if firstRow is 0 - offset = limitNumber(@getCount(-1), min: offset) - firstRow + offset - -# keymap: M -class MoveToMiddleOfScreen extends MoveToTopOfScreen - @extend() - getScreenRow: -> - startRow = getFirstVisibleScreenRow(@editor) - endRow = limitNumber(@editor.getLastVisibleScreenRow(), max: @getVimLastScreenRow()) - startRow + Math.floor((endRow - startRow) / 2) - -# keymap: L -class MoveToBottomOfScreen extends MoveToTopOfScreen - @extend() - getScreenRow: -> - # [FIXME] - # At least Atom v1.6.0, there are two implementation of getLastVisibleScreenRow() - # editor.getLastVisibleScreenRow() and editorElement.getLastVisibleScreenRow() - # Those two methods return different value, editor's one is corrent. - # So I intentionally use editor.getLastScreenRow here. - vimLastScreenRow = @getVimLastScreenRow() - row = limitNumber(@editor.getLastVisibleScreenRow(), max: vimLastScreenRow) - offset = @getScrolloff() + 1 - offset = 0 if row is vimLastScreenRow - offset = limitNumber(@getCount(-1), min: offset) - row - offset - -# Scrolling -# Half: ctrl-d, ctrl-u -# Full: ctrl-f, ctrl-b -# ------------------------- -# [FIXME] count behave differently from original Vim. -class Scroll extends Motion - @extend(false) - verticalMotion: true - - isSmoothScrollEnabled: -> - if Math.abs(@amountOfPage) is 1 - @getConfig('smoothScrollOnFullScrollMotion') - else - @getConfig('smoothScrollOnHalfScrollMotion') - - getSmoothScrollDuation: -> - if Math.abs(@amountOfPage) is 1 - @getConfig('smoothScrollOnFullScrollMotionDuration') - else - @getConfig('smoothScrollOnHalfScrollMotionDuration') - - getPixelRectTopForSceenRow: (row) -> - point = new Point(row, 0) - @editor.element.pixelRectForScreenRange(new Range(point, point)).top - - smoothScroll: (fromRow, toRow, done) -> - topPixelFrom = {top: @getPixelRectTopForSceenRow(fromRow)} - topPixelTo = {top: @getPixelRectTopForSceenRow(toRow)} - # [NOTE] - # intentionally use `element.component.setScrollTop` instead of `element.setScrollTop`. - # SInce element.setScrollTop will throw exception when element.component no longer exists. - step = (newTop) => - if @editor.element.component? - @editor.element.component.setScrollTop(newTop) - @editor.element.component.updateSync() - - duration = @getSmoothScrollDuation() - @vimState.requestScrollAnimation(topPixelFrom, topPixelTo, {duration, step, done}) - - getAmountOfRows: -> - Math.ceil(@amountOfPage * @editor.getRowsPerPage() * @getCount()) - - getBufferRow: (cursor) -> - screenRow = getValidVimScreenRow(@editor, cursor.getScreenRow() + @getAmountOfRows()) - @editor.bufferRowForScreenRow(screenRow) - - moveCursor: (cursor) -> - bufferRow = @getBufferRow(cursor) - @setCursorBufferRow(cursor, @getBufferRow(cursor), autoscroll: false) - - if cursor.isLastCursor() - if @isSmoothScrollEnabled() - @vimState.finishScrollAnimation() - - firstVisibileScreenRow = @editor.getFirstVisibleScreenRow() - newFirstVisibileBufferRow = @editor.bufferRowForScreenRow(firstVisibileScreenRow + @getAmountOfRows()) - newFirstVisibileScreenRow = @editor.screenRowForBufferRow(newFirstVisibileBufferRow) - done = => - @editor.setFirstVisibleScreenRow(newFirstVisibileScreenRow) - # [FIXME] sometimes, scrollTop is not updated, calling this fix. - # Investigate and find better approach then remove this workaround. - @editor.element.component?.updateSync() - - if @isSmoothScrollEnabled() - @smoothScroll(firstVisibileScreenRow, newFirstVisibileScreenRow, done) - else - done() - - -# keymap: ctrl-f -class ScrollFullScreenDown extends Scroll - @extend(true) - amountOfPage: +1 - -# keymap: ctrl-b -class ScrollFullScreenUp extends Scroll - @extend() - amountOfPage: -1 - -# keymap: ctrl-d -class ScrollHalfScreenDown extends Scroll - @extend() - amountOfPage: +1 / 2 - -# keymap: ctrl-u -class ScrollHalfScreenUp extends Scroll - @extend() - amountOfPage: -1 / 2 - -# Find -# ------------------------- -# keymap: f -class Find extends Motion - @extend() - backwards: false - inclusive: true - offset: 0 - requireInput: true - caseSensitivityKind: "Find" - - restoreEditorState: -> - @_restoreEditorState?() - @_restoreEditorState = null - - cancelOperation: -> - @restoreEditorState() - super - - initialize: -> - super - - @repeatIfNecessary() if @getConfig("reuseFindForRepeatFind") - return if @isComplete() - - charsMax = @getConfig("findCharsMax") - - if (charsMax > 1) - @_restoreEditorState = saveEditorState(@editor) - - options = - autoConfirmTimeout: @getConfig("findConfirmByTimeout") - onConfirm: (@input) => if @input then @processOperation() else @cancelOperation() - onChange: (@preConfirmedChars) => @highlightTextInCursorRows(@preConfirmedChars, "pre-confirm") - onCancel: => - @vimState.highlightFind.clearMarkers() - @cancelOperation() - commands: - "vim-mode-plus:find-next-pre-confirmed": => @findPreConfirmed(+1) - "vim-mode-plus:find-previous-pre-confirmed": => @findPreConfirmed(-1) - - options ?= {} - options.purpose = "find" - options.charsMax = charsMax - - @focusInput(options) - - findPreConfirmed: (delta) -> - if @preConfirmedChars and @getConfig("highlightFindChar") - index = @highlightTextInCursorRows(@preConfirmedChars, "pre-confirm", @getCount(-1) + delta, true) - @count = index + 1 - - repeatIfNecessary: -> - currentFind = @vimState.globalState.get("currentFind") - isSequentialExecution = @vimState.operationStack.getLastCommandName() in ["Find", "FindBackwards", "Till", "TillBackwards"] - if currentFind? and isSequentialExecution - @input = currentFind.input - @repeated = true - - isBackwards: -> - @backwards - - execute: -> - super - decorationType = "post-confirm" - decorationType += " long" if (@operator? and not @operator?.instanceof("SelectBase")) - @editor.component.getNextUpdatePromise().then => - @highlightTextInCursorRows(@input, decorationType) - - return # Don't return Promise here. OperationStack treat Promise differently. - - getPoint: (fromPoint) -> - scanRange = @editor.bufferRangeForBufferRow(fromPoint.row) - points = [] - regex = @getRegex(@input) - indexWantAccess = @getCount(-1) - - translation = new Point(0, if @isBackwards() then @offset else -@offset) - fromPoint = fromPoint.translate(translation.negate()) if @repeated - - if @isBackwards() - scanRange.start = Point.ZERO if @getConfig("findAcrossLines") - @editor.backwardsScanInBufferRange regex, scanRange, ({range, stop}) -> - if range.start.isLessThan(fromPoint) - points.push(range.start) - stop() if points.length > indexWantAccess - else - scanRange.end = @editor.getEofBufferPosition() if @getConfig("findAcrossLines") - @editor.scanInBufferRange regex, scanRange, ({range, stop}) -> - if range.start.isGreaterThan(fromPoint) - points.push(range.start) - stop() if points.length > indexWantAccess - - points[indexWantAccess]?.translate(translation) - - highlightTextInCursorRows: (text, decorationType, index = @getCount(-1), adjustIndex = false) -> - return unless @getConfig("highlightFindChar") - @vimState.highlightFind.highlightCursorRows(@getRegex(text), decorationType, @isBackwards(), @offset, index, adjustIndex) - - moveCursor: (cursor) -> - point = @getPoint(cursor.getBufferPosition()) - if point? - cursor.setBufferPosition(point) - else - @restoreEditorState() - - @globalState.set('currentFind', this) unless @repeated - - getRegex: (term) -> - modifiers = if @isCaseSensitive(term) then 'g' else 'gi' - new RegExp(_.escapeRegExp(term), modifiers) - -# keymap: F -class FindBackwards extends Find - @extend() - inclusive: false - backwards: true - -# keymap: t -class Till extends Find - @extend() - offset: 1 - - getPoint: -> - @point = super - @moveSucceeded = @point? - return @point - -# keymap: T -class TillBackwards extends Till - @extend() - inclusive: false - backwards: true - -# Mark -# ------------------------- -# keymap: ` -class MoveToMark extends Motion - @extend() - jump: true - requireInput: true - input: null # set when instatntiated via vimState::moveToMark() - - initialize: -> - super - @readChar() unless @isComplete() - - getPoint: -> - @vimState.mark.get(@input) - - moveCursor: (cursor) -> - if point = @getPoint() - cursor.setBufferPosition(point) - cursor.autoscroll(center: true) - -# keymap: ' -class MoveToMarkLine extends MoveToMark - @extend() - wise: 'linewise' - - getPoint: -> - if point = super - @getFirstCharacterPositionForBufferRow(point.row) - -# Fold -# ------------------------- -class MoveToPreviousFoldStart extends Motion - @extend() - @description: "Move to previous fold start" - wise: 'characterwise' - which: 'start' - direction: 'prev' - - initialize: -> - super - @rows = @getFoldRows(@which) - @rows.reverse() if @direction is 'prev' - - getFoldRows: (which) -> - index = if which is 'start' then 0 else 1 - rows = getCodeFoldRowRanges(@editor).map (rowRange) -> - rowRange[index] - _.sortBy(_.uniq(rows), (row) -> row) - - getScanRows: (cursor) -> - cursorRow = cursor.getBufferRow() - isValidRow = switch @direction - when 'prev' then (row) -> row < cursorRow - when 'next' then (row) -> row > cursorRow - @rows.filter(isValidRow) - - detectRow: (cursor) -> - @getScanRows(cursor)[0] - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - if (row = @detectRow(cursor))? - moveCursorToFirstCharacterAtRow(cursor, row) - -class MoveToNextFoldStart extends MoveToPreviousFoldStart - @extend() - @description: "Move to next fold start" - direction: 'next' - -class MoveToPreviousFoldStartWithSameIndent extends MoveToPreviousFoldStart - @extend() - @description: "Move to previous same-indented fold start" - detectRow: (cursor) -> - baseIndentLevel = @getIndentLevelForBufferRow(cursor.getBufferRow()) - for row in @getScanRows(cursor) - if @getIndentLevelForBufferRow(row) is baseIndentLevel - return row - null - -class MoveToNextFoldStartWithSameIndent extends MoveToPreviousFoldStartWithSameIndent - @extend() - @description: "Move to next same-indented fold start" - direction: 'next' - -class MoveToPreviousFoldEnd extends MoveToPreviousFoldStart - @extend() - @description: "Move to previous fold end" - which: 'end' - -class MoveToNextFoldEnd extends MoveToPreviousFoldEnd - @extend() - @description: "Move to next fold end" - direction: 'next' - -# ------------------------- -class MoveToPreviousFunction extends MoveToPreviousFoldStart - @extend() - @description: "Move to previous function" - direction: 'prev' - detectRow: (cursor) -> - _.detect @getScanRows(cursor), (row) => - isIncludeFunctionScopeForRow(@editor, row) - -class MoveToNextFunction extends MoveToPreviousFunction - @extend() - @description: "Move to next function" - direction: 'next' - -# Scope based -# ------------------------- -class MoveToPositionByScope extends Motion - @extend(false) - direction: 'backward' - scope: '.' - - getPoint: (fromPoint) -> - detectScopeStartPositionForScope(@editor, fromPoint, @direction, @scope) - - moveCursor: (cursor) -> - @moveCursorCountTimes cursor, => - @setBufferPositionSafely(cursor, @getPoint(cursor.getBufferPosition())) - -class MoveToPreviousString extends MoveToPositionByScope - @extend() - @description: "Move to previous string(searched by `string.begin` scope)" - direction: 'backward' - scope: 'string.begin' - -class MoveToNextString extends MoveToPreviousString - @extend() - @description: "Move to next string(searched by `string.begin` scope)" - direction: 'forward' - -class MoveToPreviousNumber extends MoveToPositionByScope - @extend() - direction: 'backward' - @description: "Move to previous number(searched by `constant.numeric` scope)" - scope: 'constant.numeric' - -class MoveToNextNumber extends MoveToPreviousNumber - @extend() - @description: "Move to next number(searched by `constant.numeric` scope)" - direction: 'forward' - -class MoveToNextOccurrence extends Motion - @extend() - # Ensure this command is available when has-occurrence - @commandScope: 'atom-text-editor.vim-mode-plus.has-occurrence' - jump: true - direction: 'next' - - getRanges: -> - @vimState.occurrenceManager.getMarkers().map (marker) -> - marker.getBufferRange() - - execute: -> - @ranges = @vimState.utils.sortRanges(@getRanges()) - super - - moveCursor: (cursor) -> - range = @ranges[getIndex(@getIndex(cursor.getBufferPosition()), @ranges)] - point = range.start - cursor.setBufferPosition(point, autoscroll: false) - - if cursor.isLastCursor() - @editor.unfoldBufferRow(point.row) - smartScrollToBufferPosition(@editor, point) - - if @getConfig('flashOnMoveToOccurrence') - @vimState.flash(range, type: 'search') - - getIndex: (fromPoint) -> - index = null - for range, i in @ranges when range.start.isGreaterThan(fromPoint) - index = i - break - (index ? 0) + @getCount(-1) - -class MoveToPreviousOccurrence extends MoveToNextOccurrence - @extend() - direction: 'previous' - - getIndex: (fromPoint) -> - index = null - for range, i in @ranges by -1 when range.end.isLessThan(fromPoint) - index = i - break - (index ? @ranges.length - 1) - @getCount(-1) - -# ------------------------- -# keymap: % -class MoveToPair extends Motion - @extend() - inclusive: true - jump: true - member: ['Parenthesis', 'CurlyBracket', 'SquareBracket'] - - moveCursor: (cursor) -> - @setBufferPositionSafely(cursor, @getPoint(cursor)) - - getPointForTag: (point) -> - pairInfo = @new("ATag").getPairInfo(point) - return null unless pairInfo? - {openRange, closeRange} = pairInfo - openRange = openRange.translate([0, +1], [0, -1]) - closeRange = closeRange.translate([0, +1], [0, -1]) - return closeRange.start if openRange.containsPoint(point) and (not point.isEqual(openRange.end)) - return openRange.start if closeRange.containsPoint(point) and (not point.isEqual(closeRange.end)) - - getPoint: (cursor) -> - cursorPosition = cursor.getBufferPosition() - cursorRow = cursorPosition.row - return point if point = @getPointForTag(cursorPosition) - - # AAnyPairAllowForwarding return forwarding range or enclosing range. - range = @new("AAnyPairAllowForwarding", {@member}).getRange(cursor.selection) - return null unless range? - {start, end} = range - if (start.row is cursorRow) and start.isGreaterThanOrEqual(cursorPosition) - # Forwarding range found - end.translate([0, -1]) - else if end.row is cursorPosition.row - # Enclosing range was returned - # We move to start( open-pair ) only when close-pair was at same row as cursor-row. - start diff --git a/lib/motion.js b/lib/motion.js new file mode 100644 index 000000000..42b27ebc3 --- /dev/null +++ b/lib/motion.js @@ -0,0 +1,1506 @@ +"use babel" + +const _ = require("underscore-plus") +const {Point, Range} = require("atom") + +const { + moveCursorLeft, + moveCursorRight, + moveCursorUpScreen, + moveCursorDownScreen, + pointIsAtVimEndOfFile, + getFirstVisibleScreenRow, + getLastVisibleScreenRow, + getValidVimScreenRow, + getValidVimBufferRow, + moveCursorToFirstCharacterAtRow, + sortRanges, + pointIsOnWhiteSpace, + moveCursorToNextNonWhitespace, + isEmptyRow, + getCodeFoldRowRanges, + getLargestFoldRangeContainsBufferRow, + isIncludeFunctionScopeForRow, + detectScopeStartPositionForScope, + getBufferRows, + getTextInScreenRange, + setBufferRow, + setBufferColumn, + limitNumber, + getIndex, + smartScrollToBufferPosition, + pointIsAtEndOfLineAtNonEmptyRow, + getEndOfLineForBufferRow, + findRangeInBufferRow, + saveEditorState, + getList, + getScreenPositionForScreenRow, +} = require("./utils") + +const Base = require("./base") + +class Motion extends Base { + static operationKind = "motion" + inclusive = false + wise = "characterwise" + jump = false + verticalMotion = false + moveSucceeded = null + moveSuccessOnLinewise = false + selectSucceeded = false + + isLinewise() { + return this.wise === "linewise" + } + + isBlockwise() { + return this.wise === "blockwise" + } + + forceWise(wise) { + if (wise === "characterwise") { + this.inclusive = this.wise === "linewise" ? false : !this.inclusive + } + this.wise = wise + } + + resetState() { + this.selectSucceeded = false + } + + setBufferPositionSafely(cursor, point) { + if (point) cursor.setBufferPosition(point) + } + + setScreenPositionSafely(cursor, point) { + if (point) cursor.setScreenPosition(point) + } + + moveWithSaveJump(cursor) { + let cursorPosition + if (cursor.isLastCursor() && this.jump) { + cursorPosition = cursor.getBufferPosition() + } + + this.moveCursor(cursor) + + if (cursorPosition && !cursorPosition.isEqual(cursor.getBufferPosition())) { + this.vimState.mark.set("`", cursorPosition) + this.vimState.mark.set("'", cursorPosition) + } + } + + execute() { + if (this.operator) { + this.select() + } else { + for (const cursor of this.editor.getCursors()) { + this.moveWithSaveJump(cursor) + } + } + this.editor.mergeCursors() + this.editor.mergeIntersectingSelections() + } + + // NOTE: Modify selection by modtion, selection is already "normalized" before this function is called. + select() { + // need to care was visual for `.` repeated. + const isOrWasVisual = (this.operator && this.operator.instanceof("SelectBase")) || this.is("CurrentSelection") + + for (const selection of this.editor.getSelections()) { + selection.modifySelection(() => this.moveWithSaveJump(selection.cursor)) + + const selectSucceeded = + this.moveSucceeded != null + ? this.moveSucceeded + : !selection.isEmpty() || (this.isLinewise() && this.moveSuccessOnLinewise) + if (!this.selectSucceeded) this.selectSucceeded = selectSucceeded + + if (isOrWasVisual || (selectSucceeded && (this.inclusive || this.isLinewise()))) { + const $selection = this.swrap(selection) + $selection.saveProperties(true) // save property of "already-normalized-selection" + $selection.applyWise(this.wise) + } + } + + if (this.wise === "blockwise") { + this.vimState.getLastBlockwiseSelection().autoscroll() + } + } + + setCursorBufferRow(cursor, row, options) { + if (this.verticalMotion && !this.getConfig("stayOnVerticalMotion")) { + cursor.setBufferPosition(this.getFirstCharacterPositionForBufferRow(row), options) + } else { + setBufferRow(cursor, row, options) + } + } + + // [NOTE] + // Since this function checks cursor position change, a cursor position MUST be + // updated IN callback(=fn) + // Updating point only in callback is wrong-use of this funciton, + // since it stops immediately because of not cursor position change. + moveCursorCountTimes(cursor, fn) { + let oldPosition = cursor.getBufferPosition() + this.countTimes(this.getCount(), state => { + fn(state) + const newPosition = cursor.getBufferPosition() + if (newPosition.isEqual(oldPosition)) state.stop() + oldPosition = newPosition + }) + } + + isCaseSensitive(term) { + return this.getConfig(`useSmartcaseFor${this.caseSensitivityKind}`) + ? term.search(/[A-Z]/) !== -1 + : !this.getConfig(`ignoreCaseFor${this.caseSensitivityKind}`) + } +} +Motion.register(false) + +// Used as operator's target in visual-mode. +class CurrentSelection extends Motion { + selectionExtent = null + blockwiseSelectionExtent = null + inclusive = true + + initialize() { + this.pointInfoByCursor = new Map() + return super.initialize() + } + + moveCursor(cursor) { + if (this.mode === "visual") { + this.selectionExtent = this.isBlockwise() + ? this.swrap(cursor.selection).getBlockwiseSelectionExtent() + : this.editor.getSelectedBufferRange().getExtent() + } else { + // `.` repeat case + cursor.setBufferPosition(cursor.getBufferPosition().translate(this.selectionExtent)) + } + } + + select() { + if (this.mode === "visual") { + super.select() + } else { + for (const cursor of this.editor.getCursors()) { + const pointInfo = this.pointInfoByCursor.get(cursor) + if (pointInfo) { + const {cursorPosition, startOfSelection} = pointInfo + if (cursorPosition.isEqual(cursor.getBufferPosition())) { + cursor.setBufferPosition(startOfSelection) + } + } + } + super.select() + } + + // * Purpose of pointInfoByCursor? see #235 for detail. + // When stayOnTransformString is enabled, cursor pos is not set on start of + // of selected range. + // But I want following behavior, so need to preserve position info. + // 1. `vj>.` -> indent same two rows regardless of current cursor's row. + // 2. `vj>j.` -> indent two rows from cursor's row. + for (const cursor of this.editor.getCursors()) { + const startOfSelection = cursor.selection.getBufferRange().start + this.onDidFinishOperation(() => { + cursorPosition = cursor.getBufferPosition() + this.pointInfoByCursor.set(cursor, {startOfSelection, cursorPosition}) + }) + } + } +} +CurrentSelection.register(false) + +class MoveLeft extends Motion { + moveCursor(cursor) { + const allowWrap = this.getConfig("wrapLeftRightMotion") + this.moveCursorCountTimes(cursor, () => moveCursorLeft(cursor, {allowWrap})) + } +} +MoveLeft.register() + +class MoveRight extends Motion { + canWrapToNextLine(cursor) { + if (this.isAsTargetExceptSelectInVisualMode() && !cursor.isAtEndOfLine()) { + return false + } else { + return this.getConfig("wrapLeftRightMotion") + } + } + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + const cursorPosition = cursor.getBufferPosition() + this.editor.unfoldBufferRow(cursorPosition.row) + const allowWrap = this.canWrapToNextLine(cursor) + moveCursorRight(cursor) + if (cursor.isAtEndOfLine() && allowWrap && !pointIsAtVimEndOfFile(this.editor, cursorPosition)) { + moveCursorRight(cursor, {allowWrap}) + } + }) + } +} +MoveRight.register() + +class MoveRightBufferColumn extends Motion { + moveCursor(cursor) { + setBufferColumn(cursor, cursor.getBufferColumn() + this.getCount()) + } +} +MoveRightBufferColumn.register(false) + +class MoveUp extends Motion { + wise = "linewise" + wrap = false + + getBufferRow(row) { + const min = 0 + row = this.wrap && row === min ? this.getVimLastBufferRow() : limitNumber(row - 1, {min}) + return this.getFoldStartRowForRow(row) + } + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => setBufferRow(cursor, this.getBufferRow(cursor.getBufferRow()))) + } +} +MoveUp.register() + +class MoveUpWrap extends MoveUp { + wrap = true +} +MoveUpWrap.register() + +class MoveDown extends MoveUp { + wise = "linewise" + wrap = false + + getBufferRow(row) { + if (this.editor.isFoldedAtBufferRow(row)) { + row = getLargestFoldRangeContainsBufferRow(this.editor, row).end.row + } + const max = this.getVimLastBufferRow() + return this.wrap && row >= max ? 0 : limitNumber(row + 1, {max}) + } +} +MoveDown.register() + +class MoveDownWrap extends MoveDown { + wrap = true +} +MoveDownWrap.register() + +class MoveUpScreen extends Motion { + wise = "linewise" + direction = "up" + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => moveCursorUpScreen(cursor)) + } +} +MoveUpScreen.register() + +class MoveDownScreen extends MoveUpScreen { + wise = "linewise" + direction = "down" + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => moveCursorDownScreen(cursor)) + } +} +MoveDownScreen.register() + +// Move down/up to Edge +// ------------------------- +// See t9md/atom-vim-mode-plus#236 +// At least v1.7.0. bufferPosition and screenPosition cannot convert accurately +// when row is folded. +class MoveUpToEdge extends Motion { + wise = "linewise" + jump = true + direction = "up" + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + this.setScreenPositionSafely(cursor, this.getPoint(cursor.getScreenPosition())) + }) + } + + getPoint(fromPoint) { + const {column} = fromPoint + for (const row of this.getScanRows(fromPoint)) { + const point = new Point(row, column) + if (this.isEdge(point)) return point + } + } + + getScanRows({row}) { + return this.direction === "up" + ? getList(getValidVimScreenRow(this.editor, row - 1), 0, true) + : getList(getValidVimScreenRow(this.editor, row + 1), this.getVimLastScreenRow(), true) + } + + isEdge(point) { + if (this.isStoppablePoint(point)) { + // If one of above/below point was not stoppable, it's Edge! + const above = point.translate([-1, 0]) + const below = point.translate([+1, 0]) + return !this.isStoppablePoint(above) || !this.isStoppablePoint(below) + } else { + return false + } + } + + isStoppablePoint(point) { + if (this.isNonWhiteSpacePoint(point) || this.isFirstRowOrLastRowAndStoppable(point)) { + return true + } else { + const leftPoint = point.translate([0, -1]) + const rightPoint = point.translate([0, +1]) + return this.isNonWhiteSpacePoint(leftPoint) && this.isNonWhiteSpacePoint(rightPoint) + } + } + + isNonWhiteSpacePoint(point) { + const char = getTextInScreenRange(this.editor, Range.fromPointWithDelta(point, 0, 1)) + return char != null && /\S/.test(char) + } + + isFirstRowOrLastRowAndStoppable(point) { + // In normal-mode we adjust cursor by moving-left if cursor at EOL of non-blank row. + // So explicitly guard to not answer it stoppable. + if (this.isMode("normal") && pointIsAtEndOfLineAtNonEmptyRow(this.editor, point)) { + return false + } else { + return ( + point.isEqual(this.editor.clipScreenPosition(point)) && + (point.row === 0 || point.row === this.getVimLastScreenRow()) + ) + } + } +} +MoveUpToEdge.register() + +class MoveDownToEdge extends MoveUpToEdge { + direction = "down" +} +MoveDownToEdge.register() + +// word +// ------------------------- +class MoveToNextWord extends Motion { + wordRegex = null + + getPoint(regex, from) { + let wordRange + let found = false + + this.scanForward(regex, {from}, function({range, matchText, stop}) { + wordRange = range + // Ignore 'empty line' matches between '\r' and '\n' + if (matchText === "" && range.start.column !== 0) return + if (range.start.isGreaterThan(from)) { + found = true + stop() + } + }) + + if (found) { + const point = wordRange.start + return pointIsAtEndOfLineAtNonEmptyRow(this.editor, point) && !point.isEqual(this.getVimEofBufferPosition()) + ? point.traverse([1, 0]) + : point + } else { + return wordRange ? wordRange.end : from + } + } + + // Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is + // on a non-blank. This is because "cw" is interpreted as change-word, and a + // word does not include the following white space. {Vi: "cw" when on a blank + // followed by other blanks changes only the first blank; this is probably a + // bug, because "dw" deletes all the blanks} + // + // Another special case: When using the "w" motion in combination with an + // operator and the last word moved over is at the end of a line, the end of + // that word becomes the end of the operated text, not the first word in the + // next line. + moveCursor(cursor) { + const cursorPosition = cursor.getBufferPosition() + if (pointIsAtVimEndOfFile(this.editor, cursorPosition)) return + + const wasOnWhiteSpace = pointIsOnWhiteSpace(this.editor, cursorPosition) + const isAsTargetExceptSelectInVisualMode = this.isAsTargetExceptSelectInVisualMode() + + this.moveCursorCountTimes(cursor, ({isFinal}) => { + const cursorPosition = cursor.getBufferPosition() + if (isEmptyRow(this.editor, cursorPosition.row) && isAsTargetExceptSelectInVisualMode) { + cursor.setBufferPosition(cursorPosition.traverse([1, 0])) + } else { + const regex = this.wordRegex || cursor.wordRegExp() + let point = this.getPoint(regex, cursorPosition) + if (isFinal && isAsTargetExceptSelectInVisualMode) { + if (this.operator.is("Change") && !wasOnWhiteSpace) { + point = cursor.getEndOfCurrentWordBufferPosition({wordRegex: this.wordRegex}) + } else { + point = Point.min(point, getEndOfLineForBufferRow(this.editor, cursorPosition.row)) + } + } + cursor.setBufferPosition(point) + } + }) + } +} +MoveToNextWord.register() + +// b +class MoveToPreviousWord extends Motion { + wordRegex = null + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + const point = cursor.getBeginningOfCurrentWordBufferPosition({wordRegex: this.wordRegex}) + cursor.setBufferPosition(point) + }) + } +} +MoveToPreviousWord.register() + +class MoveToEndOfWord extends Motion { + wordRegex = null + inclusive = true + + moveToNextEndOfWord(cursor) { + moveCursorToNextNonWhitespace(cursor) + const point = cursor.getEndOfCurrentWordBufferPosition({wordRegex: this.wordRegex}).translate([0, -1]) + cursor.setBufferPosition(Point.min(point, this.getVimEofBufferPosition())) + } + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + const originalPoint = cursor.getBufferPosition() + this.moveToNextEndOfWord(cursor) + if (originalPoint.isEqual(cursor.getBufferPosition())) { + // Retry from right column if cursor was already on EndOfWord + cursor.moveRight() + this.moveToNextEndOfWord(cursor) + } + }) + } +} +MoveToEndOfWord.register() + +// [TODO: Improve, accuracy] +class MoveToPreviousEndOfWord extends MoveToPreviousWord { + inclusive = true + + moveCursor(cursor) { + const wordRange = cursor.getCurrentWordBufferRange() + const cursorPosition = cursor.getBufferPosition() + + // if we're in the middle of a word then we need to move to its start + let times = this.getCount() + if (cursorPosition.isGreaterThan(wordRange.start) && cursorPosition.isLessThan(wordRange.end)) { + times += 1 + } + + for (const i in getList(1, times)) { + const point = cursor.getBeginningOfCurrentWordBufferPosition({wordRegex: this.wordRegex}) + cursor.setBufferPosition(point) + } + + this.moveToNextEndOfWord(cursor) + if (cursor.getBufferPosition().isGreaterThanOrEqual(cursorPosition)) { + cursor.setBufferPosition([0, 0]) + } + } + + moveToNextEndOfWord(cursor) { + const point = cursor.getEndOfCurrentWordBufferPosition({wordRegex: this.wordRegex}).translate([0, -1]) + cursor.setBufferPosition(Point.min(point, this.getVimEofBufferPosition())) + } +} +MoveToPreviousEndOfWord.register() + +// Whole word +// ------------------------- +class MoveToNextWholeWord extends MoveToNextWord { + wordRegex = /^$|\S+/g +} +MoveToNextWholeWord.register() + +class MoveToPreviousWholeWord extends MoveToPreviousWord { + wordRegex = /^$|\S+/g +} +MoveToPreviousWholeWord.register() + +class MoveToEndOfWholeWord extends MoveToEndOfWord { + wordRegex = /\S+/ +} +MoveToEndOfWholeWord.register() + +// [TODO: Improve, accuracy] +class MoveToPreviousEndOfWholeWord extends MoveToPreviousEndOfWord { + wordRegex = /\S+/ +} +MoveToPreviousEndOfWholeWord.register() + +// Alphanumeric word [Experimental] +// ------------------------- +class MoveToNextAlphanumericWord extends MoveToNextWord { + wordRegex = /\w+/g +} +MoveToNextAlphanumericWord.register() + +class MoveToPreviousAlphanumericWord extends MoveToPreviousWord { + wordRegex = /\w+/ +} +MoveToPreviousAlphanumericWord.register() + +class MoveToEndOfAlphanumericWord extends MoveToEndOfWord { + wordRegex = /\w+/ +} +MoveToEndOfAlphanumericWord.register() + +// Alphanumeric word [Experimental] +// ------------------------- +class MoveToNextSmartWord extends MoveToNextWord { + wordRegex = /[\w-]+/g +} +MoveToNextSmartWord.register() + +class MoveToPreviousSmartWord extends MoveToPreviousWord { + wordRegex = /[\w-]+/ +} +MoveToPreviousSmartWord.register() + +class MoveToEndOfSmartWord extends MoveToEndOfWord { + wordRegex = /[\w-]+/ +} +MoveToEndOfSmartWord.register() + +// Subword +// ------------------------- +class MoveToNextSubword extends MoveToNextWord { + moveCursor(cursor) { + this.wordRegex = cursor.subwordRegExp() + super.moveCursor(cursor) + } +} +MoveToNextSubword.register() + +class MoveToPreviousSubword extends MoveToPreviousWord { + moveCursor(cursor) { + this.wordRegex = cursor.subwordRegExp() + super.moveCursor(cursor) + } +} +MoveToPreviousSubword.register() + +class MoveToEndOfSubword extends MoveToEndOfWord { + moveCursor(cursor) { + this.wordRegex = cursor.subwordRegExp() + super.moveCursor(cursor) + } +} +MoveToEndOfSubword.register() + +// Sentence +// ------------------------- +// Sentence is defined as below +// - end with ['.', '!', '?'] +// - optionally followed by [')', ']', '"', "'"] +// - followed by ['$', ' ', '\t'] +// - paragraph boundary is also sentence boundary +// - section boundary is also sentence boundary(ignore) +class MoveToNextSentence extends Motion { + jump = true + sentenceRegex = new RegExp(`(?:[\\.!\\?][\\)\\]"']*\\s+)|(\\n|\\r\\n)`, "g") + direction = "next" + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + this.setBufferPositionSafely(cursor, this.getPoint(cursor.getBufferPosition())) + }) + } + + getPoint(fromPoint) { + if (this.direction === "next") { + return this.getNextStartOfSentence(fromPoint) + } else if (this.direction === "previous") { + return this.getPreviousStartOfSentence(fromPoint) + } + } + + isBlankRow(row) { + return this.editor.isBufferRowBlank(row) + } + + getNextStartOfSentence(from) { + let foundPoint + this.scanForward(this.sentenceRegex, {from}, ({range, matchText, match, stop}) => { + if (match[1] != null) { + const [startRow, endRow] = Array.from([range.start.row, range.end.row]) + if (this.skipBlankRow && this.isBlankRow(endRow)) return + if (this.isBlankRow(startRow) !== this.isBlankRow(endRow)) { + foundPoint = this.getFirstCharacterPositionForBufferRow(endRow) + } + } else { + foundPoint = range.end + } + if (foundPoint) stop() + }) + return foundPoint || this.getVimEofBufferPosition() + } + + getPreviousStartOfSentence(from) { + let foundPoint + this.scanBackward(this.sentenceRegex, {from}, ({range, match, stop, matchText}) => { + if (match[1] != null) { + const [startRow, endRow] = Array.from([range.start.row, range.end.row]) + if (!this.isBlankRow(endRow) && this.isBlankRow(startRow)) { + const point = this.getFirstCharacterPositionForBufferRow(endRow) + if (point.isLessThan(from)) { + foundPoint = point + } else { + if (this.skipBlankRow) return + foundPoint = this.getFirstCharacterPositionForBufferRow(startRow) + } + } + } else { + if (range.end.isLessThan(from)) foundPoint = range.end + } + if (foundPoint) stop() + }) + return foundPoint || [0, 0] + } +} +MoveToNextSentence.register() + +class MoveToPreviousSentence extends MoveToNextSentence { + direction = "previous" +} +MoveToPreviousSentence.register() + +class MoveToNextSentenceSkipBlankRow extends MoveToNextSentence { + skipBlankRow = true +} +MoveToNextSentenceSkipBlankRow.register() + +class MoveToPreviousSentenceSkipBlankRow extends MoveToPreviousSentence { + skipBlankRow = true +} +MoveToPreviousSentenceSkipBlankRow.register() + +// Paragraph +// ------------------------- +class MoveToNextParagraph extends Motion { + jump = true + direction = "next" + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + this.setBufferPositionSafely(cursor, this.getPoint(cursor.getBufferPosition())) + }) + } + + getPoint(fromPoint) { + const startRow = fromPoint.row + let wasAtNonBlankRow = !this.editor.isBufferRowBlank(startRow) + for (let row of getBufferRows(this.editor, {startRow, direction: this.direction})) { + if (this.editor.isBufferRowBlank(row)) { + if (wasAtNonBlankRow) return new Point(row, 0) + } else { + wasAtNonBlankRow = true + } + } + + // fallback + return this.direction === "previous" ? new Point(0, 0) : this.getVimEofBufferPosition() + } +} +MoveToNextParagraph.register() + +class MoveToPreviousParagraph extends MoveToNextParagraph { + direction = "previous" +} +MoveToPreviousParagraph.register() + +// ------------------------- +// keymap: 0 +class MoveToBeginningOfLine extends Motion { + moveCursor(cursor) { + setBufferColumn(cursor, 0) + } +} +MoveToBeginningOfLine.register() + +class MoveToColumn extends Motion { + moveCursor(cursor) { + setBufferColumn(cursor, this.getCount(-1)) + } +} +MoveToColumn.register() + +class MoveToLastCharacterOfLine extends Motion { + moveCursor(cursor) { + const row = getValidVimBufferRow(this.editor, cursor.getBufferRow() + this.getCount(-1)) + cursor.setBufferPosition([row, Infinity]) + cursor.goalColumn = Infinity + } +} +MoveToLastCharacterOfLine.register() + +class MoveToLastNonblankCharacterOfLineAndDown extends Motion { + inclusive = true + + moveCursor(cursor) { + cursor.setBufferPosition(this.getPoint(cursor.getBufferPosition())) + } + + getPoint({row}) { + row = limitNumber(row + this.getCount(-1), {max: this.getVimLastBufferRow()}) + const range = findRangeInBufferRow(this.editor, /\S|^/, row, {direction: "backward"}) + return range ? range.start : new Point(row, 0) + } +} +MoveToLastNonblankCharacterOfLineAndDown.register() + +// MoveToFirstCharacterOfLine faimily +// ------------------------------------ +// ^ +class MoveToFirstCharacterOfLine extends Motion { + moveCursor(cursor) { + const point = this.getFirstCharacterPositionForBufferRow(cursor.getBufferRow()) + this.setBufferPositionSafely(cursor, point) + } +} +MoveToFirstCharacterOfLine.register() + +class MoveToFirstCharacterOfLineUp extends MoveToFirstCharacterOfLine { + wise = "linewise" + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, function() { + const point = cursor.getBufferPosition() + if (point.row > 0) { + cursor.setBufferPosition(point.translate([-1, 0])) + } + }) + super.moveCursor(cursor) + } +} +MoveToFirstCharacterOfLineUp.register() + +class MoveToFirstCharacterOfLineDown extends MoveToFirstCharacterOfLine { + wise = "linewise" + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + const point = cursor.getBufferPosition() + if (point.row < this.getVimLastBufferRow()) { + cursor.setBufferPosition(point.translate([+1, 0])) + } + }) + super.moveCursor(cursor) + } +} +MoveToFirstCharacterOfLineDown.register() + +class MoveToFirstCharacterOfLineAndDown extends MoveToFirstCharacterOfLineDown { + getCount() { + return super.getCount(-1) + } +} +MoveToFirstCharacterOfLineAndDown.register() + +// keymap: g g +class MoveToFirstLine extends Motion { + wise = "linewise" + jump = true + verticalMotion = true + moveSuccessOnLinewise = true + + moveCursor(cursor) { + this.setCursorBufferRow(cursor, getValidVimBufferRow(this.editor, this.getRow())) + cursor.autoscroll({center: true}) + } + + getRow() { + return this.getCount(-1) + } +} +MoveToFirstLine.register() + +class MoveToScreenColumn extends Motion { + moveCursor(cursor) { + const allowOffScreenPosition = this.getConfig("allowMoveToOffScreenColumnOnScreenLineMotion") + const point = getScreenPositionForScreenRow(this.editor, cursor.getScreenRow(), this.which, { + allowOffScreenPosition, + }) + this.setScreenPositionSafely(cursor, point) + } +} +MoveToScreenColumn.register(false) + +// keymap: g 0 +class MoveToBeginningOfScreenLine extends MoveToScreenColumn { + which = "beginning" +} +MoveToBeginningOfScreenLine.register() + +// g ^: `move-to-first-character-of-screen-line` +class MoveToFirstCharacterOfScreenLine extends MoveToScreenColumn { + which = "first-character" +} +MoveToFirstCharacterOfScreenLine.register() + +// keymap: g $ +class MoveToLastCharacterOfScreenLine extends MoveToScreenColumn { + which = "last-character" +} +MoveToLastCharacterOfScreenLine.register() + +// keymap: G +class MoveToLastLine extends MoveToFirstLine { + defaultCount = Infinity +} +MoveToLastLine.register() + +// keymap: N% e.g. 10% +class MoveToLineByPercent extends MoveToFirstLine { + getRow() { + const percent = limitNumber(this.getCount(), {max: 100}) + return Math.floor((this.editor.getLineCount() - 1) * (percent / 100)) + } +} +MoveToLineByPercent.register() + +class MoveToRelativeLine extends Motion { + wise = "linewise" + moveSuccessOnLinewise = true + + moveCursor(cursor) { + let row + let count = this.getCount() + if (count < 0) { + // Support negative count + // Negative count can be passed like `operationStack.run("MoveToRelativeLine", {count: -5})`. + // Currently used in vim-mode-plus-ex-mode pkg. + count += 1 + row = this.getFoldStartRowForRow(cursor.getBufferRow()) + while (count++ < 0) row = this.getFoldStartRowForRow(row - 1) + } else { + count -= 1 + row = this.getFoldEndRowForRow(cursor.getBufferRow()) + while (count-- > 0) row = this.getFoldEndRowForRow(row + 1) + } + setBufferRow(cursor, row) + } +} +MoveToRelativeLine.register(false) + +class MoveToRelativeLineMinimumTwo extends MoveToRelativeLine { + getCount(...args) { + return limitNumber(super.getCount(...args), {min: 2}) + } +} +MoveToRelativeLineMinimumTwo.register(false) + +// Position cursor without scrolling., H, M, L +// ------------------------- +// keymap: H +class MoveToTopOfScreen extends Motion { + wise = "linewise" + jump = true + scrolloff = 2 + defaultCount = 0 + verticalMotion = true + + moveCursor(cursor) { + const bufferRow = this.editor.bufferRowForScreenRow(this.getScreenRow()) + this.setCursorBufferRow(cursor, bufferRow) + } + + getScrolloff() { + return this.isAsTargetExceptSelectInVisualMode() ? 0 : this.scrolloff + } + + getScreenRow() { + const firstRow = getFirstVisibleScreenRow(this.editor) + let offset = this.getScrolloff() + if (firstRow === 0) { + offset = 0 + } + offset = limitNumber(this.getCount(-1), {min: offset}) + return firstRow + offset + } +} +MoveToTopOfScreen.register() + +// keymap: M +class MoveToMiddleOfScreen extends MoveToTopOfScreen { + getScreenRow() { + const startRow = getFirstVisibleScreenRow(this.editor) + const endRow = limitNumber(this.editor.getLastVisibleScreenRow(), {max: this.getVimLastScreenRow()}) + return startRow + Math.floor((endRow - startRow) / 2) + } +} +MoveToMiddleOfScreen.register() + +// keymap: L +class MoveToBottomOfScreen extends MoveToTopOfScreen { + getScreenRow() { + // [FIXME] + // At least Atom v1.6.0, there are two implementation of getLastVisibleScreenRow() + // editor.getLastVisibleScreenRow() and editorElement.getLastVisibleScreenRow() + // Those two methods return different value, editor's one is corrent. + // So I intentionally use editor.getLastScreenRow here. + const vimLastScreenRow = this.getVimLastScreenRow() + const row = limitNumber(this.editor.getLastVisibleScreenRow(), {max: vimLastScreenRow}) + let offset = this.getScrolloff() + 1 + if (row === vimLastScreenRow) { + offset = 0 + } + offset = limitNumber(this.getCount(-1), {min: offset}) + return row - offset + } +} +MoveToBottomOfScreen.register() + +// Scrolling +// Half: ctrl-d, ctrl-u +// Full: ctrl-f, ctrl-b +// ------------------------- +// [FIXME] count behave differently from original Vim. +class Scroll extends Motion { + verticalMotion = true + + isSmoothScrollEnabled() { + return Math.abs(this.amountOfPage) === 1 + ? this.getConfig("smoothScrollOnFullScrollMotion") + : this.getConfig("smoothScrollOnHalfScrollMotion") + } + + getSmoothScrollDuation() { + return Math.abs(this.amountOfPage) === 1 + ? this.getConfig("smoothScrollOnFullScrollMotionDuration") + : this.getConfig("smoothScrollOnHalfScrollMotionDuration") + } + + getPixelRectTopForSceenRow(row) { + const point = new Point(row, 0) + return this.editor.element.pixelRectForScreenRange(new Range(point, point)).top + } + + smoothScroll(fromRow, toRow, done) { + const topPixelFrom = {top: this.getPixelRectTopForSceenRow(fromRow)} + const topPixelTo = {top: this.getPixelRectTopForSceenRow(toRow)} + // [NOTE] + // intentionally use `element.component.setScrollTop` instead of `element.setScrollTop`. + // SInce element.setScrollTop will throw exception when element.component no longer exists. + const step = newTop => { + if (this.editor.element.component) { + this.editor.element.component.setScrollTop(newTop) + this.editor.element.component.updateSync() + } + } + + const duration = this.getSmoothScrollDuation() + this.vimState.requestScrollAnimation(topPixelFrom, topPixelTo, {duration, step, done}) + } + + getAmountOfRows() { + return Math.ceil(this.amountOfPage * this.editor.getRowsPerPage() * this.getCount()) + } + + getBufferRow(cursor) { + const screenRow = getValidVimScreenRow(this.editor, cursor.getScreenRow() + this.getAmountOfRows()) + return this.editor.bufferRowForScreenRow(screenRow) + } + + moveCursor(cursor) { + const bufferRow = this.getBufferRow(cursor) + this.setCursorBufferRow(cursor, this.getBufferRow(cursor), {autoscroll: false}) + + if (cursor.isLastCursor()) { + if (this.isSmoothScrollEnabled()) this.vimState.finishScrollAnimation() + + const firstVisibileScreenRow = this.editor.getFirstVisibleScreenRow() + const newFirstVisibileBufferRow = this.editor.bufferRowForScreenRow( + firstVisibileScreenRow + this.getAmountOfRows() + ) + const newFirstVisibileScreenRow = this.editor.screenRowForBufferRow(newFirstVisibileBufferRow) + const done = () => { + this.editor.setFirstVisibleScreenRow(newFirstVisibileScreenRow) + // [FIXME] sometimes, scrollTop is not updated, calling this fix. + // Investigate and find better approach then remove this workaround. + if (this.editor.element.component) this.editor.element.component.updateSync() + } + + if (this.isSmoothScrollEnabled()) this.smoothScroll(firstVisibileScreenRow, newFirstVisibileScreenRow, done) + else done() + } + } +} +Scroll.register(false) + +// keymap: ctrl-f +class ScrollFullScreenDown extends Scroll { + amountOfPage = +1 +} +ScrollFullScreenDown.register() + +// keymap: ctrl-b +class ScrollFullScreenUp extends Scroll { + amountOfPage = -1 +} +ScrollFullScreenUp.register() + +// keymap: ctrl-d +class ScrollHalfScreenDown extends Scroll { + amountOfPage = +1 / 2 +} +ScrollHalfScreenDown.register() + +// keymap: ctrl-u +class ScrollHalfScreenUp extends Scroll { + amountOfPage = -1 / 2 +} +ScrollHalfScreenUp.register() + +// Find +// ------------------------- +// keymap: f +class Find extends Motion { + backwards = false + inclusive = true + offset = 0 + requireInput = true + caseSensitivityKind = "Find" + + restoreEditorState() { + if (this._restoreEditorState) this._restoreEditorState() + this._restoreEditorState = null + } + + cancelOperation() { + this.restoreEditorState() + super.cancelOperation() + } + + initialize() { + if (this.getConfig("reuseFindForRepeatFind")) this.repeatIfNecessary() + if (!this.isComplete()) { + const charsMax = this.getConfig("findCharsMax") + const optionsBase = {purpose: "find", charsMax} + + if (charsMax === 1) { + this.focusInput(optionsBase) + } else { + this._restoreEditorState = saveEditorState(this.editor) + const options = { + autoConfirmTimeout: this.getConfig("findConfirmByTimeout"), + onConfirm: input => { + this.input = input + if (input) this.processOperation() + else this.cancelOperation() + }, + onChange: preConfirmedChars => { + this.preConfirmedChars = preConfirmedChars + this.highlightTextInCursorRows(this.preConfirmedChars, "pre-confirm") + }, + onCancel: () => { + this.vimState.highlightFind.clearMarkers() + this.cancelOperation() + }, + commands: { + "vim-mode-plus:find-next-pre-confirmed": () => this.findPreConfirmed(+1), + "vim-mode-plus:find-previous-pre-confirmed": () => this.findPreConfirmed(-1), + }, + } + this.focusInput(Object.assign(options, optionsBase)) + } + } + return super.initialize() + } + + findPreConfirmed(delta) { + if (this.preConfirmedChars && this.getConfig("highlightFindChar")) { + const index = this.highlightTextInCursorRows( + this.preConfirmedChars, + "pre-confirm", + this.getCount(-1) + delta, + true + ) + this.count = index + 1 + } + } + + repeatIfNecessary() { + const findCommandNames = ["Find", "FindBackwards", "Till", "TillBackwards"] + const currentFind = this.globalState.get("currentFind") + if (currentFind && findCommandNames.includes(this.vimState.operationStack.getLastCommandName())) { + this.input = currentFind.input + this.repeated = true + } + } + + isBackwards() { + return this.backwards + } + + execute() { + super.execute() + let decorationType = "post-confirm" + if (this.operator != null && !(this.operator != null ? this.operator.instanceof("SelectBase") : undefined)) { + decorationType += " long" + } + this.editor.component.getNextUpdatePromise().then(() => { + this.highlightTextInCursorRows(this.input, decorationType) + }) + } + + getPoint(fromPoint) { + const scanRange = this.editor.bufferRangeForBufferRow(fromPoint.row) + const points = [] + const regex = this.getRegex(this.input) + const indexWantAccess = this.getCount(-1) + + const translation = new Point(0, this.isBackwards() ? this.offset : -this.offset) + if (this.repeated) { + fromPoint = fromPoint.translate(translation.negate()) + } + + if (this.isBackwards()) { + if (this.getConfig("findAcrossLines")) scanRange.start = Point.ZERO + + this.editor.backwardsScanInBufferRange(regex, scanRange, ({range, stop}) => { + if (range.start.isLessThan(fromPoint)) { + points.push(range.start) + if (points.length > indexWantAccess) { + stop() + } + } + }) + } else { + if (this.getConfig("findAcrossLines")) scanRange.end = this.editor.getEofBufferPosition() + this.editor.scanInBufferRange(regex, scanRange, ({range, stop}) => { + if (range.start.isGreaterThan(fromPoint)) { + points.push(range.start) + if (points.length > indexWantAccess) { + stop() + } + } + }) + } + + const point = points[indexWantAccess] + if (point) return point.translate(translation) + } + + // FIXME: bad naming, this function must return index + highlightTextInCursorRows(text, decorationType, index = this.getCount(-1), adjustIndex = false) { + if (!this.getConfig("highlightFindChar")) return + + return this.vimState.highlightFind.highlightCursorRows( + this.getRegex(text), + decorationType, + this.isBackwards(), + this.offset, + index, + adjustIndex + ) + } + + moveCursor(cursor) { + const point = this.getPoint(cursor.getBufferPosition()) + if (point) cursor.setBufferPosition(point) + else this.restoreEditorState() + + if (!this.repeated) this.globalState.set("currentFind", this) + } + + getRegex(term) { + const modifiers = this.isCaseSensitive(term) ? "g" : "gi" + return new RegExp(_.escapeRegExp(term), modifiers) + } +} +Find.register() + +// keymap: F +class FindBackwards extends Find { + inclusive = false + backwards = true +} +FindBackwards.register() + +// keymap: t +class Till extends Find { + offset = 1 + getPoint(...args) { + const point = super.getPoint(...args) + this.moveSucceeded = point != null + return point + } +} +Till.register() + +// keymap: T +class TillBackwards extends Till { + inclusive = false + backwards = true +} +TillBackwards.register() + +// Mark +// ------------------------- +// keymap: ` +class MoveToMark extends Motion { + jump = true + requireInput = true + input = null + + initialize() { + if (!this.isComplete()) this.readChar() + return super.initialize() + } + + getPoint() { + return this.vimState.mark.get(this.input) + } + + moveCursor(cursor) { + const point = this.getPoint() + if (point) { + cursor.setBufferPosition(point) + cursor.autoscroll({center: true}) + } + } +} +MoveToMark.register() + +// keymap: ' +class MoveToMarkLine extends MoveToMark { + wise = "linewise" + + getPoint() { + const point = super.getPoint() + if (point) { + return this.getFirstCharacterPositionForBufferRow(point.row) + } + } +} +MoveToMarkLine.register() + +// Fold +// ------------------------- +class MoveToPreviousFoldStart extends Motion { + wise = "characterwise" + which = "start" + direction = "prev" + + execute() { + this.rows = this.getFoldRows(this.which) + if (this.direction === "prev") this.rows.reverse() + + super.execute() + } + + getFoldRows(which) { + const index = which === "start" ? 0 : 1 + const rows = getCodeFoldRowRanges(this.editor).map(rowRange => rowRange[index]) + return _.sortBy(_.uniq(rows), row => row) + } + + getScanRows(cursor) { + const cursorRow = cursor.getBufferRow() + const isVald = this.direction === "prev" ? row => row < cursorRow : row => row > cursorRow + return this.rows.filter(isVald) + } + + detectRow(cursor) { + return this.getScanRows(cursor)[0] + } + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + const row = this.detectRow(cursor) + if (row != null) moveCursorToFirstCharacterAtRow(cursor, row) + }) + } +} +MoveToPreviousFoldStart.register() + +class MoveToNextFoldStart extends MoveToPreviousFoldStart { + direction = "next" +} +MoveToNextFoldStart.register() + +class MoveToPreviousFoldStartWithSameIndent extends MoveToPreviousFoldStart { + detectRow(cursor) { + const baseIndentLevel = this.editor.indentationForBufferRow(cursor.getBufferRow()) + return this.getScanRows(cursor).find(row => this.editor.indentationForBufferRow(row) === baseIndentLevel) + } +} +MoveToPreviousFoldStartWithSameIndent.register() + +class MoveToNextFoldStartWithSameIndent extends MoveToPreviousFoldStartWithSameIndent { + direction = "next" +} +MoveToNextFoldStartWithSameIndent.register() + +class MoveToPreviousFoldEnd extends MoveToPreviousFoldStart { + which = "end" +} +MoveToPreviousFoldEnd.register() + +class MoveToNextFoldEnd extends MoveToPreviousFoldEnd { + direction = "next" +} +MoveToNextFoldEnd.register() + +// ------------------------- +class MoveToPreviousFunction extends MoveToPreviousFoldStart { + direction = "prev" + detectRow(cursor) { + return this.getScanRows(cursor).find(row => isIncludeFunctionScopeForRow(this.editor, row)) + } +} +MoveToPreviousFunction.register() + +class MoveToNextFunction extends MoveToPreviousFunction { + direction = "next" +} +MoveToNextFunction.register() + +// Scope based +// ------------------------- +class MoveToPositionByScope extends Motion { + direction = "backward" + scope = "." + + getPoint(fromPoint) { + return detectScopeStartPositionForScope(this.editor, fromPoint, this.direction, this.scope) + } + + moveCursor(cursor) { + this.moveCursorCountTimes(cursor, () => { + this.setBufferPositionSafely(cursor, this.getPoint(cursor.getBufferPosition())) + }) + } +} +MoveToPositionByScope.register(false) + +class MoveToPreviousString extends MoveToPositionByScope { + direction = "backward" + scope = "string.begin" +} +MoveToPreviousString.register() + +class MoveToNextString extends MoveToPreviousString { + direction = "forward" +} +MoveToNextString.register() + +class MoveToPreviousNumber extends MoveToPositionByScope { + direction = "backward" + scope = "constant.numeric" +} +MoveToPreviousNumber.register() + +class MoveToNextNumber extends MoveToPreviousNumber { + direction = "forward" +} +MoveToNextNumber.register() + +class MoveToNextOccurrence extends Motion { + // Ensure this command is available when only has-occurrence + static commandScope = "atom-text-editor.vim-mode-plus.has-occurrence" + jump = true + direction = "next" + + execute() { + this.ranges = sortRanges(this.occurrenceManager.getMarkers().map(marker => marker.getBufferRange())) + super.execute() + } + + moveCursor(cursor) { + const range = this.ranges[getIndex(this.getIndex(cursor.getBufferPosition()), this.ranges)] + const point = range.start + cursor.setBufferPosition(point, {autoscroll: false}) + + if (cursor.isLastCursor()) { + this.editor.unfoldBufferRow(point.row) + smartScrollToBufferPosition(this.editor, point) + } + + if (this.getConfig("flashOnMoveToOccurrence")) { + this.vimState.flash(range, {type: "search"}) + } + } + + getIndex(fromPoint) { + const index = this.ranges.findIndex(range => range.start.isGreaterThan(fromPoint)) + return (index >= 0 ? index : 0) + this.getCount(-1) + } +} +MoveToNextOccurrence.register() + +class MoveToPreviousOccurrence extends MoveToNextOccurrence { + direction = "previous" + + getIndex(fromPoint) { + const ranges = this.ranges.slice().reverse() + const range = ranges.find(range => range.end.isLessThan(fromPoint)) + const index = range ? this.ranges.indexOf(range) : this.ranges.length - 1 + return index - this.getCount(-1) + } +} +MoveToPreviousOccurrence.register() + +// ------------------------- +// keymap: % +class MoveToPair extends Motion { + inclusive = true + jump = true + member = ["Parenthesis", "CurlyBracket", "SquareBracket"] + + moveCursor(cursor) { + this.setBufferPositionSafely(cursor, this.getPoint(cursor)) + } + + getPointForTag(point) { + const pairInfo = this.getInstance("ATag").getPairInfo(point) + if (!pairInfo) return + + let {openRange, closeRange} = pairInfo + openRange = openRange.translate([0, +1], [0, -1]) + closeRange = closeRange.translate([0, +1], [0, -1]) + if (openRange.containsPoint(point) && !point.isEqual(openRange.end)) { + return closeRange.start + } + if (closeRange.containsPoint(point) && !point.isEqual(closeRange.end)) { + return openRange.start + } + } + + getPoint(cursor) { + const cursorPosition = cursor.getBufferPosition() + const cursorRow = cursorPosition.row + const point = this.getPointForTag(cursorPosition) + if (point) return point + + // AAnyPairAllowForwarding return forwarding range or enclosing range. + const range = this.getInstance("AAnyPairAllowForwarding", {member: this.member}).getRange(cursor.selection) + if (!range) return + + const {start, end} = range + if (start.row === cursorRow && start.isGreaterThanOrEqual(cursorPosition)) { + // Forwarding range found + return end.translate([0, -1]) + } else if (end.row === cursorPosition.row) { + // Enclosing range was returned + // We move to start( open-pair ) only when close-pair was at same row as cursor-row. + return start + } + } +} +MoveToPair.register() diff --git a/lib/occurrence-manager.js b/lib/occurrence-manager.js index 3a3c1f5db..77d9146bf 100644 --- a/lib/occurrence-manager.js +++ b/lib/occurrence-manager.js @@ -57,7 +57,7 @@ module.exports = class OccurrenceManager { return true } else { const options = { - message: `Too many(${length}) matches. Do you want to continue?`, + message: `Too many(${length}) occurrences. Do you want to continue?`, detailedMessage: `If you want increase threshold(current: ${threshold}), change "Confirm Threshold On Create Preset Occurrences" configuration.`, buttons: ["Continue", "Cancel"], } diff --git a/lib/operation-stack.js b/lib/operation-stack.js index af2054756..e98080721 100644 --- a/lib/operation-stack.js +++ b/lib/operation-stack.js @@ -1,6 +1,6 @@ const {Disposable, CompositeDisposable} = require("atom") const Base = require("./base") -let OperationAbortedError, SelectInVisualMode, MoveToRelativeLine +let OperationAbortedError // opration life in operationStack // 1. run @@ -67,16 +67,6 @@ module.exports = class OperationStack { return this.stack.length === 0 } - newMoveToRelativeLine() { - if (!MoveToRelativeLine) MoveToRelativeLine = Base.getClass("MoveToRelativeLine") - return new MoveToRelativeLine(this.vimState) - } - - newSelectWithTarget(target) { - if (!SelectInVisualMode) SelectInVisualMode = Base.getClass("SelectInVisualMode") - return new SelectInVisualMode(this.vimState).setTarget(target) - } - // Main // ------------------------- run(klass, properties) { @@ -101,15 +91,14 @@ module.exports = class OperationStack { const stackTop = this.peekTop() if (stackTop && stackTop.constructor === klass) { // Replace operator when identical one repeated, e.g. `dd`, `cc`, `gUgU` - operation = this.newMoveToRelativeLine() - } else { - operation = new klass(this.vimState, properties) + klass = "MoveToRelativeLine" } + operation = Base.getInstance(this.vimState, klass, properties) } if (this.isEmpty()) { if ((this.mode === "visual" && operation.isMotion()) || operation.isTextObject()) { - operation = this.newSelectWithTarget(operation) + operation = Base.getInstance(this.vimState, "SelectInVisualMode").setTarget(operation) } this.stack.push(operation) this.process() @@ -142,15 +131,20 @@ module.exports = class OperationStack { this.run(operation) } - runRecordedMotion(key, {reverse} = {}) { - const recoded = this.vimState.globalState.get(key) - if (!recoded) return + // Currently used in repeat-search and repeat-find("n", "N", ";", ","). + runRecordedMotion(key, {reverse = false} = {}) { + const recorded = this.vimState.globalState.get(key) + if (!recorded) return - const operation = recoded.clone(this.vimState) - operation.repeated = true - operation.resetCount() - if (reverse) operation.backwards = !operation.backwards - this.run(operation) + recorded.vimState = this.vimState + recorded.repeated = true + recorded.resetCount() + + const originalBackwards = recorded.backwards + if (reverse) recorded.backwards = !originalBackwards + + this.run(recorded) + recorded.backwards = originalBackwards } runCurrentFind(options) { @@ -189,23 +183,13 @@ module.exports = class OperationStack { if (this.mode === "normal" && top.isOperator()) { this.modeManager.activate("operator-pending") } - this.addOperatorSpecificPendingScope(top) + // Temporary set while command is running to achieve operation-specific keymap scopes + this.addToClassList(top.getCommandNameWithoutPrefix() + "-pending") } else { this.execute(this.stack.pop()) } } - addOperatorSpecificPendingScope(operation) { - // Temporary set while command is running - const commandName = - typeof operation.constructor.getCommandNameWithoutPrefix === "function" - ? operation.constructor.getCommandNameWithoutPrefix() - : undefined - if (commandName) { - this.addToClassList(commandName + "-pending") - } - } - execute(operation) { // Intentionally avoild wrapping by Promise.resolve() to make test easy. // Since almost all command don't return promise, finish synchronously. diff --git a/lib/operator-insert.coffee b/lib/operator-insert.coffee deleted file mode 100644 index 2e4721e70..000000000 --- a/lib/operator-insert.coffee +++ /dev/null @@ -1,373 +0,0 @@ -_ = require 'underscore-plus' -{Range} = require 'atom' - -{ - moveCursorLeft - moveCursorRight - limitNumber - isEmptyRow - setBufferRow -} = require './utils' -Operator = require('./base').getClass('Operator') - -# Operator which start 'insert-mode' -# ------------------------- -# [NOTE] -# Rule: Don't make any text mutation before calling `@selectTarget()`. -class ActivateInsertMode extends Operator - @extend() - requireTarget: false - flashTarget: false - finalSubmode: null - supportInsertionCount: true - - observeWillDeactivateMode: -> - disposable = @vimState.modeManager.preemptWillDeactivateMode ({mode}) => - return unless mode is 'insert' - disposable.dispose() - - @vimState.mark.set('^', @editor.getCursorBufferPosition()) # Last insert-mode position - textByUserInput = '' - if change = @getChangeSinceCheckpoint('insert') - @lastChange = change - @setMarkForChange(new Range(change.start, change.start.traverse(change.newExtent))) - textByUserInput = change.newText - @vimState.register.set('.', text: textByUserInput) # Last inserted text - - _.times @getInsertionCount(), => - text = @textByOperator + textByUserInput - for selection in @editor.getSelections() - selection.insertText(text, autoIndent: true) - - # This cursor state is restored on undo. - # So cursor state has to be updated before next groupChangesSinceCheckpoint() - if @getConfig('clearMultipleCursorsOnEscapeInsertMode') - @vimState.clearSelections() - - # grouping changes for undo checkpoint need to come last - if @getConfig('groupChangesWhenLeavingInsertMode') - @groupChangesSinceBufferCheckpoint('undo') - - # When each mutaion's extent is not intersecting, muitiple changes are recorded - # e.g - # - Multicursors edit - # - Cursor moved in insert-mode(e.g ctrl-f, ctrl-b) - # But I don't care multiple changes just because I'm lazy(so not perfect implementation). - # I only take care of one change happened at earliest(topCursor's change) position. - # Thats' why I save topCursor's position to @topCursorPositionAtInsertionStart to compare traversal to deletionStart - # Why I use topCursor's change? Just because it's easy to use first change returned by getChangeSinceCheckpoint(). - getChangeSinceCheckpoint: (purpose) -> - checkpoint = @getBufferCheckpoint(purpose) - @editor.buffer.getChangesSinceCheckpoint(checkpoint)[0] - - # [BUG-BUT-OK] Replaying text-deletion-operation is not compatible to pure Vim. - # Pure Vim record all operation in insert-mode as keystroke level and can distinguish - # character deleted by `Delete` or by `ctrl-u`. - # But I can not and don't trying to minic this level of compatibility. - # So basically deletion-done-in-one is expected to work well. - replayLastChange: (selection) -> - if @lastChange? - {start, newExtent, oldExtent, newText} = @lastChange - unless oldExtent.isZero() - traversalToStartOfDelete = start.traversalFrom(@topCursorPositionAtInsertionStart) - deletionStart = selection.cursor.getBufferPosition().traverse(traversalToStartOfDelete) - deletionEnd = deletionStart.traverse(oldExtent) - selection.setBufferRange([deletionStart, deletionEnd]) - else - newText = '' - selection.insertText(newText, autoIndent: true) - - # called when repeated - # [FIXME] to use replayLastChange in repeatInsert overriding subclasss. - repeatInsert: (selection, text) -> - @replayLastChange(selection) - - getInsertionCount: -> - @insertionCount ?= if @supportInsertionCount then @getCount(-1) else 0 - # Avoid freezing by acccidental big count(e.g. `5555555555555i`), See #560, #596 - limitNumber(@insertionCount, max: 100) - - investigateCursorPosition: -> - [c1, c2, c3] = [] - c1 = @editor.getCursorBufferPosition() - - @onWillActivateMode ({mode, submode}) => - c2 = @editor.getCursorBufferPosition() - console.info 'diff c1, c2', c1.toString(), c2.toString() unless c1.isEqual(c2) - - @onDidActivateMode ({mode, submode}) => - c3 = @editor.getCursorBufferPosition() - if c2.row isnt c3.row - console.warn 'dff c2, c3', c2.toString(), c3.toString() - - execute: -> - if @repeated - @flashTarget = @trackChange = true - - @startMutation => - @selectTarget() if @target? - @mutateText?() - for selection in @editor.getSelections() - @repeatInsert(selection, @lastChange?.newText ? '') - moveCursorLeft(selection.cursor) - @mutationManager.setCheckpoint('did-finish') - - if @getConfig('clearMultipleCursorsOnEscapeInsertMode') - @vimState.clearSelections() - - else - @investigateCursorPosition() if @getConfig("debug") - - @normalizeSelectionsIfNecessary() - @createBufferCheckpoint('undo') - @selectTarget() if @target? - @observeWillDeactivateMode() - - @mutateText?() - - if @getInsertionCount() > 0 - @textByOperator = @getChangeSinceCheckpoint('undo')?.newText ? '' - - @createBufferCheckpoint('insert') - topCursor = @editor.getCursorsOrderedByBufferPosition()[0] - @topCursorPositionAtInsertionStart = topCursor.getBufferPosition() - - # Skip normalization of blockwiseSelection. - # Since want to keep multi-cursor and it's position in when shift to insert-mode. - for blockwiseSelection in @getBlockwiseSelections() - blockwiseSelection.skipNormalization() - @activateMode('insert', @finalSubmode) - -class ActivateReplaceMode extends ActivateInsertMode - @extend() - finalSubmode: 'replace' - - repeatInsert: (selection, text) -> - for char in text when (char isnt "\n") - break if selection.cursor.isAtEndOfLine() - selection.selectRight() - selection.insertText(text, autoIndent: false) - -class InsertAfter extends ActivateInsertMode - @extend() - execute: -> - moveCursorRight(cursor) for cursor in @editor.getCursors() - super - -# key: 'g I' in all mode -class InsertAtBeginningOfLine extends ActivateInsertMode - @extend() - execute: -> - if @mode is 'visual' and @submode in ['characterwise', 'linewise'] - @editor.splitSelectionsIntoLines() - @editor.moveToBeginningOfLine() - super - -# key: normal 'A' -class InsertAfterEndOfLine extends ActivateInsertMode - @extend() - execute: -> - @editor.moveToEndOfLine() - super - -# key: normal 'I' -class InsertAtFirstCharacterOfLine extends ActivateInsertMode - @extend() - execute: -> - @editor.moveToBeginningOfLine() - @editor.moveToFirstCharacterOfLine() - super - -class InsertAtLastInsert extends ActivateInsertMode - @extend() - execute: -> - if (point = @vimState.mark.get('^')) - @editor.setCursorBufferPosition(point) - @editor.scrollToCursorPosition({center: true}) - super - -class InsertAboveWithNewline extends ActivateInsertMode - @extend() - - initialize: -> - if @getConfig('groupChangesWhenLeavingInsertMode') - @originalCursorPositionMarker = @editor.markBufferPosition(@editor.getCursorBufferPosition()) - - # This is for `o` and `O` operator. - # On undo/redo put cursor at original point where user type `o` or `O`. - groupChangesSinceBufferCheckpoint: -> - lastCursor = @editor.getLastCursor() - cursorPosition = lastCursor.getBufferPosition() - lastCursor.setBufferPosition(@originalCursorPositionMarker.getHeadBufferPosition()) - @originalCursorPositionMarker.destroy() - - super - - lastCursor.setBufferPosition(cursorPosition) - - autoIndentEmptyRows: -> - for cursor in @editor.getCursors() - row = cursor.getBufferRow() - @editor.autoIndentBufferRow(row) if isEmptyRow(@editor, row) - - mutateText: -> - @editor.insertNewlineAbove() - @autoIndentEmptyRows() if @editor.autoIndent - - repeatInsert: (selection, text) -> - selection.insertText(text.trimLeft(), autoIndent: true) - -class InsertBelowWithNewline extends InsertAboveWithNewline - @extend() - mutateText: -> - for cursor in @editor.getCursors() when cursorRow = cursor.getBufferRow() - setBufferRow(cursor, @getFoldEndRowForRow(cursorRow)) - - @editor.insertNewlineBelow() - @autoIndentEmptyRows() if @editor.autoIndent - -# Advanced Insertion -# ------------------------- -class InsertByTarget extends ActivateInsertMode - @extend(false) - requireTarget: true - which: null # one of ['start', 'end', 'head', 'tail'] - - initialize: -> - # HACK - # When g i is mapped to `insert-at-start-of-target`. - # `g i 3 l` start insert at 3 column right position. - # In this case, we don't want repeat insertion 3 times. - # This @getCount() call cache number at the timing BEFORE '3' is specified. - @getCount() - super - - execute: -> - @onDidSelectTarget => - # In vC/vL, when occurrence marker was NOT selected, - # it behave's very specially - # vC: `I` and `A` behaves as shoft hand of `ctrl-v I` and `ctrl-v A`. - # vL: `I` and `A` place cursors at each selected lines of start( or end ) of non-white-space char. - if not @occurrenceSelected and @mode is 'visual' and @submode in ['characterwise', 'linewise'] - for $selection in @swrap.getSelections(@editor) - $selection.normalize() - $selection.applyWise('blockwise') - - if @submode is 'linewise' - for blockwiseSelection in @getBlockwiseSelections() - blockwiseSelection.expandMemberSelectionsOverLineWithTrimRange() - - for $selection in @swrap.getSelections(@editor) - $selection.setBufferPositionTo(@which) - super - -# key: 'I', Used in 'visual-mode.characterwise', visual-mode.blockwise -class InsertAtStartOfTarget extends InsertByTarget - @extend() - which: 'start' - -# key: 'A', Used in 'visual-mode.characterwise', 'visual-mode.blockwise' -class InsertAtEndOfTarget extends InsertByTarget - @extend() - which: 'end' - -class InsertAtHeadOfTarget extends InsertByTarget - @extend() - which: 'head' - -class InsertAtStartOfOccurrence extends InsertAtStartOfTarget - @extend() - occurrence: true - -class InsertAtEndOfOccurrence extends InsertAtEndOfTarget - @extend() - occurrence: true - -class InsertAtHeadOfOccurrence extends InsertAtHeadOfTarget - @extend() - occurrence: true - -class InsertAtStartOfSubwordOccurrence extends InsertAtStartOfOccurrence - @extend() - occurrenceType: 'subword' - -class InsertAtEndOfSubwordOccurrence extends InsertAtEndOfOccurrence - @extend() - occurrenceType: 'subword' - -class InsertAtHeadOfSubwordOccurrence extends InsertAtHeadOfOccurrence - @extend() - occurrenceType: 'subword' - -class InsertAtStartOfSmartWord extends InsertByTarget - @extend() - which: 'start' - target: "MoveToPreviousSmartWord" - -class InsertAtEndOfSmartWord extends InsertByTarget - @extend() - which: 'end' - target: "MoveToEndOfSmartWord" - -class InsertAtPreviousFoldStart extends InsertByTarget - @extend() - @description: "Move to previous fold start then enter insert-mode" - which: 'start' - target: 'MoveToPreviousFoldStart' - -class InsertAtNextFoldStart extends InsertByTarget - @extend() - @description: "Move to next fold start then enter insert-mode" - which: 'end' - target: 'MoveToNextFoldStart' - -# ------------------------- -class Change extends ActivateInsertMode - @extend() - requireTarget: true - trackChange: true - supportInsertionCount: false - - mutateText: -> - # Allways dynamically determine selection wise wthout consulting target.wise - # Reason: when `c i {`, wise is 'characterwise', but actually selected range is 'linewise' - # { - # a - # } - isLinewiseTarget = @swrap.detectWise(@editor) is 'linewise' - for selection in @editor.getSelections() - @setTextToRegisterForSelection(selection) unless @getConfig('dontUpdateRegisterOnChangeOrSubstitute') - if isLinewiseTarget - selection.insertText("\n", autoIndent: true) - selection.cursor.moveLeft() - else - selection.insertText('', autoIndent: true) - -class ChangeOccurrence extends Change - @extend() - @description: "Change all matching word within target range" - occurrence: true - -class Substitute extends Change - @extend() - target: 'MoveRight' - -class SubstituteLine extends Change - @extend() - wise: 'linewise' # [FIXME] to re-override target.wise in visual-mode - target: 'MoveToRelativeLine' - -# alias -class ChangeLine extends SubstituteLine - @extend() - -class ChangeToLastCharacterOfLine extends Change - @extend() - target: 'MoveToLastCharacterOfLine' - - execute: -> - if @target.wise is 'blockwise' - @onDidSelectTarget => - for blockwiseSelection in @getBlockwiseSelections() - blockwiseSelection.extendMemberSelectionsToEndOfLine() - super diff --git a/lib/operator-insert.js b/lib/operator-insert.js new file mode 100644 index 000000000..d7cc4531d --- /dev/null +++ b/lib/operator-insert.js @@ -0,0 +1,446 @@ +"use babel" + +const _ = require("underscore-plus") +const {Range} = require("atom") + +const {moveCursorLeft, moveCursorRight, limitNumber, isEmptyRow, setBufferRow} = require("./utils") +const Operator = require("./base").getClass("Operator") + +// Operator which start 'insert-mode' +// ------------------------- +// [NOTE] +// Rule: Don't make any text mutation before calling `@selectTarget()`. +class ActivateInsertMode extends Operator { + requireTarget = false + flashTarget = false + finalSubmode = null + supportInsertionCount = true + + observeWillDeactivateMode() { + let disposable = this.vimState.modeManager.preemptWillDeactivateMode(({mode}) => { + if (mode !== "insert") return + disposable.dispose() + + this.vimState.mark.set("^", this.editor.getCursorBufferPosition()) // Last insert-mode position + let textByUserInput = "" + const change = this.getChangeSinceCheckpoint("insert") + if (change) { + this.lastChange = change + this.setMarkForChange(new Range(change.start, change.start.traverse(change.newExtent))) + textByUserInput = change.newText + } + this.vimState.register.set(".", {text: textByUserInput}) // Last inserted text + + _.times(this.getInsertionCount(), () => { + const textToInsert = this.textByOperator + textByUserInput + for (const selection of this.editor.getSelections()) { + selection.insertText(textToInsert, {autoIndent: true}) + } + }) + + // This cursor state is restored on undo. + // So cursor state has to be updated before next groupChangesSinceCheckpoint() + if (this.getConfig("clearMultipleCursorsOnEscapeInsertMode")) { + this.vimState.clearSelections() + } + + // grouping changes for undo checkpoint need to come last + if (this.getConfig("groupChangesWhenLeavingInsertMode")) { + return this.groupChangesSinceBufferCheckpoint("undo") + } + }) + } + + // When each mutaion's extent is not intersecting, muitiple changes are recorded + // e.g + // - Multicursors edit + // - Cursor moved in insert-mode(e.g ctrl-f, ctrl-b) + // But I don't care multiple changes just because I'm lazy(so not perfect implementation). + // I only take care of one change happened at earliest(topCursor's change) position. + // Thats' why I save topCursor's position to @topCursorPositionAtInsertionStart to compare traversal to deletionStart + // Why I use topCursor's change? Just because it's easy to use first change returned by getChangeSinceCheckpoint(). + getChangeSinceCheckpoint(purpose) { + const checkpoint = this.getBufferCheckpoint(purpose) + return this.editor.buffer.getChangesSinceCheckpoint(checkpoint)[0] + } + + // [BUG-BUT-OK] Replaying text-deletion-operation is not compatible to pure Vim. + // Pure Vim record all operation in insert-mode as keystroke level and can distinguish + // character deleted by `Delete` or by `ctrl-u`. + // But I can not and don't trying to minic this level of compatibility. + // So basically deletion-done-in-one is expected to work well. + replayLastChange(selection) { + let textToInsert + if (this.lastChange != null) { + const {start, newExtent, oldExtent, newText} = this.lastChange + if (!oldExtent.isZero()) { + const traversalToStartOfDelete = start.traversalFrom(this.topCursorPositionAtInsertionStart) + const deletionStart = selection.cursor.getBufferPosition().traverse(traversalToStartOfDelete) + const deletionEnd = deletionStart.traverse(oldExtent) + selection.setBufferRange([deletionStart, deletionEnd]) + } + textToInsert = newText + } else { + textToInsert = "" + } + selection.insertText(textToInsert, {autoIndent: true}) + } + + // called when repeated + // [FIXME] to use replayLastChange in repeatInsert overriding subclasss. + repeatInsert(selection, text) { + this.replayLastChange(selection) + } + + getInsertionCount() { + if (this.insertionCount == null) { + this.insertionCount = this.supportInsertionCount ? this.getCount(-1) : 0 + } + // Avoid freezing by acccidental big count(e.g. `5555555555555i`), See #560, #596 + return limitNumber(this.insertionCount, {max: 100}) + } + + execute() { + if (this.repeated) { + this.flashTarget = this.trackChange = true + + this.startMutation(() => { + if (this.target) this.selectTarget() + if (this.mutateText) this.mutateText() + + for (const selection of this.editor.getSelections()) { + const textToInsert = (this.lastChange && this.lastChange.newText) || "" + this.repeatInsert(selection, textToInsert) + moveCursorLeft(selection.cursor) + } + this.mutationManager.setCheckpoint("did-finish") + }) + + if (this.getConfig("clearMultipleCursorsOnEscapeInsertMode")) this.vimState.clearSelections() + } else { + this.normalizeSelectionsIfNecessary() + this.createBufferCheckpoint("undo") + if (this.target) this.selectTarget() + this.observeWillDeactivateMode() + if (this.mutateText) this.mutateText() + + if (this.getInsertionCount() > 0) { + const change = this.getChangeSinceCheckpoint("undo") + this.textByOperator = (change && change.newText) || "" + } + + this.createBufferCheckpoint("insert") + const topCursor = this.editor.getCursorsOrderedByBufferPosition()[0] + this.topCursorPositionAtInsertionStart = topCursor.getBufferPosition() + + // Skip normalization of blockwiseSelection. + // Since want to keep multi-cursor and it's position in when shift to insert-mode. + for (const blockwiseSelection of this.getBlockwiseSelections()) { + blockwiseSelection.skipNormalization() + } + this.activateMode("insert", this.finalSubmode) + } + } +} +ActivateInsertMode.register() + +class ActivateReplaceMode extends ActivateInsertMode { + finalSubmode = "replace" + + repeatInsert(selection, text) { + for (const char of text) { + if (char === "\n") continue + if (selection.cursor.isAtEndOfLine()) break + selection.selectRight() + } + selection.insertText(text, {autoIndent: false}) + } +} +ActivateReplaceMode.register() + +class InsertAfter extends ActivateInsertMode { + execute() { + for (const cursor of this.editor.getCursors()) { + moveCursorRight(cursor) + } + super.execute() + } +} +InsertAfter.register() + +// key: 'g I' in all mode +class InsertAtBeginningOfLine extends ActivateInsertMode { + execute() { + if (this.mode === "visual" && this.submode !== "blockwise") { + this.editor.splitSelectionsIntoLines() + } + this.editor.moveToBeginningOfLine() + super.execute() + } +} +InsertAtBeginningOfLine.register() + +// key: normal 'A' +class InsertAfterEndOfLine extends ActivateInsertMode { + execute() { + this.editor.moveToEndOfLine() + super.execute() + } +} +InsertAfterEndOfLine.register() + +// key: normal 'I' +class InsertAtFirstCharacterOfLine extends ActivateInsertMode { + execute() { + this.editor.moveToBeginningOfLine() + this.editor.moveToFirstCharacterOfLine() + super.execute() + } +} +InsertAtFirstCharacterOfLine.register() + +class InsertAtLastInsert extends ActivateInsertMode { + execute() { + const point = this.vimState.mark.get("^") + if (point) { + this.editor.setCursorBufferPosition(point) + this.editor.scrollToCursorPosition({center: true}) + } + super.execute() + } +} +InsertAtLastInsert.register() + +class InsertAboveWithNewline extends ActivateInsertMode { + initialize() { + if (this.getConfig("groupChangesWhenLeavingInsertMode")) { + this.originalCursorPositionMarker = this.editor.markBufferPosition(this.editor.getCursorBufferPosition()) + } + return super.initialize() + } + + // This is for `o` and `O` operator. + // On undo/redo put cursor at original point where user type `o` or `O`. + groupChangesSinceBufferCheckpoint(purpose) { + const lastCursor = this.editor.getLastCursor() + const cursorPosition = lastCursor.getBufferPosition() + lastCursor.setBufferPosition(this.originalCursorPositionMarker.getHeadBufferPosition()) + this.originalCursorPositionMarker.destroy() + + super.groupChangesSinceBufferCheckpoint(purpose) + + lastCursor.setBufferPosition(cursorPosition) + } + + autoIndentEmptyRows() { + for (const cursor of this.editor.getCursors()) { + const row = cursor.getBufferRow() + if (isEmptyRow(this.editor, row)) { + this.editor.autoIndentBufferRow(row) + } + } + } + + mutateText() { + this.editor.insertNewlineAbove() + if (this.editor.autoIndent) { + this.autoIndentEmptyRows() + } + } + + repeatInsert(selection, text) { + selection.insertText(text.trimLeft(), {autoIndent: true}) + } +} +InsertAboveWithNewline.register() + +class InsertBelowWithNewline extends InsertAboveWithNewline { + mutateText() { + for (const cursor of this.editor.getCursors()) { + setBufferRow(cursor, this.getFoldEndRowForRow(cursor.getBufferRow())) + } + + this.editor.insertNewlineBelow() + if (this.editor.autoIndent) this.autoIndentEmptyRows() + } +} +InsertBelowWithNewline.register() + +// Advanced Insertion +// ------------------------- +class InsertByTarget extends ActivateInsertMode { + requireTarget = true + which = null // one of ['start', 'end', 'head', 'tail'] + + initialize() { + // HACK + // When g i is mapped to `insert-at-start-of-target`. + // `g i 3 l` start insert at 3 column right position. + // In this case, we don't want repeat insertion 3 times. + // This @getCount() call cache number at the timing BEFORE '3' is specified. + this.getCount() + return super.initialize() + } + + execute() { + this.onDidSelectTarget(() => { + // In vC/vL, when occurrence marker was NOT selected, + // it behave's very specially + // vC: `I` and `A` behaves as shoft hand of `ctrl-v I` and `ctrl-v A`. + // vL: `I` and `A` place cursors at each selected lines of start( or end ) of non-white-space char. + if (!this.occurrenceSelected && this.mode === "visual" && this.submode !== "blockwise") { + for (const $selection of this.swrap.getSelections(this.editor)) { + $selection.normalize() + $selection.applyWise("blockwise") + } + + if (this.submode === "linewise") { + for (const blockwiseSelection of this.getBlockwiseSelections()) { + blockwiseSelection.expandMemberSelectionsOverLineWithTrimRange() + } + } + } + + for (const $selection of this.swrap.getSelections(this.editor)) { + $selection.setBufferPositionTo(this.which) + } + }) + super.execute() + } +} +InsertByTarget.register(false) + +// key: 'I', Used in 'visual-mode.characterwise', visual-mode.blockwise +class InsertAtStartOfTarget extends InsertByTarget { + which = "start" +} +InsertAtStartOfTarget.register() + +// key: 'A', Used in 'visual-mode.characterwise', 'visual-mode.blockwise' +class InsertAtEndOfTarget extends InsertByTarget { + which = "end" +} +InsertAtEndOfTarget.register() + +class InsertAtHeadOfTarget extends InsertByTarget { + which = "head" +} +InsertAtHeadOfTarget.register() + +class InsertAtStartOfOccurrence extends InsertAtStartOfTarget { + occurrence = true +} +InsertAtStartOfOccurrence.register() + +class InsertAtEndOfOccurrence extends InsertAtEndOfTarget { + occurrence = true +} +InsertAtEndOfOccurrence.register() + +class InsertAtHeadOfOccurrence extends InsertAtHeadOfTarget { + occurrence = true +} +InsertAtHeadOfOccurrence.register() + +class InsertAtStartOfSubwordOccurrence extends InsertAtStartOfOccurrence { + occurrenceType = "subword" +} +InsertAtStartOfSubwordOccurrence.register() + +class InsertAtEndOfSubwordOccurrence extends InsertAtEndOfOccurrence { + occurrenceType = "subword" +} +InsertAtEndOfSubwordOccurrence.register() + +class InsertAtHeadOfSubwordOccurrence extends InsertAtHeadOfOccurrence { + occurrenceType = "subword" +} +InsertAtHeadOfSubwordOccurrence.register() + +class InsertAtStartOfSmartWord extends InsertByTarget { + which = "start" + target = "MoveToPreviousSmartWord" +} +InsertAtStartOfSmartWord.register() + +class InsertAtEndOfSmartWord extends InsertByTarget { + which = "end" + target = "MoveToEndOfSmartWord" +} +InsertAtEndOfSmartWord.register() + +class InsertAtPreviousFoldStart extends InsertByTarget { + which = "start" + target = "MoveToPreviousFoldStart" +} +InsertAtPreviousFoldStart.register() + +class InsertAtNextFoldStart extends InsertByTarget { + which = "end" + target = "MoveToNextFoldStart" +} +InsertAtNextFoldStart.register() + +// ------------------------- +class Change extends ActivateInsertMode { + requireTarget = true + trackChange = true + supportInsertionCount = false + + mutateText() { + // Allways dynamically determine selection wise wthout consulting target.wise + // Reason: when `c i {`, wise is 'characterwise', but actually selected range is 'linewise' + // { + // a + // } + const isLinewiseTarget = this.swrap.detectWise(this.editor) === "linewise" + for (const selection of this.editor.getSelections()) { + if (!this.getConfig("dontUpdateRegisterOnChangeOrSubstitute")) { + this.setTextToRegisterForSelection(selection) + } + if (isLinewiseTarget) { + selection.insertText("\n", {autoIndent: true}) + selection.cursor.moveLeft() + } else { + selection.insertText("", {autoIndent: true}) + } + } + } +} +Change.register() + +class ChangeOccurrence extends Change { + occurrence = true +} +ChangeOccurrence.register() + +class Substitute extends Change { + target = "MoveRight" +} +Substitute.register() + +class SubstituteLine extends Change { + wise = "linewise" // [FIXME] to re-override target.wise in visual-mode + target = "MoveToRelativeLine" +} +SubstituteLine.register() + +// alias +class ChangeLine extends SubstituteLine {} +ChangeLine.register() + +class ChangeToLastCharacterOfLine extends Change { + target = "MoveToLastCharacterOfLine" + + execute() { + this.onDidSelectTarget(() => { + if (this.target.wise === "blockwise") { + for (const blockwiseSelection of this.getBlockwiseSelections()) { + blockwiseSelection.extendMemberSelectionsToEndOfLine() + } + } + }) + super.execute() + } +} +ChangeToLastCharacterOfLine.register() diff --git a/lib/operator-transform-string.coffee b/lib/operator-transform-string.coffee deleted file mode 100644 index 72f6753a0..000000000 --- a/lib/operator-transform-string.coffee +++ /dev/null @@ -1,778 +0,0 @@ -_ = require 'underscore-plus' -{BufferedProcess, Range} = require 'atom' - -{ - isSingleLineText - isLinewiseRange - limitNumber - toggleCaseForCharacter - splitTextByNewLine - splitArguments - getIndentLevelForBufferRow - adjustIndentWithKeepingLayout -} = require './utils' -Base = require './base' -Operator = Base.getClass('Operator') - -# TransformString -# ================================ -class TransformString extends Operator - @extend(false) - trackChange: true - stayOptionName: 'stayOnTransformString' - autoIndent: false - autoIndentNewline: false - autoIndentAfterInsertText: false - @stringTransformers: [] - - @registerToSelectList: -> - @stringTransformers.push(this) - - mutateSelection: (selection) -> - if text = @getNewText(selection.getText(), selection) - if @autoIndentAfterInsertText - startRow = selection.getBufferRange().start.row - startRowIndentLevel = getIndentLevelForBufferRow(@editor, startRow) - range = selection.insertText(text, {@autoIndent, @autoIndentNewline}) - - if @autoIndentAfterInsertText - # Currently used by SplitArguments and Surround( linewise target only ) - range = range.translate([0, 0], [-1, 0]) if @target.isLinewise() - @editor.setIndentationForBufferRow(range.start.row, startRowIndentLevel) - @editor.setIndentationForBufferRow(range.end.row, startRowIndentLevel) - # Adjust inner range, end.row is already( if needed ) translated so no need to re-translate. - adjustIndentWithKeepingLayout(@editor, range.translate([1, 0], [0, 0])) - -class ToggleCase extends TransformString - @extend() - @registerToSelectList() - @description: "`Hello World` -> `hELLO wORLD`" - displayName: 'Toggle ~' - - getNewText: (text) -> - text.replace(/./g, toggleCaseForCharacter) - -class ToggleCaseAndMoveRight extends ToggleCase - @extend() - flashTarget: false - restorePositions: false - target: 'MoveRight' - -class UpperCase extends TransformString - @extend() - @registerToSelectList() - @description: "`Hello World` -> `HELLO WORLD`" - displayName: 'Upper' - getNewText: (text) -> - text.toUpperCase() - -class LowerCase extends TransformString - @extend() - @registerToSelectList() - @description: "`Hello World` -> `hello world`" - displayName: 'Lower' - getNewText: (text) -> - text.toLowerCase() - -# Replace -# ------------------------- -class Replace extends TransformString - @extend() - @registerToSelectList() - flashCheckpoint: 'did-select-occurrence' - input: null - requireInput: true - autoIndentNewline: true - supportEarlySelect: true - - initialize: -> - @onDidSelectTarget => - @focusInput(hideCursor: true) - super - - getNewText: (text) -> - if @target.is('MoveRightBufferColumn') and text.length isnt @getCount() - return - - input = @input or "\n" - if input is "\n" - @restorePositions = false - text.replace(/./g, input) - -class ReplaceCharacter extends Replace - @extend() - target: "MoveRightBufferColumn" - -# ------------------------- -# DUP meaning with SplitString need consolidate. -class SplitByCharacter extends TransformString - @extend() - @registerToSelectList() - getNewText: (text) -> - text.split('').join(' ') - -class CamelCase extends TransformString - @extend() - @registerToSelectList() - displayName: 'Camelize' - @description: "`hello-world` -> `helloWorld`" - getNewText: (text) -> - _.camelize(text) - -class SnakeCase extends TransformString - @extend() - @registerToSelectList() - @description: "`HelloWorld` -> `hello_world`" - displayName: 'Underscore _' - getNewText: (text) -> - _.underscore(text) - -class PascalCase extends TransformString - @extend() - @registerToSelectList() - @description: "`hello_world` -> `HelloWorld`" - displayName: 'Pascalize' - getNewText: (text) -> - _.capitalize(_.camelize(text)) - -class DashCase extends TransformString - @extend() - @registerToSelectList() - displayName: 'Dasherize -' - @description: "HelloWorld -> hello-world" - getNewText: (text) -> - _.dasherize(text) - -class TitleCase extends TransformString - @extend() - @registerToSelectList() - @description: "`HelloWorld` -> `Hello World`" - displayName: 'Titlize' - getNewText: (text) -> - _.humanizeEventName(_.dasherize(text)) - -class EncodeUriComponent extends TransformString - @extend() - @registerToSelectList() - @description: "`Hello World` -> `Hello%20World`" - displayName: 'Encode URI Component %' - getNewText: (text) -> - encodeURIComponent(text) - -class DecodeUriComponent extends TransformString - @extend() - @registerToSelectList() - @description: "`Hello%20World` -> `Hello World`" - displayName: 'Decode URI Component %%' - getNewText: (text) -> - decodeURIComponent(text) - -class TrimString extends TransformString - @extend() - @registerToSelectList() - @description: "` hello ` -> `hello`" - displayName: 'Trim string' - getNewText: (text) -> - text.trim() - -class CompactSpaces extends TransformString - @extend() - @registerToSelectList() - @description: "` a b c` -> `a b c`" - displayName: 'Compact space' - getNewText: (text) -> - if text.match(/^[ ]+$/) - ' ' - else - # Don't compact for leading and trailing white spaces. - text.replace /^(\s*)(.*?)(\s*)$/gm, (m, leading, middle, trailing) -> - leading + middle.split(/[ \t]+/).join(' ') + trailing - -class RemoveLeadingWhiteSpaces extends TransformString - @extend() - @registerToSelectList() - wise: 'linewise' - @description: "` a b c` -> `a b c`" - getNewText: (text, selection) -> - trimLeft = (text) -> text.trimLeft() - splitTextByNewLine(text).map(trimLeft).join("\n") + "\n" - -class ConvertToSoftTab extends TransformString - @extend() - @registerToSelectList() - displayName: 'Soft Tab' - wise: 'linewise' - - mutateSelection: (selection) -> - @scanForward /\t/g, {scanRange: selection.getBufferRange()}, ({range, replace}) => - # Replace \t to spaces which length is vary depending on tabStop and tabLenght - # So we directly consult it's screen representing length. - length = @editor.screenRangeForBufferRange(range).getExtent().column - replace(" ".repeat(length)) - -class ConvertToHardTab extends TransformString - @extend() - @registerToSelectList() - displayName: 'Hard Tab' - - mutateSelection: (selection) -> - tabLength = @editor.getTabLength() - @scanForward /[ \t]+/g, {scanRange: selection.getBufferRange()}, ({range, replace}) => - {start, end} = @editor.screenRangeForBufferRange(range) - startColumn = start.column - endColumn = end.column - - # We can't naively replace spaces to tab, we have to consider valid tabStop column - # If nextTabStop column exceeds replacable range, we pad with spaces. - newText = '' - loop - remainder = startColumn %% tabLength - nextTabStop = startColumn + (if remainder is 0 then tabLength else remainder) - if nextTabStop > endColumn - newText += " ".repeat(endColumn - startColumn) - else - newText += "\t" - startColumn = nextTabStop - break if startColumn >= endColumn - - replace(newText) - -# ------------------------- -class TransformStringByExternalCommand extends TransformString - @extend(false) - autoIndent: true - command: '' # e.g. command: 'sort' - args: [] # e.g args: ['-rn'] - stdoutBySelection: null - - execute: -> - @normalizeSelectionsIfNecessary() - if @selectTarget() - new Promise (resolve) => - @collect(resolve) - .then => - for selection in @editor.getSelections() - text = @getNewText(selection.getText(), selection) - selection.insertText(text, {@autoIndent}) - @restoreCursorPositionsIfNecessary() - @activateMode("normal") - - collect: (resolve) -> - @stdoutBySelection = new Map - processRunning = processFinished = 0 - for selection in @editor.getSelections() - {command, args} = @getCommand(selection) ? {} - return unless (command? and args?) - processRunning++ - do (selection) => - stdin = @getStdin(selection) - stdout = (output) => - @stdoutBySelection.set(selection, output) - exit = (code) -> - processFinished++ - resolve() if (processRunning is processFinished) - @runExternalCommand {command, args, stdout, exit, stdin} - - runExternalCommand: (options) -> - stdin = options.stdin - delete options.stdin - bufferedProcess = new BufferedProcess(options) - bufferedProcess.onWillThrowError ({error, handle}) => - # Suppress command not found error intentionally. - if error.code is 'ENOENT' and error.syscall.indexOf('spawn') is 0 - commandName = @constructor.getCommandName() - console.log "#{commandName}: Failed to spawn command #{error.path}." - handle() - @cancelOperation() - - if stdin - bufferedProcess.process.stdin.write(stdin) - bufferedProcess.process.stdin.end() - - getNewText: (text, selection) -> - @getStdout(selection) ? text - - # For easily extend by vmp plugin. - getCommand: (selection) -> {@command, @args} - getStdin: (selection) -> selection.getText() - getStdout: (selection) -> @stdoutBySelection.get(selection) - -# ------------------------- -class TransformStringBySelectList extends TransformString - @extend() - @description: "Interactively choose string transformation operator from select-list" - @selectListItems: null - requireInput: true - - getItems: -> - @constructor.selectListItems ?= @constructor.stringTransformers.map (klass) -> - if klass::hasOwnProperty('displayName') - displayName = klass::displayName - else - displayName = _.humanizeEventName(_.dasherize(klass.name)) - {name: klass, displayName} - - initialize: -> - super - - @vimState.onDidConfirmSelectList (item) => - transformer = item.name - @target = transformer::target if transformer::target? - @vimState.reset() - if @target? - @vimState.operationStack.run(transformer, {@target}) - else - @vimState.operationStack.run(transformer) - - @focusSelectList(items: @getItems()) - - execute: -> - # NEVER be executed since operationStack is replaced with selected transformer - throw new Error("#{@name} should not be executed") - -class TransformWordBySelectList extends TransformStringBySelectList - @extend() - target: "InnerWord" - -class TransformSmartWordBySelectList extends TransformStringBySelectList - @extend() - @description: "Transform InnerSmartWord by `transform-string-by-select-list`" - target: "InnerSmartWord" - -# ------------------------- -class ReplaceWithRegister extends TransformString - @extend() - @description: "Replace target with specified register value" - flashType: 'operator-long' - - initialize: -> - @vimState.sequentialPasteManager.onInitialize(this) - - execute: -> - @sequentialPaste = @vimState.sequentialPasteManager.onExecute(this) - - super - - for selection in @editor.getSelections() - range = @mutationManager.getMutatedBufferRangeForSelection(selection) - @vimState.sequentialPasteManager.savePastedRangeForSelection(selection, range) - - getNewText: (text, selection) -> - @vimState.register.get(null, selection, @sequentialPaste)?.text ? "" - -# Save text to register before replace -class SwapWithRegister extends TransformString - @extend() - @description: "Swap register value with target" - getNewText: (text, selection) -> - newText = @vimState.register.getText() - @setTextToRegister(text, selection) - newText - -# Indent < TransformString -# ------------------------- -class Indent extends TransformString - @extend() - stayByMarker: true - setToFirstCharacterOnLinewise: true - wise: 'linewise' - - mutateSelection: (selection) -> - # Need count times indentation in visual-mode and its repeat(`.`). - if @target.is('CurrentSelection') - oldText = null - # limit to 100 to avoid freezing by accidental big number. - count = limitNumber(@getCount(), max: 100) - @countTimes count, ({stop}) => - oldText = selection.getText() - @indent(selection) - stop() if selection.getText() is oldText - else - @indent(selection) - - indent: (selection) -> - selection.indentSelectedRows() - -class Outdent extends Indent - @extend() - indent: (selection) -> - selection.outdentSelectedRows() - -class AutoIndent extends Indent - @extend() - indent: (selection) -> - selection.autoIndentSelectedRows() - -class ToggleLineComments extends TransformString - @extend() - flashTarget: false - stayByMarker: true - wise: 'linewise' - - mutateSelection: (selection) -> - selection.toggleLineComments() - -class Reflow extends TransformString - @extend() - mutateSelection: (selection) -> - atom.commands.dispatch(@editorElement, 'autoflow:reflow-selection') - -class ReflowWithStay extends Reflow - @extend() - stayAtSamePosition: true - -# Surround < TransformString -# ------------------------- -class SurroundBase extends TransformString - @extend(false) - pairs: [ - ['[', ']'] - ['(', ')'] - ['{', '}'] - ['<', '>'] - ] - pairsByAlias: { - b: ['(', ')'] - B: ['{', '}'] - r: ['[', ']'] - a: ['<', '>'] - } - - pairCharsAllowForwarding: '[](){}' - input: null - requireInput: true - supportEarlySelect: true # Experimental - - focusInputForSurroundChar: -> - @focusInput(hideCursor: true) - - focusInputForTargetPairChar: -> - @focusInput(onConfirm: (char) => @onConfirmTargetPairChar(char)) - - getPair: (char) -> - pair = @pairsByAlias[char] - pair ?= _.detect(@pairs, (pair) -> char in pair) - pair ?= [char, char] - pair - - surround: (text, char, options={}) -> - keepLayout = options.keepLayout ? false - [open, close] = @getPair(char) - if (not keepLayout) and text.endsWith("\n") - @autoIndentAfterInsertText = true - open += "\n" - close += "\n" - - if char in @getConfig('charactersToAddSpaceOnSurround') and isSingleLineText(text) - text = ' ' + text + ' ' - - open + text + close - - deleteSurround: (text) -> - [open, innerText..., close] = text - innerText = innerText.join('') - if isSingleLineText(text) and (open isnt close) - innerText.trim() - else - innerText - - onConfirmTargetPairChar: (char) -> - @setTarget @new('APair', pair: @getPair(char)) - -class Surround extends SurroundBase - @extend() - @description: "Surround target by specified character like `(`, `[`, `\"`" - - initialize: -> - @onDidSelectTarget(@focusInputForSurroundChar.bind(this)) - super - - getNewText: (text) -> - @surround(text, @input) - -class SurroundWord extends Surround - @extend() - @description: "Surround **word**" - target: 'InnerWord' - -class SurroundSmartWord extends Surround - @extend() - @description: "Surround **smart-word**" - target: 'InnerSmartWord' - -class MapSurround extends Surround - @extend() - @description: "Surround each word(`/\w+/`) within target" - occurrence: true - patternForOccurrence: /\w+/g - -# Delete Surround -# ------------------------- -class DeleteSurround extends SurroundBase - @extend() - @description: "Delete specified surround character like `(`, `[`, `\"`" - - initialize: -> - @focusInputForTargetPairChar() unless @target? - super - - onConfirmTargetPairChar: (char) -> - super - @input = char - @processOperation() - - getNewText: (text) -> - @deleteSurround(text) - -class DeleteSurroundAnyPair extends DeleteSurround - @extend() - @description: "Delete surround character by auto-detect paired char from cursor enclosed pair" - target: 'AAnyPair' - requireInput: false - -class DeleteSurroundAnyPairAllowForwarding extends DeleteSurroundAnyPair - @extend() - @description: "Delete surround character by auto-detect paired char from cursor enclosed pair and forwarding pair within same line" - target: 'AAnyPairAllowForwarding' - -# Change Surround -# ------------------------- -class ChangeSurround extends SurroundBase - @extend() - @description: "Change surround character, specify both from and to pair char" - - showDeleteCharOnHover: -> - hoverPoint = @mutationManager.getInitialPointForSelection(@editor.getLastSelection()) - char = @editor.getSelectedText()[0] - @vimState.hover.set(char, hoverPoint) - - initialize: -> - if @target? - @onDidFailSelectTarget(@abort.bind(this)) - else - @onDidFailSelectTarget(@cancelOperation.bind(this)) - @focusInputForTargetPairChar() - super - - @onDidSelectTarget => - @showDeleteCharOnHover() - @focusInputForSurroundChar() - - getNewText: (text) -> - innerText = @deleteSurround(text) - @surround(innerText, @input, keepLayout: true) - -class ChangeSurroundAnyPair extends ChangeSurround - @extend() - @description: "Change surround character, from char is auto-detected" - target: "AAnyPair" - -class ChangeSurroundAnyPairAllowForwarding extends ChangeSurroundAnyPair - @extend() - @description: "Change surround character, from char is auto-detected from enclosed and forwarding area" - target: "AAnyPairAllowForwarding" - -# ------------------------- -# FIXME -# Currently native editor.joinLines() is better for cursor position setting -# So I use native methods for a meanwhile. -class Join extends TransformString - @extend() - target: "MoveToRelativeLine" - flashTarget: false - restorePositions: false - - mutateSelection: (selection) -> - range = selection.getBufferRange() - - # When cursor is at last BUFFER row, it select last-buffer-row, then - # joinning result in "clear last-buffer-row text". - # I believe this is BUG of upstream atom-core. guard this situation here - unless (range.isSingleLine() and range.end.row is @editor.getLastBufferRow()) - if isLinewiseRange(range) - selection.setBufferRange(range.translate([0, 0], [-1, Infinity])) - selection.joinLines() - end = selection.getBufferRange().end - selection.cursor.setBufferPosition(end.translate([0, -1])) - -class JoinBase extends TransformString - @extend(false) - wise: 'linewise' - trim: false - target: "MoveToRelativeLineMinimumOne" - - initialize: -> - @focusInput(charsMax: 10) if @requireInput - super - - getNewText: (text) -> - if @trim - pattern = /\r?\n[ \t]*/g - else - pattern = /\r?\n/g - text.trimRight().replace(pattern, @input) + "\n" - -class JoinWithKeepingSpace extends JoinBase - @extend() - @registerToSelectList() - input: '' - -class JoinByInput extends JoinBase - @extend() - @registerToSelectList() - @description: "Transform multi-line to single-line by with specified separator character" - requireInput: true - trim: true - -class JoinByInputWithKeepingSpace extends JoinByInput - @extend() - @registerToSelectList() - @description: "Join lines without padding space between each line" - trim: false - -# ------------------------- -# String suffix in name is to avoid confusion with 'split' window. -class SplitString extends TransformString - @extend() - @registerToSelectList() - @description: "Split single-line into multi-line by splitting specified separator chars" - requireInput: true - input: null - target: "MoveToRelativeLine" - keepSplitter: false - - initialize: -> - @onDidSetTarget => - @focusInput(charsMax: 10) - super - - getNewText: (text) -> - input = @input or "\\n" - regex = ///#{_.escapeRegExp(input)}///g - if @keepSplitter - lineSeparator = @input + "\n" - else - lineSeparator = "\n" - text.replace(regex, lineSeparator) - -class SplitStringWithKeepingSplitter extends SplitString - @extend() - @registerToSelectList() - keepSplitter: true - -class SplitArguments extends TransformString - @extend() - @registerToSelectList() - keepSeparator: true - autoIndentAfterInsertText: true - - getNewText: (text) -> - allTokens = splitArguments(text.trim()) - newText = '' - while allTokens.length - {text, type} = allTokens.shift() - if type is 'separator' - if @keepSeparator - text = text.trim() + "\n" - else - text = "\n" - newText += text - "\n" + newText + "\n" - -class SplitArgumentsWithRemoveSeparator extends SplitArguments - @extend() - @registerToSelectList() - keepSeparator: false - -class SplitArgumentsOfInnerAnyPair extends SplitArguments - @extend() - @registerToSelectList() - target: "InnerAnyPair" - -class ChangeOrder extends TransformString - @extend(false) - getNewText: (text) -> - if @target.isLinewise() - @getNewList(splitTextByNewLine(text)).join("\n") + "\n" - else - @sortArgumentsInTextBy(text, (args) => @getNewList(args)) - - sortArgumentsInTextBy: (text, fn) -> - leadingSpaces = trailingSpaces = '' - start = text.search(/\S/) - end = text.search(/\s*$/) - leadingSpaces = trailingSpaces = '' - leadingSpaces = text[0...start] if start isnt -1 - trailingSpaces = text[end...] if end isnt -1 - text = text[start...end] - - allTokens = splitArguments(text) - args = allTokens - .filter (token) -> token.type is 'argument' - .map (token) -> token.text - newArgs = fn(args) - - newText = '' - while allTokens.length - {text, type} = allTokens.shift() - newText += switch type - when 'separator' then text - when 'argument' then newArgs.shift() - leadingSpaces + newText + trailingSpaces - -class Reverse extends ChangeOrder - @extend() - @registerToSelectList() - getNewList: (rows) -> - rows.reverse() - -class ReverseInnerAnyPair extends Reverse - @extend() - target: "InnerAnyPair" - -class Rotate extends ChangeOrder - @extend() - @registerToSelectList() - backwards: false - getNewList: (rows) -> - if @backwards - rows.push(rows.shift()) - else - rows.unshift(rows.pop()) - rows - -class RotateBackwards extends ChangeOrder - @extend() - @registerToSelectList() - backwards: true - -class RotateArgumentsOfInnerPair extends Rotate - @extend() - target: "InnerAnyPair" - -class RotateArgumentsBackwardsOfInnerPair extends RotateArgumentsOfInnerPair - @extend() - backwards: true - -class Sort extends ChangeOrder - @extend() - @registerToSelectList() - @description: "Sort alphabetically" - getNewList: (rows) -> - rows.sort() - -class SortCaseInsensitively extends ChangeOrder - @extend() - @registerToSelectList() - @description: "Sort alphabetically with case insensitively" - getNewList: (rows) -> - rows.sort (rowA, rowB) -> - rowA.localeCompare(rowB, sensitivity: 'base') - -class SortByNumber extends ChangeOrder - @extend() - @registerToSelectList() - @description: "Sort numerically" - getNewList: (rows) -> - _.sortBy rows, (row) -> - Number.parseInt(row) or Infinity diff --git a/lib/operator-transform-string.js b/lib/operator-transform-string.js new file mode 100644 index 000000000..fb7cf42a3 --- /dev/null +++ b/lib/operator-transform-string.js @@ -0,0 +1,891 @@ +"use babel" + +const _ = require("underscore-plus") +const {BufferedProcess, Range} = require("atom") + +const { + isSingleLineText, + isLinewiseRange, + limitNumber, + toggleCaseForCharacter, + splitTextByNewLine, + splitArguments, + adjustIndentWithKeepingLayout, +} = require("./utils") +const Base = require("./base") +const Operator = Base.getClass("Operator") + +// TransformString +// ================================ +class TransformString extends Operator { + static stringTransformers = [] + trackChange = true + stayOptionName = "stayOnTransformString" + autoIndent = false + autoIndentNewline = false + autoIndentAfterInsertText = false + + static registerToSelectList() { + this.stringTransformers.push(this) + } + + mutateSelection(selection) { + const text = this.getNewText(selection.getText(), selection) + if (text) { + let startRowIndentLevel + if (this.autoIndentAfterInsertText) { + const startRow = selection.getBufferRange().start.row + startRowIndentLevel = this.editor.indentationForBufferRow(startRow) + } + let range = selection.insertText(text, {autoIndent: this.autoIndent, autoIndentNewline: this.autoIndentNewline}) + + if (this.autoIndentAfterInsertText) { + // Currently used by SplitArguments and Surround( linewise target only ) + if (this.target.isLinewise()) { + range = range.translate([0, 0], [-1, 0]) + } + this.editor.setIndentationForBufferRow(range.start.row, startRowIndentLevel) + this.editor.setIndentationForBufferRow(range.end.row, startRowIndentLevel) + // Adjust inner range, end.row is already( if needed ) translated so no need to re-translate. + adjustIndentWithKeepingLayout(this.editor, range.translate([1, 0], [0, 0])) + } + } + } +} +TransformString.register(false) + +class ToggleCase extends TransformString { + static displayName = "Toggle ~" + + getNewText(text) { + return text.replace(/./g, toggleCaseForCharacter) + } +} +ToggleCase.register() + +class ToggleCaseAndMoveRight extends ToggleCase { + flashTarget = false + restorePositions = false + target = "MoveRight" +} +ToggleCaseAndMoveRight.register() + +class UpperCase extends TransformString { + static displayName = "Upper" + + getNewText(text) { + return text.toUpperCase() + } +} +UpperCase.register() + +class LowerCase extends TransformString { + static displayName = "Lower" + + getNewText(text) { + return text.toLowerCase() + } +} +LowerCase.register() + +// Replace +// ------------------------- +class Replace extends TransformString { + flashCheckpoint = "did-select-occurrence" + input = null + requireInput = true + autoIndentNewline = true + supportEarlySelect = true + + initialize() { + this.onDidSelectTarget(() => this.focusInput({hideCursor: true})) + return super.initialize() + } + + getNewText(text) { + if (this.target.is("MoveRightBufferColumn") && text.length !== this.getCount()) { + return + } + + const input = this.input || "\n" + if (input === "\n") { + this.restorePositions = false + } + return text.replace(/./g, input) + } +} +Replace.register() + +class ReplaceCharacter extends Replace { + target = "MoveRightBufferColumn" +} +ReplaceCharacter.register() + +// ------------------------- +// DUP meaning with SplitString need consolidate. +class SplitByCharacter extends TransformString { + getNewText(text) { + return text.split("").join(" ") + } +} +SplitByCharacter.register() + +class CamelCase extends TransformString { + static displayName = "Camelize" + getNewText(text) { + return _.camelize(text) + } +} +CamelCase.register() + +class SnakeCase extends TransformString { + static displayName = "Underscore _" + getNewText(text) { + return _.underscore(text) + } +} +SnakeCase.register() + +class PascalCase extends TransformString { + static displayName = "Pascalize" + getNewText(text) { + return _.capitalize(_.camelize(text)) + } +} +PascalCase.register() + +class DashCase extends TransformString { + static displayName = "Dasherize -" + getNewText(text) { + return _.dasherize(text) + } +} +DashCase.register() + +class TitleCase extends TransformString { + static displayName = "Titlize" + getNewText(text) { + return _.humanizeEventName(_.dasherize(text)) + } +} +TitleCase.register() + +class EncodeUriComponent extends TransformString { + static displayName = "Encode URI Component %" + getNewText(text) { + return encodeURIComponent(text) + } +} +EncodeUriComponent.register() + +class DecodeUriComponent extends TransformString { + static displayName = "Decode URI Component %%" + getNewText(text) { + return decodeURIComponent(text) + } +} +DecodeUriComponent.register() + +class TrimString extends TransformString { + static displayName = "Trim string" + getNewText(text) { + return text.trim() + } +} +TrimString.register() + +class CompactSpaces extends TransformString { + static displayName = "Compact space" + getNewText(text) { + if (text.match(/^[ ]+$/)) { + return " " + } else { + // Don't compact for leading and trailing white spaces. + const regex = /^(\s*)(.*?)(\s*)$/gm + return text.replace(regex, (m, leading, middle, trailing) => { + return leading + middle.split(/[ \t]+/).join(" ") + trailing + }) + } + } +} +CompactSpaces.register() + +class RemoveLeadingWhiteSpaces extends TransformString { + wise = "linewise" + getNewText(text, selection) { + const trimLeft = text => text.trimLeft() + return ( + splitTextByNewLine(text) + .map(trimLeft) + .join("\n") + "\n" + ) + } +} +RemoveLeadingWhiteSpaces.register() + +class ConvertToSoftTab extends TransformString { + static displayName = "Soft Tab" + wise = "linewise" + + mutateSelection(selection) { + return this.scanForward(/\t/g, {scanRange: selection.getBufferRange()}, ({range, replace}) => { + // Replace \t to spaces which length is vary depending on tabStop and tabLenght + // So we directly consult it's screen representing length. + const length = this.editor.screenRangeForBufferRange(range).getExtent().column + return replace(" ".repeat(length)) + }) + } +} +ConvertToSoftTab.register() + +class ConvertToHardTab extends TransformString { + static displayName = "Hard Tab" + + mutateSelection(selection) { + const tabLength = this.editor.getTabLength() + this.scanForward(/[ \t]+/g, {scanRange: selection.getBufferRange()}, ({range, replace}) => { + const {start, end} = this.editor.screenRangeForBufferRange(range) + let startColumn = start.column + const endColumn = end.column + + // We can't naively replace spaces to tab, we have to consider valid tabStop column + // If nextTabStop column exceeds replacable range, we pad with spaces. + let newText = "" + while (true) { + const remainder = startColumn % tabLength + const nextTabStop = startColumn + (remainder === 0 ? tabLength : remainder) + if (nextTabStop > endColumn) { + newText += " ".repeat(endColumn - startColumn) + } else { + newText += "\t" + } + startColumn = nextTabStop + if (startColumn >= endColumn) { + break + } + } + + replace(newText) + }) + } +} +ConvertToHardTab.register() + +// ------------------------- +class TransformStringByExternalCommand extends TransformString { + autoIndent = true + command = "" // e.g. command: 'sort' + args = [] // e.g args: ['-rn'] + stdoutBySelection = null + + execute() { + this.normalizeSelectionsIfNecessary() + if (this.selectTarget()) { + return new Promise(resolve => this.collect(resolve)).then(() => { + for (const selection of this.editor.getSelections()) { + const text = this.getNewText(selection.getText(), selection) + selection.insertText(text, {autoIndent: this.autoIndent}) + } + this.restoreCursorPositionsIfNecessary() + this.activateMode("normal") + }) + } + } + + collect(resolve) { + this.stdoutBySelection = new Map() + let processFinished = 0, + processRunning = 0 + for (const selection of this.editor.getSelections()) { + const {command, args} = this.getCommand(selection) || {} + if (command == null || args == null) return + + processRunning++ + this.runExternalCommand({ + command: command, + args: args, + stdin: this.getStdin(selection), + stdout: output => this.stdoutBySelection.set(selection, output), + exit: code => { + processFinished++ + if (processRunning === processFinished) resolve() + }, + }) + } + } + + runExternalCommand(options) { + const {stdin} = options + delete options.stdin + const bufferedProcess = new BufferedProcess(options) + bufferedProcess.onWillThrowError(({error, handle}) => { + // Suppress command not found error intentionally. + if (error.code === "ENOENT" && error.syscall.indexOf("spawn") === 0) { + console.log(`${this.getCommandName()}: Failed to spawn command ${error.path}.`) + handle() + } + this.cancelOperation() + }) + + if (stdin) { + bufferedProcess.process.stdin.write(stdin) + bufferedProcess.process.stdin.end() + } + } + + getNewText(text, selection) { + return this.getStdout(selection) || text + } + + // For easily extend by vmp plugin. + getCommand(selection) { + return {command: this.command, args: this.args} + } + getStdin(selection) { + return selection.getText() + } + getStdout(selection) { + return this.stdoutBySelection.get(selection) + } +} +TransformStringByExternalCommand.register(false) + +// ------------------------- +class TransformStringBySelectList extends TransformString { + static electListItems = null + requireInput = true + + static getSelectListItems() { + if (!this.selectListItems) { + this.selectListItems = this.stringTransformers.map(klass => ({ + klass: klass, + displayName: klass.hasOwnProperty("displayName") + ? klass.displayName + : _.humanizeEventName(_.dasherize(klass.name)), + })) + } + return this.selectListItems + } + + getItems() { + return this.constructor.getSelectListItems() + } + + initialize() { + this.vimState.onDidConfirmSelectList(item => { + const transformer = item.klass + if (transformer.prototype.target) { + this.target = transformer.prototype.target + } + this.vimState.reset() + if (this.target) { + this.vimState.operationStack.run(transformer, {target: this.target}) + } else { + this.vimState.operationStack.run(transformer) + } + }) + + this.focusSelectList({items: this.getItems()}) + + return super.initialize() + } + + execute() { + // NEVER be executed since operationStack is replaced with selected transformer + throw new Error(`${this.name} should not be executed`) + } +} +TransformStringBySelectList.register() + +class TransformWordBySelectList extends TransformStringBySelectList { + target = "InnerWord" +} +TransformWordBySelectList.register() + +class TransformSmartWordBySelectList extends TransformStringBySelectList { + target = "InnerSmartWord" +} +TransformSmartWordBySelectList.register() + +// ------------------------- +class ReplaceWithRegister extends TransformString { + flashType = "operator-long" + + initialize() { + this.vimState.sequentialPasteManager.onInitialize(this) + return super.initialize() + } + + execute() { + this.sequentialPaste = this.vimState.sequentialPasteManager.onExecute(this) + + super.execute() + + for (const selection of this.editor.getSelections()) { + const range = this.mutationManager.getMutatedBufferRangeForSelection(selection) + this.vimState.sequentialPasteManager.savePastedRangeForSelection(selection, range) + } + } + + getNewText(text, selection) { + const value = this.vimState.register.get(null, selection, this.sequentialPaste) + return value ? value.text : "" + } +} +ReplaceWithRegister.register() + +// Save text to register before replace +class SwapWithRegister extends TransformString { + getNewText(text, selection) { + const newText = this.vimState.register.getText() + this.setTextToRegister(text, selection) + return newText + } +} +SwapWithRegister.register() + +// Indent < TransformString +// ------------------------- +class Indent extends TransformString { + stayByMarker = true + setToFirstCharacterOnLinewise = true + wise = "linewise" + + mutateSelection(selection) { + // Need count times indentation in visual-mode and its repeat(`.`). + if (this.target.is("CurrentSelection")) { + let oldText + // limit to 100 to avoid freezing by accidental big number. + const count = limitNumber(this.getCount(), {max: 100}) + this.countTimes(count, ({stop}) => { + oldText = selection.getText() + this.indent(selection) + if (selection.getText() === oldText) stop() + }) + } else { + this.indent(selection) + } + } + + indent(selection) { + selection.indentSelectedRows() + } +} +Indent.register() + +class Outdent extends Indent { + indent(selection) { + selection.outdentSelectedRows() + } +} +Outdent.register() + +class AutoIndent extends Indent { + indent(selection) { + selection.autoIndentSelectedRows() + } +} +AutoIndent.register() + +class ToggleLineComments extends TransformString { + flashTarget = false + stayByMarker = true + wise = "linewise" + + mutateSelection(selection) { + selection.toggleLineComments() + } +} +ToggleLineComments.register() + +class Reflow extends TransformString { + mutateSelection(selection) { + atom.commands.dispatch(this.editorElement, "autoflow:reflow-selection") + } +} +Reflow.register() + +class ReflowWithStay extends Reflow { + stayAtSamePosition = true +} +ReflowWithStay.register() + +// Surround < TransformString +// ------------------------- +class SurroundBase extends TransformString { + pairs = [["(", ")"], ["{", "}"], ["[", "]"], ["<", ">"]] + pairsByAlias = { + b: ["(", ")"], + B: ["{", "}"], + r: ["[", "]"], + a: ["<", ">"], + } + + pairCharsAllowForwarding = "[](){}" + input = null + requireInput = true + supportEarlySelect = true // Experimental + + focusInputForSurroundChar() { + this.focusInput({hideCursor: true}) + } + + focusInputForTargetPairChar() { + this.focusInput({onConfirm: char => this.onConfirmTargetPairChar(char)}) + } + + getPair(char) { + let pair + return char in this.pairsByAlias + ? this.pairsByAlias[char] + : [...this.pairs, [char, char]].find(pair => pair.includes(char)) + } + + surround(text, char, {keepLayout = false} = {}) { + let [open, close] = this.getPair(char) + if (!keepLayout && text.endsWith("\n")) { + this.autoIndentAfterInsertText = true + open += "\n" + close += "\n" + } + + if (this.getConfig("charactersToAddSpaceOnSurround").includes(char) && isSingleLineText(text)) { + text = " " + text + " " + } + + return open + text + close + } + + deleteSurround(text) { + // Assume surrounding char is one-char length. + const open = text[0] + const close = text[text.length - 1] + const innerText = text.slice(1, text.length - 1) + return isSingleLineText(text) && open !== close ? innerText.trim() : innerText + } + + onConfirmTargetPairChar(char) { + this.setTarget(this.getInstance("APair", {pair: this.getPair(char)})) + } +} +SurroundBase.register(false) + +class Surround extends SurroundBase { + initialize() { + this.onDidSelectTarget(() => this.focusInputForSurroundChar()) + return super.initialize() + } + + getNewText(text) { + return this.surround(text, this.input) + } +} +Surround.register() + +class SurroundWord extends Surround { + target = "InnerWord" +} +SurroundWord.register() + +class SurroundSmartWord extends Surround { + target = "InnerSmartWord" +} +SurroundSmartWord.register() + +class MapSurround extends Surround { + occurrence = true + patternForOccurrence = /\w+/g +} +MapSurround.register() + +// Delete Surround +// ------------------------- +class DeleteSurround extends SurroundBase { + initialize() { + if (!this.target) { + this.focusInputForTargetPairChar() + } + return super.initialize() + } + + onConfirmTargetPairChar(char) { + super.onConfirmTargetPairChar(char) + this.input = char + this.processOperation() + } + + getNewText(text) { + return this.deleteSurround(text) + } +} +DeleteSurround.register() + +class DeleteSurroundAnyPair extends DeleteSurround { + target = "AAnyPair" + requireInput = false +} +DeleteSurroundAnyPair.register() + +class DeleteSurroundAnyPairAllowForwarding extends DeleteSurroundAnyPair { + target = "AAnyPairAllowForwarding" +} +DeleteSurroundAnyPairAllowForwarding.register() + +// Change Surround +// ------------------------- +class ChangeSurround extends SurroundBase { + showDeleteCharOnHover() { + const hoverPoint = this.mutationManager.getInitialPointForSelection(this.editor.getLastSelection()) + const char = this.editor.getSelectedText()[0] + this.vimState.hover.set(char, hoverPoint) + } + + initialize() { + if (this.target) { + this.onDidFailSelectTarget(() => this.abort()) + } else { + this.onDidFailSelectTarget(() => this.cancelOperation()) + this.focusInputForTargetPairChar() + } + + this.onDidSelectTarget(() => { + this.showDeleteCharOnHover() + this.focusInputForSurroundChar() + }) + return super.initialize() + } + + getNewText(text) { + const innerText = this.deleteSurround(text) + return this.surround(innerText, this.input, {keepLayout: true}) + } +} +ChangeSurround.register() + +class ChangeSurroundAnyPair extends ChangeSurround { + target = "AAnyPair" +} +ChangeSurroundAnyPair.register() + +class ChangeSurroundAnyPairAllowForwarding extends ChangeSurroundAnyPair { + target = "AAnyPairAllowForwarding" +} +ChangeSurroundAnyPairAllowForwarding.register() + +// ------------------------- +// FIXME +// Currently native editor.joinLines() is better for cursor position setting +// So I use native methods for a meanwhile. +class Join extends TransformString { + target = "MoveToRelativeLine" + flashTarget = false + restorePositions = false + + mutateSelection(selection) { + const range = selection.getBufferRange() + + // When cursor is at last BUFFER row, it select last-buffer-row, then + // joinning result in "clear last-buffer-row text". + // I believe this is BUG of upstream atom-core. guard this situation here + if (!range.isSingleLine() || range.end.row !== this.editor.getLastBufferRow()) { + if (isLinewiseRange(range)) { + selection.setBufferRange(range.translate([0, 0], [-1, Infinity])) + } + selection.joinLines() + } + const point = selection.getBufferRange().end.translate([0, -1]) + return selection.cursor.setBufferPosition(point) + } +} +Join.register() + +class JoinBase extends TransformString { + wise = "linewise" + trim = false + target = "MoveToRelativeLineMinimumTwo" + + initialize() { + if (this.requireInput) { + this.focusInput({charsMax: 10}) + } + return super.initialize() + } + + getNewText(text) { + const regex = this.trim ? /\r?\n[ \t]*/g : /\r?\n/g + return text.trimRight().replace(regex, this.input) + "\n" + } +} +JoinBase.register(false) + +class JoinWithKeepingSpace extends JoinBase { + input = "" +} +JoinWithKeepingSpace.register() + +class JoinByInput extends JoinBase { + requireInput = true + trim = true +} +JoinByInput.register() + +class JoinByInputWithKeepingSpace extends JoinByInput { + trim = false +} +JoinByInputWithKeepingSpace.register() + +// ------------------------- +// String suffix in name is to avoid confusion with 'split' window. +class SplitString extends TransformString { + requireInput = true + input = null + target = "MoveToRelativeLine" + keepSplitter = false + + initialize() { + this.onDidSetTarget(() => { + this.focusInput({charsMax: 10}) + }) + return super.initialize() + } + + getNewText(text) { + const regex = new RegExp(_.escapeRegExp(this.input || "\\n"), "g") + const lineSeparator = (this.keepSplitter ? this.input : "") + "\n" + return text.replace(regex, lineSeparator) + } +} +SplitString.register() + +class SplitStringWithKeepingSplitter extends SplitString { + keepSplitter = true +} +SplitStringWithKeepingSplitter.register() + +class SplitArguments extends TransformString { + keepSeparator = true + autoIndentAfterInsertText = true + + getNewText(text) { + const allTokens = splitArguments(text.trim()) + let newText = "" + while (allTokens.length) { + const {text, type} = allTokens.shift() + newText += type === "separator" ? (this.keepSeparator ? text.trim() : "") + "\n" : text + } + return `\n${newText}\n` + } +} +SplitArguments.register() + +class SplitArgumentsWithRemoveSeparator extends SplitArguments { + keepSeparator = false +} +SplitArgumentsWithRemoveSeparator.register() + +class SplitArgumentsOfInnerAnyPair extends SplitArguments { + target = "InnerAnyPair" +} +SplitArgumentsOfInnerAnyPair.register() + +class ChangeOrder extends TransformString { + getNewText(text) { + return this.target.isLinewise() + ? this.getNewList(splitTextByNewLine(text)).join("\n") + "\n" + : this.sortArgumentsInTextBy(text, args => this.getNewList(args)) + } + + sortArgumentsInTextBy(text, fn) { + const start = text.search(/\S/) + const end = text.search(/\s*$/) + const leadingSpaces = start !== -1 ? text.slice(0, start) : "" + const trailingSpaces = end !== -1 ? text.slice(end) : "" + const allTokens = splitArguments(text.slice(start, end)) + const args = allTokens.filter(token => token.type === "argument").map(token => token.text) + const newArgs = fn(args) + + let newText = "" + while (allTokens.length) { + const token = allTokens.shift() + // token.type is "separator" or "argument" + newText += token.type === "separator" ? token.text : newArgs.shift() + } + return leadingSpaces + newText + trailingSpaces + } +} +ChangeOrder.register(false) + +class Reverse extends ChangeOrder { + getNewList(rows) { + return rows.reverse() + } +} +Reverse.register() + +class ReverseInnerAnyPair extends Reverse { + target = "InnerAnyPair" +} +ReverseInnerAnyPair.register() + +class Rotate extends ChangeOrder { + backwards = false + getNewList(rows) { + if (this.backwards) rows.push(rows.shift()) + else rows.unshift(rows.pop()) + return rows + } +} +Rotate.register() + +class RotateBackwards extends ChangeOrder { + backwards = true +} +RotateBackwards.register() + +class RotateArgumentsOfInnerPair extends Rotate { + target = "InnerAnyPair" +} +RotateArgumentsOfInnerPair.register() + +class RotateArgumentsBackwardsOfInnerPair extends RotateArgumentsOfInnerPair { + backwards = true +} +RotateArgumentsBackwardsOfInnerPair.register() + +class Sort extends ChangeOrder { + getNewList(rows) { + return rows.sort() + } +} +Sort.register() + +class SortCaseInsensitively extends ChangeOrder { + getNewList(rows) { + return rows.sort((rowA, rowB) => rowA.localeCompare(rowB, {sensitivity: "base"})) + } +} +SortCaseInsensitively.register() + +class SortByNumber extends ChangeOrder { + getNewList(rows) { + return _.sortBy(rows, row => Number.parseInt(row) || Infinity) + } +} +SortByNumber.register() + +// prettier-ignore +const classesToRegisterToSelectList = [ + ToggleCase, UpperCase, LowerCase, + Replace, SplitByCharacter, + CamelCase, SnakeCase, PascalCase, DashCase, TitleCase, + EncodeUriComponent, DecodeUriComponent, + TrimString, CompactSpaces, RemoveLeadingWhiteSpaces, + ConvertToSoftTab, ConvertToHardTab, + JoinWithKeepingSpace, JoinByInput, JoinByInputWithKeepingSpace, + SplitString, SplitStringWithKeepingSplitter, + SplitArguments, SplitArgumentsWithRemoveSeparator, SplitArgumentsOfInnerAnyPair, + Reverse, Rotate, RotateBackwards, Sort, SortCaseInsensitively, SortByNumber, +] +for (const klass of classesToRegisterToSelectList) { + klass.registerToSelectList() +} diff --git a/lib/operator.coffee b/lib/operator.coffee deleted file mode 100644 index ec6a36c08..000000000 --- a/lib/operator.coffee +++ /dev/null @@ -1,692 +0,0 @@ -_ = require 'underscore-plus' -{ - isEmptyRow - getWordPatternAtBufferPosition - getSubwordPatternAtBufferPosition - insertTextAtBufferPosition - setBufferRow - moveCursorToFirstCharacterAtRow - ensureEndsWithNewLineForBufferRow - adjustIndentWithKeepingLayout - isSingleLineText -} = require './utils' -Base = require './base' - -class Operator extends Base - @extend(false) - @operationKind: 'operator' - requireTarget: true - recordable: true - - wise: null - occurrence: false - occurrenceType: 'base' - - flashTarget: true - flashCheckpoint: 'did-finish' - flashType: 'operator' - flashTypeForOccurrence: 'operator-occurrence' - trackChange: false - - patternForOccurrence: null - stayAtSamePosition: null - stayOptionName: null - stayByMarker: false - restorePositions: true - setToFirstCharacterOnLinewise: false - - acceptPresetOccurrence: true - acceptPersistentSelection: true - - bufferCheckpointByPurpose: null - mutateSelectionOrderd: false - - # Experimentaly allow selectTarget before input Complete - # ------------------------- - supportEarlySelect: false - targetSelected: null - canEarlySelect: -> - @supportEarlySelect and not @repeated - # ------------------------- - - # Called when operation finished - # This is essentially to reset state for `.` repeat. - resetState: -> - @targetSelected = null - @occurrenceSelected = false - - # Two checkpoint for different purpose - # - one for undo(handled by modeManager) - # - one for preserve last inserted text - createBufferCheckpoint: (purpose) -> - @bufferCheckpointByPurpose ?= {} - @bufferCheckpointByPurpose[purpose] = @editor.createCheckpoint() - - getBufferCheckpoint: (purpose) -> - @bufferCheckpointByPurpose?[purpose] - - deleteBufferCheckpoint: (purpose) -> - if @bufferCheckpointByPurpose? - delete @bufferCheckpointByPurpose[purpose] - - groupChangesSinceBufferCheckpoint: (purpose) -> - if checkpoint = @getBufferCheckpoint(purpose) - @editor.groupChangesSinceCheckpoint(checkpoint) - @deleteBufferCheckpoint(purpose) - - setMarkForChange: (range) -> - @vimState.mark.set('[', range.start) - @vimState.mark.set(']', range.end) - - needFlash: -> - @flashTarget and @getConfig('flashOnOperate') and - (@name not in @getConfig('flashOnOperateBlacklist')) and - ((@mode isnt 'visual') or (@submode isnt @target.wise)) # e.g. Y in vC - - flashIfNecessary: (ranges) -> - if @needFlash() - @vimState.flash(ranges, type: @getFlashType()) - - flashChangeIfNecessary: -> - if @needFlash() - @onDidFinishOperation => - ranges = @mutationManager.getSelectedBufferRangesForCheckpoint(@flashCheckpoint) - @vimState.flash(ranges, type: @getFlashType()) - - getFlashType: -> - if @occurrenceSelected - @flashTypeForOccurrence - else - @flashType - - trackChangeIfNecessary: -> - return unless @trackChange - - @onDidFinishOperation => - if range = @mutationManager.getMutatedBufferRangeForSelection(@editor.getLastSelection()) - @setMarkForChange(range) - - constructor: -> - super - {@mutationManager, @occurrenceManager, @persistentSelection} = @vimState - @subscribeResetOccurrencePatternIfNeeded() - @initialize() - @onDidSetOperatorModifier(@setModifier.bind(this)) - - # When preset-occurrence was exists, operate on occurrence-wise - if @acceptPresetOccurrence and @occurrenceManager.hasMarkers() - @occurrence = true - - # [FIXME] ORDER-MATTER - # To pick cursor-word to find occurrence base pattern. - # This has to be done BEFORE converting persistent-selection into real-selection. - # Since when persistent-selection is actuall selected, it change cursor position. - if @occurrence and not @occurrenceManager.hasMarkers() - @occurrenceManager.addPattern(@patternForOccurrence ? @getPatternForOccurrenceType(@occurrenceType)) - - - # This change cursor position. - if @selectPersistentSelectionIfNecessary() - # [FIXME] selection-wise is not synched if it already visual-mode - unless @mode is 'visual' - @vimState.modeManager.activate('visual', @swrap.detectWise(@editor)) - - @target = 'CurrentSelection' if @mode is 'visual' and @requireTarget - @setTarget(@new(@target)) if _.isString(@target) - - subscribeResetOccurrencePatternIfNeeded: -> - # [CAUTION] - # This method has to be called in PROPER timing. - # If occurrence is true but no preset-occurrence - # Treat that `occurrence` is BOUNDED to operator itself, so cleanp at finished. - if @occurrence and not @occurrenceManager.hasMarkers() - @onDidResetOperationStack(=> @occurrenceManager.resetPatterns()) - - setModifier: (options) -> - if options.wise? - @wise = options.wise - return - - if options.occurrence? - @occurrence = options.occurrence - if @occurrence - @occurrenceType = options.occurrenceType - # This is o modifier case(e.g. `c o p`, `d O f`) - # We RESET existing occurence-marker when `o` or `O` modifier is typed by user. - pattern = @getPatternForOccurrenceType(@occurrenceType) - @occurrenceManager.addPattern(pattern, {reset: true, @occurrenceType}) - @onDidResetOperationStack(=> @occurrenceManager.resetPatterns()) - - # return true/false to indicate success - selectPersistentSelectionIfNecessary: -> - if @acceptPersistentSelection and - @getConfig('autoSelectPersistentSelectionOnOperate') and - not @persistentSelection.isEmpty() - - @persistentSelection.select() - @editor.mergeIntersectingSelections() - for $selection in @swrap.getSelections(@editor) when not $selection.hasProperties() - $selection.saveProperties() - true - else - false - - getPatternForOccurrenceType: (occurrenceType) -> - switch occurrenceType - when 'base' - getWordPatternAtBufferPosition(@editor, @getCursorBufferPosition()) - when 'subword' - getSubwordPatternAtBufferPosition(@editor, @getCursorBufferPosition()) - - # target is TextObject or Motion to operate on. - setTarget: (@target) -> - @target.operator = this - @emitDidSetTarget(this) - - if @canEarlySelect() - @normalizeSelectionsIfNecessary() - @createBufferCheckpoint('undo') - @selectTarget() - this - - setTextToRegisterForSelection: (selection) -> - @setTextToRegister(selection.getText(), selection) - - setTextToRegister: (text, selection) -> - text += "\n" if (@target.isLinewise() and (not text.endsWith('\n'))) - if text - @vimState.register.set(null, {text, selection}) - - if @vimState.register.isUnnamed() - if @instanceof("Delete") or @instanceof("Change") - if not @needSaveToNumberedRegister(@target) and isSingleLineText(text) # small-change - @vimState.register.set('-', {text, selection}) - else - @vimState.register.set('1', {text, selection}) - - else if @instanceof("Yank") - @vimState.register.set('0', {text, selection}) - - needSaveToNumberedRegister: (target) -> - # Used to determine what register to use on change and delete operation. - # Following motion should save to 1-9 register regerdless of content is small or big. - goesToNumberedRegisterMotionNames = [ - "MoveToPair" # % - "MoveToNextSentence" # (, ) - "Search" # /, ?, n, N - "MoveToNextParagraph" # {, } - ] - goesToNumberedRegisterMotionNames.some((name) -> target.instanceof(name)) - - normalizeSelectionsIfNecessary: -> - if @target?.isMotion() and (@mode is 'visual') - @swrap.normalize(@editor) - - startMutation: (fn) -> - if @canEarlySelect() - # - Skip selection normalization: already normalized before @selectTarget() - # - Manual checkpoint grouping: to create checkpoint before @selectTarget() - fn() - @emitWillFinishMutation() - @groupChangesSinceBufferCheckpoint('undo') - - else - @normalizeSelectionsIfNecessary() - @editor.transact => - fn() - @emitWillFinishMutation() - - @emitDidFinishMutation() - - # Main - execute: -> - @startMutation => - if @selectTarget() - if @mutateSelectionOrderd - selections = @editor.getSelectionsOrderedByBufferPosition() - else - selections = @editor.getSelections() - for selection in selections - @mutateSelection(selection) - @mutationManager.setCheckpoint('did-finish') - @restoreCursorPositionsIfNecessary() - - # Even though we fail to select target and fail to mutate, - # we have to return to normal-mode from operator-pending or visual - @activateMode('normal') - - # Return true unless all selection is empty. - selectTarget: -> - return @targetSelected if @targetSelected? - @mutationManager.init({@stayByMarker}) - - @target.forceWise(@wise) if @wise? - @emitWillSelectTarget() - - # Allow cursor position adjustment 'on-will-select-target' hook. - # so checkpoint comes AFTER @emitWillSelectTarget() - @mutationManager.setCheckpoint('will-select') - - # NOTE - # Since MoveToNextOccurrence, MoveToPreviousOccurrence motion move by - # occurrence-marker, occurrence-marker has to be created BEFORE `@target.execute()` - # And when repeated, occurrence pattern is already cached at @patternForOccurrence - if @repeated and @occurrence and not @occurrenceManager.hasMarkers() - @occurrenceManager.addPattern(@patternForOccurrence, {@occurrenceType}) - - @target.execute() - - @mutationManager.setCheckpoint('did-select') - if @occurrence - # To repoeat(`.`) operation where multiple occurrence patterns was set. - # Here we save patterns which represent unioned regex which @occurrenceManager knows. - @patternForOccurrence ?= @occurrenceManager.buildPattern() - - @occurrenceWise = @wise ? "characterwise" - if @occurrenceManager.select(@occurrenceWise) - @occurrenceSelected = true - @mutationManager.setCheckpoint('did-select-occurrence') - - if @targetSelected = @vimState.haveSomeNonEmptySelection() or @target.name is "Empty" - @emitDidSelectTarget() - @flashChangeIfNecessary() - @trackChangeIfNecessary() - else - @emitDidFailSelectTarget() - return @targetSelected - - restoreCursorPositionsIfNecessary: -> - return unless @restorePositions - stay = @stayAtSamePosition ? @getConfig(@stayOptionName) or (@occurrenceSelected and @getConfig('stayOnOccurrence')) - wise = if @occurrenceSelected then @occurrenceWise else @target.wise - @mutationManager.restoreCursorPositions({stay, wise, @setToFirstCharacterOnLinewise}) - -class SelectBase extends Operator - @extend(false) - flashTarget: false - recordable: false - - execute: -> - @startMutation => @selectTarget() - - if (@target.selectSucceeded) - @editor.scrollToCursorPosition() if @target.isTextObject() - wise = if @occurrenceSelected then @occurrenceWise else @target.wise - @activateModeIfNecessary('visual', wise) - else - @cancelOperation() - -class Select extends SelectBase - @extend() - execute: -> - for $selection in @swrap.getSelections(@editor) when not $selection.hasProperties() - $selection.saveProperties() - super - -class SelectLatestChange extends SelectBase - @extend() - @description: "Select latest yanked or changed range" - target: 'ALatestChange' - -class SelectPreviousSelection extends SelectBase - @extend() - target: "PreviousSelection" - -class SelectPersistentSelection extends SelectBase - @extend() - @description: "Select persistent-selection and clear all persistent-selection, it's like convert to real-selection" - target: "APersistentSelection" - acceptPersistentSelection: false - -class SelectOccurrence extends SelectBase - @extend() - @description: "Add selection onto each matching word within target range" - occurrence: true - -# SelectInVisualMode: used in visual-mode -# When text-object is invoked from normal or viusal-mode, operation would be -# => SelectInVisualMode operator with target=text-object -# When motion is invoked from visual-mode, operation would be -# => SelectInVisualMode operator with target=motion) -# ================================ -# SelectInVisualMode is used in TWO situation. -# - visual-mode operation -# - e.g: `v l`, `V j`, `v i p`... -# - Directly invoke text-object from normal-mode -# - e.g: Invoke `Inner Paragraph` from command-palette. -class SelectInVisualMode extends SelectBase - @extend(false) - acceptPresetOccurrence: false - acceptPersistentSelection: false - -# Persistent Selection -# ========================= -class CreatePersistentSelection extends Operator - @extend() - flashTarget: false - stayAtSamePosition: true - acceptPresetOccurrence: false - acceptPersistentSelection: false - - mutateSelection: (selection) -> - @persistentSelection.markBufferRange(selection.getBufferRange()) - -class TogglePersistentSelection extends CreatePersistentSelection - @extend() - - isComplete: -> - point = @editor.getCursorBufferPosition() - @markerToRemove = @persistentSelection.getMarkerAtPoint(point) - @markerToRemove? or super - - execute: -> - if @markerToRemove? - @markerToRemove.destroy() - else - super - -# Preset Occurrence -# ========================= -class TogglePresetOccurrence extends Operator - @extend() - target: "Empty" - flashTarget: false - acceptPresetOccurrence: false - acceptPersistentSelection: false - occurrenceType: 'base' - - execute: -> - if marker = @occurrenceManager.getMarkerAtPoint(@editor.getCursorBufferPosition()) - @occurrenceManager.destroyMarkers([marker]) - else - pattern = null - isNarrowed = @vimState.modeManager.isNarrowed() - - if @mode is 'visual' and not isNarrowed - @occurrenceType = 'base' - pattern = new RegExp(_.escapeRegExp(@editor.getSelectedText()), 'g') - else - pattern = @getPatternForOccurrenceType(@occurrenceType) - - @occurrenceManager.addPattern(pattern, {@occurrenceType}) - @occurrenceManager.saveLastPattern(@occurrenceType) - - @activateMode('normal') unless isNarrowed - -class TogglePresetSubwordOccurrence extends TogglePresetOccurrence - @extend() - occurrenceType: 'subword' - -# Want to rename RestoreOccurrenceMarker -class AddPresetOccurrenceFromLastOccurrencePattern extends TogglePresetOccurrence - @extend() - execute: -> - @occurrenceManager.resetPatterns() - if pattern = @vimState.globalState.get('lastOccurrencePattern') - occurrenceType = @vimState.globalState.get("lastOccurrenceType") - @occurrenceManager.addPattern(pattern, {occurrenceType}) - @activateMode('normal') - -# Delete -# ================================ -class Delete extends Operator - @extend() - trackChange: true - flashCheckpoint: 'did-select-occurrence' - flashTypeForOccurrence: 'operator-remove-occurrence' - stayOptionName: 'stayOnDelete' - setToFirstCharacterOnLinewise: true - - execute: -> - @onDidSelectTarget => - @flashTarget = false if (@occurrenceSelected and @occurrenceWise is "linewise") - - @restorePositions = false if @target.wise is 'blockwise' - super - - mutateSelection: (selection) -> - @setTextToRegisterForSelection(selection) - selection.deleteSelectedText() - -class DeleteRight extends Delete - @extend() - target: 'MoveRight' - -class DeleteLeft extends Delete - @extend() - target: 'MoveLeft' - -class DeleteToLastCharacterOfLine extends Delete - @extend() - target: 'MoveToLastCharacterOfLine' - - execute: -> - if @target.wise is 'blockwise' - @onDidSelectTarget => - for blockwiseSelection in @getBlockwiseSelections() - blockwiseSelection.extendMemberSelectionsToEndOfLine() - super - -class DeleteLine extends Delete - @extend() - wise: 'linewise' - target: "MoveToRelativeLine" - flashTarget: false - -# Yank -# ========================= -class Yank extends Operator - @extend() - trackChange: true - stayOptionName: 'stayOnYank' - - mutateSelection: (selection) -> - @setTextToRegisterForSelection(selection) - -class YankLine extends Yank - @extend() - wise: 'linewise' - target: "MoveToRelativeLine" - -class YankToLastCharacterOfLine extends Yank - @extend() - target: 'MoveToLastCharacterOfLine' - -# ------------------------- -# [ctrl-a] -class Increase extends Operator - @extend() - target: "Empty" # ctrl-a in normal-mode find target number in current line manually - flashTarget: false # do manually - restorePositions: false # do manually - step: 1 - - execute: -> - @newRanges = [] - super - if @newRanges.length - if @getConfig('flashOnOperate') and @name not in @getConfig('flashOnOperateBlacklist') - @vimState.flash(@newRanges, type: @flashTypeForOccurrence) - - replaceNumberInBufferRange: (scanRange, fn=null) -> - newRanges = [] - @pattern ?= ///#{@getConfig('numberRegex')}///g - @scanForward @pattern, {scanRange}, (event) => - return if fn? and not fn(event) - {matchText, replace} = event - nextNumber = @getNextNumber(matchText) - newRanges.push(replace(String(nextNumber))) - newRanges - - mutateSelection: (selection) -> - {cursor} = selection - if @target.is('Empty') # ctrl-a, ctrl-x in `normal-mode` - cursorPosition = cursor.getBufferPosition() - scanRange = @editor.bufferRangeForBufferRow(cursorPosition.row) - newRanges = @replaceNumberInBufferRange scanRange, ({range, stop}) -> - if range.end.isGreaterThan(cursorPosition) - stop() - true - else - false - - point = newRanges[0]?.end.translate([0, -1]) ? cursorPosition - cursor.setBufferPosition(point) - else - scanRange = selection.getBufferRange() - @newRanges.push(@replaceNumberInBufferRange(scanRange)...) - cursor.setBufferPosition(scanRange.start) - - getNextNumber: (numberString) -> - Number.parseInt(numberString, 10) + @step * @getCount() - -# [ctrl-x] -class Decrease extends Increase - @extend() - step: -1 - -# ------------------------- -# [g ctrl-a] -class IncrementNumber extends Increase - @extend() - baseNumber: null - target: null - mutateSelectionOrderd: true - - getNextNumber: (numberString) -> - if @baseNumber? - @baseNumber += @step * @getCount() - else - @baseNumber = Number.parseInt(numberString, 10) - @baseNumber - -# [g ctrl-x] -class DecrementNumber extends IncrementNumber - @extend() - step: -1 - -# Put -# ------------------------- -# Cursor placement: -# - place at end of mutation: paste non-multiline characterwise text -# - place at start of mutation: non-multiline characterwise text(characterwise, linewise) -class PutBefore extends Operator - @extend() - location: 'before' - target: 'Empty' - flashType: 'operator-long' - restorePositions: false # manage manually - flashTarget: false # manage manually - trackChange: false # manage manually - - initialize: -> - @vimState.sequentialPasteManager.onInitialize(this) - - execute: -> - @mutationsBySelection = new Map() - @sequentialPaste = @vimState.sequentialPasteManager.onExecute(this) - - @onDidFinishMutation => - @adjustCursorPosition() unless @cancelled - - super - - return if @cancelled - - @onDidFinishOperation => - # TrackChange - if newRange = @mutationsBySelection.get(@editor.getLastSelection()) - @setMarkForChange(newRange) - - # Flash - if @getConfig('flashOnOperate') and @name not in @getConfig('flashOnOperateBlacklist') - toRange = (selection) => @mutationsBySelection.get(selection) - @vimState.flash(@editor.getSelections().map(toRange), type: @getFlashType()) - - adjustCursorPosition: -> - for selection in @editor.getSelections() when @mutationsBySelection.has(selection) - {cursor} = selection - {start, end} = newRange = @mutationsBySelection.get(selection) - if @linewisePaste - moveCursorToFirstCharacterAtRow(cursor, start.row) - else - if newRange.isSingleLine() - cursor.setBufferPosition(end.translate([0, -1])) - else - cursor.setBufferPosition(start) - - mutateSelection: (selection) -> - {text, type} = @vimState.register.get(null, selection, @sequentialPaste) - unless text - @cancelled = true - return - - text = _.multiplyString(text, @getCount()) - @linewisePaste = type is 'linewise' or @isMode('visual', 'linewise') - newRange = @paste(selection, text, {@linewisePaste}) - @mutationsBySelection.set(selection, newRange) - @vimState.sequentialPasteManager.savePastedRangeForSelection(selection, newRange) - - paste: (selection, text, {linewisePaste}) -> - if @sequentialPaste - @pasteCharacterwise(selection, text) - else if linewisePaste - @pasteLinewise(selection, text) - else - @pasteCharacterwise(selection, text) - - pasteCharacterwise: (selection, text) -> - {cursor} = selection - if selection.isEmpty() and @location is 'after' and not isEmptyRow(@editor, cursor.getBufferRow()) - cursor.moveRight() - return selection.insertText(text) - - # Return newRange - pasteLinewise: (selection, text) -> - {cursor} = selection - cursorRow = cursor.getBufferRow() - text += "\n" unless text.endsWith("\n") - if selection.isEmpty() - if @location is 'before' - insertTextAtBufferPosition(@editor, [cursorRow, 0], text) - else if @location is 'after' - targetRow = @getFoldEndRowForRow(cursorRow) - ensureEndsWithNewLineForBufferRow(@editor, targetRow) - insertTextAtBufferPosition(@editor, [targetRow + 1, 0], text) - else - selection.insertText("\n") unless @isMode('visual', 'linewise') - selection.insertText(text) - -class PutAfter extends PutBefore - @extend() - location: 'after' - -class PutBeforeWithAutoIndent extends PutBefore - @extend() - - pasteLinewise: (selection, text) -> - newRange = super - adjustIndentWithKeepingLayout(@editor, newRange) - return newRange - -class PutAfterWithAutoIndent extends PutBeforeWithAutoIndent - @extend() - location: 'after' - -class AddBlankLineBelow extends Operator - @extend() - flashTarget: false - target: "Empty" - stayAtSamePosition: true - stayByMarker: true - where: 'below' - - mutateSelection: (selection) -> - row = selection.getHeadBufferPosition().row - row += 1 if @where is 'below' - point = [row, 0] - @editor.setTextInBufferRange([point, point], "\n".repeat(@getCount())) - -class AddBlankLineAbove extends AddBlankLineBelow - @extend() - where: 'above' diff --git a/lib/operator.js b/lib/operator.js new file mode 100644 index 000000000..1490c5557 --- /dev/null +++ b/lib/operator.js @@ -0,0 +1,862 @@ +"use babel" + +const _ = require("underscore-plus") +const { + isEmptyRow, + getWordPatternAtBufferPosition, + getSubwordPatternAtBufferPosition, + insertTextAtBufferPosition, + setBufferRow, + moveCursorToFirstCharacterAtRow, + ensureEndsWithNewLineForBufferRow, + adjustIndentWithKeepingLayout, + isSingleLineText, +} = require("./utils") +const Base = require("./base") + +class Operator extends Base { + static operationKind = "operator" + requireTarget = true + recordable = true + + wise = null + occurrence = false + occurrenceType = "base" + + flashTarget = true + flashCheckpoint = "did-finish" + flashType = "operator" + flashTypeForOccurrence = "operator-occurrence" + trackChange = false + + patternForOccurrence = null + stayAtSamePosition = null + stayOptionName = null + stayByMarker = false + restorePositions = true + setToFirstCharacterOnLinewise = false + + acceptPresetOccurrence = true + acceptPersistentSelection = true + + bufferCheckpointByPurpose = null + mutateSelectionOrderd = false + + // Experimentaly allow selectTarget before input Complete + // ------------------------- + supportEarlySelect = false + targetSelected = null + + canEarlySelect() { + return this.supportEarlySelect && !this.repeated + } + // ------------------------- + + // Called when operation finished + // This is essentially to reset state for `.` repeat. + resetState() { + this.targetSelected = null + this.occurrenceSelected = false + } + + // Two checkpoint for different purpose + // - one for undo(handled by modeManager) + // - one for preserve last inserted text + createBufferCheckpoint(purpose) { + if (!this.bufferCheckpointByPurpose) this.bufferCheckpointByPurpose = {} + this.bufferCheckpointByPurpose[purpose] = this.editor.createCheckpoint() + } + + getBufferCheckpoint(purpose) { + if (this.bufferCheckpointByPurpose) { + return this.bufferCheckpointByPurpose[purpose] + } + } + + deleteBufferCheckpoint(purpose) { + if (this.bufferCheckpointByPurpose) { + delete this.bufferCheckpointByPurpose[purpose] + } + } + + groupChangesSinceBufferCheckpoint(purpose) { + const checkpoint = this.getBufferCheckpoint(purpose) + if (checkpoint) { + this.editor.groupChangesSinceCheckpoint(checkpoint) + this.deleteBufferCheckpoint(purpose) + } + } + + setMarkForChange(range) { + this.vimState.mark.set("[", range.start) + this.vimState.mark.set("]", range.end) + } + + needFlash() { + return ( + this.flashTarget && + this.getConfig("flashOnOperate") && + !this.getConfig("flashOnOperateBlacklist").includes(this.name) && + (this.mode !== "visual" || this.submode !== this.target.wise) // e.g. Y in vC + ) + } + + flashIfNecessary(ranges) { + if (this.needFlash()) { + this.vimState.flash(ranges, {type: this.getFlashType()}) + } + } + + flashChangeIfNecessary() { + if (this.needFlash()) { + this.onDidFinishOperation(() => { + const ranges = this.mutationManager.getSelectedBufferRangesForCheckpoint(this.flashCheckpoint) + this.vimState.flash(ranges, {type: this.getFlashType()}) + }) + } + } + + getFlashType() { + return this.occurrenceSelected ? this.flashTypeForOccurrence : this.flashType + } + + trackChangeIfNecessary() { + if (!this.trackChange) return + this.onDidFinishOperation(() => { + const range = this.mutationManager.getMutatedBufferRangeForSelection(this.editor.getLastSelection()) + if (range) this.setMarkForChange(range) + }) + } + + initialize() { + this.subscribeResetOccurrencePatternIfNeeded() + this.onDidSetOperatorModifier(options => this.setModifier(options)) + + // When preset-occurrence was exists, operate on occurrence-wise + if (this.acceptPresetOccurrence && this.occurrenceManager.hasMarkers()) { + this.occurrence = true + } + + // [FIXME] ORDER-MATTER + // To pick cursor-word to find occurrence base pattern. + // This has to be done BEFORE converting persistent-selection into real-selection. + // Since when persistent-selection is actuall selected, it change cursor position. + if (this.occurrence && !this.occurrenceManager.hasMarkers()) { + const regex = this.patternForOccurrence || this.getPatternForOccurrenceType(this.occurrenceType) + this.occurrenceManager.addPattern(regex) + } + + // This change cursor position. + if (this.selectPersistentSelectionIfNecessary()) { + // [FIXME] selection-wise is not synched if it already visual-mode + if (this.mode !== "visual") { + this.vimState.modeManager.activate("visual", this.swrap.detectWise(this.editor)) + } + } + + if (this.mode === "visual" && this.requireTarget) { + this.target = "CurrentSelection" + } + if (_.isString(this.target)) { + this.setTarget(this.getInstance(this.target)) + } + + return super.initialize() + } + + subscribeResetOccurrencePatternIfNeeded() { + // [CAUTION] + // This method has to be called in PROPER timing. + // If occurrence is true but no preset-occurrence + // Treat that `occurrence` is BOUNDED to operator itself, so cleanp at finished. + if (this.occurrence && !this.occurrenceManager.hasMarkers()) { + this.onDidResetOperationStack(() => this.occurrenceManager.resetPatterns()) + } + } + + setModifier({wise, occurrence, occurrenceType}) { + if (wise) { + this.wise = wise + } else if (occurrence) { + this.occurrence = occurrence + this.occurrenceType = occurrenceType + // This is o modifier case(e.g. `c o p`, `d O f`) + // We RESET existing occurence-marker when `o` or `O` modifier is typed by user. + const regex = this.getPatternForOccurrenceType(occurrenceType) + this.occurrenceManager.addPattern(regex, {reset: true, occurrenceType}) + this.onDidResetOperationStack(() => this.occurrenceManager.resetPatterns()) + } + } + + // return true/false to indicate success + selectPersistentSelectionIfNecessary() { + if ( + this.acceptPersistentSelection && + this.getConfig("autoSelectPersistentSelectionOnOperate") && + !this.persistentSelection.isEmpty() + ) { + this.persistentSelection.select() + this.editor.mergeIntersectingSelections() + for (const $selection of this.swrap.getSelections(this.editor)) { + if (!$selection.hasProperties()) $selection.saveProperties() + } + + return true + } else { + return false + } + } + + getPatternForOccurrenceType(occurrenceType) { + if (occurrenceType === "base") { + return getWordPatternAtBufferPosition(this.editor, this.getCursorBufferPosition()) + } else if (occurrenceType === "subword") { + return getSubwordPatternAtBufferPosition(this.editor, this.getCursorBufferPosition()) + } + } + + // target is TextObject or Motion to operate on. + setTarget(target) { + this.target = target + this.target.operator = this + this.emitDidSetTarget(this) + + if (this.canEarlySelect()) { + this.normalizeSelectionsIfNecessary() + this.createBufferCheckpoint("undo") + this.selectTarget() + } + return this + } + + setTextToRegisterForSelection(selection) { + this.setTextToRegister(selection.getText(), selection) + } + + setTextToRegister(text, selection) { + if (this.target.isLinewise() && !text.endsWith("\n")) { + text += "\n" + } + if (text) { + this.vimState.register.set(null, {text, selection}) + + if (this.vimState.register.isUnnamed()) { + if (this.instanceof("Delete") || this.instanceof("Change")) { + if (!this.needSaveToNumberedRegister(this.target) && isSingleLineText(text)) { + this.vimState.register.set("-", {text, selection}) // small-change + } else { + this.vimState.register.set("1", {text, selection}) + } + } else if (this.instanceof("Yank")) { + this.vimState.register.set("0", {text, selection}) + } + } + } + } + + needSaveToNumberedRegister(target) { + // Used to determine what register to use on change and delete operation. + // Following motion should save to 1-9 register regerdless of content is small or big. + const goesToNumberedRegisterMotionNames = [ + "MoveToPair", // % + "MoveToNextSentence", // (, ) + "Search", // /, ?, n, N + "MoveToNextParagraph", // {, } + ] + return goesToNumberedRegisterMotionNames.some(name => target.instanceof(name)) + } + + normalizeSelectionsIfNecessary() { + if (this.target && this.target.isMotion() && this.mode === "visual") { + this.swrap.normalize(this.editor) + } + } + + startMutation(fn) { + if (this.canEarlySelect()) { + // - Skip selection normalization: already normalized before @selectTarget() + // - Manual checkpoint grouping: to create checkpoint before @selectTarget() + fn() + this.emitWillFinishMutation() + this.groupChangesSinceBufferCheckpoint("undo") + } else { + this.normalizeSelectionsIfNecessary() + this.editor.transact(() => { + fn() + this.emitWillFinishMutation() + }) + } + + this.emitDidFinishMutation() + } + + // Main + execute() { + this.startMutation(() => { + if (this.selectTarget()) { + const selections = this.mutateSelectionOrderd + ? this.editor.getSelectionsOrderedByBufferPosition() + : this.editor.getSelections() + + for (const selection of selections) { + this.mutateSelection(selection) + } + this.mutationManager.setCheckpoint("did-finish") + this.restoreCursorPositionsIfNecessary() + } + }) + + // Even though we fail to select target and fail to mutate, + // we have to return to normal-mode from operator-pending or visual + this.activateMode("normal") + } + + // Return true unless all selection is empty. + selectTarget() { + if (this.targetSelected != null) { + return this.targetSelected + } + this.mutationManager.init({stayByMarker: this.stayByMarker}) + + if (this.target.isMotion() && this.mode === "visual") this.target.wise = this.submode + if (this.wise != null) this.target.forceWise(this.wise) + + this.emitWillSelectTarget() + + // Allow cursor position adjustment 'on-will-select-target' hook. + // so checkpoint comes AFTER @emitWillSelectTarget() + this.mutationManager.setCheckpoint("will-select") + + // NOTE + // Since MoveToNextOccurrence, MoveToPreviousOccurrence motion move by + // occurrence-marker, occurrence-marker has to be created BEFORE `@target.execute()` + // And when repeated, occurrence pattern is already cached at @patternForOccurrence + if (this.repeated && this.occurrence && !this.occurrenceManager.hasMarkers()) { + this.occurrenceManager.addPattern(this.patternForOccurrence, {occurrenceType: this.occurrenceType}) + } + + this.target.execute() + + this.mutationManager.setCheckpoint("did-select") + if (this.occurrence) { + // To repoeat(`.`) operation where multiple occurrence patterns was set. + // Here we save patterns which represent unioned regex which @occurrenceManager knows. + if (!this.patternForOccurrence) { + this.patternForOccurrence = this.occurrenceManager.buildPattern() + } + + this.occurrenceWise = this.wise || "characterwise" + if (this.occurrenceManager.select(this.occurrenceWise)) { + this.occurrenceSelected = true + this.mutationManager.setCheckpoint("did-select-occurrence") + } + } + + this.targetSelected = this.vimState.haveSomeNonEmptySelection() || this.target.name === "Empty" + if (this.targetSelected) { + this.emitDidSelectTarget() + this.flashChangeIfNecessary() + this.trackChangeIfNecessary() + } else { + this.emitDidFailSelectTarget() + } + + return this.targetSelected + } + + restoreCursorPositionsIfNecessary() { + if (!this.restorePositions) return + + const stay = + this.stayAtSamePosition != null + ? this.stayAtSamePosition + : this.getConfig(this.stayOptionName) || (this.occurrenceSelected && this.getConfig("stayOnOccurrence")) + const wise = this.occurrenceSelected ? this.occurrenceWise : this.target.wise + const {setToFirstCharacterOnLinewise} = this + this.mutationManager.restoreCursorPositions({stay, wise, setToFirstCharacterOnLinewise}) + } +} +Operator.register(false) + +class SelectBase extends Operator { + flashTarget = false + recordable = false + + execute() { + this.startMutation(() => this.selectTarget()) + + if (this.target.selectSucceeded) { + if (this.target.isTextObject()) { + this.editor.scrollToCursorPosition() + } + const wise = this.occurrenceSelected ? this.occurrenceWise : this.target.wise + this.activateModeIfNecessary("visual", wise) + } else { + this.cancelOperation() + } + } +} +SelectBase.register(false) + +class Select extends SelectBase { + execute() { + for (const $selection of this.swrap.getSelections(this.editor)) { + if (!$selection.hasProperties()) $selection.saveProperties() + } + super.execute() + } +} +Select.register() + +class SelectLatestChange extends SelectBase { + target = "ALatestChange" +} +SelectLatestChange.register() + +class SelectPreviousSelection extends SelectBase { + target = "PreviousSelection" +} +SelectPreviousSelection.register() + +class SelectPersistentSelection extends SelectBase { + target = "APersistentSelection" + acceptPersistentSelection = false +} +SelectPersistentSelection.register() + +class SelectOccurrence extends SelectBase { + occurrence = true +} +SelectOccurrence.register() + +// SelectInVisualMode: used in visual-mode +// When text-object is invoked from normal or viusal-mode, operation would be +// => SelectInVisualMode operator with target=text-object +// When motion is invoked from visual-mode, operation would be +// => SelectInVisualMode operator with target=motion) +// ================================ +// SelectInVisualMode is used in TWO situation. +// - visual-mode operation +// - e.g: `v l`, `V j`, `v i p`... +// - Directly invoke text-object from normal-mode +// - e.g: Invoke `Inner Paragraph` from command-palette. +class SelectInVisualMode extends SelectBase { + acceptPresetOccurrence = false + acceptPersistentSelection = false +} +SelectInVisualMode.register(false) + +// Persistent Selection +// ========================= +class CreatePersistentSelection extends Operator { + flashTarget = false + stayAtSamePosition = true + acceptPresetOccurrence = false + acceptPersistentSelection = false + + mutateSelection(selection) { + this.persistentSelection.markBufferRange(selection.getBufferRange()) + } +} +CreatePersistentSelection.register() + +class TogglePersistentSelection extends CreatePersistentSelection { + isComplete() { + const point = this.editor.getCursorBufferPosition() + this.markerToRemove = this.persistentSelection.getMarkerAtPoint(point) + return this.markerToRemove || super.isComplete() + } + + execute() { + if (this.markerToRemove) { + this.markerToRemove.destroy() + } else { + super.execute() + } + } +} +TogglePersistentSelection.register() + +// Preset Occurrence +// ========================= +class TogglePresetOccurrence extends Operator { + target = "Empty" + flashTarget = false + acceptPresetOccurrence = false + acceptPersistentSelection = false + occurrenceType = "base" + + execute() { + const marker = this.occurrenceManager.getMarkerAtPoint(this.editor.getCursorBufferPosition()) + if (marker) { + this.occurrenceManager.destroyMarkers([marker]) + } else { + const isNarrowed = this.vimState.modeManager.isNarrowed() + + let regex + if (this.mode === "visual" && !isNarrowed) { + this.occurrenceType = "base" + regex = new RegExp(_.escapeRegExp(this.editor.getSelectedText()), "g") + } else { + regex = this.getPatternForOccurrenceType(this.occurrenceType) + } + + this.occurrenceManager.addPattern(regex, {occurrenceType: this.occurrenceType}) + this.occurrenceManager.saveLastPattern(this.occurrenceType) + + if (!isNarrowed) this.activateMode("normal") + } + } +} +TogglePresetOccurrence.register() + +class TogglePresetSubwordOccurrence extends TogglePresetOccurrence { + occurrenceType = "subword" +} +TogglePresetSubwordOccurrence.register() + +// Want to rename RestoreOccurrenceMarker +class AddPresetOccurrenceFromLastOccurrencePattern extends TogglePresetOccurrence { + execute() { + this.occurrenceManager.resetPatterns() + const regex = this.globalState.get("lastOccurrencePattern") + if (regex) { + const occurrenceType = this.globalState.get("lastOccurrenceType") + this.occurrenceManager.addPattern(regex, {occurrenceType}) + this.activateMode("normal") + } + } +} +AddPresetOccurrenceFromLastOccurrencePattern.register() + +// Delete +// ================================ +class Delete extends Operator { + trackChange = true + flashCheckpoint = "did-select-occurrence" + flashTypeForOccurrence = "operator-remove-occurrence" + stayOptionName = "stayOnDelete" + setToFirstCharacterOnLinewise = true + + execute() { + this.onDidSelectTarget(() => { + if (this.occurrenceSelected && this.occurrenceWise === "linewise") { + this.flashTarget = false + } + }) + + if (this.target.wise === "blockwise") { + this.restorePositions = false + } + super.execute() + } + + mutateSelection(selection) { + this.setTextToRegisterForSelection(selection) + selection.deleteSelectedText() + } +} +Delete.register() + +class DeleteRight extends Delete { + target = "MoveRight" +} +DeleteRight.register() + +class DeleteLeft extends Delete { + target = "MoveLeft" +} +DeleteLeft.register() + +class DeleteToLastCharacterOfLine extends Delete { + target = "MoveToLastCharacterOfLine" + + execute() { + this.onDidSelectTarget(() => { + if (this.target.wise === "blockwise") { + for (const blockwiseSelection of this.getBlockwiseSelections()) { + blockwiseSelection.extendMemberSelectionsToEndOfLine() + } + } + }) + super.execute() + } +} +DeleteToLastCharacterOfLine.register() + +class DeleteLine extends Delete { + wise = "linewise" + target = "MoveToRelativeLine" + flashTarget = false +} +DeleteLine.register() + +// Yank +// ========================= +class Yank extends Operator { + trackChange = true + stayOptionName = "stayOnYank" + + mutateSelection(selection) { + this.setTextToRegisterForSelection(selection) + } +} +Yank.register() + +class YankLine extends Yank { + wise = "linewise" + target = "MoveToRelativeLine" +} +YankLine.register() + +class YankToLastCharacterOfLine extends Yank { + target = "MoveToLastCharacterOfLine" +} +YankToLastCharacterOfLine.register() + +// ------------------------- +// [ctrl-a] +class Increase extends Operator { + target = "Empty" // ctrl-a in normal-mode find target number in current line manually + flashTarget = false // do manually + restorePositions = false // do manually + step = 1 + + execute() { + this.newRanges = [] + if (!this.regex) this.regex = new RegExp(`${this.getConfig("numberRegex")}`, "g") + + super.execute() + + if (this.newRanges.length) { + if (this.getConfig("flashOnOperate") && !this.getConfig("flashOnOperateBlacklist").includes(this.name)) { + this.vimState.flash(this.newRanges, {type: this.flashTypeForOccurrence}) + } + } + } + + replaceNumberInBufferRange(scanRange, fn) { + const newRanges = [] + this.scanForward(this.regex, {scanRange}, event => { + if (fn) { + if (fn(event)) event.stop() + else return + } + const nextNumber = this.getNextNumber(event.matchText) + newRanges.push(event.replace(String(nextNumber))) + }) + return newRanges + } + + mutateSelection(selection) { + const {cursor} = selection + if (this.target.is("Empty")) { + // ctrl-a, ctrl-x in `normal-mode` + const cursorPosition = cursor.getBufferPosition() + const scanRange = this.editor.bufferRangeForBufferRow(cursorPosition.row) + const newRanges = this.replaceNumberInBufferRange(scanRange, event => + event.range.end.isGreaterThan(cursorPosition) + ) + const point = (newRanges.length && newRanges[0].end.translate([0, -1])) || cursorPosition + cursor.setBufferPosition(point) + } else { + const scanRange = selection.getBufferRange() + this.newRanges.push(...this.replaceNumberInBufferRange(scanRange)) + cursor.setBufferPosition(scanRange.start) + } + } + + getNextNumber(numberString) { + return Number.parseInt(numberString, 10) + this.step * this.getCount() + } +} +Increase.register() + +// [ctrl-x] +class Decrease extends Increase { + step = -1 +} +Decrease.register() + +// ------------------------- +// [g ctrl-a] +class IncrementNumber extends Increase { + baseNumber = null + target = null + mutateSelectionOrderd = true + + getNextNumber(numberString) { + if (this.baseNumber != null) { + this.baseNumber += this.step * this.getCount() + } else { + this.baseNumber = Number.parseInt(numberString, 10) + } + return this.baseNumber + } +} +IncrementNumber.register() + +// [g ctrl-x] +class DecrementNumber extends IncrementNumber { + step = -1 +} +DecrementNumber.register() + +// Put +// ------------------------- +// Cursor placement: +// - place at end of mutation: paste non-multiline characterwise text +// - place at start of mutation: non-multiline characterwise text(characterwise, linewise) +class PutBefore extends Operator { + location = "before" + target = "Empty" + flashType = "operator-long" + restorePositions = false // manage manually + flashTarget = false // manage manually + trackChange = false // manage manually + + initialize() { + this.vimState.sequentialPasteManager.onInitialize(this) + return super.initialize() + } + + execute() { + this.mutationsBySelection = new Map() + this.sequentialPaste = this.vimState.sequentialPasteManager.onExecute(this) + + this.onDidFinishMutation(() => { + if (!this.cancelled) this.adjustCursorPosition() + }) + + super.execute() + + if (this.cancelled) return + + this.onDidFinishOperation(() => { + // TrackChange + const newRange = this.mutationsBySelection.get(this.editor.getLastSelection()) + if (newRange) this.setMarkForChange(newRange) + + // Flash + if (this.getConfig("flashOnOperate") && !this.getConfig("flashOnOperateBlacklist").includes(this.name)) { + const ranges = this.editor.getSelections().map(selection => this.mutationsBySelection.get(selection)) + this.vimState.flash(ranges, {type: this.getFlashType()}) + } + }) + } + + adjustCursorPosition() { + for (const selection of this.editor.getSelections()) { + if (!this.mutationsBySelection.has(selection)) continue + + const {cursor} = selection + const newRange = this.mutationsBySelection.get(selection) + if (this.linewisePaste) { + moveCursorToFirstCharacterAtRow(cursor, newRange.start.row) + } else { + if (newRange.isSingleLine()) { + cursor.setBufferPosition(newRange.end.translate([0, -1])) + } else { + cursor.setBufferPosition(newRange.start) + } + } + } + } + + mutateSelection(selection) { + const value = this.vimState.register.get(null, selection, this.sequentialPaste) + if (!value.text) { + this.cancelled = true + return + } + + const textToPaste = _.multiplyString(value.text, this.getCount()) + this.linewisePaste = value.type === "linewise" || this.isMode("visual", "linewise") + const newRange = this.paste(selection, textToPaste, {linewisePaste: this.linewisePaste}) + this.mutationsBySelection.set(selection, newRange) + this.vimState.sequentialPasteManager.savePastedRangeForSelection(selection, newRange) + } + + // Return pasted range + paste(selection, text, {linewisePaste}) { + if (this.sequentialPaste) { + return this.pasteCharacterwise(selection, text) + } else if (linewisePaste) { + return this.pasteLinewise(selection, text) + } else { + return this.pasteCharacterwise(selection, text) + } + } + + pasteCharacterwise(selection, text) { + const {cursor} = selection + if (selection.isEmpty() && this.location === "after" && !isEmptyRow(this.editor, cursor.getBufferRow())) { + cursor.moveRight() + } + return selection.insertText(text) + } + + // Return newRange + pasteLinewise(selection, text) { + const {cursor} = selection + const cursorRow = cursor.getBufferRow() + if (!text.endsWith("\n")) { + text += "\n" + } + if (selection.isEmpty()) { + if (this.location === "before") { + return insertTextAtBufferPosition(this.editor, [cursorRow, 0], text) + } else if (this.location === "after") { + const targetRow = this.getFoldEndRowForRow(cursorRow) + ensureEndsWithNewLineForBufferRow(this.editor, targetRow) + return insertTextAtBufferPosition(this.editor, [targetRow + 1, 0], text) + } + } else { + if (!this.isMode("visual", "linewise")) { + selection.insertText("\n") + } + return selection.insertText(text) + } + } +} +PutBefore.register() + +class PutAfter extends PutBefore { + location = "after" +} +PutAfter.register() + +class PutBeforeWithAutoIndent extends PutBefore { + pasteLinewise(selection, text) { + const newRange = super.pasteLinewise(selection, text) + adjustIndentWithKeepingLayout(this.editor, newRange) + return newRange + } +} +PutBeforeWithAutoIndent.register() + +class PutAfterWithAutoIndent extends PutBeforeWithAutoIndent { + location = "after" +} +PutAfterWithAutoIndent.register() + +class AddBlankLineBelow extends Operator { + flashTarget = false + target = "Empty" + stayAtSamePosition = true + stayByMarker = true + where = "below" + + mutateSelection(selection) { + const point = selection.getHeadBufferPosition() + if (this.where === "below") point.row++ + point.column = 0 + this.editor.setTextInBufferRange([point, point], "\n".repeat(this.getCount())) + } +} +AddBlankLineBelow.register() + +class AddBlankLineAbove extends AddBlankLineBelow { + where = "above" +} +AddBlankLineAbove.register() diff --git a/lib/selection-wrapper.js b/lib/selection-wrapper.js index 1b82a4590..9bd4e85ba 100644 --- a/lib/selection-wrapper.js +++ b/lib/selection-wrapper.js @@ -8,7 +8,7 @@ const { isLinewiseRange, assertWithException, getFoldEndRowForRow, - getRange, + getList, } = require("./utils") const settings = require("./settings") const BlockwiseSelection = require("./blockwise-selection") @@ -83,7 +83,7 @@ class SelectionWrapper { getRows() { const [startRow, endRow] = this.selection.getBufferRowRange() - return getRange(startRow, endRow) + return getList(startRow, endRow) } getRowCount() { diff --git a/lib/settings.js b/lib/settings.js index cffc52f2d..658f48ca8 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -46,6 +46,20 @@ class Settings { }) } + notifyCoffeeScriptNoLongerSupportedToExtendVMP() { + if (!this.get("notifiedCoffeeScriptNoLongerSupportedToExtendVMP")) { + this.set("notifiedCoffeeScriptNoLongerSupportedToExtendVMP", true) + const message = [ + this.scope, + "- From vmp-v.1.9.0 all operations are defined as ES6 class which is NOT extend-able by CoffeeScript.", + "- If you have vmp custom operations in your `init.coffee`. Those are no longer work(You might saw error already).", + "- Sorry for not providing gradual migration path, I couldn't find the way and also I'm lazy.", + "- See [CHANGELOG](https://github.com/t9md/atom-vim-mode-plus/blob/master/CHANGELOG.md) and [Wiki](https://github.com/t9md/atom-vim-mode-plus/wiki/ExtendVimModePlusInInitFile) for detail.", + ].join("\n") + atom.notifications.addInfo(message, {dismissable: true}) + } + } + migrateRenamedParams() { const messages = [] @@ -475,6 +489,10 @@ module.exports = new Settings("vim-mode-plus", { description: "When attempt to create occurrence-marker exceeding this threshold, vmp asks confirmation to continue
This is to prevent editor from freezing while creating tons of markers.
Affects: `g o` or `o` modifier(e.g. `c o p`)", }, + notifiedCoffeeScriptNoLongerSupportedToExtendVMP: { + // TODO: Remove in future:(added at v1.19.0 release). + default: false, + }, debug: { default: false, description: "[Dev use]", diff --git a/lib/text-object.coffee b/lib/text-object.coffee deleted file mode 100644 index 8981c5ec7..000000000 --- a/lib/text-object.coffee +++ /dev/null @@ -1,710 +0,0 @@ -{Range, Point} = require 'atom' -_ = require 'underscore-plus' - -# [TODO] Need overhaul -# - [ ] Make expandable by selection.getBufferRange().union(@getRange(selection)) -# - [ ] Count support(priority low)? -Base = require './base' -{ - getLineTextToBufferPosition - getCodeFoldRowRangesContainesForRow - isIncludeFunctionScopeForRow - expandRangeToWhiteSpaces - getVisibleBufferRange - translatePointAndClip - getBufferRows - getValidVimBufferRow - trimRange - sortRanges - pointIsAtEndOfLine - splitArguments - traverseTextFromPoint -} = require './utils' -PairFinder = null - -class TextObject extends Base - @extend(false) - @operationKind: 'text-object' - wise: 'characterwise' - supportCount: false # FIXME #472, #66 - selectOnce: false - selectSucceeded: false - - @deriveInnerAndA: -> - @generateClass("A" + @name, false) - @generateClass("Inner" + @name, true) - - @deriveInnerAndAForAllowForwarding: -> - @generateClass("A" + @name + "AllowForwarding", false, true) - @generateClass("Inner" + @name + "AllowForwarding", true, true) - - @generateClass: (klassName, inner, allowForwarding) -> - klass = class extends this - Object.defineProperty klass, 'name', get: -> klassName - klass::inner = inner - klass::allowForwarding = true if allowForwarding - klass.extend() - - constructor: -> - super - @initialize() - - isInner: -> - @inner - - isA: -> - not @inner - - isLinewise: -> @wise is 'linewise' - isBlockwise: -> @wise is 'blockwise' - - forceWise: (wise) -> - @wise = wise # FIXME currently not well supported - - resetState: -> - @selectSucceeded = false - - execute: -> - # Whennever TextObject is executed, it has @operator - # Called from Operator::selectTarget() - # - `v i p`, is `SelectInVisualMode` operator with @target = `InnerParagraph`. - # - `d i p`, is `Delete` operator with @target = `InnerParagraph`. - if @operator? - @select() - else - throw new Error('in TextObject: Must not happen') - - select: -> - if @isMode('visual', 'blockwise') - @swrap.normalize(@editor) - - @countTimes @getCount(), ({stop}) => - stop() unless @supportCount # quick-fix for #560 - for selection in @editor.getSelections() - oldRange = selection.getBufferRange() - if @selectTextObject(selection) - @selectSucceeded = true - stop() if selection.getBufferRange().isEqual(oldRange) - break if @selectOnce - - @editor.mergeIntersectingSelections() - # Some TextObject's wise is NOT deterministic. It has to be detected from selected range. - @wise ?= @swrap.detectWise(@editor) - - if @operator.instanceof("SelectBase") - if @selectSucceeded - switch @wise - when 'characterwise' - $selection.saveProperties() for $selection in @swrap.getSelections(@editor) - when 'linewise' - # When target is persistent-selection, new selection is added after selectTextObject. - # So we have to assure all selection have selction property. - # Maybe this logic can be moved to operation stack. - for $selection in @swrap.getSelections(@editor) - if @getConfig('stayOnSelectTextObject') - $selection.saveProperties() unless $selection.hasProperties() - else - $selection.saveProperties() - $selection.fixPropertyRowToRowRange() - - if @submode is 'blockwise' - for $selection in @swrap.getSelections(@editor) - $selection.normalize() - $selection.applyWise('blockwise') - - # Return true or false - selectTextObject: (selection) -> - if range = @getRange(selection) - @swrap(selection).setBufferRange(range) - return true - - # to override - getRange: (selection) -> - null - -# Section: Word -# ========================= -class Word extends TextObject - @extend(false) - @deriveInnerAndA() - - getRange: (selection) -> - point = @getCursorPositionForSelection(selection) - {range} = @getWordBufferRangeAndKindAtBufferPosition(point, {@wordRegex}) - if @isA() - expandRangeToWhiteSpaces(@editor, range) - else - range - -class WholeWord extends Word - @extend(false) - @deriveInnerAndA() - wordRegex: /\S+/ - -# Just include _, - -class SmartWord extends Word - @extend(false) - @deriveInnerAndA() - @description: "A word that consists of alphanumeric chars(`/[A-Za-z0-9_]/`) and hyphen `-`" - wordRegex: /[\w-]+/ - -# Just include _, - -class Subword extends Word - @extend(false) - @deriveInnerAndA() - getRange: (selection) -> - @wordRegex = selection.cursor.subwordRegExp() - super - -# Section: Pair -# ========================= -class Pair extends TextObject - @extend(false) - supportCount: true - allowNextLine: null - adjustInnerRange: true - pair: null - inclusive: true - - initialize: -> - PairFinder ?= require './pair-finder' - super - - - isAllowNextLine: -> - @allowNextLine ? (@pair? and @pair[0] isnt @pair[1]) - - adjustRange: ({start, end}) -> - # Dirty work to feel natural for human, to behave compatible with pure Vim. - # Where this adjustment appear is in following situation. - # op-1: `ci{` replace only 2nd line - # op-2: `di{` delete only 2nd line. - # text: - # { - # aaa - # } - if pointIsAtEndOfLine(@editor, start) - start = start.traverse([1, 0]) - - if getLineTextToBufferPosition(@editor, end).match(/^\s*$/) - if @mode is 'visual' - # This is slightly innconsistent with regular Vim - # - regular Vim: select new line after EOL - # - vim-mode-plus: select to EOL(before new line) - # This is intentional since to make submode `characterwise` when auto-detect submode - # innerEnd = new Point(innerEnd.row - 1, Infinity) - end = new Point(end.row - 1, Infinity) - else - end = new Point(end.row, 0) - - new Range(start, end) - - getFinder: -> - options = {allowNextLine: @isAllowNextLine(), @allowForwarding, @pair, @inclusive} - if @pair[0] is @pair[1] - new PairFinder.QuoteFinder(@editor, options) - else - new PairFinder.BracketFinder(@editor, options) - - getPairInfo: (from) -> - pairInfo = @getFinder().find(from) - unless pairInfo? - return null - pairInfo.innerRange = @adjustRange(pairInfo.innerRange) if @adjustInnerRange - pairInfo.targetRange = if @isInner() then pairInfo.innerRange else pairInfo.aRange - pairInfo - - getRange: (selection) -> - originalRange = selection.getBufferRange() - pairInfo = @getPairInfo(@getCursorPositionForSelection(selection)) - # When range was same, try to expand range - if pairInfo?.targetRange.isEqual(originalRange) - pairInfo = @getPairInfo(pairInfo.aRange.end) - pairInfo?.targetRange - -# Used by DeleteSurround -class APair extends Pair - @extend(false) - -class AnyPair extends Pair - @extend(false) - @deriveInnerAndA() - allowForwarding: false - member: [ - 'DoubleQuote', 'SingleQuote', 'BackTick', - 'CurlyBracket', 'AngleBracket', 'SquareBracket', 'Parenthesis' - ] - - getRanges: (selection) -> - @member - .map (klass) => @new(klass, {@inner, @allowForwarding, @inclusive}).getRange(selection) - .filter (range) -> range? - - getRange: (selection) -> - _.last(sortRanges(@getRanges(selection))) - -class AnyPairAllowForwarding extends AnyPair - @extend(false) - @deriveInnerAndA() - @description: "Range surrounded by auto-detected paired chars from enclosed and forwarding area" - allowForwarding: true - getRange: (selection) -> - ranges = @getRanges(selection) - from = selection.cursor.getBufferPosition() - [forwardingRanges, enclosingRanges] = _.partition ranges, (range) -> - range.start.isGreaterThanOrEqual(from) - enclosingRange = _.last(sortRanges(enclosingRanges)) - forwardingRanges = sortRanges(forwardingRanges) - - # When enclosingRange is exists, - # We don't go across enclosingRange.end. - # So choose from ranges contained in enclosingRange. - if enclosingRange - forwardingRanges = forwardingRanges.filter (range) -> - enclosingRange.containsRange(range) - - forwardingRanges[0] or enclosingRange - -class AnyQuote extends AnyPair - @extend(false) - @deriveInnerAndA() - allowForwarding: true - member: ['DoubleQuote', 'SingleQuote', 'BackTick'] - getRange: (selection) -> - ranges = @getRanges(selection) - # Pick range which end.colum is leftmost(mean, closed first) - _.first(_.sortBy(ranges, (r) -> r.end.column)) if ranges.length - -class Quote extends Pair - @extend(false) - allowForwarding: true - -class DoubleQuote extends Quote - @extend(false) - @deriveInnerAndA() - pair: ['"', '"'] - -class SingleQuote extends Quote - @extend(false) - @deriveInnerAndA() - pair: ["'", "'"] - -class BackTick extends Quote - @extend(false) - @deriveInnerAndA() - pair: ['`', '`'] - -class CurlyBracket extends Pair - @extend(false) - @deriveInnerAndA() - @deriveInnerAndAForAllowForwarding() - pair: ['{', '}'] - -class SquareBracket extends Pair - @extend(false) - @deriveInnerAndA() - @deriveInnerAndAForAllowForwarding() - pair: ['[', ']'] - -class Parenthesis extends Pair - @extend(false) - @deriveInnerAndA() - @deriveInnerAndAForAllowForwarding() - pair: ['(', ')'] - -class AngleBracket extends Pair - @extend(false) - @deriveInnerAndA() - @deriveInnerAndAForAllowForwarding() - pair: ['<', '>'] - -class Tag extends Pair - @extend(false) - @deriveInnerAndA() - allowNextLine: true - allowForwarding: true - adjustInnerRange: false - - getTagStartPoint: (from) -> - tagRange = null - pattern = PairFinder.TagFinder.pattern - @scanForward pattern, {from: [from.row, 0]}, ({range, stop}) -> - if range.containsPoint(from, true) - tagRange = range - stop() - tagRange?.start - - getFinder: -> - new PairFinder.TagFinder(@editor, {allowNextLine: @isAllowNextLine(), @allowForwarding, @inclusive}) - - getPairInfo: (from) -> - super(@getTagStartPoint(from) ? from) - -# Section: Paragraph -# ========================= -# Paragraph is defined as consecutive (non-)blank-line. -class Paragraph extends TextObject - @extend(false) - @deriveInnerAndA() - wise: 'linewise' - supportCount: true - - findRow: (fromRow, direction, fn) -> - fn.reset?() - foundRow = fromRow - for row in getBufferRows(@editor, {startRow: fromRow, direction}) - break unless fn(row, direction) - foundRow = row - - foundRow - - findRowRangeBy: (fromRow, fn) -> - startRow = @findRow(fromRow, 'previous', fn) - endRow = @findRow(fromRow, 'next', fn) - [startRow, endRow] - - getPredictFunction: (fromRow, selection) -> - fromRowResult = @editor.isBufferRowBlank(fromRow) - - if @isInner() - predict = (row, direction) => - @editor.isBufferRowBlank(row) is fromRowResult - else - if selection.isReversed() - directionToExtend = 'previous' - else - directionToExtend = 'next' - - flip = false - predict = (row, direction) => - result = @editor.isBufferRowBlank(row) is fromRowResult - if flip - not result - else - if (not result) and (direction is directionToExtend) - flip = true - return true - result - - predict.reset = -> - flip = false - predict - - getRange: (selection) -> - originalRange = selection.getBufferRange() - fromRow = @getCursorPositionForSelection(selection).row - if @isMode('visual', 'linewise') - if selection.isReversed() - fromRow-- - else - fromRow++ - fromRow = getValidVimBufferRow(@editor, fromRow) - - rowRange = @findRowRangeBy(fromRow, @getPredictFunction(fromRow, selection)) - selection.getBufferRange().union(@getBufferRangeForRowRange(rowRange)) - -class Indentation extends Paragraph - @extend(false) - @deriveInnerAndA() - - getRange: (selection) -> - fromRow = @getCursorPositionForSelection(selection).row - - baseIndentLevel = @getIndentLevelForBufferRow(fromRow) - predict = (row) => - if @editor.isBufferRowBlank(row) - @isA() - else - @getIndentLevelForBufferRow(row) >= baseIndentLevel - - rowRange = @findRowRangeBy(fromRow, predict) - @getBufferRangeForRowRange(rowRange) - -# Section: Comment -# ========================= -class Comment extends TextObject - @extend(false) - @deriveInnerAndA() - wise: 'linewise' - - getRange: (selection) -> - row = @getCursorPositionForSelection(selection).row - rowRange = @editor.languageMode.rowRangeForCommentAtBufferRow(row) - rowRange ?= [row, row] if @editor.isBufferRowCommented(row) - if rowRange? - @getBufferRangeForRowRange(rowRange) - -class CommentOrParagraph extends TextObject - @extend(false) - @deriveInnerAndA() - wise: 'linewise' - - getRange: (selection) -> - for klass in ['Comment', 'Paragraph'] - if range = @new(klass, {@inner}).getRange(selection) - return range - -# Section: Fold -# ========================= -class Fold extends TextObject - @extend(false) - @deriveInnerAndA() - wise: 'linewise' - - adjustRowRange: (rowRange) -> - return rowRange if @isA() - - [startRow, endRow] = rowRange - if @getIndentLevelForBufferRow(startRow) is @getIndentLevelForBufferRow(endRow) - endRow -= 1 - startRow += 1 - [startRow, endRow] - - getFoldRowRangesContainsForRow: (row) -> - getCodeFoldRowRangesContainesForRow(@editor, row).reverse() - - getRange: (selection) -> - row = @getCursorPositionForSelection(selection).row - selectedRange = selection.getBufferRange() - for rowRange in @getFoldRowRangesContainsForRow(row) - range = @getBufferRangeForRowRange(@adjustRowRange(rowRange)) - - # Don't change to `if range.containsRange(selectedRange, true)` - # There is behavior diff when cursor is at beginning of line( column 0 ). - unless selectedRange.containsRange(range) - return range - -# NOTE: Function range determination is depending on fold. -class Function extends Fold - @extend(false) - @deriveInnerAndA() - # Some language don't include closing `}` into fold. - scopeNamesOmittingEndRow: ['source.go', 'source.elixir'] - - isGrammarNotFoldEndRow: -> - {scopeName, packageName} = @editor.getGrammar() - if scopeName in @scopeNamesOmittingEndRow - true - else - # HACK: Rust have two package `language-rust` and `atom-language-rust` - # language-rust don't fold ending `}`, but atom-language-rust does. - scopeName is 'source.rust' and packageName is "language-rust" - - getFoldRowRangesContainsForRow: (row) -> - (super).filter (rowRange) => - isIncludeFunctionScopeForRow(@editor, rowRange[0]) - - adjustRowRange: (rowRange) -> - [startRow, endRow] = super - # NOTE: This adjustment shoud not be necessary if language-syntax is properly defined. - if @isA() and @isGrammarNotFoldEndRow() - endRow += 1 - [startRow, endRow] - -# Section: Other -# ========================= -class Arguments extends TextObject - @extend(false) - @deriveInnerAndA() - - newArgInfo: (argStart, arg, separator) -> - argEnd = traverseTextFromPoint(argStart, arg) - argRange = new Range(argStart, argEnd) - - separatorEnd = traverseTextFromPoint(argEnd, separator ? '') - separatorRange = new Range(argEnd, separatorEnd) - - innerRange = argRange - aRange = argRange.union(separatorRange) - {argRange, separatorRange, innerRange, aRange} - - getArgumentsRangeForSelection: (selection) -> - member = [ - 'CurlyBracket' - 'SquareBracket' - 'Parenthesis' - ] - @new("InnerAnyPair", {inclusive: false, member: member}).getRange(selection) - - getRange: (selection) -> - range = @getArgumentsRangeForSelection(selection) - pairRangeFound = range? - range ?= @new("InnerCurrentLine").getRange(selection) # fallback - return unless range - - range = trimRange(@editor, range) - - text = @editor.getTextInBufferRange(range) - allTokens = splitArguments(text, pairRangeFound) - - argInfos = [] - argStart = range.start - - # Skip starting separator - if allTokens.length and allTokens[0].type is 'separator' - token = allTokens.shift() - argStart = traverseTextFromPoint(argStart, token.text) - - while allTokens.length - token = allTokens.shift() - if token.type is 'argument' - separator = allTokens.shift()?.text - argInfo = @newArgInfo(argStart, token.text, separator) - - if (allTokens.length is 0) and (lastArgInfo = _.last(argInfos)) - argInfo.aRange = argInfo.argRange.union(lastArgInfo.separatorRange) - - argStart = argInfo.aRange.end - argInfos.push(argInfo) - else - throw new Error('must not happen') - - point = @getCursorPositionForSelection(selection) - for {innerRange, aRange} in argInfos - if innerRange.end.isGreaterThanOrEqual(point) - return if @isInner() then innerRange else aRange - null - -class CurrentLine extends TextObject - @extend(false) - @deriveInnerAndA() - - getRange: (selection) -> - row = @getCursorPositionForSelection(selection).row - range = @editor.bufferRangeForBufferRow(row) - if @isA() - range - else - trimRange(@editor, range) - -class Entire extends TextObject - @extend(false) - @deriveInnerAndA() - wise: 'linewise' - selectOnce: true - - getRange: (selection) -> - @editor.buffer.getRange() - -class Empty extends TextObject - @extend(false) - selectOnce: true - -class LatestChange extends TextObject - @extend(false) - @deriveInnerAndA() - wise: null - selectOnce: true - getRange: (selection) -> - start = @vimState.mark.get('[') - end = @vimState.mark.get(']') - if start? and end? - new Range(start, end) - -class SearchMatchForward extends TextObject - @extend() - backward: false - - findMatch: (fromPoint, pattern) -> - fromPoint = translatePointAndClip(@editor, fromPoint, "forward") if (@mode is 'visual') - found = null - @scanForward pattern, {from: [fromPoint.row, 0]}, ({range, stop}) -> - if range.end.isGreaterThan(fromPoint) - found = range - stop() - {range: found, whichIsHead: 'end'} - - getRange: (selection) -> - pattern = @globalState.get('lastSearchPattern') - return unless pattern? - - fromPoint = selection.getHeadBufferPosition() - {range, whichIsHead} = @findMatch(fromPoint, pattern) - if range? - @unionRangeAndDetermineReversedState(selection, range, whichIsHead) - - unionRangeAndDetermineReversedState: (selection, found, whichIsHead) -> - if selection.isEmpty() - found - else - head = found[whichIsHead] - tail = selection.getTailBufferPosition() - - if @backward - head = translatePointAndClip(@editor, head, 'forward') if tail.isLessThan(head) - else - head = translatePointAndClip(@editor, head, 'backward') if head.isLessThan(tail) - - @reversed = head.isLessThan(tail) - new Range(tail, head).union(@swrap(selection).getTailBufferRange()) - - selectTextObject: (selection) -> - if range = @getRange(selection) - @swrap(selection).setBufferRange(range, {reversed: @reversed ? @backward}) - return true - -class SearchMatchBackward extends SearchMatchForward - @extend() - backward: true - - findMatch: (fromPoint, pattern) -> - fromPoint = translatePointAndClip(@editor, fromPoint, "backward") if (@mode is 'visual') - found = null - @scanBackward pattern, {from: [fromPoint.row, Infinity]}, ({range, stop}) -> - if range.start.isLessThan(fromPoint) - found = range - stop() - {range: found, whichIsHead: 'start'} - -# [Limitation: won't fix]: Selected range is not submode aware. always characterwise. -# So even if original selection was vL or vB, selected range by this text-object -# is always vC range. -class PreviousSelection extends TextObject - @extend() - wise: null - selectOnce: true - - selectTextObject: (selection) -> - {properties, submode} = @vimState.previousSelection - if properties? and submode? - @wise = submode - @swrap(@editor.getLastSelection()).selectByProperties(properties) - return true - -class PersistentSelection extends TextObject - @extend(false) - @deriveInnerAndA() - wise: null - selectOnce: true - - selectTextObject: (selection) -> - if @vimState.hasPersistentSelections() - @vimState.persistentSelection.setSelectedBufferRanges() - return true - -# Used only by ReplaceWithRegister and PutBefore and its' children. -class LastPastedRange extends TextObject - @extend(false) - wise: null - selectOnce: true - - selectTextObject: (selection) -> - for selection in @editor.getSelections() - range = @vimState.sequentialPasteManager.getPastedRangeForSelection(selection) - selection.setBufferRange(range) - - return true - -class VisibleArea extends TextObject - @extend(false) - @deriveInnerAndA() - selectOnce: true - - getRange: (selection) -> - # [BUG?] Need translate to shilnk top and bottom to fit actual row. - # The reason I need -2 at bottom is because of status bar? - bufferRange = getVisibleBufferRange(@editor) - if bufferRange.getRows() > @editor.getRowsPerPage() - bufferRange.translate([+1, 0], [-3, 0]) - else - bufferRange diff --git a/lib/text-object.js b/lib/text-object.js new file mode 100644 index 000000000..e3173e55f --- /dev/null +++ b/lib/text-object.js @@ -0,0 +1,802 @@ +"use babel" + +const {Range, Point} = require("atom") +const _ = require("underscore-plus") + +// [TODO] Need overhaul +// - [ ] Make expandable by selection.getBufferRange().union(this.getRange(selection)) +// - [ ] Count support(priority low)? +const Base = require("./base") +const { + getLineTextToBufferPosition, + getCodeFoldRowRangesContainesForRow, + isIncludeFunctionScopeForRow, + expandRangeToWhiteSpaces, + getVisibleBufferRange, + translatePointAndClip, + getBufferRows, + getValidVimBufferRow, + trimRange, + sortRanges, + pointIsAtEndOfLine, + splitArguments, + traverseTextFromPoint, +} = require("./utils") +let PairFinder + +class TextObject extends Base { + static operationKind = "text-object" + + wise = "characterwise" + supportCount = false // FIXME #472, #66 + selectOnce = false + selectSucceeded = false + + static register(isCommand, deriveInnerAndA, deriveInnerAndAForAllowForwarding) { + super.register(isCommand) + + if (deriveInnerAndA) { + this.generateClass(`A${this.name}`, false) + this.generateClass(`Inner${this.name}`, true) + } + + if (deriveInnerAndAForAllowForwarding) { + this.generateClass(`A${this.name}AllowForwarding`, false, true) + this.generateClass(`Inner${this.name}AllowForwarding`, true, true) + } + } + + static generateClass(klassName, inner, allowForwarding) { + const klass = class extends this { + static get name() { + return klassName + } + constructor(vimState) { + super(vimState) + this.inner = inner + if (allowForwarding != null) this.allowForwarding = allowForwarding + } + } + klass.register() + } + + isInner() { + return this.inner + } + + isA() { + return !this.inner + } + + isLinewise() { + return this.wise === "linewise" + } + + isBlockwise() { + return this.wise === "blockwise" + } + + forceWise(wise) { + return (this.wise = wise) // FIXME currently not well supported + } + + resetState() { + this.selectSucceeded = false + } + + // execute: Called from Operator::selectTarget() + // - `v i p`, is `SelectInVisualMode` operator with @target = `InnerParagraph`. + // - `d i p`, is `Delete` operator with @target = `InnerParagraph`. + execute() { + // Whennever TextObject is executed, it has @operator + if (!this.operator) throw new Error("in TextObject: Must not happen") + this.select() + } + + select() { + if (this.isMode("visual", "blockwise")) { + this.swrap.normalize(this.editor) + } + + this.countTimes(this.getCount(), ({stop}) => { + if (!this.supportCount) stop() // quick-fix for #560 + + for (const selection of this.editor.getSelections()) { + const oldRange = selection.getBufferRange() + if (this.selectTextObject(selection)) this.selectSucceeded = true + if (selection.getBufferRange().isEqual(oldRange)) stop() + if (this.selectOnce) break + } + }) + + this.editor.mergeIntersectingSelections() + // Some TextObject's wise is NOT deterministic. It has to be detected from selected range. + if (this.wise == null) this.wise = this.swrap.detectWise(this.editor) + + if (this.operator.instanceof("SelectBase")) { + if (this.selectSucceeded) { + if (this.wise === "characterwise") { + this.swrap.getSelections(this.editor).forEach($selection => $selection.saveProperties()) + for (const $selection of this.swrap.getSelections(this.editor)) { + $selection.saveProperties() + } + } else if (this.wise === "linewise") { + // When target is persistent-selection, new selection is added after selectTextObject. + // So we have to assure all selection have selction property. + // Maybe this logic can be moved to operation stack. + for (const $selection of this.swrap.getSelections(this.editor)) { + if (this.getConfig("stayOnSelectTextObject")) { + if (!$selection.hasProperties()) $selection.saveProperties() + } else { + $selection.saveProperties() + } + $selection.fixPropertyRowToRowRange() + } + } + } + + if (this.submode === "blockwise") { + for (const $selection of this.swrap.getSelections(this.editor)) { + $selection.normalize() + $selection.applyWise("blockwise") + } + } + } + } + + // Return true or false + selectTextObject(selection) { + const range = this.getRange(selection) + if (range) { + this.swrap(selection).setBufferRange(range) + return true + } else { + return false + } + } + + // to override + getRange(selection) {} +} +TextObject.register(false) + +// Section: Word +// ========================= +class Word extends TextObject { + getRange(selection) { + const point = this.getCursorPositionForSelection(selection) + const {range} = this.getWordBufferRangeAndKindAtBufferPosition(point, {wordRegex: this.wordRegex}) + return this.isA() ? expandRangeToWhiteSpaces(this.editor, range) : range + } +} +Word.register(false, true) + +class WholeWord extends Word { + wordRegex = /\S+/ +} +WholeWord.register(false, true) + +// Just include _, - +class SmartWord extends Word { + wordRegex = /[\w-]+/ +} +SmartWord.register(false, true) + +// Just include _, - +class Subword extends Word { + getRange(selection) { + this.wordRegex = selection.cursor.subwordRegExp() + return super.getRange(selection) + } +} +Subword.register(false, true) + +// Section: Pair +// ========================= +class Pair extends TextObject { + supportCount = true + allowNextLine = null + adjustInnerRange = true + pair = null + inclusive = true + + constructor(...args) { + super(...args) + if (!PairFinder) PairFinder = require("./pair-finder") + } + + isAllowNextLine() { + return this.allowNextLine != null ? this.allowNextLine : this.pair != null && this.pair[0] !== this.pair[1] + } + + adjustRange({start, end}) { + // Dirty work to feel natural for human, to behave compatible with pure Vim. + // Where this adjustment appear is in following situation. + // op-1: `ci{` replace only 2nd line + // op-2: `di{` delete only 2nd line. + // text: + // { + // aaa + // } + if (pointIsAtEndOfLine(this.editor, start)) { + start = start.traverse([1, 0]) + } + + if (getLineTextToBufferPosition(this.editor, end).match(/^\s*$/)) { + if (this.mode === "visual") { + // This is slightly innconsistent with regular Vim + // - regular Vim: select new line after EOL + // - vim-mode-plus: select to EOL(before new line) + // This is intentional since to make submode `characterwise` when auto-detect submode + // innerEnd = new Point(innerEnd.row - 1, Infinity) + end = new Point(end.row - 1, Infinity) + } else { + end = new Point(end.row, 0) + } + } + return new Range(start, end) + } + + getFinder() { + const finderName = this.pair[0] === this.pair[1] ? "QuoteFinder" : "BracketFinder" + return new PairFinder[finderName](this.editor, { + allowNextLine: this.isAllowNextLine(), + allowForwarding: this.allowForwarding, + pair: this.pair, + inclusive: this.inclusive, + }) + } + + getPairInfo(from) { + const pairInfo = this.getFinder().find(from) + if (pairInfo) { + if (this.adjustInnerRange) pairInfo.innerRange = this.adjustRange(pairInfo.innerRange) + pairInfo.targetRange = this.isInner() ? pairInfo.innerRange : pairInfo.aRange + return pairInfo + } + } + + getRange(selection) { + const originalRange = selection.getBufferRange() + let pairInfo = this.getPairInfo(this.getCursorPositionForSelection(selection)) + // When range was same, try to expand range + if (pairInfo && pairInfo.targetRange.isEqual(originalRange)) { + pairInfo = this.getPairInfo(pairInfo.aRange.end) + } + if (pairInfo) return pairInfo.targetRange + } +} +Pair.register(false) + +// Used by DeleteSurround +class APair extends Pair {} +APair.register(false) + +class AnyPair extends Pair { + allowForwarding = false + member = ["DoubleQuote", "SingleQuote", "BackTick", "CurlyBracket", "AngleBracket", "SquareBracket", "Parenthesis"] + + getRanges(selection) { + const options = {inner: this.inner, allowForwarding: this.allowForwarding, inclusive: this.inclusive} + return this.member.map(member => this.getInstance(member, options).getRange(selection)).filter(range => range) + } + + getRange(selection) { + return _.last(sortRanges(this.getRanges(selection))) + } +} +AnyPair.register(false, true) + +class AnyPairAllowForwarding extends AnyPair { + allowForwarding = true + + getRange(selection) { + const ranges = this.getRanges(selection) + const from = selection.cursor.getBufferPosition() + let [forwardingRanges, enclosingRanges] = _.partition(ranges, range => range.start.isGreaterThanOrEqual(from)) + const enclosingRange = _.last(sortRanges(enclosingRanges)) + forwardingRanges = sortRanges(forwardingRanges) + + // When enclosingRange is exists, + // We don't go across enclosingRange.end. + // So choose from ranges contained in enclosingRange. + if (enclosingRange) { + forwardingRanges = forwardingRanges.filter(range => enclosingRange.containsRange(range)) + } + + return forwardingRanges[0] || enclosingRange + } +} +AnyPairAllowForwarding.register(false, true) + +class AnyQuote extends AnyPair { + allowForwarding = true + member = ["DoubleQuote", "SingleQuote", "BackTick"] + + getRange(selection) { + const ranges = this.getRanges(selection) + // Pick range which end.colum is leftmost(mean, closed first) + if (ranges.length) return _.first(_.sortBy(ranges, r => r.end.column)) + } +} +AnyQuote.register(false, true) + +class Quote extends Pair { + allowForwarding = true +} +Quote.register(false) + +class DoubleQuote extends Quote { + pair = ['"', '"'] +} +DoubleQuote.register(false, true) + +class SingleQuote extends Quote { + pair = ["'", "'"] +} +SingleQuote.register(false, true) + +class BackTick extends Quote { + pair = ["`", "`"] +} +BackTick.register(false, true) + +class CurlyBracket extends Pair { + pair = ["{", "}"] +} +CurlyBracket.register(false, true, true) + +class SquareBracket extends Pair { + pair = ["[", "]"] +} +SquareBracket.register(false, true, true) + +class Parenthesis extends Pair { + pair = ["(", ")"] +} +Parenthesis.register(false, true, true) + +class AngleBracket extends Pair { + pair = ["<", ">"] +} +AngleBracket.register(false, true, true) + +class Tag extends Pair { + allowNextLine = true + allowForwarding = true + adjustInnerRange = false + + getTagStartPoint(from) { + let tagRange + const {pattern} = PairFinder.TagFinder + this.scanForward(pattern, {from: [from.row, 0]}, ({range, stop}) => { + if (range.containsPoint(from, true)) { + tagRange = range + stop() + } + }) + if (tagRange) return tagRange.start + } + + getFinder() { + return new PairFinder.TagFinder(this.editor, { + allowNextLine: this.isAllowNextLine(), + allowForwarding: this.allowForwarding, + inclusive: this.inclusive, + }) + } + + getPairInfo(from) { + return super.getPairInfo(this.getTagStartPoint(from) || from) + } +} +Tag.register(false, true) + +// Section: Paragraph +// ========================= +// Paragraph is defined as consecutive (non-)blank-line. +class Paragraph extends TextObject { + wise = "linewise" + supportCount = true + + findRow(fromRow, direction, fn) { + if (fn.reset) fn.reset() + let foundRow = fromRow + for (const row of getBufferRows(this.editor, {startRow: fromRow, direction})) { + if (!fn(row, direction)) break + foundRow = row + } + return foundRow + } + + findRowRangeBy(fromRow, fn) { + const startRow = this.findRow(fromRow, "previous", fn) + const endRow = this.findRow(fromRow, "next", fn) + return [startRow, endRow] + } + + getPredictFunction(fromRow, selection) { + const fromRowResult = this.editor.isBufferRowBlank(fromRow) + + if (this.isInner()) { + return (row, direction) => this.editor.isBufferRowBlank(row) === fromRowResult + } else { + const directionToExtend = selection.isReversed() ? "previous" : "next" + + let flip = false + const predict = (row, direction) => { + const result = this.editor.isBufferRowBlank(row) === fromRowResult + if (flip) { + return !result + } else { + if (!result && direction === directionToExtend) { + return (flip = true) + } + return result + } + } + predict.reset = () => (flip = false) + return predict + } + } + + getRange(selection) { + const originalRange = selection.getBufferRange() + let fromRow = this.getCursorPositionForSelection(selection).row + if (this.isMode("visual", "linewise")) { + if (selection.isReversed()) fromRow-- + else fromRow++ + fromRow = getValidVimBufferRow(this.editor, fromRow) + } + const rowRange = this.findRowRangeBy(fromRow, this.getPredictFunction(fromRow, selection)) + return selection.getBufferRange().union(this.getBufferRangeForRowRange(rowRange)) + } +} +Paragraph.register(false, true) + +class Indentation extends Paragraph { + getRange(selection) { + const fromRow = this.getCursorPositionForSelection(selection).row + const baseIndentLevel = this.editor.indentationForBufferRow(fromRow) + const rowRange = this.findRowRangeBy(fromRow, row => { + return this.editor.isBufferRowBlank(row) + ? this.isA() + : this.editor.indentationForBufferRow(row) >= baseIndentLevel + }) + return this.getBufferRangeForRowRange(rowRange) + } +} +Indentation.register(false, true) + +// Section: Comment +// ========================= +class Comment extends TextObject { + wise = "linewise" + + getRange(selection) { + const {row} = this.getCursorPositionForSelection(selection) + const rowRange = this.utils.getRowRangeForCommentAtBufferRow(this.editor, row) + if (rowRange) { + return this.getBufferRangeForRowRange(rowRange) + } + } +} +Comment.register(false, true) + +class CommentOrParagraph extends TextObject { + wise = "linewise" + + getRange(selection) { + const {inner} = this + for (const klass of ["Comment", "Paragraph"]) { + const range = this.getInstance(klass, {inner}).getRange(selection) + if (range) return range + } + } +} +CommentOrParagraph.register(false, true) + +// Section: Fold +// ========================= +class Fold extends TextObject { + wise = "linewise" + + adjustRowRange(rowRange) { + if (this.isA()) return rowRange + + let [startRow, endRow] = rowRange + if (this.editor.indentationForBufferRow(startRow) === this.editor.indentationForBufferRow(endRow)) { + endRow -= 1 + } + startRow += 1 + return [startRow, endRow] + } + + getFoldRowRangesContainsForRow(row) { + return getCodeFoldRowRangesContainesForRow(this.editor, row).reverse() + } + + getRange(selection) { + const {row} = this.getCursorPositionForSelection(selection) + const selectedRange = selection.getBufferRange() + for (const rowRange of this.getFoldRowRangesContainsForRow(row)) { + const range = this.getBufferRangeForRowRange(this.adjustRowRange(rowRange)) + + // Don't change to `if range.containsRange(selectedRange, true)` + // There is behavior diff when cursor is at beginning of line( column 0 ). + if (!selectedRange.containsRange(range)) return range + } + } +} +Fold.register(false, true) + +// NOTE: Function range determination is depending on fold. +class Function extends Fold { + // Some language don't include closing `}` into fold. + scopeNamesOmittingEndRow = ["source.go", "source.elixir"] + + isGrammarNotFoldEndRow() { + const {scopeName, packageName} = this.editor.getGrammar() + if (this.scopeNamesOmittingEndRow.includes(scopeName)) { + return true + } else { + // HACK: Rust have two package `language-rust` and `atom-language-rust` + // language-rust don't fold ending `}`, but atom-language-rust does. + return scopeName === "source.rust" && packageName === "language-rust" + } + } + + getFoldRowRangesContainsForRow(row) { + return super.getFoldRowRangesContainsForRow(row).filter(rowRange => { + return isIncludeFunctionScopeForRow(this.editor, rowRange[0]) + }) + } + + adjustRowRange(rowRange) { + let [startRow, endRow] = super.adjustRowRange(rowRange) + // NOTE: This adjustment shoud not be necessary if language-syntax is properly defined. + if (this.isA() && this.isGrammarNotFoldEndRow()) endRow += 1 + return [startRow, endRow] + } +} +Function.register(false, true) + +// Section: Other +// ========================= +class Arguments extends TextObject { + newArgInfo(argStart, arg, separator) { + const argEnd = traverseTextFromPoint(argStart, arg) + const argRange = new Range(argStart, argEnd) + + const separatorEnd = traverseTextFromPoint(argEnd, separator != null ? separator : "") + const separatorRange = new Range(argEnd, separatorEnd) + + const innerRange = argRange + const aRange = argRange.union(separatorRange) + return {argRange, separatorRange, innerRange, aRange} + } + + getArgumentsRangeForSelection(selection) { + const options = { + member: ["CurlyBracket", "SquareBracket", "Parenthesis"], + inclusive: false, + } + return this.getInstance("InnerAnyPair", options).getRange(selection) + } + + getRange(selection) { + let range = this.getArgumentsRangeForSelection(selection) + const pairRangeFound = range != null + + range = range || this.getInstance("InnerCurrentLine").getRange(selection) // fallback + if (!range) return + + range = trimRange(this.editor, range) + + const text = this.editor.getTextInBufferRange(range) + const allTokens = splitArguments(text, pairRangeFound) + + const argInfos = [] + let argStart = range.start + + // Skip starting separator + if (allTokens.length && allTokens[0].type === "separator") { + const token = allTokens.shift() + argStart = traverseTextFromPoint(argStart, token.text) + } + + while (allTokens.length) { + const token = allTokens.shift() + if (token.type === "argument") { + const nextToken = allTokens.shift() + const separator = nextToken ? nextToken.text : undefined + const argInfo = this.newArgInfo(argStart, token.text, separator) + + if (allTokens.length === 0 && argInfos.length) { + argInfo.aRange = argInfo.argRange.union(_.last(argInfos).separatorRange) + } + + argStart = argInfo.aRange.end + argInfos.push(argInfo) + } else { + throw new Error("must not happen") + } + } + + const point = this.getCursorPositionForSelection(selection) + for (const {innerRange, aRange} of argInfos) { + if (innerRange.end.isGreaterThanOrEqual(point)) { + return this.isInner() ? innerRange : aRange + } + } + } +} +Arguments.register(false, true) + +class CurrentLine extends TextObject { + getRange(selection) { + const {row} = this.getCursorPositionForSelection(selection) + const range = this.editor.bufferRangeForBufferRow(row) + return this.isA() ? range : trimRange(this.editor, range) + } +} +CurrentLine.register(false, true) + +class Entire extends TextObject { + wise = "linewise" + selectOnce = true + + getRange(selection) { + return this.editor.buffer.getRange() + } +} +Entire.register(false, true) + +class Empty extends TextObject { + selectOnce = true +} +Empty.register(false) + +class LatestChange extends TextObject { + wise = null + selectOnce = true + getRange(selection) { + const start = this.vimState.mark.get("[") + const end = this.vimState.mark.get("]") + if (start && end) { + return new Range(start, end) + } + } +} +LatestChange.register(false, true) + +class SearchMatchForward extends TextObject { + backward = false + + findMatch(fromPoint, pattern) { + if (this.mode === "visual") { + fromPoint = translatePointAndClip(this.editor, fromPoint, "forward") + } + let foundRange + this.scanForward(pattern, {from: [fromPoint.row, 0]}, ({range, stop}) => { + if (range.end.isGreaterThan(fromPoint)) { + foundRange = range + stop() + } + }) + return {range: foundRange, whichIsHead: "end"} + } + + getRange(selection) { + const pattern = this.globalState.get("lastSearchPattern") + if (!pattern) return + + const fromPoint = selection.getHeadBufferPosition() + const {range, whichIsHead} = this.findMatch(fromPoint, pattern) + if (range) { + return this.unionRangeAndDetermineReversedState(selection, range, whichIsHead) + } + } + + unionRangeAndDetermineReversedState(selection, range, whichIsHead) { + if (selection.isEmpty()) return range + + let head = range[whichIsHead] + const tail = selection.getTailBufferPosition() + + if (this.backward) { + if (tail.isLessThan(head)) head = translatePointAndClip(this.editor, head, "forward") + } else { + if (head.isLessThan(tail)) head = translatePointAndClip(this.editor, head, "backward") + } + + this.reversed = head.isLessThan(tail) + return new Range(tail, head).union(this.swrap(selection).getTailBufferRange()) + } + + selectTextObject(selection) { + const range = this.getRange(selection) + if (range) { + this.swrap(selection).setBufferRange(range, {reversed: this.reversed != null ? this.reversed : this.backward}) + return true + } + } +} +SearchMatchForward.register() + +class SearchMatchBackward extends SearchMatchForward { + backward = true + + findMatch(fromPoint, pattern) { + if (this.mode === "visual") { + fromPoint = translatePointAndClip(this.editor, fromPoint, "backward") + } + let foundRange + this.scanBackward(pattern, {from: [fromPoint.row, Infinity]}, ({range, stop}) => { + if (range.start.isLessThan(fromPoint)) { + foundRange = range + stop() + } + }) + return {range: foundRange, whichIsHead: "start"} + } +} +SearchMatchBackward.register() + +// [Limitation: won't fix]: Selected range is not submode aware. always characterwise. +// So even if original selection was vL or vB, selected range by this text-object +// is always vC range. +class PreviousSelection extends TextObject { + wise = null + selectOnce = true + + selectTextObject(selection) { + const {properties, submode} = this.vimState.previousSelection + if (properties && submode) { + this.wise = submode + this.swrap(this.editor.getLastSelection()).selectByProperties(properties) + return true + } + } +} +PreviousSelection.register() + +class PersistentSelection extends TextObject { + wise = null + selectOnce = true + + selectTextObject(selection) { + if (this.vimState.hasPersistentSelections()) { + this.persistentSelection.setSelectedBufferRanges() + return true + } + } +} +PersistentSelection.register(false, true) + +// Used only by ReplaceWithRegister and PutBefore and its' children. +class LastPastedRange extends TextObject { + wise = null + selectOnce = true + + selectTextObject(selection) { + for (selection of this.editor.getSelections()) { + const range = this.vimState.sequentialPasteManager.getPastedRangeForSelection(selection) + selection.setBufferRange(range) + } + return true + } +} +LastPastedRange.register(false) + +class VisibleArea extends TextObject { + selectOnce = true + + getRange(selection) { + // [BUG?] Need translate to shilnk top and bottom to fit actual row. + // The reason I need -2 at bottom is because of status bar? + const range = getVisibleBufferRange(this.editor) + return range.getRows() > this.editor.getRowsPerPage() ? range.translate([+1, 0], [-3, 0]) : range + } +} +VisibleArea.register(false, true) diff --git a/lib/utils.js b/lib/utils.js index 8d5384772..3b4a0597d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,4 +1,4 @@ -let fs +let fs, semver const settings = require("./settings") const {Range, Point} = require("atom") const _ = require("underscore-plus") @@ -177,10 +177,10 @@ function moveCursorToNextNonWhitespace(cursor) { function getBufferRows(editor, {startRow, direction}) { switch (direction) { case "previous": - return startRow <= 0 ? [] : getRange(startRow - 1, 0) + return startRow <= 0 ? [] : getList(startRow - 1, 0) case "next": const endRow = getVimLastBufferRow(editor) - return startRow >= endRow ? [] : getRange(startRow + 1, endRow) + return startRow >= endRow ? [] : getList(startRow + 1, endRow) } } @@ -343,14 +343,19 @@ function getLineTextToBufferPosition(editor, {row, column}, {exclusive = true} = return editor.lineTextForBufferRow(row).slice(0, exclusive ? column : column + 1) } -function getIndentLevelForBufferRow(editor, row) { - return editor.indentLevelForLine(editor.lineTextForBufferRow(row)) -} - function getCodeFoldRowRanges(editor) { - return getRange(0, editor.getLastBufferRow()) - .map(row => editor.languageMode.rowRangeForCodeFoldAtBufferRow(row)) - .filter(rowRange => rowRange != null && rowRange[0] != null && rowRange[1] != null) + if (atomVersionSatisfies(">=1.22.0-beta0")) { + return editor.tokenizedBuffer + .getFoldableRanges() + .filter(range => !editor.tokenizedBuffer.isRowCommented(range.start.row)) + .map(range => [range.start.row, range.end.row]) + } else { + const seen = {} + return getList(0, editor.getLastBufferRow()) + .map(row => editor.languageMode.rowRangeForCodeFoldAtBufferRow(row)) + .filter(rowRange => rowRange != null && rowRange[0] != null && rowRange[1] != null) + .filter(rowRange => (seen[rowRange] ? false : (seen[rowRange] = true))) + } } // Used in vmp-jasmine-increase-focus @@ -365,25 +370,13 @@ function getCodeFoldRowRangesContainesForRow(editor, bufferRow, {includeStartRow function getFoldRowRangesContainedByFoldStartsAtRow(editor, row) { if (!editor.isFoldableAtBufferRow(row)) return null - const [startRow, endRow] = editor.languageMode.rowRangeForFoldAtBufferRow(row) - - const seen = {} - return getRange(startRow, endRow) - .map(row => editor.languageMode.rowRangeForFoldAtBufferRow(row)) - .filter(rowRange => rowRange != null && rowRange[0] != null && rowRange[1] != null) - .filter(rowRange => (seen[rowRange] ? false : (seen[rowRange] = true))) -} - -function getFoldRowRanges(editor) { - const seen = {} - return getRange(0, editor.getLastBufferRow()) - .map(row => editor.languageMode.rowRangeForCodeFoldAtBufferRow(row)) - .filter(rowRange => rowRange != null && rowRange[0] != null && rowRange[1] != null) - .filter(rowRange => (seen[rowRange] ? false : (seen[rowRange] = true))) + const rowRanges = getCodeFoldRowRanges(editor) + const foldRowRange = rowRanges.find(rowRange => rowRange[0] === row) + return rowRanges.filter(rowRange => foldRowRange[0] <= rowRange[0] && foldRowRange[1] >= rowRange[1]) } function getFoldRangesWithIndent(editor) { - return getFoldRowRanges(editor).map(([startRow, endRow]) => ({ + return getCodeFoldRowRanges(editor).map(([startRow, endRow]) => ({ startRow, endRow, indent: editor.indentationForBufferRow(startRow), @@ -417,9 +410,7 @@ function getFoldInfoByKind(editor) { } function getBufferRangeForRowRange(editor, [startRow, endRow]) { - const startRange = editor.bufferRangeForBufferRow(startRow, {includeNewline: true}) - const endRange = editor.bufferRangeForBufferRow(endRow, {includeNewline: true}) - return startRange.union(endRange) + return new Range([startRow, 0], [startRow, 0]).union(editor.bufferRangeForBufferRow(endRow, {includeNewline: true})) } function getTokenizedLineForRow(editor, row) { @@ -435,10 +426,10 @@ function scanForScopeStart(editor, fromPoint, direction, fn) { let scanRows, isValidToken if (direction === "forward") { - scanRows = getRange(fromPoint.row, editor.getLastBufferRow()) + scanRows = getList(fromPoint.row, editor.getLastBufferRow()) isValidToken = ({position}) => position.isGreaterThan(fromPoint) } else if (direction === "backward") { - scanRows = getRange(fromPoint.row, 0) + scanRows = getList(fromPoint.row, 0) isValidToken = ({position}) => position.isLessThan(fromPoint) } @@ -881,6 +872,9 @@ function splitAndJoinBy(text, regex, fn) { return leadingSpaces + newText + trailingSpaces } +// Return list of argument token. +// Token is object like {text: String, type: String} +// type should be "separator" or "argument" function splitArguments(text, joinSpaceSeparatedToken = true) { const separatorChars = "\t, \r\n" const quoteChars = "\"'`" @@ -1015,9 +1009,9 @@ function adjustIndentWithKeepingLayout(editor, range) { const rowAndActualLevels = [] let minLevel - for (const row of getRange(range.start.row, range.end.row, false)) { + for (const row of getList(range.start.row, range.end.row, false)) { if (isEmptyRow(editor, row)) continue - const actualLevel = getIndentLevelForBufferRow(editor, row) + const actualLevel = editor.indentationForBufferRow(row) rowAndActualLevels.push([row, actualLevel]) minLevel = minLevel == null ? actualLevel : Math.min(minLevel, actualLevel) } @@ -1054,12 +1048,17 @@ function getTraversalForText(text) { return new Point(row, column) } +// Return startRow of fold if row was folded or just return passed row. +function getFoldStartRowForRow(editor, row) { + return editor.isFoldedAtBufferRow(row) ? getLargestFoldRangeContainsBufferRow(editor, row).start.row : row +} + // Return endRow of fold if row was folded or just return passed row. function getFoldEndRowForRow(editor, row) { return editor.isFoldedAtBufferRow(row) ? getLargestFoldRangeContainsBufferRow(editor, row).end.row : row } -function getRange(start, end, inclusive = true) { +function getList(start, end, inclusive = true) { const range = [] if (start < end) { if (inclusive) for (let i = start; i <= end; i++) range.push(i) @@ -1118,6 +1117,7 @@ function detectMinimumIndentLengthInText(text) { return minIndent === Infinity ? 0 : minIndent } +// FIXME: really, this is garbage. function normalizeIndent(text, editor, targetRange) { // text = convertTabToSpace(text, editor.getTabLength()) const mapEachLine = (text, fn) => @@ -1143,6 +1143,29 @@ function normalizeIndent(text, editor, targetRange) { return text } +function atomVersionSatisfies(condition) { + if (!semver) semver = require("semver") + return semver.satisfies(atom.appVersion, condition) +} + +function getRowRangeForCommentAtBufferRow(editor, row) { + if (atomVersionSatisfies(">=1.22.0-beta0")) { + isRowCommented = row => editor.tokenizedBuffer.isRowCommented(row) + if (!isRowCommented(row)) return + + let startRow = row + let endRow = row + + while (isRowCommented(startRow - 1)) startRow-- + while (isRowCommented(endRow + 1)) endRow++ + + return [startRow, endRow] + } else { + if (!editor.isBufferRowCommented(row)) return + return editor.languageMode.rowRangeForCommentAtBufferRow(row) || [row, row] + } +} + module.exports = { assertWithException, getAncestors, @@ -1176,14 +1199,12 @@ module.exports = { getValidVimScreenRow, moveCursorToFirstCharacterAtRow, getLineTextToBufferPosition, - getIndentLevelForBufferRow, getTextInScreenRange, moveCursorToNextNonWhitespace, isEmptyRow, getCodeFoldRowRanges, getCodeFoldRowRangesContainesForRow, getFoldRowRangesContainedByFoldStartsAtRow, - getFoldRowRanges, getFoldRangesWithIndent, getFoldInfoByKind, getBufferRangeForRowRange, @@ -1234,9 +1255,12 @@ module.exports = { adjustIndentWithKeepingLayout, rangeContainsPointWithEndExclusive, traverseTextFromPoint, + getFoldStartRowForRow, getFoldEndRowForRow, - getRange, + getList, unindent, removeIndent, normalizeIndent, + atomVersionSatisfies, + getRowRangeForCommentAtBufferRow, } diff --git a/lib/vim-state.js b/lib/vim-state.js index 40142e210..3b17f5d2d 100644 --- a/lib/vim-state.js +++ b/lib/vim-state.js @@ -1,4 +1,3 @@ -const Delegato = require("delegato") let jQuery, focusInput const {Emitter, Disposable, CompositeDisposable, Range} = require("atom") @@ -73,6 +72,9 @@ module.exports = class VimState { getCount(...args) { return this.operationStack.getCount(...args) } + hasCount(...args) { + return this.operationStack.hasCount(...args) + } setCount(...args) { this.operationStack.setCount(...args) } diff --git a/package.json b/package.json index 04fbcdfcf..2d5bec5a0 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,9 @@ }, "dependencies": { "atom-space-pen-views": "^2.1.1", - "delegato": "^1.0.0", "fs-plus": "^2.8.1", "fuzzaldrin": "^2.1.0", "season": "^6.0.0", - "semver": "^5.1.0", "underscore-plus": "1.x" }, "consumedServices": { @@ -41,7 +39,7 @@ "devDependencies": { "coffeelint": "^1.10.1", "prettier": "^1.7.0", - "semver": "^5.3.0" + "semver": "^5.4.1" }, "scripts": { "test": "apm test", diff --git a/spec/fast-activation-spec.coffee b/spec/fast-activation-spec.coffee index 83814c1c3..bfe5a2d9c 100644 --- a/spec/fast-activation-spec.coffee +++ b/spec/fast-activation-spec.coffee @@ -62,9 +62,7 @@ describe "dirty work for fast package activation", -> describe "requrie as minimum num of file as possible on startup", -> shouldRequireFilesInOrdered = [ "lib/main.js" - "lib/base.coffee" - "node_modules/delegato/lib/delegator.js" - "node_modules/mixto/lib/mixin.js" + "lib/base.js" "lib/settings.js" "lib/global-state.js" "lib/vim-state.js" @@ -110,7 +108,7 @@ describe "dirty work for fast package activation", -> "node_modules/underscore-plus/lib/underscore-plus.js" "node_modules/underscore/underscore.js" "lib/blockwise-selection.js" - "lib/motion.coffee" + "lib/motion.js" "lib/cursor-style-manager.js" ] files = shouldRequireFilesInOrdered.concat(extraShouldRequireFilesInOrdered) diff --git a/spec/motion-find-spec.coffee b/spec/motion-find-spec.coffee index 1e5a8f924..85b48b0de 100644 --- a/spec/motion-find-spec.coffee +++ b/spec/motion-find-spec.coffee @@ -371,7 +371,7 @@ describe "Motion Find", -> describe "can find one or two char", -> it "adjust to next-pre-confirmed", -> set textC: "| a ab a cd a" - keystroke "f a " + keystroke "f a" element = vimState.inputEditor.element dispatch(element, "vim-mode-plus:find-next-pre-confirmed") dispatch(element, "vim-mode-plus:find-next-pre-confirmed") diff --git a/spec/text-object-spec.coffee b/spec/text-object-spec.coffee index 58bb029af..d157ad716 100644 --- a/spec/text-object-spec.coffee +++ b/spec/text-object-spec.coffee @@ -1747,7 +1747,7 @@ describe "TextObject", -> } """ - describe 'slingle line comma separated text', -> + describe 'single line comma separated text', -> describe "change 1st arg", -> beforeEach -> set textC: "var a = func(f|irst(1, 2, 3), second(), 3)" it 'change', -> ensure 'c i ,', textC: "var a = func(|, second(), 3)"