1
0
mirror of https://github.com/chylex/IntelliJ-AceJump.git synced 2025-09-15 14:32:08 +02:00

20 Commits

Author SHA1 Message Date
2dacffd6d0 [WIP] Change plugin version 2021-02-17 03:58:09 +01:00
93b069ee21 [WIP] Remove word start jump to simplify 2021-02-17 03:58:08 +01:00
47a84da904 [WIP] Pressing Enter before typing query starts jump mode for character at caret 2021-02-06 10:59:57 +01:00
2f6a5f2a23 [WIP] Interactive modes 2021-02-06 08:49:15 +01:00
054f604f96 Add tag shadow & highlight outline, increase padding 2021-02-06 07:38:27 +01:00
97a5de919f Change default color scheme 2021-02-06 07:38:27 +01:00
9e73deef4f Swap editor shortcuts for searching line starts and indents 2021-02-06 07:20:37 +01:00
26945c6e20 Make Enter immediately tag search occurrences 2021-01-18 20:26:48 +01:00
1f8d6b8d6f Add horizontal padding to tags and tweak highlighting 2021-01-18 20:26:48 +01:00
36c5fdcb45 Remove option for rounded tag corners 2021-01-17 13:07:31 +01:00
4cd91a20e9 Remove tag visiting functionality 2021-01-17 13:07:31 +01:00
983720dbb8 Remove whole file search 2021-01-17 13:07:30 +01:00
91e285a3ff Add option to set minimum typed characters for tagging to simplify tags 2021-01-17 12:49:35 +01:00
18bb6ddb9b Fix exception when regex occurrence highlight goes beyond the end of file 2021-01-16 15:03:25 +01:00
6f950ecf95 Fix occasional conflicts between tags and search query when assigning vacant results 2021-01-16 10:49:09 +01:00
d7789be9a3 Prevent editing document while AceJump is active 2020-12-13 08:04:15 +01:00
ed0fc19cfb Enforce LF line endings & fix build.gradle 2020-12-13 07:13:11 +01:00
ad8c84be27 Make jump mode cycling wrap around & add shortcut to cycle modes in reverse 2020-12-13 06:00:41 +01:00
f44003b47a Add all regex search patterns to keymap 2020-12-13 06:00:36 +01:00
feaffb3640 Major AceJump refactoring!
See https://github.com/acejump/AceJump/issues/348 for information on what's changed and what more needs to be done.
2020-12-06 06:46:10 +01:00
63 changed files with 2947 additions and 3034 deletions

View File

@@ -1,7 +1,7 @@
[*] [*]
charset=utf-8 charset = utf-8
end_of_line=lf end_of_line = lf
insert_final_newline=false insert_final_newline = true
indent_style=space indent_style = space
indent_size=2 indent_size = 2
max_line_length=80 max_line_length = 140

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -1,14 +1,12 @@
import org.jetbrains.changelog.closure import org.jetbrains.changelog.closure
import org.jetbrains.intellij.tasks.*
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.intellij.tasks.PatchPluginXmlTask import org.jetbrains.intellij.tasks.PatchPluginXmlTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
idea apply true idea apply true
kotlin("jvm") version "1.3.72" kotlin("jvm") version "1.3.72"
id("org.jetbrains.intellij") version "0.6.4" id("org.jetbrains.intellij") version "0.6.5"
id("org.jetbrains.changelog") version "0.6.2" id("org.jetbrains.changelog") version "0.6.2"
id("com.github.ben-manes.versions") version "0.36.0"
} }
tasks { tasks {
@@ -16,22 +14,7 @@ tasks {
kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString()
kotlinOptions.freeCompilerArgs += "-progressive" kotlinOptions.freeCompilerArgs += "-progressive"
} }
named<Zip>("buildPlugin") {
dependsOn("test")
archiveFileName.set("AceJump.zip")
}
withType<RunIdeTask> {
dependsOn("test")
findProperty("luginDev")?.let { args = listOf(projectDir.absolutePath) }
}
withType<PublishTask> {
val intellijPublishToken: String? by project
token(intellijPublishToken)
}
withType<PatchPluginXmlTask> { withType<PatchPluginXmlTask> {
sinceBuild("201.6668.0") sinceBuild("201.6668.0")
changeNotes({ changelog.getLatest().toHTML() }) changeNotes({ changelog.getLatest().toHTML() })
@@ -44,10 +27,7 @@ changelog {
} }
dependencies { dependencies {
// gradle-intellij-plugin doesn't attach sources properly for Kotlin :(
compileOnly(kotlin("stdlib-jdk8")) compileOnly(kotlin("stdlib-jdk8"))
// https://github.com/promeG/TinyPinyin
implementation("com.github.promeg:tinypinyin:2.0.3")
} }
repositories { repositories {
@@ -63,4 +43,4 @@ intellij {
} }
group = "org.acejump" group = "org.acejump"
version = "3.6.4" version = "chylex"

View File

@@ -0,0 +1,109 @@
package org.acejump
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actions.EditorActionUtil
annotation class ExternalUsage
/**
* Returns an immutable version of the currently edited document.
*/
val Editor.immutableText
get() = this.document.immutableCharSequence
/**
* Returns true if [this] contains [otherText] at the specified offset.
*/
fun CharSequence.matchesAt(selfOffset: Int, otherText: String, ignoreCase: Boolean): Boolean {
return this.regionMatches(selfOffset, otherText, 0, otherText.length, ignoreCase)
}
/**
* Calculates the length of a common prefix in [this] starting at index [selfOffset], and [otherText] starting at index 0.
*/
fun CharSequence.countMatchingCharacters(selfOffset: Int, otherText: String): Int {
var i = 0
var o = selfOffset + i
while (i < otherText.length && o < this.length && otherText[i].equals(this[o], ignoreCase = true)) {
i++
o++
}
return i
}
/**
* Determines which characters form a "word" for the purposes of functions below.
*/
val Char.isWordPart
get() = this.isJavaIdentifierPart()
/**
* Finds index of the first character in a word.
*/
inline fun CharSequence.wordStart(pos: Int, isPartOfWord: (Char) -> Boolean = Char::isWordPart): Int {
var start = pos
while (start > 0 && isPartOfWord(this[start - 1])) {
--start
}
return start
}
/**
* Finds index of the last character in a word.
*/
inline fun CharSequence.wordEnd(pos: Int, isPartOfWord: (Char) -> Boolean = Char::isWordPart): Int {
var end = pos
while (end < length - 1 && isPartOfWord(this[end + 1])) {
++end
}
return end
}
/**
* Finds index of the previous "camelHumps" hump in a word.
*/
inline fun CharSequence.humpStart(pos: Int, isPartOfWord: (Char) -> Boolean = Char::isWordPart): Int {
var start = pos
while (start > 0 && isPartOfWord(this[start - 1]) && !EditorActionUtil.isHumpBound(this, start, true)) {
--start
}
return start
}
/**
* Finds index of the next "camelHumps" hump in a word.
*/
inline fun CharSequence.humpEnd(pos: Int, isPartOfWord: (Char) -> Boolean = Char::isWordPart): Int {
var end = pos
while (end < length - 1 && isPartOfWord(this[end + 1]) && !EditorActionUtil.isHumpBound(this, end + 1, false)) {
++end
}
return end
}
/**
* Finds index of the first word character following a sequence of non-word characters following the end of a word.
*/
inline fun CharSequence.wordEndPlus(pos: Int, isPartOfWord: (Char) -> Boolean = Char::isWordPart): Int {
var end = this.wordEnd(pos, isPartOfWord)
while (end < length - 1 && !isPartOfWord(this[end + 1])) {
++end
}
if (end < length - 1 && isPartOfWord(this[end + 1])) {
++end
}
return end
}

View File

@@ -0,0 +1,58 @@
package org.acejump.action
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
import org.acejump.boundaries.StandardBoundaries
import org.acejump.search.Pattern
import org.acejump.session.Session
import org.acejump.session.SessionManager
/**
* Base class for keyboard-activated overrides of existing editor actions, that have a different meaning during an AceJump [Session].
*/
sealed class AceEditorAction(private val originalHandler: EditorActionHandler) : EditorActionHandler() {
final override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return SessionManager[editor] != null || originalHandler.isEnabled(editor, caret, dataContext)
}
final override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
val session = SessionManager[editor]
if (session != null) {
run(session)
}
else if (originalHandler.isEnabled(editor, caret, dataContext)) {
originalHandler.execute(editor, caret, dataContext)
}
}
protected abstract fun run(session: Session)
// Actions
class Reset(originalHandler: EditorActionHandler) : AceEditorAction(originalHandler) {
override fun run(session: Session) = session.end()
}
class ClearSearch(originalHandler: EditorActionHandler) : AceEditorAction(originalHandler) {
override fun run(session: Session) = session.restart()
}
class TagImmediately(originalHandler: EditorActionHandler) : AceEditorAction(originalHandler) {
override fun run(session: Session) = session.tagImmediately()
}
class SearchLineStarts(originalHandler: EditorActionHandler) : AceEditorAction(originalHandler) {
override fun run(session: Session) = session.startRegexSearch(Pattern.LINE_STARTS, StandardBoundaries.VISIBLE_ON_SCREEN)
}
class SearchLineEnds(originalHandler: EditorActionHandler) : AceEditorAction(originalHandler) {
override fun run(session: Session) = session.startRegexSearch(Pattern.LINE_ENDS, StandardBoundaries.VISIBLE_ON_SCREEN)
}
class SearchLineIndents(originalHandler: EditorActionHandler) : AceEditorAction(originalHandler) {
override fun run(session: Session) = session.startRegexSearch(Pattern.LINE_INDENTS, StandardBoundaries.VISIBLE_ON_SCREEN)
}
}

View File

@@ -0,0 +1,51 @@
package org.acejump.action
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR
import com.intellij.openapi.project.DumbAwareAction
import org.acejump.boundaries.Boundaries
import org.acejump.boundaries.StandardBoundaries.*
import org.acejump.search.Pattern
import org.acejump.session.Session
import org.acejump.session.SessionManager
/**
* Base class for keyboard-activated actions that create or update an AceJump [Session].
*/
sealed class AceKeyboardAction : DumbAwareAction() {
final override fun update(action: AnActionEvent) {
action.presentation.isEnabled = action.getData(EDITOR) != null
}
final override fun actionPerformed(e: AnActionEvent) {
invoke(SessionManager.start(e.getData(EDITOR) ?: return))
}
abstract operator fun invoke(session: Session)
/**
* Generic action type that starts a regex search.
*/
abstract class BaseRegexSearchAction(private val pattern: Pattern, private val boundaries: Boundaries) : AceKeyboardAction() {
override fun invoke(session: Session) = session.startRegexSearch(pattern, boundaries)
}
/**
* Starts or ends an AceJump session.
*/
object ActivateAceJump : AceKeyboardAction() {
override fun invoke(session: Session) = session.cycleMode()
}
// @formatter:off
object StartAllWordsMode : BaseRegexSearchAction(Pattern.ALL_WORDS, VISIBLE_ON_SCREEN)
object StartAllWordsBackwardsMode : BaseRegexSearchAction(Pattern.ALL_WORDS, BEFORE_CARET.intersection(VISIBLE_ON_SCREEN))
object StartAllWordsForwardMode : BaseRegexSearchAction(Pattern.ALL_WORDS, AFTER_CARET.intersection(VISIBLE_ON_SCREEN))
object StartAllLineStartsMode : BaseRegexSearchAction(Pattern.LINE_STARTS, VISIBLE_ON_SCREEN)
object StartAllLineEndsMode : BaseRegexSearchAction(Pattern.LINE_ENDS, VISIBLE_ON_SCREEN)
object StartAllLineIndentsMode : BaseRegexSearchAction(Pattern.LINE_INDENTS, VISIBLE_ON_SCREEN)
object StartAllLineMarksMode : BaseRegexSearchAction(Pattern.LINE_ALL_MARKS, VISIBLE_ON_SCREEN)
// @formatter:on
}

View File

@@ -0,0 +1,526 @@
package org.acejump.action
import com.intellij.codeInsight.intention.actions.ShowIntentionActionsAction
import com.intellij.codeInsight.navigation.actions.GotoDeclarationAction
import com.intellij.codeInsight.navigation.actions.GotoTypeDeclarationAction
import com.intellij.find.actions.FindUsagesAction
import com.intellij.find.actions.ShowUsagesAction
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.command.UndoConfirmationPolicy
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.CaretState
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.actionSystem.DocCommandGroupId
import com.intellij.openapi.editor.actions.EditorActionUtil
import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.playback.commands.ActionCommand
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.refactoring.actions.RefactoringQuickListPopupAction
import com.intellij.refactoring.actions.RenameElementAction
import org.acejump.*
import org.acejump.search.SearchProcessor
import kotlin.math.max
/**
* Base class for actions available after typing a tag.
*/
sealed class AceTagAction {
abstract operator fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean)
abstract class BaseJumpAction : AceTagAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
val caretModel = editor.caretModel
val oldCarets = if (shiftMode) caretModel.caretsAndSelections else emptyList()
recordCaretPosition(editor)
moveCaretTo(editor, getCaretOffset(editor, searchProcessor, offset))
if (shiftMode) {
caretModel.caretsAndSelections = oldCarets + caretModel.caretsAndSelections
}
}
abstract fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int
}
abstract class BaseWordAction : BaseJumpAction() {
final override fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int {
val matchingChars = countMatchingCharacters(editor, searchProcessor, offset)
val targetOffset = offset + matchingChars
val isInsideWord = matchingChars > 0 && editor.immutableText.let { it[targetOffset - 1].isWordPart && it[targetOffset].isWordPart }
return getCaretOffset(editor, offset, targetOffset, isInsideWord)
}
abstract fun getCaretOffset(editor: Editor, queryStartOffset: Int, queryEndOffset: Int, isInsideWord: Boolean): Int
}
abstract class BaseSelectAction : AceTagAction() {
final override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
if (shiftMode) {
val caretModel = editor.caretModel
val oldCarets = caretModel.caretsAndSelections
val oldOffsetPosition = caretModel.logicalPosition
invoke(editor, searchProcessor, offset)
if (caretModel.caretsAndSelections.any { isSelectionOverlapping(oldOffsetPosition, it) }) {
oldCarets.removeAll { isSelectionOverlapping(oldOffsetPosition, it) }
}
caretModel.caretsAndSelections = oldCarets + caretModel.caretsAndSelections
}
else {
invoke(editor, searchProcessor, offset)
}
}
private fun isSelectionOverlapping(offset: LogicalPosition, oldCaret: CaretState): Boolean {
return oldCaret.caretPosition == offset || oldCaret.selectionStart == offset || oldCaret.selectionEnd == offset
}
protected abstract operator fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int)
}
abstract class BasePerCaretWriteAction(private val selector: AceTagAction) : AceTagAction() {
final override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
val oldCarets = editor.caretModel.caretsAndSelections
selector(editor, searchProcessor, offset, shiftMode = false)
val range = editor.selectionModel.let { TextRange(it.selectionStart, it.selectionEnd) }
editor.caretModel.caretsAndSelections = oldCarets
invoke(editor, range, shiftMode)
}
protected abstract operator fun invoke(editor: Editor, range: TextRange, shiftMode: Boolean)
protected fun insertAtCarets(editor: Editor, text: String) {
val document = editor.document
editor.caretModel.runForEachCaret {
if (it.hasSelection()) {
document.replaceString(it.selectionStart, it.selectionEnd, text)
fixIndents(editor, it.selectionStart, it.selectionEnd)
}
else {
document.insertString(it.offset, text)
fixIndents(editor, it.offset, it.offset + text.length)
}
}
}
private fun fixIndents(editor: Editor, startOffset: Int, endOffset: Int) {
val project = editor.project ?: return
val document = editor.document
val documentManager = PsiDocumentManager.getInstance(project)
documentManager.commitAllDocuments()
val file = documentManager.getPsiFile(document) ?: return
val text = document.charsSequence
if (startOffset > 0 && endOffset > startOffset + 1 && text[endOffset - 1] == '\n' && text[startOffset - 1] == '\n') {
CodeStyleManager.getInstance(project).adjustLineIndent(file, TextRange(startOffset, endOffset - 1))
}
else {
CodeStyleManager.getInstance(project).adjustLineIndent(file, TextRange(startOffset, endOffset))
}
}
}
private companion object {
fun countMatchingCharacters(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int {
return editor.immutableText.countMatchingCharacters(offset, searchProcessor.query.rawText)
}
fun recordCaretPosition(editor: Editor) = with(editor) {
project?.let { addCurrentPositionToHistory(it, document) }
}
fun moveCaretTo(editor: Editor, offset: Int) = with(editor) {
selectionModel.removeSelection(true)
caretModel.removeSecondaryCarets()
caretModel.moveToOffset(offset)
}
fun selectRange(editor: Editor, fromOffset: Int, toOffset: Int, cursorOffset: Int = toOffset) = with(editor) {
selectionModel.removeSelection(true)
selectionModel.setSelection(fromOffset, toOffset)
caretModel.moveToOffset(cursorOffset)
}
fun performAction(action: AnAction) {
ActionManager.getInstance().tryToExecute(action, ActionCommand.getInputEvent(null), null, null, true)
}
private fun addCurrentPositionToHistory(project: Project, document: Document) {
CommandProcessor.getInstance().executeCommand(project, {
with(IdeDocumentHistory.getInstance(project)) {
setCurrentCommandHasMoves()
includeCurrentCommandAsNavigation()
includeCurrentPlaceAsChangePlace()
}
}, "AceJumpHistoryAppender", DocCommandGroupId.noneGroupId(document), UndoConfirmationPolicy.DO_NOT_REQUEST_CONFIRMATION, document)
}
}
// Actions
/**
* On default action, places the caret at the first character of the search query.
* On shift action, adds the new caret to existing carets.
*/
object JumpToSearchStart : BaseJumpAction() {
override fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int {
return offset
}
}
/**
* On default action, places the caret at the last character of the search query.
* On shift action, adds the new caret to existing carets.
*/
object JumpToSearchEnd : BaseJumpAction() {
override fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int {
return offset + max(0, countMatchingCharacters(editor, searchProcessor, offset) - 1)
}
}
/**
* On default action, places the caret just past the last character of the search query.
* On shift action, adds the new caret to existing carets.
*/
object JumpPastSearchEnd : BaseJumpAction() {
override fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int {
return offset + countMatchingCharacters(editor, searchProcessor, offset)
}
}
/**
* On default action, places the caret at the start of a word. Word detection uses [Character.isJavaIdentifierPart] to count some special
* characters, such as underscores, as part of a word. If there is no word at the last character of the search query, then the caret is
* placed at the first character of the search query.
*
* On shift action, adds the new caret to existing carets.
*/
object JumpToWordStart : BaseWordAction() {
override fun getCaretOffset(editor: Editor, queryStartOffset: Int, queryEndOffset: Int, isInsideWord: Boolean): Int {
return if (isInsideWord)
editor.immutableText.wordStart(queryEndOffset)
else
queryStartOffset
}
}
/**
* On default action, places the caret at the end of a word. Word detection uses [Character.isJavaIdentifierPart] to count some special
* characters, such as underscores, as part of a word. If there is no word at the last character of the search query, then the caret is
* placed after the last character of the search query.
*
* On shift action, adds the new caret to existing carets.
*/
object JumpToWordEnd : BaseWordAction() {
override fun getCaretOffset(editor: Editor, queryStartOffset: Int, queryEndOffset: Int, isInsideWord: Boolean): Int {
return if (isInsideWord)
editor.immutableText.wordEnd(queryEndOffset) + 1
else
queryEndOffset
}
}
/**
* On default action, places the caret at the end of the line.
* On shift action, adds the new caret to existing carets.
*/
object JumpToLineEnd : BaseWordAction() {
override fun getCaretOffset(editor: Editor, queryStartOffset: Int, queryEndOffset: Int, isInsideWord: Boolean): Int {
val document = editor.document
val line = document.getLineNumber(queryEndOffset)
return document.getLineEndOffset(line)
}
}
/**
* On default action, selects all characters covered by the search query.
* On shift action, adds the new selection to existing selections.
*/
object SelectQuery : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
recordCaretPosition(editor)
val startOffset = JumpToSearchStart.getCaretOffset(editor, searchProcessor, offset)
val endOffset = JumpPastSearchEnd.getCaretOffset(editor, searchProcessor, offset)
selectRange(editor, startOffset, endOffset)
}
}
/**
* On default action, places the caret at the end of a word, and also selects the entire word. Word detection uses
* [Character.isJavaIdentifierPart] to count some special characters, such as underscores, as part of a word. If there is no word at the
* last character of the search query, then the caret is placed after the last character of the search query, and all text between the
* start and end of the search query is selected.
*
* On shift action, adds the new selection to existing selections.
*/
object SelectWord : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
val chars = editor.immutableText
val queryEndOffset = JumpToSearchEnd.getCaretOffset(editor, searchProcessor, offset)
if (chars[queryEndOffset].isWordPart) {
recordCaretPosition(editor)
val startOffset = JumpToWordStart.getCaretOffset(editor, offset, queryEndOffset, isInsideWord = true)
val endOffset = JumpToWordEnd.getCaretOffset(editor, offset, queryEndOffset, isInsideWord = true)
selectRange(editor, startOffset, endOffset)
}
else {
SelectQuery(editor, searchProcessor, offset, shiftMode = false)
}
}
}
/**
* On default action, places the caret at the end of a camel hump inside a word, and also selects the hump. If there is no word at the
* last character of the search query, then the search query is selected. See [SelectWord] and [SelectQuery] for details.
*
* On shift action, adds the new selection to existing selections.
*/
object SelectHump : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
val chars = editor.immutableText
val queryEndOffset = JumpToSearchEnd.getCaretOffset(editor, searchProcessor, offset)
if (chars[queryEndOffset].isWordPart) {
recordCaretPosition(editor)
val startOffset = chars.humpStart(queryEndOffset)
val endOffset = chars.humpEnd(queryEndOffset) + 1
selectRange(editor, startOffset, endOffset)
}
else {
SelectQuery(editor, searchProcessor, offset, shiftMode = false)
}
}
}
/**
* On default action, selects a word according to [SelectWord], and then extends the selection in either or both directions based
* on the characters around the word. TODO
*
* On shift action, adds the new selection to existing selections.
*/
object SelectAroundWord : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
SelectWord(editor, searchProcessor, offset, shiftMode = false)
val text = editor.immutableText
var selectionStart = editor.selectionModel.selectionStart
var selectionEnd = editor.selectionModel.selectionEnd
val indentStart = EditorActionUtil.findFirstNonSpaceOffsetOnTheLine(editor.document, editor.caretModel.logicalPosition.line)
while (selectionStart > 0 && selectionStart > indentStart && text[selectionStart - 1].let { it == ' ' || it == ',' }) {
--selectionStart
}
while (selectionEnd < text.length && text[selectionEnd].let { it == ' ' || it == ',' }) {
++selectionEnd
}
if (selectionStart > 0 && text[selectionStart - 1] == '!') {
--selectionStart
}
selectRange(editor, selectionStart, selectionEnd)
}
}
/**
* On default action, selects the line at the tag, excluding the indent.
* On shift action, adds the new selection to existing selections.
*/
object SelectLine : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
JumpToSearchEnd(editor, searchProcessor, offset, shiftMode = false)
val document = editor.document
val line = editor.caretModel.logicalPosition.line
val lineStart = EditorActionUtil.findFirstNonSpaceOffsetOnTheLine(document, line)
val lineEnd = document.getLineEndOffset(line)
selectRange(editor, lineStart, lineEnd)
}
}
/**
* On default action, places the caret at the last character of the search query, and then performs Extend Selection a set amount of
* times.
*
* On shift action, adds the new selection to existing selections.
*/
class SelectExtended(private val extendCount: Int) : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
JumpToSearchEnd(editor, searchProcessor, offset, shiftMode = false)
val action = ActionManager.getInstance().getAction(IdeActions.ACTION_EDITOR_SELECT_WORD_AT_CARET)
repeat(extendCount) {
performAction(action)
}
}
}
/**
* On default action, selects the range between the caret and a position decided by the provided [BaseJumpAction].
* On shift action, adds the new selection to existing selections.
*/
class SelectToCaret(private val jumper: BaseJumpAction) : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
val caretModel = editor.caretModel
val oldOffset = caretModel.offset
val oldSelection = editor.selectionModel.takeIf { it.hasSelection(false) }?.let { it.selectionStart..it.selectionEnd }
jumper(editor, searchProcessor, offset, shiftMode = false)
val newOffset = caretModel.offset
if (oldSelection == null) {
selectRange(editor, oldOffset, newOffset)
}
else {
selectRange(editor, minOf(oldOffset, newOffset, oldSelection.first), maxOf(oldOffset, newOffset, oldSelection.last), newOffset)
}
}
}
/**
* On default action, selects the range between [firstOffset] and a position decided by the provided [BaseJumpAction].
* On shift action, adds the new selection to existing selections.
*/
class SelectBetweenPoints(private val firstOffset: Int, private val secondOffsetJumper: BaseJumpAction) : BaseSelectAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int) {
secondOffsetJumper(editor, searchProcessor, offset, shiftMode = false)
selectRange(editor, firstOffset, editor.caretModel.offset)
}
}
/**
* On default action, selects text based on the provided [selector] action, and deletes it without moving the existing carets.
* On shift action, moves caret to the position where deletion occurred.
*/
class Delete(private val selector: AceTagAction) : AceTagAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
val oldCarets = editor.caretModel.caretsAndSelections
selector(editor, searchProcessor, offset, shiftMode = false)
WriteCommandAction.writeCommandAction(editor.project).withName("AceJump Delete").run<Throwable> {
editor.selectionModel.let { editor.document.deleteString(it.selectionStart, it.selectionEnd) }
}
if (!shiftMode) {
editor.caretModel.caretsAndSelections = oldCarets
}
}
}
/**
* Selects text based on the provided [selector] action and clones it at every existing caret, selecting the cloned text. If a caret
* has a selection, the selected text will be replaced.
*/
class CloneToCaret(selector: AceTagAction) : BasePerCaretWriteAction(selector) {
override fun invoke(editor: Editor, range: TextRange, shiftMode: Boolean) {
WriteCommandAction.writeCommandAction(editor.project).withName("AceJump Clone").run<Throwable> {
insertAtCarets(editor, editor.document.getText(range))
}
}
}
/**
* Selects text based on the provided [selector] action and clones it to every existing caret, selecting the cloned text and deleting
* the original. If a caret has a selection, the selected text will be replaced.
*/
open class MoveToCaret(selector: AceTagAction) : BasePerCaretWriteAction(selector) {
override fun invoke(editor: Editor, range: TextRange, shiftMode: Boolean) {
val difference = if (shiftMode) editor.caretModel.caretsAndSelections.sumBy {
val start = it.selectionStart?.let(editor::logicalPositionToOffset)
val end = it.selectionEnd?.let(editor::logicalPositionToOffset)
if (start == null || end == null || end > range.endOffset) 0 else range.length - (end - start)
} else 0
WriteCommandAction.writeCommandAction(editor.project).withName("AceJump Move").run<Throwable> {
val document = editor.document
val text = document.getText(range)
document.deleteString(range.startOffset, range.endOffset)
insertAtCarets(editor, text)
}
if (shiftMode) {
editor.selectionModel.removeSelection(true)
editor.caretModel.moveToOffset(range.startOffset + difference)
}
}
}
/**
* On default action, performs the Go To Declaration action, available via `Navigate | Declaration or Usages`.
* On shift action, performs the Go To Type Declaration action, available via `Navigate | Type Declaration`.
* Always places the caret at the start of the word.
*/
object GoToDeclaration : AceTagAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
JumpToWordStart(editor, searchProcessor, offset, shiftMode = false)
performAction(if (shiftMode) GotoTypeDeclarationAction() else GotoDeclarationAction())
}
}
/**
* On default action, performs the Show Usages action, available via the context menu.
* On shift action, performs the Find Usages action, available via the context menu.
* Always places the caret at the start of the word.
*/
object ShowUsages : AceTagAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
JumpToWordStart(editor, searchProcessor, offset, shiftMode = false)
performAction(if (shiftMode) FindUsagesAction() else ShowUsagesAction())
}
}
/**
* Performs the Show Context Actions action, available via the context menu or Alt+Enter.
* Always places the caret at the start of the word.
*/
object ShowIntentions : AceTagAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
JumpToWordStart(editor, searchProcessor, offset, shiftMode = false)
performAction(ShowIntentionActionsAction())
}
}
/**
* On default action, performs the Refactor This action, available via the main menu.
* On shift action, performs the Rename... refactoring, available via the main menu.
* Always places the caret at the start of the word.
*/
object Refactor : AceTagAction() {
override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean) {
JumpToWordStart(editor, searchProcessor, offset, shiftMode = false)
if (shiftMode) {
ApplicationManager.getApplication().invokeLater { performAction(RenameElementAction()) }
}
else {
performAction(RefactoringQuickListPopupAction())
}
}
}
}

View File

@@ -0,0 +1,44 @@
package org.acejump.boundaries
import com.intellij.openapi.editor.Editor
import kotlin.math.max
import kotlin.math.min
/**
* Defines a (possibly) disjoint set of editor offsets that partitions the whole editor into two groups - offsets inside the range, and
* offsets outside the range.
*/
interface Boundaries {
/**
* Returns a range of editor offsets, starting at the first offset in the boundary, and ending at the last offset in the boundary.
* May include offsets outside the boundary, for ex. when the boundary is rectangular and the file has long lines which are only
* partially visible.
*/
fun getOffsetRange(editor: Editor, cache: EditorOffsetCache = EditorOffsetCache.Uncached): IntRange
/**
* Returns whether the editor offset is included within the boundary.
*/
fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache = EditorOffsetCache.Uncached): Boolean
/**
* Creates a boundary so that an offset/range is within the boundary iff it is within both original boundaries.
*/
fun intersection(other: Boundaries): Boundaries {
if (this === other) {
return this
}
return object : Boundaries {
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache): IntRange {
val b1 = this@Boundaries.getOffsetRange(editor, cache)
val b2 = other.getOffsetRange(editor, cache)
return max(b1.first, b2.first)..min(b1.last, b2.last)
}
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean {
return this@Boundaries.isOffsetInside(editor, offset, cache) && other.isOffsetInside(editor, offset, cache)
}
}
}
}

View File

@@ -0,0 +1,91 @@
package org.acejump.boundaries
import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap
import java.awt.Point
/**
* Optionally caches slow operations of (1) retrieving the currently visible editor area, and (2) converting between editor offsets and
* pixel coordinates.
*
* To avoid unnecessary overhead, there is no automatic detection of when the editor, its contents, or its visible area has changed, so the
* cache must only be used for a single rendered frame of a single [Editor].
*/
sealed class EditorOffsetCache {
/**
* Returns the top left and bottom right points of the visible area rectangle.
*/
abstract fun visibleArea(editor: Editor): Pair<Point, Point>
/**
* Returns the editor offset at the provided pixel coordinate.
*/
abstract fun xyToOffset(editor: Editor, pos: Point): Int
/**
* Returns the top left pixel coordinate of the character at the provided editor offset.
*/
abstract fun offsetToXY(editor: Editor, offset: Int): Point
companion object {
fun new(): EditorOffsetCache {
return Cache()
}
}
private class Cache : EditorOffsetCache() {
private var visibleArea: Pair<Point, Point>? = null
private val pointToOffset = Object2IntOpenHashMap<Point>().apply { defaultReturnValue(-1) }
private val offsetToPoint = Int2ObjectOpenHashMap<Point>()
override fun visibleArea(editor: Editor): Pair<Point, Point> {
return visibleArea ?: Uncached.visibleArea(editor).also { visibleArea = it }
}
override fun xyToOffset(editor: Editor, pos: Point): Int {
val offset = pointToOffset.getInt(pos)
if (offset != -1) {
return offset
}
return Uncached.xyToOffset(editor, pos).also {
@Suppress("ReplacePutWithAssignment")
pointToOffset.put(pos, it)
}
}
override fun offsetToXY(editor: Editor, offset: Int): Point {
val pos = offsetToPoint.get(offset)
if (pos != null) {
return pos
}
return Uncached.offsetToXY(editor, offset).also {
@Suppress("ReplacePutWithAssignment")
offsetToPoint.put(offset, it)
}
}
}
object Uncached : EditorOffsetCache() {
override fun visibleArea(editor: Editor): Pair<Point, Point> {
val visibleRect = editor.scrollingModel.visibleArea
return Pair(
visibleRect.location,
visibleRect.location.apply { translate(visibleRect.width, visibleRect.height) }
)
}
override fun xyToOffset(editor: Editor, pos: Point): Int {
return editor.logicalPositionToOffset(editor.xyToLogicalPosition(pos))
}
override fun offsetToXY(editor: Editor, offset: Int): Point {
return editor.offsetToXY(offset, true, false)
}
}
}

View File

@@ -0,0 +1,55 @@
package org.acejump.boundaries
import com.intellij.openapi.editor.Editor
enum class StandardBoundaries : Boundaries {
VISIBLE_ON_SCREEN {
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache): IntRange {
val (topLeft, bottomRight) = cache.visibleArea(editor)
val startOffset = cache.xyToOffset(editor, topLeft)
val endOffset = cache.xyToOffset(editor, bottomRight)
return startOffset..endOffset
}
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean {
// If we are not using a cache, calling getOffsetRange will cause additional 1-2 pixel coordinate -> offset lookups, which is a lot
// more expensive than one lookup compared against the visible area.
// However, if we are using a cache, it's likely that the topmost and bottommost positions are already cached whereas the provided
// offset isn't, so we save a lookup for every offset outside the range.
if (cache !== EditorOffsetCache.Uncached && offset !in getOffsetRange(editor, cache)) {
return false
}
val (topLeft, bottomRight) = cache.visibleArea(editor)
val pos = cache.offsetToXY(editor, offset)
val x = pos.x
val y = pos.y
return x >= topLeft.x && y >= topLeft.y && x <= bottomRight.x && y <= bottomRight.y
}
},
BEFORE_CARET {
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache): IntRange {
return 0..(editor.caretModel.offset)
}
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean {
return offset <= editor.caretModel.offset
}
},
AFTER_CARET {
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache): IntRange {
return editor.caretModel.offset until editor.document.textLength
}
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean {
return offset >= editor.caretModel.offset
}
}
}

View File

@@ -4,112 +4,37 @@ import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.components.ServiceManager
import com.intellij.openapi.components.State import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage import com.intellij.openapi.components.Storage
import com.intellij.openapi.diagnostic.Logger import org.acejump.input.KeyLayoutCache
import org.acejump.label.Pattern
import org.acejump.label.Pattern.Companion.KeyLayout
import org.acejump.label.mapIndices
import org.acejump.search.JumpMode
import java.awt.Color
/** /**
* Ensures consistiency between [AceSettings] and [AceSettingsPanel]. * Ensures consistiency between [AceSettings] and [AceSettingsPanel]. Persists the state of the AceJump IDE settings across IDE restarts.
* Persists the state of the AceJump IDE settings across IDE restarts. * [https://www.jetbrains.org/intellij/sdk/docs/basics/persisting_state_of_components.html]
* https://www.jetbrains.org/intellij/sdk/docs/basics/persisting_state_of_components.html
*/ */
@State(name = "AceConfig", storages = [(Storage("\$APP_CONFIG\$/AceJump.xml"))]) @State(name = "AceConfig", storages = [(Storage("\$APP_CONFIG\$/AceJump.xml"))])
class AceConfig: PersistentStateComponent<AceSettings> { class AceConfig : PersistentStateComponent<AceSettings> {
private val logger = Logger.getInstance(AceConfig::class.java) private var aceSettings = AceSettings()
internal var aceSettings = AceSettings()
set(value) {
allPossibleTags = value.allowedChars.bigrams(defaultTagOrder(value.layout))
field = value
}
companion object { companion object {
val settings: AceSettings val settings
get() = ServiceManager.getService(AceConfig::class.java).aceSettings get() = ServiceManager.getService(AceConfig::class.java).aceSettings
val allowedChars: String get() = settings.allowedChars
val layout: KeyLayout get() = settings.layout val layout get() = settings.layout
val cycleMode1: JumpMode get() = settings.cycleMode1 val minQueryLength get() = settings.minQueryLength
val cycleMode2: JumpMode get() = settings.cycleMode2 val jumpModeColor get() = settings.jumpModeColor
val cycleMode3: JumpMode get() = settings.cycleMode3 val fromCaretModeColor get() = settings.fromCaretModeColor
val cycleMode4: JumpMode get() = settings.cycleMode4 val betweenPointsModeColor get() = settings.betweenPointsModeColor
val jumpModeColor: Color get() = settings.jumpModeColor val textHighlightColor get() = settings.textHighlightColor
val jumpEndModeColor: Color get() = settings.jumpEndModeColor val tagForegroundColor get() = settings.tagForegroundColor
val targetModeColor: Color get() = settings.targetModeColor val tagBackgroundColor get() = settings.tagBackgroundColor
val definitionModeColor: Color get() = settings.definitionModeColor val acceptedTagColor get() = settings.acceptedTagColor
val textHighlightColor: Color get() = settings.textHighlightColor
val tagForegroundColor: Color get() = settings.tagForegroundColor
val tagBackgroundColor: Color get() = settings.tagBackgroundColor
val roundedTagCorners: Boolean get() = settings.roundedTagCorners
val searchWholeFile: Boolean get() = settings.searchWholeFile
val supportPinyin: Boolean get() = settings.supportPinyin
private val nearby: Map<Char, Map<Char, Int>> = mapOf(
// Values are QWERTY keys sorted by physical proximity to the map key
'j' to "jikmnhuolbgypvftcdrxsezawq8796054321",
'f' to "ftgvcdryhbxseujnzawqikmolp5463728190",
'k' to "kolmjipnhubgyvftcdrxsezawq9807654321",
'd' to "drfcxsetgvzawyhbqujnikmolp4352617890",
'l' to "lkopmjinhubgyvftcdrxsezawq0987654321",
's' to "sedxzawrfcqtgvyhbujnikmolp3241567890",
'a' to "aqwszedxrfctgvyhbujnikmolp1234567890",
'h' to "hujnbgyikmvftolcdrpxsezawq6758493021",
'g' to "gyhbvftujncdrikmxseolzawpq5647382910",
'y' to "yuhgtijnbvfrokmcdeplxswzaq6758493021",
't' to "tygfruhbvcdeijnxswokmzaqpl5647382910",
'u' to "uijhyokmnbgtplvfrcdexswzaq7869504321",
'r' to "rtfdeygvcxswuhbzaqijnokmpl4536271890",
'n' to "nbhjmvgyuiklocftpxdrzseawq7685940321",
'v' to "vcfgbxdrtyhnzseujmawikqolp5463728190",
'm' to "mnjkbhuilvgyopcftxdrzseawq8970654321",
'c' to "cxdfvzsertgbawyhnqujmikolp4352617890",
'b' to "bvghncftyujmxdrikzseolawqp6574839201",
'i' to "iokjuplmnhybgtvfrcdexswzaq8970654321",
'e' to "erdswtfcxzaqygvuhbijnokmpl3425167890",
'x' to "xzsdcawerfvqtgbyhnujmikolp3241567890",
'z' to "zasxqwedcrfvtgbyhnujmikolp1234567890",
'o' to "oplkimjunhybgtvfrcdexswzaq9087654321",
'w' to "wesaqrdxztfcygvuhbijnokmpl2314567890",
'p' to "plokimjunhybgtvfrcdexswzaq0987654321",
'q' to "qwaeszrdxtfcygvuhbijnokmpl1234567890",
'1' to "1234567890qawzsexdrcftvgybhunjimkolp",
'2' to "2134567890qwasezxdrcftvgybhunjimkolp",
'3' to "3241567890weqasdrzxcftvgybhunjimkolp",
'4' to "4352617890erwsdftqazxcvgybhunjimkolp",
'5' to "5463728190rtedfgywsxcvbhuqaznjimkolp",
'6' to "6574839201tyrfghuedcvbnjiwsxmkoqazlp",
'7' to "7685940321yutghjirfvbnmkoedclpwsxqaz",
'8' to "8796054321uiyhjkotgbnmlprfvedcwsxqaz",
'9' to "9807654321ioujklpyhnmtgbrfvedcwsxqaz",
'0' to "0987654321opiklujmyhntgbrfvedcwsxqaz")
.mapValues { (_, v) -> v.mapIndices() }
private fun distance(fromKey: Char, toKey: Char) = nearby[fromKey]!![toKey]
val defaultTagOrder = defaultTagOrder(this.layout)
private fun defaultTagOrder(layout: KeyLayout) = compareBy(
{ it[0].isDigit() || it[1].isDigit() },
{ distance(it[0], it.last()) },
layout.priority { it[0] })
internal var allPossibleTags: Set<String> = settings.allowedChars.bigrams()
internal fun String.bigrams(comparator: Comparator<String> = defaultTagOrder): Set<String> {
return run { flatMap { e -> map { c -> "$e$c" } } }.sortedWith(comparator).toSet()
}
fun getCompatibleTags(query: String, matching: (String) -> Boolean) =
Pattern.filter(allPossibleTags, query).filter(matching).toSet()
} }
override fun getState() = aceSettings override fun getState(): AceSettings {
return aceSettings
}
override fun loadState(state: AceSettings) { override fun loadState(state: AceSettings) {
logger.info("Loaded AceConfig settings: $aceSettings")
aceSettings = state aceSettings = state
KeyLayoutCache.reset(state)
} }
} }

View File

@@ -1,65 +1,41 @@
package org.acejump.config package org.acejump.config
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.Configurable
import org.acejump.config.AceConfig.Companion.bigrams
import org.acejump.config.AceConfig.Companion.settings import org.acejump.config.AceConfig.Companion.settings
import org.acejump.input.KeyLayoutCache
class AceConfigurable: Configurable { class AceConfigurable : Configurable {
private val logger = Logger.getInstance(AceConfigurable::class.java) private val panel by lazy(::AceSettingsPanel)
private val panel by lazy { AceSettingsPanel() }
override fun getDisplayName() = "AceJump" override fun getDisplayName() = "AceJump"
override fun createComponent() = panel.rootPanel override fun createComponent() = panel.rootPanel
override fun isModified() = override fun isModified() =
panel.allowedChars != settings.allowedChars || panel.allowedChars != settings.allowedChars ||
panel.keyboardLayout != settings.layout || panel.keyboardLayout != settings.layout ||
panel.cycleMode1 != settings.cycleMode1 || panel.minQueryLengthInt != settings.minQueryLength ||
panel.cycleMode2 != settings.cycleMode2 ||
panel.cycleMode3 != settings.cycleMode3 ||
panel.cycleMode4 != settings.cycleMode4 ||
panel.jumpModeColor != settings.jumpModeColor || panel.jumpModeColor != settings.jumpModeColor ||
panel.jumpEndModeColor != settings.jumpEndModeColor || panel.fromCaretModeColor != settings.fromCaretModeColor ||
panel.targetModeColor != settings.targetModeColor || panel.betweenPointsModeColor != settings.betweenPointsModeColor ||
panel.definitionModeColor != settings.definitionModeColor ||
panel.textHighlightColor != settings.textHighlightColor || panel.textHighlightColor != settings.textHighlightColor ||
panel.tagForegroundColor != settings.tagForegroundColor || panel.tagForegroundColor != settings.tagForegroundColor ||
panel.tagBackgroundColor != settings.tagBackgroundColor || panel.tagBackgroundColor != settings.tagBackgroundColor ||
panel.roundedTagCorners != settings.roundedTagCorners || panel.acceptedTagColor != settings.acceptedTagColor
panel.searchWholeFile != settings.searchWholeFile ||
panel.supportPinyin != settings.supportPinyin
private fun String.distinctAlphanumerics() =
if (isEmpty()) settings.layout.text
else toList().distinct().filter(Char::isLetterOrDigit).joinToString("")
override fun apply() { override fun apply() {
panel.allowedChars.distinctAlphanumerics().let { settings.allowedChars = panel.allowedChars
settings.allowedChars = it
AceConfig.allPossibleTags = it.bigrams()
}
settings.layout = panel.keyboardLayout settings.layout = panel.keyboardLayout
settings.cycleMode1 = panel.cycleMode1 settings.minQueryLength = panel.minQueryLengthInt ?: settings.minQueryLength
settings.cycleMode2 = panel.cycleMode2 panel.jumpModeColor?.let { settings.jumpModeColor = it }
settings.cycleMode3 = panel.cycleMode3 panel.fromCaretModeColor?.let { settings.fromCaretModeColor = it }
settings.cycleMode4 = panel.cycleMode4 panel.betweenPointsModeColor?.let { settings.betweenPointsModeColor = it }
panel.jumpModeColor ?.let { settings.jumpModeColor = it } panel.textHighlightColor?.let { settings.textHighlightColor = it }
panel.jumpEndModeColor?.let { settings.jumpEndModeColor = it } panel.tagForegroundColor?.let { settings.tagForegroundColor = it }
panel.targetModeColor ?.let { settings.targetModeColor = it } panel.tagBackgroundColor?.let { settings.tagBackgroundColor = it }
panel.definitionModeColor ?.let { settings.definitionModeColor = it } panel.acceptedTagColor?.let { settings.acceptedTagColor = it }
panel.textHighlightColor ?.let { settings.textHighlightColor = it } KeyLayoutCache.reset(settings)
panel.tagForegroundColor ?.let { settings.tagForegroundColor = it }
panel.tagBackgroundColor ?.let { settings.tagBackgroundColor = it }
settings.roundedTagCorners = panel.roundedTagCorners
settings.searchWholeFile = panel.searchWholeFile
settings.supportPinyin = panel.supportPinyin
logger.info("User applied new settings: $settings")
} }
override fun reset() = panel.reset(settings) override fun reset() = panel.reset(settings)
} }

View File

@@ -1,47 +1,33 @@
package org.acejump.config package org.acejump.config
import com.intellij.util.xmlb.annotations.OptionTag import com.intellij.util.xmlb.annotations.OptionTag
import org.acejump.label.Pattern.Companion.KeyLayout import org.acejump.input.KeyLayout
import org.acejump.label.Pattern.Companion.KeyLayout.QWERTY import org.acejump.input.KeyLayout.QWERTY
import org.acejump.search.JumpMode
import java.awt.Color import java.awt.Color
/**
* Settings model located for [AceSettingsPanel].
*/
// TODO: https://github.com/acejump/AceJump/issues/215
data class AceSettings( data class AceSettings(
var layout: KeyLayout = QWERTY, var layout: KeyLayout = QWERTY,
var allowedChars: String = layout.text, var allowedChars: String = layout.allChars,
var cycleMode1: JumpMode = JumpMode.JUMP, var minQueryLength: Int = 1,
var cycleMode2: JumpMode = JumpMode.DEFINE,
var cycleMode3: JumpMode = JumpMode.TARGET,
var cycleMode4: JumpMode = JumpMode.JUMP_END,
@OptionTag("jumpModeRGB", converter = ColorConverter::class) @OptionTag("jumpModeRGB", converter = ColorConverter::class)
var jumpModeColor: Color = Color.BLUE, var jumpModeColor: Color = Color(0xFFFFFF),
@OptionTag("jumpEndModeRGB", converter = ColorConverter::class) @OptionTag("fromCaretModeRGB", converter = ColorConverter::class)
var jumpEndModeColor: Color = Color.CYAN, var fromCaretModeColor: Color = Color(0xFFB700),
@OptionTag("targetModeRGB", converter = ColorConverter::class) @OptionTag("betweenPointsModeRGB", converter = ColorConverter::class)
var targetModeColor: Color = Color.RED, var betweenPointsModeColor: Color = Color(0x6FC5FF),
@OptionTag("definitionModeRGB", converter = ColorConverter::class)
var definitionModeColor: Color = Color.MAGENTA,
@OptionTag("textHighlightRGB", converter = ColorConverter::class) @OptionTag("textHighlightRGB", converter = ColorConverter::class)
var textHighlightColor: Color = Color.GREEN, var textHighlightColor: Color = Color(0x394B58),
@OptionTag("tagForegroundRGB", converter = ColorConverter::class) @OptionTag("tagForegroundRGB", converter = ColorConverter::class)
var tagForegroundColor: Color = Color.BLACK, var tagForegroundColor: Color = Color(0xFFFFFF),
@OptionTag("tagBackgroundRGB", converter = ColorConverter::class) @OptionTag("tagBackgroundRGB", converter = ColorConverter::class)
var tagBackgroundColor: Color = Color.YELLOW, var tagBackgroundColor: Color = Color(0x008299),
var displayQuery: Boolean = false, @OptionTag("acceptedTagRGB", converter = ColorConverter::class)
var roundedTagCorners: Boolean = true, var acceptedTagColor: Color = Color(0x394B58)
var searchWholeFile: Boolean = true, )
var supportPinyin: Boolean = false
)

View File

@@ -2,16 +2,13 @@ package org.acejump.config
import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.ColorPanel import com.intellij.ui.ColorPanel
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBTextArea import com.intellij.ui.components.JBTextArea
import com.intellij.ui.components.JBTextField import com.intellij.ui.components.JBTextField
import com.intellij.ui.layout.Cell import com.intellij.ui.layout.Cell
import com.intellij.ui.layout.GrowPolicy.MEDIUM_TEXT import com.intellij.ui.layout.GrowPolicy.MEDIUM_TEXT
import com.intellij.ui.layout.GrowPolicy.SHORT_TEXT import com.intellij.ui.layout.GrowPolicy.SHORT_TEXT
import com.intellij.ui.layout.panel import com.intellij.ui.layout.panel
import org.acejump.label.Pattern.Companion.KeyLayout import org.acejump.input.KeyLayout
import org.acejump.search.JumpMode
import org.acejump.search.aceString
import java.awt.Color import java.awt.Color
import java.awt.Font import java.awt.Font
import javax.swing.JCheckBox import javax.swing.JCheckBox
@@ -23,154 +20,97 @@ import kotlin.reflect.KProperty
/** /**
* Settings view located in File | Settings | Tools | AceJump. * Settings view located in File | Settings | Tools | AceJump.
*/ */
@Suppress("UsePropertyAccessSyntax")
internal class AceSettingsPanel { internal class AceSettingsPanel {
private val tagCharsField = JBTextField() private val tagCharsField = JBTextField()
private val keyboardLayoutCombo = ComboBox<KeyLayout>() private val keyboardLayoutCombo = ComboBox<KeyLayout>()
private val keyboardLayoutArea = JBTextArea() private val keyboardLayoutArea = JBTextArea().apply { isEditable = false }
private val cycleModeCombo1 = ComboBox<JumpMode>() private val minQueryLengthField = JBTextField()
private val cycleModeCombo2 = ComboBox<JumpMode>()
private val cycleModeCombo3 = ComboBox<JumpMode>()
private val cycleModeCombo4 = ComboBox<JumpMode>()
private val jumpModeColorWheel = ColorPanel() private val jumpModeColorWheel = ColorPanel()
private val jumpEndModeColorWheel = ColorPanel() private val fromCaretModeColorWheel = ColorPanel()
private val targetModeColorWheel = ColorPanel() private val betweenPointsModeColorWheel = ColorPanel()
private val definitionModeColorWheel = ColorPanel()
private val textHighlightColorWheel = ColorPanel() private val textHighlightColorWheel = ColorPanel()
private val tagForegroundColorWheel = ColorPanel() private val tagForegroundColorWheel = ColorPanel()
private val tagBackgroundColorWheel = ColorPanel() private val tagBackgroundColorWheel = ColorPanel()
private val displayQueryCheckBox = JBCheckBox().apply { isEnabled = false } private val acceptedTagColorWheel = ColorPanel()
private val roundedTagCornersCheckBox = JBCheckBox()
private val searchWholeFileCheckBox = JBCheckBox()
private val supportPinyinCheckBox = JBCheckBox()
init { init {
tagCharsField.apply { font = Font("monospaced", font.style, font.size) } tagCharsField.apply { font = Font("monospaced", font.style, font.size) }
keyboardLayoutArea.apply { keyboardLayoutArea.apply { font = Font("monospaced", font.style, font.size) }
font = Font("monospaced", font.style, font.size) keyboardLayoutCombo.setupEnumItems { keyChars = it.rows.joinToString("\n") }
isEditable = false
}
keyboardLayoutCombo.run {
KeyLayout.values().forEach { addItem(it) }
addActionListener { keyChars = (selectedItem as KeyLayout).joinBy("\n") }
}
cycleModeCombo1.run {
JumpMode.values().forEach { addItem(it) }
addActionListener { cycleMode1 = selectedItem as JumpMode }
}
cycleModeCombo2.run {
JumpMode.values().forEach { addItem(it) }
addActionListener { cycleMode2 = selectedItem as JumpMode }
}
cycleModeCombo3.run {
JumpMode.values().forEach { addItem(it) }
addActionListener { cycleMode3 = selectedItem as JumpMode }
}
cycleModeCombo4.run {
JumpMode.values().forEach { addItem(it) }
addActionListener { cycleMode4 = selectedItem as JumpMode }
}
} }
// https://github.com/JetBrains/intellij-community/blob/master/platform/platform-impl/src/com/intellij/ui/layout/readme.md
internal val rootPanel: JPanel = panel { internal val rootPanel: JPanel = panel {
fun Cell.short(component: JComponent) = component(growPolicy = SHORT_TEXT) fun Cell.short(component: JComponent) = component(growPolicy = SHORT_TEXT)
fun Cell.medium(component: JComponent) = component(growPolicy = MEDIUM_TEXT) fun Cell.medium(component: JComponent) = component(growPolicy = MEDIUM_TEXT)
titledRow(aceString("charactersAndLayoutHeading")) { titledRow("Characters and Layout") {
row(aceString("tagCharsToBeUsedLabel")) { medium(tagCharsField) } row("Allowed characters in tags:") { medium(tagCharsField) }
row(aceString("keyboardLayoutLabel")) { short(keyboardLayoutCombo) } row("Keyboard layout:") { short(keyboardLayoutCombo) }
row(aceString("keyboardDesignLabel")) { short(keyboardLayoutArea) } row("Keyboard design:") { short(keyboardLayoutArea) }
} }
titledRow(aceString("modesHeading")) { titledRow("Behavior") {
row(aceString("cycleModeOrderLabel")) { row("Minimum typed characters (1-10):") { short(minQueryLengthField) }
cell(isVerticalFlow = false, isFullWidth = false) {
cycleModeCombo1()
cycleModeCombo2()
cycleModeCombo3()
cycleModeCombo4()
}
}
} }
titledRow(aceString("colorsHeading")) { titledRow("Colors") {
row(aceString("jumpModeColorLabel")) { short(jumpModeColorWheel) } row("Jump mode caret background:") { short(jumpModeColorWheel) }
row(aceString("jumpEndModeColorLabel")) { short(jumpEndModeColorWheel) } row("From Caret mode caret background:") { short(fromCaretModeColorWheel) }
row(aceString("targetModeColorLabel")) { short(targetModeColorWheel) } row("Between Points mode caret background:") { short(betweenPointsModeColorWheel) }
row(aceString("definitionModeColorLabel")) { short(definitionModeColorWheel) } row("Searched text background:") { short(textHighlightColorWheel) }
row(aceString("textHighlightColorLabel")) { short(textHighlightColorWheel) } row("Tag foreground:") { short(tagForegroundColorWheel) }
row(aceString("tagForegroundColorLabel")) { short(tagForegroundColorWheel) } row("Tag background:") { short(tagBackgroundColorWheel) }
row(aceString("tagBackgroundColorLabel")) { short(tagBackgroundColorWheel) } row("Accepted tag position background:") { short(acceptedTagColorWheel) }
}
titledRow(aceString("appearanceHeading")) {
row { short(displayQueryCheckBox.apply { text = aceString("displayQueryLabel") }) }
row { short(roundedTagCornersCheckBox.apply { text = aceString("roundedTagCornersLabel") }) }
}
titledRow(aceString("behaviorHeading")) {
row { short(searchWholeFileCheckBox.apply { text = aceString("searchWholeFileLabel") }) }
row { short(supportPinyinCheckBox.apply { text = aceString("supportPinyin") }) }
} }
} }
// Property-to-property delegation: https://stackoverflow.com/q/45074596/1772342 // Property-to-property delegation: https://stackoverflow.com/q/45074596/1772342
internal var allowedChars by tagCharsField internal var allowedChars by tagCharsField
internal var keyboardLayout by keyboardLayoutCombo internal var keyboardLayout by keyboardLayoutCombo
internal var keyChars by keyboardLayoutArea internal var keyChars by keyboardLayoutArea
internal var cycleMode1 by cycleModeCombo1 internal var minQueryLength by minQueryLengthField
internal var cycleMode2 by cycleModeCombo2
internal var cycleMode3 by cycleModeCombo3
internal var cycleMode4 by cycleModeCombo4
internal var jumpModeColor by jumpModeColorWheel internal var jumpModeColor by jumpModeColorWheel
internal var jumpEndModeColor by jumpEndModeColorWheel internal var fromCaretModeColor by fromCaretModeColorWheel
internal var targetModeColor by targetModeColorWheel internal var betweenPointsModeColor by betweenPointsModeColorWheel
internal var definitionModeColor by definitionModeColorWheel
internal var textHighlightColor by textHighlightColorWheel internal var textHighlightColor by textHighlightColorWheel
internal var tagForegroundColor by tagForegroundColorWheel internal var tagForegroundColor by tagForegroundColorWheel
internal var tagBackgroundColor by tagBackgroundColorWheel internal var tagBackgroundColor by tagBackgroundColorWheel
internal var displayQuery by displayQueryCheckBox internal var acceptedTagColor by acceptedTagColorWheel
internal var roundedTagCorners by roundedTagCornersCheckBox
internal var searchWholeFile by searchWholeFileCheckBox internal var minQueryLengthInt
internal var supportPinyin by supportPinyinCheckBox get() = minQueryLength.toIntOrNull()?.coerceIn(1, 10)
set(value) { minQueryLength = value.toString() }
fun reset(settings: AceSettings) { fun reset(settings: AceSettings) {
allowedChars = settings.allowedChars allowedChars = settings.allowedChars
keyboardLayout = settings.layout keyboardLayout = settings.layout
cycleMode1 = settings.cycleMode1 minQueryLength = settings.minQueryLength.toString()
cycleMode2 = settings.cycleMode2
cycleMode3 = settings.cycleMode3
cycleMode4 = settings.cycleMode4
jumpModeColor = settings.jumpModeColor jumpModeColor = settings.jumpModeColor
jumpEndModeColor = settings.jumpEndModeColor fromCaretModeColor = settings.fromCaretModeColor
targetModeColor = settings.targetModeColor betweenPointsModeColor = settings.betweenPointsModeColor
definitionModeColor = settings.definitionModeColor
textHighlightColor = settings.textHighlightColor textHighlightColor = settings.textHighlightColor
tagForegroundColor = settings.tagForegroundColor tagForegroundColor = settings.tagForegroundColor
tagBackgroundColor = settings.tagBackgroundColor tagBackgroundColor = settings.tagBackgroundColor
displayQuery = settings.displayQuery acceptedTagColor = settings.acceptedTagColor
roundedTagCorners = settings.roundedTagCorners
searchWholeFile = settings.searchWholeFile
supportPinyin = settings.supportPinyin
} }
// Removal pending support for https://youtrack.jetbrains.com/issue/KT-8575 // Removal pending support for https://youtrack.jetbrains.com/issue/KT-8575
private operator fun JTextComponent.getValue(a: AceSettingsPanel, p: KProperty<*>) = text.toLowerCase() private operator fun JTextComponent.getValue(a: AceSettingsPanel, p: KProperty<*>) = text.toLowerCase()
private operator fun JTextComponent.setValue(a: AceSettingsPanel, p: KProperty<*>, s: String) = setText(s) private operator fun JTextComponent.setValue(a: AceSettingsPanel, p: KProperty<*>, s: String) = setText(s)
private operator fun ColorPanel.getValue(a: AceSettingsPanel, p: KProperty<*>) = selectedColor private operator fun ColorPanel.getValue(a: AceSettingsPanel, p: KProperty<*>) = selectedColor
private operator fun ColorPanel.setValue(a: AceSettingsPanel, p: KProperty<*>, c: Color?) = setSelectedColor(c) private operator fun ColorPanel.setValue(a: AceSettingsPanel, p: KProperty<*>, c: Color?) = setSelectedColor(c)
private operator fun JCheckBox.getValue(a: AceSettingsPanel, p: KProperty<*>) = isSelected private operator fun JCheckBox.getValue(a: AceSettingsPanel, p: KProperty<*>) = isSelected
private operator fun JCheckBox.setValue(a: AceSettingsPanel, p: KProperty<*>, selected: Boolean) = setSelected(selected) private operator fun JCheckBox.setValue(a: AceSettingsPanel, p: KProperty<*>, selected: Boolean) = setSelected(selected)
private operator fun <T> ComboBox<T>.getValue(a: AceSettingsPanel, p: KProperty<*>) = selectedItem as T private operator fun <T> ComboBox<T>.getValue(a: AceSettingsPanel, p: KProperty<*>) = selectedItem as T
private operator fun <T> ComboBox<T>.setValue(a: AceSettingsPanel, p: KProperty<*>, item: T) = setSelectedItem(item) private operator fun <T> ComboBox<T>.setValue(a: AceSettingsPanel, p: KProperty<*>, item: T) = setSelectedItem(item)
}
private inline fun <reified T : Enum<T>> ComboBox<T>.setupEnumItems(crossinline onChanged: (T) -> Unit) {
T::class.java.enumConstants.forEach(this::addItem)
addActionListener { onChanged(selectedItem as T) }
}
}

View File

@@ -3,12 +3,12 @@ package org.acejump.config
import com.intellij.util.xmlb.Converter import com.intellij.util.xmlb.Converter
import java.awt.Color import java.awt.Color
class ColorConverter : Converter<Color>() { internal class ColorConverter : Converter<Color>() {
override fun toString(value: Color): String { override fun toString(value: Color): String {
return value.rgb.toString() return value.rgb.toString()
} }
override fun fromString(value: String): Color? { override fun fromString(value: String): Color? {
return value.toIntOrNull()?.let(::Color) return value.toIntOrNull()?.let(::Color)
} }
} }

View File

@@ -1,132 +0,0 @@
package org.acejump.control
import com.intellij.codeInsight.editorActions.SelectWordUtil
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.editor.CaretState
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.util.TextRange
import org.acejump.control.Handler.regexSearch
import org.acejump.label.Pattern
import org.acejump.label.Pattern.ALL_WORDS
import org.acejump.search.Finder
import org.acejump.search.JumpMode
import org.acejump.search.Jumper
import org.acejump.view.Boundary.*
import org.acejump.view.Model
import org.acejump.view.Model.boundaries
import org.acejump.view.Model.defaultBoundary
import org.acejump.view.Model.editor
import org.acejump.view.Model.viewBounds
/**
* Entry point for all actions. The IntelliJ Platform calls AceJump here.
*/
sealed class AceAction: DumbAwareAction() {
val logger = Logger.getInstance(javaClass)
final override fun update(action: AnActionEvent) {
action.presentation.isEnabled = action.getData(EDITOR) != null
}
final override fun actionPerformed(e: AnActionEvent) {
editor = e.getData(EDITOR) ?: return
boundaries = defaultBoundary
logger.debug { "Invoked on ${FileDocumentManager.getInstance().getFile(editor.document)?.presentableName} (${editor.document.textLength})" }
Handler.activate()
invoke()
}
abstract fun invoke()
object ActivateOrCycleMode : AceAction() {
override fun invoke() = Jumper.cycleMode()
}
object ToggleJumpMode: AceAction() {
override fun invoke() = Jumper.toggleMode(JumpMode.JUMP)
}
object ToggleJumpEndMode: AceAction() {
override fun invoke() = Jumper.toggleMode(JumpMode.JUMP_END)
}
object ToggleSelectWordMode: AceAction() {
override fun invoke() = Jumper.toggleMode(JumpMode.TARGET)
}
object ToggleDefinitionMode: AceAction() {
override fun invoke() = Jumper.toggleMode(JumpMode.DEFINE)
}
object ToggleAllLinesMode: AceAction() {
override fun invoke() = regexSearch(Pattern.LINE_MARK)
}
object ToggleAllWordsMode: AceAction() {
override fun invoke() = regexSearch(ALL_WORDS, SCREEN_BOUNDARY)
}
object ToggleAllWordsForwardMode: AceAction() {
override fun invoke() = regexSearch(ALL_WORDS, AFTER_CARET_BOUNDARY)
}
object ToggleAllWordsBackwardsMode: AceAction() {
override fun invoke() = regexSearch(ALL_WORDS, BEFORE_CARET_BOUNDARY)
}
object ActOnHighlightedWords: AceAction() {
override fun invoke() = when(JumpMode.mode) {
JumpMode.DISABLED -> {}
JumpMode.JUMP -> if (editor.caretModel.supportsMultipleCarets()) jumpToAll() else Unit
JumpMode.JUMP_END -> if (editor.caretModel.supportsMultipleCarets()) jumpToWordEnds() else Unit
JumpMode.TARGET -> if (editor.caretModel.supportsMultipleCarets()) selectAllWords() else Unit
JumpMode.DEFINE -> {}
}
private fun jumpToAll() {
val carets = Finder.allResults().map {
CaretState(editor.offsetToLogicalPosition(it), null, null)
}
if (carets.isEmpty()) return
Handler.reset()
editor.caretModel.caretsAndSelections = carets
editor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE)
}
private fun jumpToWordEnds() {
val ranges = ArrayList<TextRange>()
for (offset in Finder.allResults()) {
SelectWordUtil.addWordSelection(editor.settings.isCamelWords, Model.editorText, offset, ranges)
}
if (ranges.isEmpty()) return
Handler.reset()
editor.caretModel.caretsAndSelections = ranges.map {
CaretState(editor.offsetToLogicalPosition(it.endOffset), null, null)
}
editor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE)
}
private fun selectAllWords() {
val ranges = ArrayList<TextRange>()
for (offset in Finder.allResults()) {
SelectWordUtil.addWordSelection(editor.settings.isCamelWords, Model.editorText, offset, ranges)
}
if (ranges.isEmpty()) return
Handler.reset()
editor.caretModel.caretsAndSelections = ranges.map {
val start = editor.offsetToLogicalPosition(it.startOffset)
val end = editor.offsetToLogicalPosition(it.endOffset)
CaretState(end, start, end)
}
editor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE)
}
}
}

View File

@@ -1,22 +0,0 @@
package org.acejump.control
import com.intellij.find.EditorSearchSession
import com.intellij.find.impl.livePreview.SearchResults
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.editor.Editor
import org.acejump.search.AceFindModel
class AceSearchSession(editor: Editor, findModel: AceFindModel):
EditorSearchSession(editor, editor.project, findModel) {
init {
editor.headerComponent = component
}
override fun searchResultsUpdated(sr: SearchResults) {
super.searchResultsUpdated(sr)
}
override fun createPrimarySearchActions(): Array<AnAction> {
return super.createPrimarySearchActions()
}
}

View File

@@ -1,207 +0,0 @@
package org.acejump.control
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.IdeActions.*
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
import com.intellij.openapi.editor.actionSystem.EditorActionManager
import com.intellij.openapi.editor.actionSystem.TypedAction
import com.intellij.openapi.editor.actionSystem.TypedActionHandler
import com.intellij.openapi.editor.colors.EditorColors.CARET_COLOR
import com.intellij.openapi.editor.colors.EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.util.containers.ContainerUtil
import org.acejump.control.Scroller.restoreScroll
import org.acejump.control.Scroller.saveScroll
import org.acejump.label.Pattern
import org.acejump.label.Pattern.*
import org.acejump.label.Tagger
import org.acejump.search.*
import org.acejump.view.Boundary
import org.acejump.view.Boundary.FULL_FILE_BOUNDARY
import org.acejump.view.Canvas
import org.acejump.view.Canvas.bindCanvas
import org.acejump.view.Model
import org.acejump.view.Model.editor
import org.acejump.view.Model.setupCaret
import java.awt.Color
/**
* Handles all incoming keystrokes, IDE notifications, and UI updates.
*/
object Handler : TypedActionHandler, Resettable {
private val listeners: MutableList<AceJumpListener> = ContainerUtil.createLockFreeCopyOnWriteList()
private val logger = Logger.getInstance(Handler::class.java)
private var enabled = false
private val typingAction = TypedAction.getInstance()
private val oldHandler = typingAction.rawHandler
private val editorActionMap = mutableMapOf<String, EditorActionHandler>(
ACTION_EDITOR_BACKSPACE to makeHandler { clear() },
ACTION_EDITOR_START_NEW_LINE to makeHandler { Selector.select(false) },
ACTION_EDITOR_ENTER to makeHandler { Selector.select() },
ACTION_EDITOR_TAB to makeHandler { Scroller.scroll(true) },
ACTION_EDITOR_PREV_PARAMETER to makeHandler { Scroller.scroll(false) },
ACTION_EDITOR_MOVE_CARET_UP to makeHandler { regexSearch(CODE_INDENTS) },
ACTION_EDITOR_MOVE_CARET_LEFT to makeHandler { regexSearch(START_OF_LINE) },
ACTION_EDITOR_MOVE_CARET_RIGHT to makeHandler { regexSearch(END_OF_LINE) },
ACTION_EDITOR_MOVE_LINE_START to makeHandler { regexSearch(START_OF_LINE) },
ACTION_EDITOR_MOVE_LINE_END to makeHandler { regexSearch(END_OF_LINE) },
ACTION_EDITOR_ESCAPE to makeHandler { reset() }
)
private fun makeHandler(handle: () -> Unit) = object : EditorActionHandler() {
override fun doExecute(e: Editor, c: Caret?, dc: DataContext?) = handle()
}
@ExternalUsage
fun regexSearch(regex: Pattern, bounds: Boundary = FULL_FILE_BOUNDARY) =
Canvas.reset().also { Finder.search(regex, bounds) }
@ExternalUsage
fun customRegexSearch(regex: String, bounds: Boundary = FULL_FILE_BOUNDARY) =
Canvas.reset().also { Finder.search(regex, bounds) }
fun activate() = if (!enabled) configureEditor() else { }
private fun clear() {
applyTo(Tagger, Jumper, Finder, Canvas) { reset() }
repaintTagMarkers()
}
private fun installKeyHandler() = typingAction.setupRawHandler(this)
private fun uninstallKeyHandler() = typingAction.setupRawHandler(oldHandler)
/**
* TODO: Integrate query highlighting with [AceSearchSession]
*/
var session: AceSearchSession? = null
override fun execute(editor: Editor, key: Char, dataContext: DataContext) {
logger.info("Intercepted keystroke: $key")
Finder.query += key // This will trigger an update
// if (session == null)
// session = AceSearchSession(editor, AceFindModel(key.toString()))
// else session?.findModel!!.stringToFind += key
}
private fun configureEditor() =
editor.run {
enabled = true
saveScroll()
saveCaret()
saveColors()
runNow {
setupCaret()
bindCanvas()
swapActionHandler()
installKeyHandler()
Listener.enable()
}
}
private fun swapActionHandler() = EditorActionManager.getInstance().run {
editorActionMap.forEach { (actionId, handler) ->
editorActionMap[actionId] = getActionHandler(actionId)
setActionHandler(actionId, handler)
}
}
fun repaintTagMarkers() {
if (Tagger.tagSelected) reset() else Canvas.jumpLocations = Tagger.markers
}
fun redoFind() {
runNow {
editor.run {
restoreCanvas()
bindCanvas()
}
}
if (Finder.query.isNotEmpty() || Tagger.regex) {
Finder.search()
repaintTagMarkers()
}
}
override fun reset() {
if (enabled) Listener.disable()
session = null
// In order to get Finder.query value, listeners should
// be placed before cleanup
listeners.forEach(AceJumpListener::finished)
clear()
editor.restoreSettings()
}
@ExternalUsage
fun addAceJumpListener(listener: AceJumpListener) {
listeners += listener
}
@ExternalUsage
fun removeAceJumpListener(listener: AceJumpListener) {
listeners -= listener
}
private fun Editor.restoreSettings() = runNow {
enabled = false
swapActionHandler()
uninstallKeyHandler()
if(!isDisposed) {
restoreScroll()
restoreCanvas()
restoreCaret()
restoreColors()
}
}
private fun Editor.restoreCanvas() =
contentComponent.run {
remove(Canvas)
repaint()
}
private fun saveCaret() {
editor.settings.run {
Model.naturalBlock = isBlockCursor
Model.naturalBlink = isBlinkCaret
}
editor.caretModel.primaryCaret.visualAttributes.run {
Model.naturalCaretColor = color
?: EditorColorsManager.getInstance().globalScheme.getColor(CARET_COLOR)
?: Color.BLACK
}
}
private fun saveColors() =
EditorColorsManager.getInstance().globalScheme
.getAttributes(TEXT_SEARCH_RESULT_ATTRIBUTES)
?.backgroundColor.let { Model.naturalHighlight = it }
private fun Editor.restoreCaret() =
runNow {
settings.isBlinkCaret = Model.naturalBlink
settings.isBlockCursor = Model.naturalBlock
}
private fun Editor.restoreColors() =
runNow {
colorsScheme.run {
setAttributes(TEXT_SEARCH_RESULT_ATTRIBUTES,
getAttributes(TEXT_SEARCH_RESULT_ATTRIBUTES)
.apply { backgroundColor = Model.naturalHighlight })
}
}
interface AceJumpListener {
fun finished() {}
}
}

View File

@@ -1,92 +0,0 @@
package org.acejump.control
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.event.VisibleAreaEvent
import com.intellij.openapi.editor.event.VisibleAreaListener
import org.acejump.control.Handler.redoFind
import org.acejump.label.Tagger
import org.acejump.search.getView
import org.acejump.view.Model.editor
import org.acejump.view.Model.viewBounds
import java.awt.event.FocusEvent
import java.awt.event.FocusListener
import javax.swing.event.AncestorEvent
import javax.swing.event.AncestorListener
import kotlin.system.measureTimeMillis
/**
* Callback for GUI updates (e.g. resize, scroll). Ensures tags are painted to
* the screen whenever the view changes. Since visible tags are prioritized,
* there may be tags off-screen which have not yet been painted.
*/
internal object Listener: FocusListener, AncestorListener, VisibleAreaListener {
private val logger = Logger.getInstance(Listener::class.java)
private val redoFindTrigger = Trigger()
fun enable() =
// TODO: Do we really need `synchronized` here?
synchronized(this) {
editor.run {
component.addFocusListener(Listener)
component.addAncestorListener(Listener)
scrollingModel.addVisibleAreaListener(Listener)
}
}
fun disable() =
// TODO: Do we really need `synchronized` here?
synchronized(this) {
editor.run {
component.removeFocusListener(Listener)
component.removeAncestorListener(Listener)
scrollingModel.removeVisibleAreaListener(Listener)
}
}
/**
* This callback is very jittery. We need to delay repainting tags by a short
* duration [Trigger] in order to prevent flashing tag syndrome.
*/
override fun visibleAreaChanged(e: VisibleAreaEvent) {
var elapsed = measureTimeMillis {
if (canTagsSurviveViewResize()) {
viewBounds = editor.getView()
return
}
}
elapsed = (350L - elapsed).coerceAtLeast(0L)
redoFindTrigger(withDelay = elapsed) {
logger.info("Visible area changed")
redoFind()
}
}
private fun canTagsSurviveViewResize() =
editor.getView().run {
if (first in viewBounds && last in viewBounds) return true
else if (Tagger.full) return true
else if (Tagger.regex) return false
else !Tagger.hasMatchBetweenOldAndNewView(viewBounds, this)
}
override fun ancestorMoved(ancestorEvent: AncestorEvent?) {
if (!canTagsSurviveViewResize()) {
logger.info("Ancestor moved: $ancestorEvent")
Handler.reset()
}
}
override fun ancestorAdded(ancestorEvent: AncestorEvent?) =
logger.info("Ancestor added: $ancestorEvent").also { Handler.reset() }
override fun ancestorRemoved(ancestorEvent: AncestorEvent?) =
logger.info("Ancestor removed: $ancestorEvent").also { Handler.reset() }
override fun focusLost(focusEvent: FocusEvent?) =
logger.info("Focus lost: $focusEvent").also { Handler.reset() }
override fun focusGained(focusEvent: FocusEvent?) =
logger.info("Focus gained: $focusEvent").also { Handler.reset() }
}

View File

@@ -1,101 +0,0 @@
package org.acejump.control
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.ScrollType.CENTER
import com.intellij.openapi.editor.ScrollType.MAKE_VISIBLE
import org.acejump.label.Tagger.textMatches
import org.acejump.search.*
import org.acejump.view.Model.editor
import org.acejump.view.Model.viewBounds
/**
* Updates the editor's vertical scroll position to make search results visible.
* This will occur when the user presses TAB OR searches for text which does not
* currently appear on the screen. Once scrolling is complete, the [Listener]
* will [Trigger] an update that re-paint tags to the screen.
*/
object Scroller {
private val logger = Logger.getInstance(Scroller::class.java)
private var scrollX = 0
private var scrollY = 0
fun scroll(forward: Boolean = true): Boolean {
logger.info("Scrolling to ${if (forward) "next" else "previous"} match")
val position = if (forward) findNextPosition() else findPreviousPosition()
return if (position != null) true.also { scrollTo(position) }
else false.also { logger.info("No result found") }
}
private fun scrollTo(position: LogicalPosition) = editor.run {
scrollingModel.disableAnimation()
scrollingModel.scrollTo(position, CENTER)
val firstInView = textMatches.first { it in getView() }
val horizontalOffset = offsetToLogicalPosition(firstInView).column
if (horizontalOffset > scrollingModel.visibleArea.width)
scrollingModel.scrollHorizontally(horizontalOffset)
viewBounds = getView()
}
fun ensureCaretVisible() = editor.scrollingModel.run {
val initialArea = visibleArea
scrollToCaret(MAKE_VISIBLE)
visibleArea == initialArea
}
private fun findPreviousPosition(): LogicalPosition? {
val prevIndex = textMatches.toList().dropLastWhile { it > viewBounds.first }
.lastOrNull() ?: textMatches.lastOrNull() ?: return null
val prevLineNum = editor.offsetToLogicalPosition(prevIndex).line
// Try to capture as many previous results as will fit in a screenful
fun maximizeCoverageOfPreviousOccurrence(): LogicalPosition {
val minVisibleLine = prevLineNum - editor.getScreenHeight()
val firstVisibleIndex = editor.getLineStartOffset(minVisibleLine)
val firstIndex = textMatches.dropWhile { it < firstVisibleIndex }.first()
return editor.offsetCenter(firstIndex, prevIndex)
}
return maximizeCoverageOfPreviousOccurrence()
}
/**
* Returns the center of the next set of results that will fit in the editor.
* [textMatches] must be sorted prior to using Scroller. If [textMatches] have
* not previously been sorted, the result of calling this method is undefined.
*/
private fun findNextPosition(): LogicalPosition? {
val nextIndex = textMatches.dropWhile { it <= viewBounds.last }
.firstOrNull() ?: textMatches.firstOrNull() ?: return null
val nextLineNum = editor.offsetToLogicalPosition(nextIndex).line
// Try to capture as many subsequent results as will fit in a screenful
fun maximizeCoverageOfNextOccurrence(): LogicalPosition {
val maxVisibleLine = nextLineNum + editor.getScreenHeight()
val lastVisibleIndex = editor.getLineEndOffset(maxVisibleLine, true)
val lastIndex = textMatches.toList().dropLastWhile { it > lastVisibleIndex }.last()
return editor.offsetCenter(nextIndex, lastIndex)
}
return maximizeCoverageOfNextOccurrence()
}
fun Editor.saveScroll() {
scrollX = scrollingModel.horizontalScrollOffset
scrollY = scrollingModel.verticalScrollOffset
}
fun Editor.restoreScroll() {
if (caretModel.offset !in getView()) {
scrollingModel.scrollVertically(scrollY)
scrollingModel.scrollHorizontally(scrollX)
}
}
}

View File

@@ -1,31 +0,0 @@
package org.acejump.control
import org.acejump.search.Finder
import org.acejump.search.Jumper
import org.acejump.view.Model.caretOffset
/**
* Supports cyclical selection of tags using the ENTER/SHIFT+ENTER keys.
*
* @see [Handler.editorActionMap]
*/
object Selector {
fun select(forward: Boolean = true) {
val matches = nearestVisibleMatches(forward)
matches.ifEmpty { return }
Jumper.jumpTo(matches.first(), false)
val wasAlreadyVisible = Scroller.ensureCaretVisible()
if (matches.size == 1 && wasAlreadyVisible) Handler.reset()
}
fun nearestVisibleMatches(forward: Boolean = true): List<Int> {
val caretOffset = caretOffset
return Finder.visibleResults().sortedWith(compareBy(
{ it == caretOffset },
{ (it <= caretOffset) == forward },
{ if (forward) it - caretOffset else caretOffset - it }
))
}
}

View File

@@ -1,41 +0,0 @@
package org.acejump.control
import com.intellij.openapi.diagnostic.Logger
import org.acejump.search.runLater
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
/**
* Timer for triggering events with a designated delay.
*/
class Trigger {
private companion object {
private val executor = Executors.newSingleThreadScheduledExecutor()
}
private val logger = Logger.getInstance(Trigger::class.java)
private var task: Future<*>? = null
/**
* Can be called multiple times inside [delay], but doing so will reset the
* timer, delaying the [event] from occurring by [withDelay] milliseconds.
*/
@Synchronized
operator fun invoke(withDelay: Long, event: () -> Unit = {}) {
task?.cancel(true)
task = executor.schedule({
runLater {
try {
event()
} catch (e: Exception) {
logger.error("Exception occurred while triggering event!", e)
} finally {
task = null
}
}
}, withDelay, TimeUnit.MILLISECONDS)
}
}

View File

@@ -0,0 +1,38 @@
package org.acejump.input
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.TypedAction
import com.intellij.openapi.editor.actionSystem.TypedActionHandler
/**
* If at least one session exists, this listener redirects all characters, typed in [Editor]s with attached sessions, to the appropriate
* sessions' own handlers.
*/
internal object EditorKeyListener : TypedActionHandler {
private val action = TypedAction.getInstance()
private val attached = mutableMapOf<Editor, TypedActionHandler>()
private var originalHandler: TypedActionHandler? = null
override fun execute(editor: Editor, charTyped: Char, dataContext: DataContext) {
(attached[editor] ?: originalHandler ?: return).execute(editor, charTyped, dataContext)
}
fun attach(editor: Editor, callback: TypedActionHandler) {
if (attached.isEmpty()) {
originalHandler = action.rawHandler
action.setupRawHandler(this)
}
attached[editor] = callback
}
fun detach(editor: Editor) {
attached.remove(editor)
if (attached.isEmpty()) {
originalHandler?.let(action::setupRawHandler)
originalHandler = null
}
}
}

View File

@@ -0,0 +1,24 @@
package org.acejump.input
/**
* Defines common keyboard layouts. Each layout has a key priority order, based on each key's distance from the home row and how
* ergonomically difficult they are to press.
*/
@Suppress("unused")
enum class KeyLayout(internal val rows: Array<String>, priority: String) {
COLEMK(arrayOf("1234567890", "qwfpgjluy", "arstdhneio", "zxcvbkm"), priority = "tndhseriaovkcmbxzgjplfuwyq5849673210"),
WORKMN(arrayOf("1234567890", "qdrwbjfup", "ashtgyneoi", "zxmcvkl"), priority = "tnhegysoaiclvkmxzwfrubjdpq5849673210"),
DVORAK(arrayOf("1234567890", "pyfgcrl", "aoeuidhtns", "qjkxbmwvz"), priority = "uhetidonasxkbjmqwvzgfycprl5849673210"),
QWERTY(arrayOf("1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"), priority = "fjghdkslavncmbxzrutyeiwoqp5849673210"),
QWERTZ(arrayOf("1234567890", "qwertzuiop", "asdfghjkl", "yxcvbnm"), priority = "fjghdkslavncmbxyrutzeiwoqp5849673210"),
QGMLWY(arrayOf("1234567890", "qgmlwyfub", "dstnriaeoh", "zxcvjkp"), priority = "naterisodhvkcpjxzlfmuwygbq5849673210"),
QGMLWB(arrayOf("1234567890", "qgmlwbyuv", "dstnriaeoh", "zxcfjkp"), priority = "naterisodhfkcpjxzlymuwbgvq5849673210"),
NORMAN(arrayOf("1234567890", "qwdfkjurl", "asetgynioh", "zxcvbpm"), priority = "tneigysoahbvpcmxzjkufrdlwq5849673210");
internal val allChars = rows.joinToString("").toCharArray().apply(CharArray::sort).joinToString("")
internal val allPriorities = priority.mapIndexed { index, char -> char to index }.toMap()
internal inline fun priority(crossinline tagToChar: (String) -> Char): (String) -> Int? {
return { allPriorities[tagToChar(it)] }
}
}

View File

@@ -0,0 +1,93 @@
package org.acejump.input
import org.acejump.config.AceSettings
/**
* Stores data specific to the selected keyboard layout. We want to assign tags with easily reachable keys first, and ideally have tags
* with repeated keys (ex. FF, JJ) or adjacent keys (ex. GH, UJ).
*/
internal object KeyLayoutCache {
/**
* Stores keys ordered by proximity to other keys for the QWERTY layout.
* TODO: Support more layouts, perhaps generate automatically.
*/
private val qwertyCharacterDistances = mapOf(
'j' to "jikmnhuolbgypvftcdrxsezawq8796054321",
'f' to "ftgvcdryhbxseujnzawqikmolp5463728190",
'k' to "kolmjipnhubgyvftcdrxsezawq9807654321",
'd' to "drfcxsetgvzawyhbqujnikmolp4352617890",
'l' to "lkopmjinhubgyvftcdrxsezawq0987654321",
's' to "sedxzawrfcqtgvyhbujnikmolp3241567890",
'a' to "aqwszedxrfctgvyhbujnikmolp1234567890",
'h' to "hujnbgyikmvftolcdrpxsezawq6758493021",
'g' to "gyhbvftujncdrikmxseolzawpq5647382910",
'y' to "yuhgtijnbvfrokmcdeplxswzaq6758493021",
't' to "tygfruhbvcdeijnxswokmzaqpl5647382910",
'u' to "uijhyokmnbgtplvfrcdexswzaq7869504321",
'r' to "rtfdeygvcxswuhbzaqijnokmpl4536271890",
'n' to "nbhjmvgyuiklocftpxdrzseawq7685940321",
'v' to "vcfgbxdrtyhnzseujmawikqolp5463728190",
'm' to "mnjkbhuilvgyopcftxdrzseawq8970654321",
'c' to "cxdfvzsertgbawyhnqujmikolp4352617890",
'b' to "bvghncftyujmxdrikzseolawqp6574839201",
'i' to "iokjuplmnhybgtvfrcdexswzaq8970654321",
'e' to "erdswtfcxzaqygvuhbijnokmpl3425167890",
'x' to "xzsdcawerfvqtgbyhnujmikolp3241567890",
'z' to "zasxqwedcrfvtgbyhnujmikolp1234567890",
'o' to "oplkimjunhybgtvfrcdexswzaq9087654321",
'w' to "wesaqrdxztfcygvuhbijnokmpl2314567890",
'p' to "plokimjunhybgtvfrcdexswzaq0987654321",
'q' to "qwaeszrdxtfcygvuhbijnokmpl1234567890",
'1' to "1234567890qawzsexdrcftvgybhunjimkolp",
'2' to "2134567890qwasezxdrcftvgybhunjimkolp",
'3' to "3241567890weqasdrzxcftvgybhunjimkolp",
'4' to "4352617890erwsdftqazxcvgybhunjimkolp",
'5' to "5463728190rtedfgywsxcvbhuqaznjimkolp",
'6' to "6574839201tyrfghuedcvbnjiwsxmkoqazlp",
'7' to "7685940321yutghjirfvbnmkoedclpwsxqaz",
'8' to "8796054321uiyhjkotgbnmlprfvedcwsxqaz",
'9' to "9807654321ioujklpyhnmtgbrfvedcwsxqaz",
'0' to "0987654321opiklujmyhntgbrfvedcwsxqaz").mapValues { (_, v) -> v.mapIndexed { index, char -> char to index }.toMap() }
/**
* Sorts tags according to current keyboard layout settings, and some predefined rules that force tags with digits, and tags with two
* keys far apart, to be sorted after other (easier to type) tags.
*/
lateinit var tagOrder: Comparator<String>
private set
/**
* Returns all possible two key tags, pre-sorted according to [tagOrder].
*/
lateinit var allPossibleTags: List<String>
private set
/**
* Called before any lazily initialized properties are used, to ensure that they are initialized even if the settings are missing.
*/
fun ensureInitialized(settings: AceSettings) {
if (!::tagOrder.isInitialized) {
reset(settings)
}
}
/**
* Re-initializes cached data according to updated settings.
*/
fun reset(settings: AceSettings) {
tagOrder = compareBy(
{ it[0].isDigit() || it[1].isDigit() },
{ qwertyCharacterDistances.getValue(it[0]).getValue(it[1]) },
settings.layout.priority { it[0] }
)
val allPossibleChars = settings.allowedChars
.toCharArray()
.filter(Char::isLetterOrDigit)
.distinct()
.joinToString("")
.ifEmpty(settings.layout::allChars)
allPossibleTags = allPossibleChars.flatMap { a -> allPossibleChars.map { b -> "$a$b".intern() } }.sortedWith(tagOrder)
}
}

View File

@@ -1,72 +0,0 @@
package org.acejump.label
import org.acejump.config.AceConfig
/**
* Patterns related to key priority, separation, and regexps for line mode.
*/
enum class Pattern(val string: String) {
END_OF_LINE("\\n|\\Z"),
START_OF_LINE("^.|^\\n"),
CODE_INDENTS("[^\\s].*|^\\n"),
// START_OF_LINE("^[^\\n]{2,}|^\\n"),
// CODE_INDENTS("[^\\s][^\\n]{2,}|^\\n"),
LINE_MARK(END_OF_LINE.string + "|" +
START_OF_LINE.string + "|" +
CODE_INDENTS.string),
ALL_WORDS("(?<=[^a-zA-Z0-9_]|\\A)[a-zA-Z0-9_]");
companion object {
val NUM_TAGS: Int
get() = NUM_CHARS * NUM_CHARS
val NUM_CHARS: Int
get() = AceConfig.allowedChars.length
fun filter(bigrams: Set<String>, query: String) =
bigrams.filter { !query.endsWith(it[0]) }
/**
* Sorts available tags by key distance. Tags which are ergonomically easier
* to reach will be assigned first. We would prefer to use tags that contain
* repeated keys (ex. FF, JJ), and use tags that contain physically adjacent
* keys (ex. 12, 21) to keys that are located further apart on the keyboard.
*/
enum class KeyLayout(vararg val rows: String) {
COLEMK("1234567890", "qwfpgjluy", "arstdhneio", "zxcvbkm"),
WORKMN("1234567890", "qdrwbjfup", "ashtgyneoi", "zxmcvkl"),
DVORAK("1234567890", "pyfgcrl", "aoeuidhtns", "qjkxbmwvz"),
QWERTY("1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"),
QWERTZ("1234567890", "qwertzuiop", "asdfghjkl", "yxcvbnm"),
QGMLWY("1234567890", "qgmlwyfub", "dstnriaeoh", "zxcvjkp"),
QGMLWB("1234567890", "qgmlwbyuv", "dstnriaeoh", "zxcfjkp"),
NORMAN("1234567890", "qwdfkjurl", "asetgynioh", "zxcvbpm");
private val priority by lazy {
when (this) {
QWERTY -> "fjghdkslavncmbxzrutyeiwoqp5849673210"
QWERTZ -> "fjghdkslavncmbxyrutzeiwoqp5849673210"
COLEMK -> "tndhseriaovkcmbxzgjplfuwyq5849673210"
DVORAK -> "uhetidonasxkbjmqwvzgfycprl5849673210"
NORMAN -> "tneigysoahbvpcmxzjkufrdlwq5849673210"
QGMLWY -> "naterisodhvkcpjxzlfmuwygbq5849673210"
QGMLWB -> "naterisodhfkcpjxzlymuwbgvq5849673210"
WORKMN -> "tnhegysoaiclvkmxzwfrubjdpq5849673210"
}.mapIndices()
}
val text by lazy {
joinBy("").toCharArray().sortedBy { priority[it] }.joinToString("")
}
fun priority(tagToChar: (String) -> Char): (String) -> Int? =
{ priority[tagToChar(it)] }
fun joinBy(separator: CharSequence) = rows.joinToString(separator)
}
}
}
fun String.mapIndices() = mapIndexed { i, c -> c to i }.toMap()

View File

@@ -1,210 +0,0 @@
package org.acejump.label
import com.intellij.openapi.diagnostic.Logger
import it.unimi.dsi.fastutil.ints.*
import it.unimi.dsi.fastutil.objects.Object2IntMap
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap
import org.acejump.config.AceConfig
import org.acejump.search.wordBoundsPlus
import kotlin.math.max
import kotlin.system.measureTimeMillis
/*
* Solves the Tag Assignment Problem. The tag assignment problem can be stated
* thusly: Given a set of indices I in document d, and a set of tags T, find a
* bijection f: T*⊂T → I*⊂I s.t. d[i..k] + t ∉ d[i'..(k + |t|)], ∀ i' ∈ I\{i},
* ∀ k ∈ (i, |d|-|t|], where t ∈ T, i ∈ I. Maximize |I*|. This can be relaxed
* to t=t[0] and ∀ k ∈ (i, i+K] for some fixed K, in most natural documents.
*
* More concretely, tags are typically two-character strings containing alpha-
* numeric symbols. Documents are plaintext files. Indices are produced by a
* search query of length N, i.e. the preceding N characters of every index i in
* document d are identical. For characters proceeding d[i], all bets are off.
* We can assume that P(d[i]|d[i-1]) has some structure for d~D. Ultimately, we
* want a fast algorithm which maximizes the number of tagged document indices.
*
* Tags are used by the typist to select indices within a document. To select an
* index, the typist starts by activating AceJump and searching for a character.
* As soon as the first character is received, we begin to scan the document for
* matching locations and assign as many valid tags as possible. When subsequent
* characters are received, we refine the search results to match either:
*
* 1.) The plaintext query alone, or
* 2.) The concatenation of plaintext query and partial tag
*
* The constraint in paragraph no. 1 tries to impose the following criteria:
*
* 1.) All valid key sequences will lead to a unique location in the document
* 2.) All indices in the document will be reachable by a short key sequence
*
* If there is an insufficient number of two-character tags to cover every index
* (which typically occurs when the user searches for a common character within
* a long document), then we attempt to maximize the number of tags assigned to
* document indices. The key is, all tags must be assigned as soon as possible,
* i.e. as soon as the first character is received or whenever the user ceases
* typing (at the very latest). Once assigned, a visible tag must never change
* at any time during the selection process, so as not to confuse the user.
*/
class Solver(val text: String,
val query: String,
val results: Collection<Int>,
val availableTags: Set<String>,
val viewBounds: IntRange = 0..text.length) {
private val logger = Logger.getInstance(Solver::class.java)
private var newTags: Object2IntMap<String> =
Object2IntOpenHashMap(Pattern.NUM_TAGS)
private val newTagIndices: IntSet = IntOpenHashSet()
private var strings: Set<String> =
HashSet(results.flatMap { getWordFragments(it) })
/**
* Iterates through remaining available tags, until we find one matching our
* criteria, i.e. does not collide with an existing tag or plaintext string.
*
* @param tag the tag string which is to be assigned
* @param sites potential indices where a tag may be assigned
*/
private fun tryToAssignTag(tag: String, sites: IntArray): Boolean {
if (newTags.containsKey(tag)) return false
val index = sites.firstOrNull { it !in newTagIndices } ?: return false
@Suppress("ReplacePutWithAssignment")
newTags.put(tag, index)
newTagIndices.add(index)
return true
}
/**
* Ensures tag conservation. Most tags prefer to occupy certain sites during
* assignment, since not all tags may be assigned to all sites. Therefore, we
* must spend our tag "budget" wisely, in order to cover the most sites with
* the tags we have at our disposal. We should consider the "most restrictive"
* tags first, since they have the least chance of being available as further
* sites are assigned.
*
* Tags which are compatible with the fewest sites should have preference for
* first assignment. Here we ensure that scarce tags are prioritized for their
* subsequent binding to available sites.
*
* @see isCompatibleWithTagChar This defines how tags may be assigned to sites
*/
private val tagOrder = AceConfig.defaultTagOrder
.thenComparingInt { eligibleSitesByTag.getValue(it).size }
.thenBy(AceConfig.layout.priority { it.last() })
/**
* Sorts jump targets to determine which positions get first choice for tags,
* by taking into account the structure of the surrounding text. For example,
* if the jump target is the first letter in a word, it is advantageous to
* prioritize this location (in case we run out of tags), since the typist is
* more likely to target words by their leading character.
*/
private val siteOrder: IntComparator = IntComparator { a, b ->
val aInBounds = a in viewBounds
val bInBounds = b in viewBounds
if (aInBounds != bInBounds) {
// Sites in immediate view should come first
return@IntComparator if (aInBounds) -1 else 1
}
val aIsNotFirstLetter = text[max(0, a - 1)].isLetterOrDigit()
val bIsNotFirstLetter = text[max(0, b - 1)].isLetterOrDigit()
if (aIsNotFirstLetter != bIsNotFirstLetter) {
// Ensure that the first letter of a word is prioritized for tagging
return@IntComparator if (bIsNotFirstLetter) -1 else 1
}
when {
a < b -> -1
a > b -> 1
else -> 0
}
}
private val eligibleSitesByTag: MutableMap<String, IntList> = HashMap(100)
/**
* Maps tags to search results according to the following constraints.
*
* 1. A tag's first letter must not match any letters of the covered word.
* 2. Once assigned, a tag must never change until it has been selected. *A.
*
* Tags *should* have the following properties:
*
* A. Should be as short as possible. A tag may be "compacted" later.
* B. Should prefer keys that are physically closer on a QWERTY keyboard.
*
* @return A list of all tags and their corresponding indices
*/
fun map(): Map<String, Int> {
var totalAssigned = 0
var timeAssigned = 0L
val timeElapsed = measureTimeMillis {
val tagsByFirstLetter = availableTags.groupBy { it[0] }
for (site in results) {
for (tag in tagsByFirstLetter.getTagsCompatibleWith(site)) {
eligibleSitesByTag.getOrPut(tag){ IntArrayList(10) }.add(site)
}
}
val sortedTags = eligibleSitesByTag.keys.toMutableList().apply {
sortWith(tagOrder)
}
val matchingSites = HashMap<IntList, IntArray>()
val matchingSitesAsArrays = HashMap<String, IntArray>()
for ((key, value) in eligibleSitesByTag.entries) {
matchingSitesAsArrays[key] = matchingSites.getOrPut(value){
value.toIntArray().apply { IntArrays.mergeSort(this, siteOrder) }
}
}
timeAssigned = measureTimeMillis {
for (tag in sortedTags) {
if (totalAssigned == results.size) break
if (tryToAssignTag(tag, matchingSitesAsArrays.getValue(tag)))
totalAssigned++
}
}
}
logger.run {
info("results size: ${results.size}")
info("newTags size: ${newTags.size}")
info("Time elapsed: $timeElapsed ms")
info("Total assign: $totalAssigned")
info("Completed in: $timeAssigned ms")
}
return newTags
}
private fun Map<Char, List<String>>.getTagsCompatibleWith(site: Int) =
entries.flatMap { (firstLetter, tags) ->
if (site isCompatibleWithTagChar firstLetter) tags else emptyList()
}
/**
* Returns true IFF the tag, when inserted at any position in the word, could
* match an existing substring elsewhere in the editor text. We should never
* assign a tag which can be partly completed by typing plaintext.
*/
private infix fun Int.isCompatibleWithTagChar(char: Char) =
getWordFragments(this).map { it + char }.none { it in strings }
private fun getWordFragments(site: Int): List<String> {
val left = max(0, site + query.length - 1)
val right = text.wordBoundsPlus(site).second
val lowercase = text.substring(left, right).toLowerCase()
return (0..(right - left)).map { lowercase.substring(0, it) }
}
}

View File

@@ -1,319 +0,0 @@
package org.acejump.label
import com.google.common.collect.BiMap
import com.google.common.collect.HashBiMap
import com.intellij.openapi.diagnostic.Logger
import org.acejump.config.AceConfig
import org.acejump.control.Scroller
import org.acejump.search.AceFindModel
import org.acejump.search.Finder
import org.acejump.search.Jumper
import org.acejump.search.Resettable
import org.acejump.search.canIndicesBeSimultaneouslyVisible
import org.acejump.search.getFeasibleRegion
import org.acejump.search.runNow
import org.acejump.view.Marker
import org.acejump.view.Model.editor
import org.acejump.view.Model.editorText
import org.acejump.view.Model.viewBounds
import java.util.*
import kotlin.collections.Iterable
import kotlin.collections.List
import kotlin.collections.Map
import kotlin.collections.Set
import kotlin.collections.all
import kotlin.collections.any
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.contains
import kotlin.collections.emptyList
import kotlin.collections.filter
import kotlin.collections.firstOrNull
import kotlin.collections.isNotEmpty
import kotlin.collections.iterator
import kotlin.collections.joinToString
import kotlin.collections.lastOrNull
import kotlin.collections.map
import kotlin.collections.mapKeysTo
import kotlin.collections.none
import kotlin.collections.partition
import kotlin.collections.plus
import kotlin.collections.sortedSetOf
import kotlin.collections.sortedWith
import kotlin.collections.toMap
import kotlin.collections.toSortedSet
import kotlin.collections.zip
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
/**
* The [Tagger] works with [Finder] to assign selectable tags to search results
* in the editor. These tags may be selected by typing their label at any point
* during the search. Since there is no explicit signal to begin selecting a tag
* when AceJump is active, we must infer when a tag is being selected. We do so
* by carefully assigning tags to search results so that every search result can
* be expanded indefinitely and selected unambiguously any time the user wishes.
*
* To do so, we must solve a tag assignment problem, where each search result is
* assigned an available tag. The Tagger (1) identifies available tags (2) uses
* the [Solver] to assign them, and (3) determines when a previously assigned
* tag has been selected, then (4) calls [Jumper] to reposition the caret.
*/
object Tagger : Resettable {
var markers: List<Marker> = emptyList()
private set
var regex = false
private set
var query = ""
private set
@Volatile
var full = false // Tracks whether all search results were successfully tagged
var textMatches: SortedSet<Int> = sortedSetOf()
@Volatile
var tagMap: BiMap<String, Int> = HashBiMap.create()
private val logger = Logger.getInstance(Tagger::class.java)
@Volatile
var tagSelected = false
private fun Iterable<Int>.noneInView() = none { it in viewBounds }
fun markOrJump(model: AceFindModel, results: SortedSet<Int>) {
model.run {
if (!regex) regex = isRegularExpressions
query = if (regex) " $stringToFind" else stringToFind.mapIndexed { i, c ->
if (i == 0) c else c.toLowerCase()
}.joinToString("")
logger.info("Received query: \"$query\"")
}
val availableTags = AceConfig.getCompatibleTags(query) { it !in tagMap }
measureTimeMillis { textMatches = refineSearchResults(results) }
.let { if (!regex) logger.info("Refined search results in $it ms") }
giveJumpOpportunity()
if (!tagSelected && query.isNotEmpty()) mark(textMatches, availableTags)
if (1 < query.length && tagMap.values.noneInView())
runNow { Scroller.scroll() }
}
/**
* Narrows down results that need to be tagged. For example, "eee" need not be
* tagged three times. Furthermore, we will not be able to tag every location
* in a very large document.
*/
private fun refineSearchResults(results: SortedSet<Int>): SortedSet<Int> {
if (regex) return results
val admittance: (t: Int) -> Boolean = { editorText.admitsTagAtLocation(it) }
val sites = if (results.size < 500) results.filter(admittance)
else results.parallelStream().filter(admittance).toList()
val discards = results.size - sites.size
discards.let { if (it > 0) logger.info("Discarded $it unsuitable results") }
return sites.toSortedSet()
}
/**
* Returns whether a given index inside a String can be tagged with a two-
* character tag (either to the left or right) without visually overlapping
* any nearby tags.
*/
private fun String.admitsTagAtLocation(loc: Int) = when {
1 < query.length -> true
loc - 1 < 0 -> true
loc + 1 >= length -> true
this[loc] isUnlike this[loc - 1] -> true
this[loc] isUnlike this[loc + 1] -> true
this[loc] != this[loc - 1] -> true
this[loc] != this[loc + 1] -> true
this[loc + 1] == '\r' || this[loc + 1] == '\n' -> true
this[loc - 1] == this[loc] && this[loc] == this[loc + 1] -> false
this[loc + 1].isWhitespace() && this[(loc + 2)
.coerceAtMost(length - 1)].isWhitespace() -> true
else -> false
}
private infix fun Char.isUnlike(other: Char) =
this.isLetterOrDigit() xor other.isLetterOrDigit() ||
this.isWhitespace() xor other.isWhitespace()
/**
* Checks whether a visible tag has been selected, and if so, jumps to it.
*/
private fun giveJumpOpportunity() =
tagMap.entries.firstOrNull { it.value in viewBounds && it solves query }
?.run {
logger.info("User selected tag: ${key.toUpperCase()}")
tagSelected = true
Jumper.jumpTo(value)
}
/**
* Returns true if and only if a tag location is unambiguously completed by a
* given query. This can only happen if the query matches the underlying text,
* AND ends with the tag in question. Tags are case-insensitive.
*/
private infix fun Map.Entry<String, Int>.solves(query: String) =
query.endsWith(key, true) && isCompatibleWithQuery(query)
private fun Map.Entry<String, Int>.isCompatibleWithQuery(query: String) =
query.getPlaintextPortion(key).let { text ->
regex || editorText.regionMatches(
thisOffset = value,
other = text,
otherOffset = 0,
length = text.length,
ignoreCase = true
)
}
private fun mark(results: SortedSet<Int>, availableTags: Set<String>) =
assignMissingTags(results, availableTags).compact().apply {
tagMap = this
markers = if(results.isEmpty()) // Last query char must be a tag char
tagMap.map { (tag, index) -> Marker(query, tag, index) }
else results.map { Marker(query, tagMap.inverse()[it], it) }
full = markers.size == tagMap.size
}
/**
* Shortens previously assigned tags. Two-character tags may be shortened to
* one-character tags if and only if:
*
* 1. The shortened tag is unique among the set of visible tags.
* 3. The query does not end with the shortened tag, in whole or part.
*/
private fun Map<String, Int>.compact(): HashBiMap<String, Int> {
var timeElapsed = System.currentTimeMillis()
var totalCompacted = 0
val compacted = mapKeysTo(HashBiMap.create(size)) { e ->
val tag = e.key
if (e.value !in viewBounds) return@mapKeysTo tag
// Avoid matching query - will trigger a jump. TODO: lift this constraint.
val queryEndsWith = query.endsWith(tag[0]) || query.endsWith(tag)
return@mapKeysTo if (!queryEndsWith && tag.canBeShortened(this)) {
totalCompacted++
tag[0].toString()
} else tag
}
timeElapsed = System.currentTimeMillis() - timeElapsed
logger.info("Compacted $totalCompacted visible tags in $timeElapsed ms")
return compacted
}
private fun String.canBeShortened(tagMap: Map<String, Int>): Boolean {
var i = 0
var canBeShortened = true
runNow {
for (tag in tagMap) {
if (tag.key[0] == this[0] &&
editor.canIndicesBeSimultaneouslyVisible(tagMap[this]!!, tag.value))
i++
if (1 < i) {
canBeShortened = false; break
}
}
}
return canBeShortened
}
/**
* Assigns [availableTags] to [results]. Initially, all results are vacant.
* If there are any untagged results visible, assign as many tags as possible.
* Assuming all visible tags have been assigned, there is nothing left to do.
*/
private fun assignMissingTags(results: Set<Int>,
availableTags: Set<String>): Map<String, Int> {
var timeElapsed = System.currentTimeMillis()
val oldTags = transferExistingTagsCompatibleWithQuery()
// Ongoing queries with results in view do not need further tag assignment
oldTags.run {
if (regex && isNotEmpty() && values.all { it in viewBounds }) return this
else if (hasTagSuffixInView(query)) return this
}
val remainder = getFeasibleSites(results)
val (onScreen, offScreen) = remainder.partition { it in viewBounds }
val completeResultSet = onScreen + offScreen
// Some results are untagged. Let's assign some tags!
val vacantResults = completeResultSet.filter { it !in oldTags.values }
logger.run {
timeElapsed = System.currentTimeMillis() - timeElapsed
info("Results on screen: ${onScreen.size}, off screen: ${offScreen.size}")
info("Vacant Results: ${vacantResults.size}")
info("Available Tags: ${availableTags.size}")
info("Time elapsed: $timeElapsed ms")
}
return if (regex) solveRegex(vacantResults, availableTags) else oldTags +
Solver(editorText, query, vacantResults, availableTags, viewBounds).map()
}
private fun getFeasibleSites(results: Set<Int>): List<Int> {
val feasibleRegion = getFeasibleRegion(results)
val remainder = results.partition { it in feasibleRegion }
remainder.second.size.let { if (it > 0) logger.info("Discarded $it OOBs") }
return remainder.first
}
private fun solveRegex(vacantResults: List<Int>, availableTags: Set<String>) =
availableTags.sortedWith(AceConfig.defaultTagOrder).zip(vacantResults).toMap()
/**
* Adds pre-existing tags where search string and tag overlap. For example,
* tags starting with the last character of the query will be included. Tags
* that no longer match the query will be discarded.
*/
private fun transferExistingTagsCompatibleWithQuery() =
tagMap.filter { it.isCompatibleWithQuery(query) || it.value in textMatches }
override fun reset() {
regex = false
full = false
textMatches = sortedSetOf()
tagMap = HashBiMap.create()
query = ""
markers = emptyList()
tagSelected = false
}
private fun String.getPlaintextPortion(tag: String) = when {
endsWith(tag, true) -> dropLast(tag.length)
endsWith(tag.first(), true) -> dropLast(1)
else -> this
}
/**
* Returns true if the Tagger contains a match in the new view, that is not
* contained (visible) in the old view. This method assumes that [textMatches]
* are in ascending order by index.
*
* @return true if there is a match in the new range not in the old range
*/
fun hasMatchBetweenOldAndNewView(old: IntRange, new: IntRange) =
textMatches.lastOrNull { it < old.first } ?: -1 >= new.first ||
textMatches.firstOrNull { it > old.last } ?: new.last < new.last
fun hasTagSuffixInView(query: String) =
tagMap.any { it.value in viewBounds && it.isCompatibleWithQuery(query) }
infix fun canDiscard(i: Int) = !(Finder.skim || i in tagMap.values || i == -1)
}

View File

@@ -0,0 +1,91 @@
package org.acejump.modes
import com.intellij.openapi.editor.CaretState
import org.acejump.action.AceTagAction
import org.acejump.config.AceConfig
import org.acejump.session.SessionState
import org.acejump.session.TypeResult
class BetweenPointsMode : SessionMode {
private companion object {
private val TYPE_TAG_HINT = arrayOf(
"<b>Type to Search...</b>"
)
private val ACTION_MODE_HINT = arrayOf(
"<f>[S]</f>elect... / <f>[F]</f>rom Caret...",
"<f>[D]</f>elete...",
"<f>[C]</f>lone to Caret...",
"<f>[M]</f>ove to Caret..."
)
private const val ACTION_MODE_FROM_CARET = 'F'
private val ACTION_MODE_MAP = mapOf(
'S' to ({ action: AceTagAction.BaseSelectAction -> action }),
'D' to (AceTagAction::Delete),
'C' to (AceTagAction::CloneToCaret),
'M' to (AceTagAction::MoveToCaret)
)
}
override val caretColor
get() = AceConfig.betweenPointsModeColor
private var actionMode: ((AceTagAction.BaseSelectAction) -> AceTagAction)? = null
private var originalCarets: List<CaretState>? = null
private var firstOffset: Int? = null
override fun type(state: SessionState, charTyped: Char, acceptedTag: Int?): TypeResult {
val actionMode = actionMode
if (actionMode == null) {
if (charTyped.equals(ACTION_MODE_FROM_CARET, ignoreCase = true)) {
return TypeResult.ChangeMode(SelectFromCaretMode())
}
this.actionMode = ACTION_MODE_MAP[charTyped.toUpperCase()]
return TypeResult.Nothing
}
if (acceptedTag == null) {
return state.type(charTyped)
}
if (firstOffset == null) {
val selectAction = JumpMode.SELECT_ACTION_MAP[charTyped.toUpperCase()]
if (selectAction != null) {
state.act(actionMode(selectAction), acceptedTag, shiftMode = charTyped.isUpperCase())
return TypeResult.EndSession
}
}
val jumpAction = JumpMode.JUMP_ACTION_MAP[charTyped.toUpperCase()]
if (jumpAction == null) {
return TypeResult.Nothing
}
val firstOffset = firstOffset
if (firstOffset == null) {
val caretModel = state.editor.caretModel
this.originalCarets = caretModel.caretsAndSelections
state.act(jumpAction, acceptedTag, shiftMode = false)
this.firstOffset = caretModel.offset
return TypeResult.RestartSearch
}
originalCarets?.let { state.editor.caretModel.caretsAndSelections = it }
state.act(actionMode(AceTagAction.SelectBetweenPoints(firstOffset, jumpAction)), acceptedTag, shiftMode = charTyped.isUpperCase())
return TypeResult.EndSession
}
override fun getHint(acceptedTag: Int?, hasQuery: Boolean): Array<String>? {
return when {
actionMode == null -> ACTION_MODE_HINT
acceptedTag == null -> TYPE_TAG_HINT.takeUnless { hasQuery }
firstOffset == null -> JumpMode.JUMP_ALT_HINT + JumpMode.SELECT_HINT
else -> JumpMode.JUMP_ALT_HINT
}
}
}

View File

@@ -0,0 +1,75 @@
package org.acejump.modes
import org.acejump.action.AceTagAction
import org.acejump.config.AceConfig
import org.acejump.session.SessionState
import org.acejump.session.TypeResult
class JumpMode : SessionMode {
companion object {
private val JUMP_HINT = arrayOf(
"<f>[J]</f>ump / <f>[L]</f> past Query",
"<f>[E]</f> Word End / <f>[M]</f> Line End"
)
val JUMP_ALT_HINT = JUMP_HINT.map { it.replace("<f>[J]</f>ump ", "<f>[J]</f> at Tag ") }.toTypedArray()
val JUMP_ACTION_MAP = mapOf(
'J' to AceTagAction.JumpToSearchStart,
'L' to AceTagAction.JumpPastSearchEnd,
'E' to AceTagAction.JumpToWordEnd,
'M' to AceTagAction.JumpToLineEnd
)
val SELECT_HINT = arrayOf(
"Select <f>[W]</f>ord / <f>[H]</f>ump / <f>[A]</f>round",
"Select <f>[Q]</f>uery / <f>[N]</f> Line / <f>[1-9]</f> Expansion"
)
val SELECT_ACTION_MAP = mapOf(
'W' to AceTagAction.SelectWord,
'H' to AceTagAction.SelectHump,
'A' to AceTagAction.SelectAroundWord,
'Q' to AceTagAction.SelectQuery,
'N' to AceTagAction.SelectLine,
*('1'..'9').mapIndexed { index, char -> char to AceTagAction.SelectExtended(index + 1) }.toTypedArray()
)
private val ALL_HINTS = arrayOf(
*JUMP_HINT,
*SELECT_HINT,
"<f>[D]</f>eclaration / <f>[U]</f>sages",
"<f>[I]</f>ntentions / <f>[R]</f>efactor"
)
private val ALL_ACTION_MAP = mapOf(
*JUMP_ACTION_MAP.map { it.key to it.value }.toTypedArray(),
*SELECT_ACTION_MAP.map { it.key to it.value }.toTypedArray(),
'D' to AceTagAction.GoToDeclaration,
'U' to AceTagAction.ShowUsages,
'I' to AceTagAction.ShowIntentions,
'R' to AceTagAction.Refactor
)
}
override val caretColor
get() = AceConfig.jumpModeColor
override fun type(state: SessionState, charTyped: Char, acceptedTag: Int?): TypeResult {
if (acceptedTag == null) {
return state.type(charTyped)
}
val action = ALL_ACTION_MAP[charTyped.toUpperCase()]
if (action != null) {
state.act(action, acceptedTag, charTyped.isUpperCase())
return TypeResult.EndSession
}
return TypeResult.Nothing
}
override fun getHint(acceptedTag: Int?, hasQuery: Boolean): Array<String>? {
return ALL_HINTS.takeIf { acceptedTag != null }
}
}

View File

@@ -0,0 +1,29 @@
package org.acejump.modes
import org.acejump.action.AceTagAction
import org.acejump.config.AceConfig
import org.acejump.session.SessionState
import org.acejump.session.TypeResult
class SelectFromCaretMode : SessionMode {
override val caretColor
get() = AceConfig.fromCaretModeColor
override fun type(state: SessionState, charTyped: Char, acceptedTag: Int?): TypeResult {
if (acceptedTag == null) {
return state.type(charTyped)
}
val jumpAction = JumpMode.JUMP_ACTION_MAP[charTyped.toUpperCase()]
if (jumpAction == null) {
return TypeResult.Nothing
}
state.act(AceTagAction.SelectToCaret(jumpAction), acceptedTag, shiftMode = charTyped.isUpperCase())
return TypeResult.EndSession
}
override fun getHint(acceptedTag: Int?, hasQuery: Boolean): Array<String>? {
return JumpMode.JUMP_ALT_HINT.takeIf { acceptedTag != null }
}
}

View File

@@ -0,0 +1,12 @@
package org.acejump.modes
import org.acejump.session.SessionState
import org.acejump.session.TypeResult
import java.awt.Color
interface SessionMode {
val caretColor: Color
fun type(state: SessionState, charTyped: Char, acceptedTag: Int?): TypeResult
fun getHint(acceptedTag: Int?, hasQuery: Boolean): Array<String>?
}

View File

@@ -1,26 +0,0 @@
package org.acejump.search
import com.intellij.find.FindModel
import kotlin.text.RegexOption.IGNORE_CASE
import kotlin.text.RegexOption.MULTILINE
class AceFindModel: FindModel {
internal constructor(key: String, isRegex: Boolean = false): super() {
isCaseSensitive = false
stringToFind = key
isRegularExpressions = isRegex
isReplaceState = false
}
fun toRegex(): Regex {
var regex = stringToFind
val options = mutableSetOf(MULTILINE)
if (!isCaseSensitive && stringToFind.first().isLowerCase())
options.add(IGNORE_CASE)
if (!isRegularExpressions)
regex = Regex.escape(stringToFind)
return Regex(regex, options)
}
}

View File

@@ -1,294 +0,0 @@
package org.acejump.search
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.editor.*
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.ProjectManager
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.IntPair
import org.acejump.view.Model.MAX_TAG_RESULTS
import org.acejump.view.Model.viewBounds
import java.awt.Point
import java.util.*
import javax.swing.JComponent
import kotlin.math.*
interface Resettable {
fun reset()
}
fun aceString(s: String): String =
ResourceBundle.getBundle("AceResources").getString(s)
fun <P> applyTo(vararg ps: P, fx: P.() -> Unit) = ps.forEach { it.fx() }
operator fun Point.component1() = x
operator fun Point.component2() = y
operator fun CharSequence.get(i: Int, j: Int) = substring(i, j).toCharArray()
fun String.hasSpaceRight(i: Int) = length <= i + 1 || this[i + 1].isWhitespace()
/**
* TODO: This is mostly an antipattern and can be replaced with [ReadAction.run]
*
* Further details: https://www.jetbrains.org/intellij/sdk/docs/basics/architectural_overview/general_threading_rules.html#readwrite-lock
*/
@Deprecated("This is applied too broadly. Narrow down usages where necessary.")
fun runNow(t: () -> Unit) = ApplicationManager.getApplication().invokeAndWait(t)
@Deprecated("This is applied too broadly. Narrow down usages where necessary.")
fun runLater(t: () -> Unit) = ApplicationManager.getApplication().invokeLater(t)
fun Editor.offsetCenter(first: Int, second: Int): LogicalPosition {
val firstIndexLine = offsetToLogicalPosition(first).line
val lastIndexLine = offsetToLogicalPosition(second).line
val center = (firstIndexLine + lastIndexLine) / 2
return offsetToLogicalPosition(getLineStartOffset(center))
}
fun Editor.getNameOfFileInEditor() =
FileDocumentManager.getInstance().getFile(document)?.presentableName
fun Editor.isNotFolded(offset: Int) = !foldingModel.isOffsetCollapsed(offset)
/**
* Identifies the bounds of a word, defined as a contiguous group of letters
* and digits, by expanding the provided index until a non-matching character
* is seen on either side.
*/
fun String.wordBounds(index: Int): IntPair {
var first = index
var last = index
while (0 < first && get(first - 1).isJavaIdentifierPart()) first--
while (last < length && get(last).isJavaIdentifierPart()) last++
return IntPair(first, last)
}
operator fun IntPair.component1() = this.first
operator fun IntPair.component2() = this.second
fun String.wordBoundsPlus(index: Int): IntPair {
var (left, right) = wordBounds(index)
for (it in (right..(right + 3).coerceAtMost(length - 1))) {
if (get(it) == '\n' || get(it) == '\r') {
break
} else {
right = it
}
}
return IntPair(left, right)
}
fun defaultEditor(): Editor =
FileEditorManager.getInstance(ProjectManager.getInstance()
.run { openProjects.firstOrNull() ?: defaultProject })
.run {
selectedTextEditor ?: allEditors.firstOrNull { it is Editor } as? Editor
?: EditorFactory.getInstance().run { createEditor(createDocument("")) }
}
fun Editor.getPoint(idx: Int) = visualPositionToXY(offsetToVisualPosition(idx))
fun Editor.getPointRelative(absolute: Point, relativeToComponent: JComponent) =
RelativePoint(relativeToComponent, absolute).originalPoint
fun Editor.isFirstCharacterOfLine(index: Int) =
index == getLineStartOffset(offsetToLogicalPosition(index).line)
/**
* Returns up to [MAX_TAG_RESULTS] by accumulating results before and after the
* view boundaries, (approximately centered around the middle of the screen).
*/
fun getFeasibleRegion(results: Set<Int>, takeAtMost: Int = MAX_TAG_RESULTS) =
(viewBounds.run { first + last } / 2).let { middleOfScreen ->
results.sortedBy { abs(middleOfScreen - it) }
.take(min(results.size, takeAtMost))
}.sorted().let { if (it.isNotEmpty()) it.first()..it.last() else viewBounds }
fun Editor.getView(): IntRange {
val firstVisibleLine = max(0, getVisualLineAtTopOfScreen() - 1)
val firstLine = visualLineToLogicalLine(firstVisibleLine)
val startOffset = getLineStartOffset(firstLine)
val height = getScreenHeight() + 2
val lastLine = visualLineToLogicalLine(firstVisibleLine + height)
var endOffset = getLineEndOffset(lastLine, true)
endOffset = normalizeOffset(lastLine, endOffset)
endOffset = min(max(0, document.textLength - 1), endOffset + 1)
return startOffset..endOffset
}
fun Editor.selectRange(fromOffset: Int, toOffset: Int) = runNow {
selectionModel.removeSelection()
selectionModel.setSelection(fromOffset, toOffset)
caretModel.moveToOffset(toOffset)
}
/**
* Returns whether two indices can be simultaneously visible on screen
*/
fun Editor.canIndicesBeSimultaneouslyVisible(idx0: Int, idx1: Int): Boolean {
// Thread must must have read access
val line1 = offsetToLogicalPosition(idx0).line
val line2 = offsetToLogicalPosition(idx1).line
return abs(line1 - line2) < getScreenHeight()
}
/*
* IdeaVim - A Vim emulator plugin for IntelliJ Idea
* Copyright (C) 2003-2005 Rick Maddy
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
*/
/**
* This is a set of helper methods for working with editors.
* All line and column values are zero based.
*/
fun Editor.getVisualLineAtTopOfScreen() =
(scrollingModel.verticalScrollOffset + lineHeight - 1) / lineHeight
/**
* Gets the number of actual lines in the file
*
* @return The file line count
*/
fun Editor.getLineCount() = document.run {
lineCount - if (textLength > 0 && text[textLength - 1] == '\n') 1 else 0
}
/**
* Gets the actual number of characters in the file
*
* @param countNewLines True include newline
*
* @return The file's character count
*/
fun Editor.getFileSize(countNewLines: Boolean = false): Int {
val len = document.textLength
val doc = document.charsSequence
return if (countNewLines || len == 0 || doc[len - 1] != '\n') len else len - 1
}
/**
* Gets the number of lines than can be displayed on the screen at one time.
* This is rounded down to the nearest whole line if there is a partial line
* visible at the bottom of the screen.
*
* @return The number of screen lines
*/
fun Editor.getScreenHeight() =
(scrollingModel.visibleArea.y + scrollingModel.visibleArea.height -
getVisualLineAtTopOfScreen() * lineHeight) / lineHeight
/**
* Converts a visual line number to a logical line number.
*
* @param line The visual line number to convert
*
* @return The logical line number
*/
fun Editor.visualLineToLogicalLine(line: Int) =
normalizeLine(visualToLogicalPosition(
VisualPosition(line.coerceAtLeast(0), 0)).line)
/**
* Returns the offset of the start of the requested line.
*
* @param line The logical line to get the start offset for.
*
* @return 0 if line is &lt 0, file size of line is bigger than file, else the
* start offset for the line
*/
fun Editor.getLineStartOffset(line: Int) =
when {
line < 0 -> 0
line >= getLineCount() -> getFileSize()
else -> document.getLineStartOffset(line)
}
/**
* Returns the offset of the end of the requested line.
*
* @param line The logical line to get the end offset for
*
* @param allowEnd True include newline
*
* @return 0 if line is &lt 0, file size of line is bigger than file, else the
* end offset for the line
*/
fun Editor.getLineEndOffset(line: Int, allowEnd: Boolean = true) =
when {
line < 0 -> 0
line >= getLineCount() -> getFileSize(allowEnd)
else -> document.getLineEndOffset(line) - if (allowEnd) 0 else 1
}
/**
* Ensures that the supplied logical line is within the range 0 (incl) and the
* number of logical lines in the file (excl).
*
* @param line The logical line number to normalize
*
* @return The normalized logical line number
*/
fun Editor.normalizeLine(line: Int) = max(0, min(line, getLineCount() - 1))
/**
* Ensures that the supplied offset for the given logical line is within the
* range for the line. If allowEnd is true, the range will allow for the offset
* to be one past the last character on the line.
*
* @param line The logical line number
*
* @param offset The offset to normalize
*
* @param allowEnd true if the offset can be one past the last character on the
* line, false if not
*
* @return The normalized column number
*/
fun Editor.normalizeOffset(line: Int, offset: Int, allowEnd: Boolean = true) =
if (getFileSize(allowEnd) == 0) 0 else
max(min(offset, getLineEndOffset(line, allowEnd)), getLineStartOffset(line))
/**
* This annotation is a marker which means that the annotated function is
* used in external plugins.
*/
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class ExternalUsage

View File

@@ -1,231 +0,0 @@
package org.acejump.search
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea.EXACT_RANGE
import com.intellij.openapi.editor.markup.RangeHighlighter
import org.acejump.config.AceConfig
import org.acejump.control.Handler
import org.acejump.control.Trigger
import org.acejump.label.Pattern
import org.acejump.label.Tagger
import org.acejump.view.Boundary
import org.acejump.view.Marker
import org.acejump.view.Model.LONG_DOCUMENT
import org.acejump.view.Model.boundaries
import org.acejump.view.Model.editor
import org.acejump.view.Model.editorText
import org.acejump.view.Model.markup
import org.acejump.view.Model.viewBounds
import java.util.*
import kotlin.math.max
import kotlin.math.min
import kotlin.system.measureTimeMillis
/**
* Singleton that searches for text in editor and highlights matching results.
*/
object Finder : Resettable {
@Volatile
private var results: SortedSet<Int> = sortedSetOf()
@Volatile
private var textHighlights = listOf<RangeHighlighter>()
private var HIGHLIGHT_LAYER = HighlighterLayer.LAST + 1
private val logger = Logger.getInstance(Finder::class.java)
private val skimTrigger = Trigger()
var isShiftSelectEnabled = false
var skim = false
private set
@Volatile
var query: String = ""
set(value) {
field = value
if (query.isNotEmpty()) logger.info("Received query: \"$value\"")
isShiftSelectEnabled = value.lastOrNull()?.isUpperCase() == true
when {
value.isEmpty() -> return
Tagger.regex -> search()
value.length == 1 -> skimThenSearch()
value.isValidQuery() -> skimThenSearch()
else -> {
logger.info("Invalid query \"$field\", dropping: ${field.last()}")
field = field.dropLast(1)
}
}
}
/**
* A user has two possible intentions when launching an AceJump search.
*
* 1. To locate the position of a known string in the document (a.k.a. Find)
* 2. To reposition the caret to a known location (i.e. staring at location)
*
* Since we cannot know why the user initiated any query a priori, here we
* attempt to satisfy both goals. First, we highlight all matches on (or off)
* the screen. This operation has very low latency. As soon as the user types
* a single character, we highlight all matches immediately. If we should
* receive no further characters after a short delay (indicating a pause in
* typing cadence), then we apply tags.
*
* Typically when a user searches for a known string, they will type several
* characters in rapid succession. We can avoid unnecessary work by only
* applying tags once we have received a "chunk" of search text.
*/
private fun skimThenSearch() {
if (results.size == 0 && LONG_DOCUMENT && AceConfig.searchWholeFile) {
logger.info("Skimming document for matches of: $query")
skim = true
search()
skimTrigger(400L) { skim = false; search() }
}
else {
search()
}
}
fun search(pattern: Pattern, bounds: Boundary) {
logger.info("Searching for regular expression: ${pattern.name} in $bounds")
search(pattern.string, bounds)
}
fun search(pattern: String, bounds: Boundary) {
boundaries = bounds
// TODO: Fix this broken reset
reset()
Tagger.reset()
search(AceFindModel(pattern, true))
}
fun search(model: AceFindModel = AceFindModel(query)) {
val time = measureTimeMillis {
results = Scanner.find(model, calculateSearchBoundaries(), results)
}
logger.info("Found ${results.size} matching sites in $time ms")
markResults(results, model)
}
private fun calculateSearchBoundaries(): IntRange {
if (AceConfig.searchWholeFile) {
return boundaries.intRange()
}
val bounds1 = boundaries
val bounds2 = Boundary.SCREEN_BOUNDARY
return max(bounds1.start, bounds2.start)..min(bounds1.endInclusive, bounds2.endInclusive)
}
/**
* This method is used by IdeaVim integration plugin and must not be inlined.
*
* By default, when this function is called externally, [results] are already
* collected and [AceFindModel] should be empty. Additionally, if the flag
* [AceFindModel.isRegularExpressions] is true only one symbol is highlighted.
*/
@ExternalUsage
fun markResults(results: SortedSet<Int>,
model: AceFindModel = AceFindModel("", true)
) {
markup(results, model.isRegularExpressions)
if (!skim) tag(model, results)
}
/**
* Paints text highlights beneath each query result to the editor using the
* [com.intellij.openapi.editor.markup.MarkupModel].
*/
fun markup(markers: Set<Int> = results, isRegexQuery: Boolean = false) {
if (markers.isEmpty()) {
return
}
runLater {
val highlightLen = if (isRegexQuery) 1 else query.length
editor.document.isInBulkUpdate = true
textHighlights.forEach { markup.removeHighlighter(it) }
textHighlights = markers.map {
val start = it - if (it == editorText.length) 1 else 0
val end = start + highlightLen
createTextHighlight(max(start, 0), min(end, editorText.length - 1))
}
editor.document.isInBulkUpdate = false
}
}
private fun createTextHighlight(start: Int, end: Int) =
markup.addRangeHighlighter(start, end, HIGHLIGHT_LAYER, null, EXACT_RANGE)
.apply { customRenderer = Marker.Companion }
private fun tag(model: AceFindModel, results: SortedSet<Int>) {
synchronized(this) { Tagger.markOrJump(model, results) }
val (ivb, ovb) = textHighlights.partition { it.startOffset in viewBounds }
ivb.cull()
runLater { ovb.cull() }
if (model.stringToFind == query || model.isRegularExpressions)
Handler.repaintTagMarkers()
}
/**
* Erases highlights which are no longer compatible with the current query.
*/
private fun List<RangeHighlighter>.cull() =
eraseIf { Tagger canDiscard startOffset }
.also { newHighlights ->
val numDiscarded = size - newHighlights.size
if (numDiscarded != 0) logger.info("Discarded $numDiscarded highlights")
}
fun List<RangeHighlighter>.eraseIf(cond: RangeHighlighter.() -> Boolean): List<RangeHighlighter> {
val (erased, kept) = partition(cond)
if (erased.isNotEmpty()) {
runLater {
editor.document.isInBulkUpdate = true
erased.forEach { markup.removeHighlighter(it) }
editor.document.isInBulkUpdate = false
}
}
return kept
}
fun allResults() = results
fun visibleResults() = results.filter { it in viewBounds }
private fun String.isValidQuery() =
Tagger.hasTagSuffixInView(query) ||
results.any {
editorText.regionMatches(
thisOffset = it,
other = this,
otherOffset = 0,
length = length,
ignoreCase = true
)
}
override fun reset() {
runLater {
editor.document.isInBulkUpdate = true
markup.removeAllHighlighters()
editor.document.isInBulkUpdate = false
}
query = ""
skim = false
results = sortedSetOf()
textHighlights = listOf()
}
}

View File

@@ -1,90 +0,0 @@
package org.acejump.search
import com.intellij.openapi.editor.colors.EditorColors.CARET_COLOR
import org.acejump.config.AceConfig
import org.acejump.control.Handler
import org.acejump.view.Canvas
import org.acejump.view.Model
import org.acejump.view.Model.editor
import java.awt.Color
enum class JumpMode {
DISABLED, JUMP, JUMP_END, TARGET, DEFINE;
companion object: Resettable {
private var modeIndex = 0
var mode: JumpMode = DISABLED
private set(value) {
field = value
setCaretColor(when (field) {
JUMP -> AceConfig.jumpModeColor
JUMP_END -> AceConfig.jumpEndModeColor
DEFINE -> AceConfig.definitionModeColor
TARGET -> AceConfig.targetModeColor
DISABLED -> Model.naturalCaretColor
})
Finder.markup()
Canvas.repaint()
}
private fun setCaretColor(color: Color) =
editor.colorsScheme.setColor(CARET_COLOR, color)
fun toggle(newMode: JumpMode): JumpMode {
if (mode == newMode) {
mode = DISABLED
modeIndex = 0
Handler.reset()
}
else {
mode = newMode
modeIndex = cycleSettings.indexOfFirst { it == newMode } + 1
}
return mode
}
private val cycleSettings
get() = arrayOf(
AceConfig.cycleMode1,
AceConfig.cycleMode2,
AceConfig.cycleMode3,
AceConfig.cycleMode4
)
fun cycle(): JumpMode {
val cycleSettings = cycleSettings
for (testModeIndex in (modeIndex + 1)..(cycleSettings.size)) {
if (cycleSettings[testModeIndex - 1] != DISABLED) {
mode = cycleSettings[testModeIndex - 1]
modeIndex = testModeIndex
return mode
}
}
mode = DISABLED
modeIndex = 0
Handler.reset()
return mode
}
override fun reset() {
mode = DISABLED
modeIndex = 0
}
override fun equals(other: Any?) =
if (other is JumpMode) mode == other else super.equals(other)
}
override fun toString() = when(this) {
DISABLED -> aceString("jumpModeDisabled")
JUMP -> aceString("jumpModeJump")
JUMP_END -> aceString("jumpModeJumpEnd")
TARGET -> aceString("jumpModeTarget")
DEFINE -> aceString("jumpModeDefine")
}
}

View File

@@ -1,132 +0,0 @@
package org.acejump.search
import com.intellij.codeInsight.editorActions.SelectWordUtil.addWordSelection
import com.intellij.codeInsight.navigation.actions.GotoDeclarationAction
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.DocCommandGroupId
import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory
import com.intellij.openapi.fileEditor.impl.IdeDocumentHistoryImpl
import com.intellij.openapi.ui.playback.commands.ActionCommand
import com.intellij.openapi.util.TextRange
import org.acejump.label.Tagger
import org.acejump.search.JumpMode.*
import org.acejump.view.Model.editor
import org.acejump.view.Model.editorText
import org.acejump.view.Model.project
import kotlin.math.max
import kotlin.math.min
/**
* Updates the [com.intellij.openapi.editor.CaretModel] after a tag is selected.
*/
object Jumper: Resettable {
private val logger = Logger.getInstance(Jumper::class.java)
fun cycleMode() =
logger.info("Entering ${JumpMode.cycle()} mode")
fun toggleMode(mode: JumpMode) =
logger.info("Entering ${JumpMode.toggle(mode)} mode")
fun jumpTo(newOffset: Int, done: Boolean = true) =
editor.run {
val logPos = offsetToLogicalPosition(newOffset)
logger.debug("Jumping to line ${logPos.line}, column ${logPos.column}...")
val oldOffset = caretModel.offset
when {
JumpMode.equals(JUMP_END) ->
moveCaretToEnd(newOffset + countMatchingCharacters(newOffset, Tagger.query))
else ->
moveCaretTo(newOffset)
}
when {
Finder.isShiftSelectEnabled && done -> selectRange(oldOffset, newOffset)
JumpMode.equals(TARGET) -> selectWordAtOffset(newOffset)
JumpMode.equals(DEFINE) && done -> gotoSymbolAction()
}
}
private val aceJumpHistoryAppender = {
with(IdeDocumentHistory.getInstance(project) as IdeDocumentHistoryImpl) {
onSelectionChanged()
includeCurrentCommandAsNavigation()
includeCurrentPlaceAsChangePlace()
}
}
/**
* Ensures each jump destination is appended to [IdeDocumentHistory] so that
* users can navigate forward and backward using the IDE actions.
*/
private fun Editor.appendCaretPositionToEditorNavigationHistory() =
CommandProcessor.getInstance().executeCommand(project,
aceJumpHistoryAppender, "AceJumpHistoryAppender",
DocCommandGroupId.noneGroupId(document), document)
private fun Editor.moveCaretTo(offset: Int) {
appendCaretPositionToEditorNavigationHistory()
selectionModel.removeSelection()
caretModel.moveToOffset(offset)
}
private fun Editor.moveCaretToEnd(offset: Int) {
val ranges = ArrayList<TextRange>()
addWordSelection(settings.isCamelWords, editorText, offset, ranges)
if (ranges.isEmpty()) {
moveCaretTo(offset)
}
else {
moveCaretTo(min(ranges[0].endOffset, editorText.length))
}
}
private fun countMatchingCharacters(offset: Int, query: String): Int {
var count = 0
while (offset + count < editorText.length && count < query.length && editorText[offset + count] == query[count]) {
count++
}
return count
}
/**
* Selects a sequence of contiguous characters adjacent to the target offset
* matching [Character.isJavaIdentifierPart], or nothing at all.
*
* TODO: Make this language agnostic.
*/
private fun Editor.selectWordAtOffset(offset: Int = caretModel.offset) {
val ranges = ArrayList<TextRange>()
addWordSelection(settings.isCamelWords, editorText, offset, ranges)
ranges.ifEmpty { return }
val firstRange = ranges[0]
val startOfWordOffset = max(0, firstRange.startOffset)
val endOfWordOffset = min(firstRange.endOffset, editorText.length)
selectRange(startOfWordOffset, endOfWordOffset)
}
/**
* Navigates to the target symbol's declaration, using [GotoDeclarationAction]
*/
private fun gotoSymbolAction() =
runNow {
ActionManager.getInstance().tryToExecute(GotoDeclarationAction(),
ActionCommand.getInputEvent("NewFromTemplate"), null, null, true)
}
override fun reset() = JumpMode.reset()
}

View File

@@ -0,0 +1,9 @@
package org.acejump.search
enum class Pattern(val regex: String) {
LINE_STARTS("^.|^\\n"),
LINE_ENDS("\\n|\\Z"),
LINE_INDENTS("[^\\s].*|^\\n"),
LINE_ALL_MARKS(LINE_ENDS.regex + "|" + LINE_STARTS.regex + "|" + LINE_INDENTS.regex),
ALL_WORDS("(?<=[^a-zA-Z0-9_]|\\A)[a-zA-Z0-9_]");
}

View File

@@ -1,76 +0,0 @@
package org.acejump.search
import com.intellij.openapi.diagnostic.Logger
import org.acejump.view.Model.LONG_DOCUMENT_LENGTH
import org.acejump.view.Model.editorText
import java.util.*
import kotlin.streams.toList
/**
* Returns a set of indices indicating query matches, within the given range.
* These are full indices, i.e. are not offset to the beginning of the range.
*/
internal object Scanner {
val cores = Runtime.getRuntime().availableProcessors() - 1
private val logger = Logger.getInstance(Scanner::class.java)
/**
* Returns [SortedSet] of indices matching the [model]. Providing a [cache]
* will filter prior results instead of searching the editor contents.
*/
fun find(model: AceFindModel, boundaries: IntRange, cache: Set<Int> = emptySet()): SortedSet<Int> =
if (cache.isNotEmpty() || (boundaries.last - boundaries.first) < LONG_DOCUMENT_LENGTH)
editorText.search(model, cache, boundaries).toSortedSet()
else editorText.chunk().parallelStream().map { chunk ->
editorText.search(model, cache, chunk)
}.toList().flatten().toSortedSet()
/**
* Divides lines of text into equally-sized chunks for parallelized search.
*/
private fun String.chunk(): List<IntRange> {
val lines = splitToSequence("\n", "\r").toList()
val chunkSize = lines.size / cores + 1
logger.info("Parallelizing ${lines.size}-line search across $cores cores")
var offset = 0
return lines.chunked(chunkSize).map {
val len = it.joinToString("\n").length
(offset..(offset + len)).also { offset += len + 1 }
}
}
/**
* Searches the [cache] (if it is populated), or else the whole document.
*/
fun String.search(model: AceFindModel, cache: Set<Int>, chunk: IntRange) =
run {
val query = model.stringToFind
if (isEmpty() || query.isEmpty()) sortedSetOf<Int>()
else if (cache.isNotEmpty()) filterCache(cache, query)
else findAll(model.toRegex(), chunk)
}.toList()
private fun String.filterCache(cache: Set<Int>, query: String) =
cache.asSequence().filter { index ->
regionMatches(
thisOffset = index + query.length - 1,
other = query.last().toString(),
otherOffset = 0,
length = 1,
ignoreCase = query.last().isLowerCase()
)
}.toList()
fun CharSequence.findAll(regex: Regex, chunk: IntRange) =
generateSequence(
seedFunction = { regex.find(this, chunk.first) },
nextFunction = { result -> filterNext(result, chunk) }
).map { it.range.first }.toList()
fun filterNext(result: MatchResult, chunk: IntRange): MatchResult? =
result.next()?.let { if(it.range.first !in chunk) null else it }
}

View File

@@ -0,0 +1,127 @@
package org.acejump.search
import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.IntArrayList
import org.acejump.boundaries.Boundaries
import org.acejump.immutableText
import org.acejump.isWordPart
import org.acejump.matchesAt
/**
* Searches editor text for matches of a [SearchQuery], and updates previous results when the user [type]s a character.
*/
class SearchProcessor private constructor(private val editor: Editor, query: SearchQuery) {
companion object {
fun fromChar(editor: Editor, char: Char, boundaries: Boundaries): SearchProcessor {
return SearchProcessor(editor, SearchQuery.Literal(char.toString()), boundaries)
}
fun fromRegex(editor: Editor, pattern: String, boundaries: Boundaries): SearchProcessor {
return SearchProcessor(editor, SearchQuery.RegularExpression(pattern), boundaries)
}
}
private constructor(editor: Editor, query: SearchQuery, boundaries: Boundaries) : this(editor, query) {
val regex = query.toRegex()
if (regex != null) {
val offsetRange = boundaries.getOffsetRange(editor)
var result = regex.find(editor.immutableText, offsetRange.first)
while (result != null) {
val index = result.range.first // For some reason regex matches can be out of bounds, but boundary check prevents an exception.
val highlightEnd = index + query.getHighlightLength("", index)
if (highlightEnd > offsetRange.last) {
break
}
else if (boundaries.isOffsetInside(editor, index)) {
results.add(index)
}
result = result.next()
}
}
}
internal var query = query
private set
internal var results = IntArrayList(0)
private set
/**
* Appends a character to the search query and removes all search results that no longer match the query. If the last typed character
* transitioned the search query from a non-word to a word, it notifies the [Tagger] to reassign all tags. If the new query does not
* make sense because it would remove every result, the change is reverted and this function returns false.
*/
fun type(char: Char, tagger: Tagger): Boolean {
val newQuery = query.rawText + char
val chars = editor.immutableText
val canMatchTag = tagger.canQueryMatchAnyTag(newQuery)
// If the typed character is not compatible with any existing tag or as a continuation of any previous occurrence, reject the query
// change and return false to indicate that nothing else should happen.
if (newQuery.length > 1 && !canMatchTag && results.none { chars.matchesAt(it, newQuery, ignoreCase = true) }) {
return false
}
// If the typed character transitioned the search query from a non-word to a word, and the typed character does not belong to an
// existing tag, we basically restart the search at the beginning of every new word, and unmark existing results so that all tags get
// regenerated immediately afterwards. Although this causes tags to change, it is one solution for conflicts between tag characters and
// search query characters, and moving searches across word boundaries during search should be fairly uncommon.
if (!canMatchTag && newQuery.length >= 2 && !newQuery[newQuery.length - 2].isWordPart && char.isWordPart) {
query = SearchQuery.Literal(char.toString())
tagger.unmark()
val iter = results.iterator()
while (iter.hasNext()) {
val movedOffset = iter.nextInt() + newQuery.length - 1
if (movedOffset < chars.length && chars[movedOffset].equals(char, ignoreCase = true)) {
iter.set(movedOffset)
}
else {
iter.remove()
}
}
}
else {
removeObsoleteResults(newQuery, tagger)
query = SearchQuery.Literal(newQuery)
}
return true
}
/**
* After updating the query, removes all results that no longer match the search query.
*/
private fun removeObsoleteResults(newQuery: String, tagger: Tagger) {
val lastCharOffset = newQuery.lastIndex
val lastChar = newQuery[lastCharOffset]
val ignoreCase = newQuery[0].isLowerCase()
val chars = editor.immutableText
val remaining = IntArrayList()
val iter = results.iterator()
while (iter.hasNext()) {
val offset = iter.nextInt()
val endOffset = offset + lastCharOffset
val lastTypedCharMatches = endOffset < chars.length && chars[endOffset].equals(lastChar, ignoreCase)
if (lastTypedCharMatches || tagger.isQueryCompatibleWithTagAt(newQuery, offset)) {
remaining.add(offset)
}
}
results = remaining
}
fun clone(): SearchProcessor {
return SearchProcessor(editor, query).also { it.results.addAll(results) }
}
}

View File

@@ -0,0 +1,62 @@
package org.acejump.search
import org.acejump.countMatchingCharacters
/**
* Defines the current search query for a session.
*/
internal sealed class SearchQuery {
abstract val rawText: String
/**
* Returns how many characters the search occurrence highlight should cover.
*/
abstract fun getHighlightLength(text: CharSequence, offset: Int): Int
/**
* Converts the query into a regular expression to find the initial matches.
*/
abstract fun toRegex(): Regex?
/**
* Searches for all occurrences of a literal text query. If the first character of the query is lowercase, then the entire query will be
* case-insensitive.
*
* Each occurrence must either match the entire query, or match the query up to a point so that the rest of the query matches the
* beginning of a tag at the location of the occurrence.
*/
class Literal(override val rawText: String) : SearchQuery() {
init {
require(rawText.isNotEmpty())
}
override fun getHighlightLength(text: CharSequence, offset: Int): Int {
return text.countMatchingCharacters(offset, rawText)
}
override fun toRegex(): Regex {
val options = mutableSetOf(RegexOption.MULTILINE)
if (rawText.first().isLowerCase()) {
options.add(RegexOption.IGNORE_CASE)
}
return Regex(Regex.escape(rawText), options)
}
}
/**
* Searches for all matches of a regular expression.
*/
class RegularExpression(private val pattern: String) : SearchQuery() {
override val rawText = ""
override fun getHighlightLength(text: CharSequence, offset: Int): Int {
return 1
}
override fun toRegex(): Regex {
return Regex(pattern, setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
}
}
}

View File

@@ -0,0 +1,193 @@
package org.acejump.search
import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.*
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap
import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries
import org.acejump.config.AceConfig
import org.acejump.immutableText
import org.acejump.input.KeyLayoutCache
import org.acejump.isWordPart
import org.acejump.wordEndPlus
import java.util.*
import kotlin.collections.HashMap
import kotlin.collections.HashSet
import kotlin.math.max
/*
* Solves the Tag Assignment Problem. The tag assignment problem can be stated
* thusly: Given a set of indices I in document d, and a set of tags T, find a
* bijection f: T*⊂T → I*⊂I s.t. d[i..k] + t ∉ d[i'..(k + |t|)], ∀ i' ∈ I\{i},
* ∀ k ∈ (i, |d|-|t|], where t ∈ T, i ∈ I. Maximize |I*|. This can be relaxed
* to t=t[0] and ∀ k ∈ (i, i+K] for some fixed K, in most natural documents.
*
* More concretely, tags are typically two-character strings containing alpha-
* numeric symbols. Documents are plaintext files. Indices are produced by a
* search query of length N, i.e. the preceding N characters of every index i in
* document d are identical. For characters proceeding d[i], all bets are off.
* We can assume that P(d[i]|d[i-1]) has some structure for d~D. Ultimately, we
* want a fast algorithm which maximizes the number of tagged document indices.
*
* Tags are used by the typist to select indices within a document. To select an
* index, the typist starts by activating AceJump and searching for a character.
* As soon as the first character is received, we begin to scan the document for
* matching locations and assign as many valid tags as possible. When subsequent
* characters are received, we refine the search results to match either:
*
* 1.) The plaintext query alone, or
* 2.) The concatenation of plaintext query and partial tag
*
* The constraint in paragraph no. 1 tries to impose the following criteria:
*
* 1.) All valid key sequences will lead to a unique location in the document
* 2.) All indices in the document will be reachable by a short key sequence
*
* If there is an insufficient number of two-character tags to cover every index
* (which typically occurs when the user searches for a common character within
* a long document), then we attempt to maximize the number of tags assigned to
* document indices. The key is, all tags must be assigned as soon as possible,
* i.e. as soon as the first character is received or whenever the user ceases
* typing (at the very latest). Once assigned, a visible tag must never change
* at any time during the selection process, so as not to confuse the user.
*/
internal class Solver private constructor(
private val editor: Editor,
private val queryLength: Int,
private val newResults: IntList,
private val allResults: IntList
) {
companion object {
fun solve(
editor: Editor, query: SearchQuery, newResults: IntList, allResults: IntList, tags: List<String>, cache: EditorOffsetCache
): Map<String, Int> {
return Solver(editor, max(1, query.rawText.length), newResults, allResults).map(tags, cache)
}
}
private var newTags = Object2IntOpenHashMap<String>(KeyLayoutCache.allPossibleTags.size)
private val newTagIndices = IntOpenHashSet()
private var allWordFragments = HashSet<String>(allResults.size).apply {
val iter = allResults.iterator()
while (iter.hasNext()) {
forEachWordFragment(iter.nextInt()) { add(it) }
}
}
fun map(availableTags: List<String>, cache: EditorOffsetCache): Map<String, Int> {
val eligibleSitesByTag = HashMap<String, IntList>(100)
val tagsByFirstLetter = availableTags.groupBy { it[0] }
val iter = newResults.iterator()
while (iter.hasNext()) {
val site = iter.nextInt()
for ((firstLetter, tags) in tagsByFirstLetter.entries) {
if (canTagBeginWithChar(site, firstLetter)) {
for (tag in tags) {
eligibleSitesByTag.getOrPut(tag) { IntArrayList(10) }.add(site)
}
}
}
}
val matchingSites = HashMap<IntList, IntArray>()
val matchingSitesAsArrays = IdentityHashMap<String, IntArray>() // Keys are guaranteed to be from a single collection.
val siteOrder = siteOrder(cache)
val tagOrder = KeyLayoutCache.tagOrder
.thenComparingInt { eligibleSitesByTag.getValue(it).size }
.thenBy(AceConfig.layout.priority(String::last))
val sortedTags = eligibleSitesByTag.keys.toMutableList().apply {
sortWith(tagOrder)
}
for ((key, value) in eligibleSitesByTag.entries) {
matchingSitesAsArrays[key] = matchingSites.getOrPut(value) {
value.toIntArray().apply { IntArrays.mergeSort(this, siteOrder) }
}
}
var totalAssigned = 0
for (tag in sortedTags) {
if (totalAssigned == newResults.size) {
break
}
if (tryToAssignTag(tag, matchingSitesAsArrays.getValue(tag))) {
totalAssigned++
}
}
return newTags
}
private fun tryToAssignTag(tag: String, sites: IntArray): Boolean {
if (newTags.containsKey(tag)) {
return false
}
val index = sites.firstOrNull { it !in newTagIndices } ?: return false
@Suppress("ReplacePutWithAssignment")
newTags.put(tag, index)
newTagIndices.add(index)
return true
}
private fun siteOrder(cache: EditorOffsetCache) = IntComparator { a, b ->
val aIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, a, cache)
val bIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, b, cache)
if (aIsVisible != bIsVisible) {
// Sites in immediate view should come first.
return@IntComparator if (aIsVisible) -1 else 1
}
val chars = editor.immutableText
val aIsNotWordStart = chars[max(0, a - 1)].isWordPart
val bIsNotWordStart = chars[max(0, b - 1)].isWordPart
if (aIsNotWordStart != bIsNotWordStart) {
// Ensure that the first letter of a word is prioritized for tagging.
return@IntComparator if (bIsNotWordStart) -1 else 1
}
when {
a < b -> -1
a > b -> 1
else -> 0
}
}
private fun canTagBeginWithChar(site: Int, char: Char): Boolean {
if (char.toString() in allWordFragments) {
return false
}
forEachWordFragment(site) {
if (it + char in allWordFragments) {
return false
}
}
return true
}
private inline fun forEachWordFragment(site: Int, callback: (String) -> Unit) {
val chars = editor.immutableText
val left = max(0, site + queryLength - 1)
val right = chars.wordEndPlus(site)
val builder = StringBuilder(1 + right - left)
for (i in left..right) {
builder.append(chars[i].toLowerCase())
callback(builder.toString())
}
}
}

View File

@@ -0,0 +1,202 @@
package org.acejump.search
import com.google.common.collect.HashBiMap
import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.ints.IntList
import org.acejump.ExternalUsage
import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries
import org.acejump.immutableText
import org.acejump.input.KeyLayoutCache.allPossibleTags
import org.acejump.isWordPart
import org.acejump.matchesAt
import org.acejump.view.Tag
import java.util.AbstractMap.SimpleImmutableEntry
import kotlin.collections.component1
import kotlin.collections.component2
/**
* Assigns tags to search occurrences, updates them when the search query changes, and requests a jump if the search query matches a tag.
*/
class Tagger(private val editor: Editor) {
private var tagMap = HashBiMap.create<String, Int>()
val hasTags
get() = tagMap.isNotEmpty()
@ExternalUsage
internal val tags
get() = tagMap.map { SimpleImmutableEntry(it.key, it.value) }.sortedBy { it.value }
/**
* Removes all markers, allowing them to be regenerated from scratch.
*/
internal fun unmark() {
tagMap = HashBiMap.create()
}
/**
* Assigns tags to as many results as possible, keeping previously assigned tags. Returns a [TaggingResult.Accept] if the current search
* query matches any existing tag and we should jump to it and end the session, or [TaggingResult.Mark] to continue the session with
* updated tag markers.
*
* Note that the [results] collection will be mutated.
*/
internal fun update(query: SearchQuery, results: IntList): TaggingResult {
val isRegex = query is SearchQuery.RegularExpression
val queryText = if (isRegex) " ${query.rawText}" else query.rawText[0] + query.rawText.drop(1).toLowerCase()
val availableTags = allPossibleTags.filter { !queryText.endsWith(it[0]) && it !in tagMap }
if (!isRegex) {
for (entry in tagMap.entries) {
if (entry solves queryText) {
return TaggingResult.Accept(entry.value)
}
}
if (queryText.length == 1) {
removeResultsWithOverlappingTags(results)
}
}
if (!isRegex || tagMap.isEmpty()) {
tagMap = assignTagsAndMerge(results, availableTags, query, queryText)
}
return TaggingResult.Mark(createTagMarkers(results, query.rawText.ifEmpty { null }))
}
fun clone(): Tagger {
return Tagger(editor).also { it.tagMap.putAll(tagMap) }
}
/**
* Assigns as many unassigned tags as possible, and merges them with the existing compatible tags.
*/
private fun assignTagsAndMerge(results: IntList, availableTags: List<String>, query: SearchQuery, queryText: String): HashBiMap<String, Int> {
val cache = EditorOffsetCache.new()
results.sort { a, b ->
val aIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, a, cache)
val bIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, b, cache)
when {
aIsVisible && !bIsVisible -> -1
bIsVisible && !aIsVisible -> 1
else -> 0
}
}
val allAssignedTags = mutableMapOf<String, Int>()
val oldCompatibleTags = tagMap.filter { isTagCompatibleWithQuery(it.key, it.value, queryText) || it.value in results }
val vacantResults: IntList
if (oldCompatibleTags.isEmpty()) {
vacantResults = results
}
else {
vacantResults = IntArrayList()
val iter = results.iterator()
while (iter.hasNext()) {
val offset = iter.nextInt()
if (offset !in oldCompatibleTags.values) {
vacantResults.add(offset)
}
}
}
allAssignedTags.putAll(oldCompatibleTags)
allAssignedTags.putAll(Solver.solve(editor, query, vacantResults, results, availableTags, cache))
return allAssignedTags.mapKeysTo(HashBiMap.create(allAssignedTags.size)) { (tag, _) ->
// Avoid matching query - will trigger a jump.
// TODO: lift this constraint.
val queryEndsWith = queryText.endsWith(tag[0]) || queryText.endsWith(tag)
if (!queryEndsWith && canShortenTag(tag, allAssignedTags))
tag[0].toString()
else
tag
}
}
private infix fun Map.Entry<String, Int>.solves(query: String): Boolean {
return query.endsWith(key, true) && isTagCompatibleWithQuery(key, value, query)
}
private fun isTagCompatibleWithQuery(tag: String, offset: Int, query: String): Boolean {
return editor.immutableText.matchesAt(offset, getPlaintextPortion(query, tag), ignoreCase = true)
}
fun isQueryCompatibleWithTagAt(query: String, offset: Int): Boolean {
return tagMap.inverse()[offset].let { it != null && isTagCompatibleWithQuery(it, offset, query) }
}
fun canQueryMatchAnyTag(query: String): Boolean {
return tagMap.any { (tag, offset) ->
val tagPortion = getTagPortion(query, tag)
tagPortion.isNotEmpty() && tag.startsWith(tagPortion, ignoreCase = true) && isTagCompatibleWithQuery(tag, offset, query)
}
}
private fun removeResultsWithOverlappingTags(results: IntList) {
val iter = results.iterator()
val chars = editor.immutableText
while (iter.hasNext()) {
if (!chars.canTagWithoutOverlap(iter.nextInt())) {
iter.remove() // Very uncommon, so slow removal is fine.
}
}
}
private fun createTagMarkers(results: IntList, literalQueryText: String?): List<Tag> {
val tagMapInv = tagMap.inverse()
return results.mapNotNull { index -> tagMapInv[index]?.let { tag -> Tag.create(editor, tag, index, literalQueryText) } }
}
private companion object {
private fun CharSequence.canTagWithoutOverlap(loc: Int) = when {
loc - 1 < 0 -> true
loc + 1 >= length -> true
this[loc] isUnlike this[loc - 1] -> true
this[loc] isUnlike this[loc + 1] -> true
this[loc] != this[loc - 1] -> true
this[loc] != this[loc + 1] -> true
this[loc + 1] == '\r' || this[loc + 1] == '\n' -> true
this[loc - 1] == this[loc] && this[loc] == this[loc + 1] -> false
this[loc + 1].isWhitespace() && this[(loc + 2).coerceAtMost(length - 1)].isWhitespace() -> true
else -> false
}
private infix fun Char.isUnlike(other: Char): Boolean {
return this.isWordPart xor other.isWordPart || this.isWhitespace() xor other.isWhitespace()
}
private fun getPlaintextPortion(query: String, tag: String) = when {
query.endsWith(tag, true) -> query.dropLast(tag.length)
query.endsWith(tag.first(), true) -> query.dropLast(1)
else -> query
}
private fun getTagPortion(query: String, tag: String) = when {
query.endsWith(tag, true) -> query.takeLast(tag.length)
query.endsWith(tag.first(), true) -> query.takeLast(1)
else -> ""
}
private fun canShortenTag(tag: String, tagMap: Map<String, Int>): Boolean {
for (other in tagMap.keys) {
if (tag != other && tag[0] == other[0]) {
return false
}
}
return true
}
}
}

View File

@@ -0,0 +1,8 @@
package org.acejump.search
import org.acejump.view.Tag
internal sealed class TaggingResult {
class Accept(val offset: Int) : TaggingResult()
class Mark(val tags: List<Tag>) : TaggingResult()
}

View File

@@ -0,0 +1,53 @@
package org.acejump.session
import com.intellij.openapi.editor.Editor
/**
* Holds [Editor] caret settings. The settings are saved the moment a [Session] starts, modified to indicate AceJump states, and restored
* once the [Session] ends.
*/
internal data class EditorSettings(private val isBlockCursor: Boolean, private val isBlinkCaret: Boolean, private val isReadOnly: Boolean) {
companion object {
fun setup(editor: Editor): EditorSettings {
val settings = editor.settings
val document = editor.document
val original = EditorSettings(
isBlockCursor = settings.isBlockCursor,
isBlinkCaret = settings.isBlinkCaret,
isReadOnly = !document.isWritable
)
settings.isBlockCursor = true
settings.isBlinkCaret = false
document.setReadOnly(true)
return original
}
}
fun startEditing(editor: Editor) {
editor.document.setReadOnly(isReadOnly)
}
fun stopEditing(editor: Editor) {
editor.document.setReadOnly(true)
}
fun onTagAccepted(editor: Editor) = editor.let {
it.settings.isBlockCursor = isBlockCursor
}
fun onTagUnaccepted(editor: Editor) = editor.let {
it.settings.isBlockCursor = true
}
fun restore(editor: Editor) {
val settings = editor.settings
val document = editor.document
settings.isBlockCursor = isBlockCursor
settings.isBlinkCaret = isBlinkCaret
document.setReadOnly(isReadOnly)
}
}

View File

@@ -0,0 +1,221 @@
package org.acejump.session
import com.intellij.codeInsight.hint.HintManager
import com.intellij.codeInsight.hint.HintManagerImpl
import com.intellij.codeInsight.hint.HintUtil
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.editor.actionSystem.TypedActionHandler
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.impl.AbstractColorsScheme
import com.intellij.ui.LightweightHint
import org.acejump.ExternalUsage
import org.acejump.boundaries.Boundaries
import org.acejump.config.AceConfig
import org.acejump.immutableText
import org.acejump.input.EditorKeyListener
import org.acejump.input.KeyLayoutCache
import org.acejump.modes.BetweenPointsMode
import org.acejump.modes.JumpMode
import org.acejump.modes.SessionMode
import org.acejump.search.*
import org.acejump.view.TagCanvas
import org.acejump.view.TextHighlighter
/**
* Manages an AceJump session for a single [Editor].
*/
class Session(private val editor: Editor) {
private val editorSettings = EditorSettings.setup(editor)
private lateinit var mode: SessionMode
private var state: SessionStateImpl? = null
private var tagger = Tagger(editor)
private var acceptedTag: Int? = null
set(value) {
field = value
if (value != null) {
tagCanvas.removeMarkers()
editorSettings.onTagAccepted(editor)
}
}
private val textHighlighter = TextHighlighter(editor)
private val tagCanvas = TagCanvas(editor)
@ExternalUsage
val tags
get() = tagger.tags
init {
KeyLayoutCache.ensureInitialized(AceConfig.settings)
EditorKeyListener.attach(editor, object : TypedActionHandler {
override fun execute(editor: Editor, charTyped: Char, context: DataContext) {
val state = state ?: return
val hadTags = tagger.hasTags
editorSettings.startEditing(editor)
val result = mode.type(state, charTyped, acceptedTag)
editorSettings.stopEditing(editor)
when (result) {
TypeResult.Nothing -> updateHint()
TypeResult.RestartSearch -> restart().also { this@Session.state = SessionStateImpl(editor, tagger); updateHint() }
is TypeResult.UpdateResults -> updateSearch(result.processor, markImmediately = hadTags)
is TypeResult.ChangeMode -> setMode(result.mode)
TypeResult.EndSession -> end()
}
}
})
}
/**
* Updates text highlights and tag markers according to the current search state. Dispatches jumps if the search query matches a tag.
*/
private fun updateSearch(processor: SearchProcessor, markImmediately: Boolean) {
val query = processor.query
val results = processor.results
if (!markImmediately && query.rawText.let { it.length < AceConfig.minQueryLength && it.all(Char::isLetterOrDigit) }) {
textHighlighter.renderOccurrences(results, query)
return
}
when (val result = tagger.update(query, results.clone())) {
is TaggingResult.Accept -> {
val offset = result.offset
acceptedTag = offset
textHighlighter.renderFinal(offset, processor.query)
}
is TaggingResult.Mark -> {
val tags = result.tags
tagCanvas.setMarkers(tags)
textHighlighter.renderOccurrences(results, query)
}
}
updateHint()
}
private fun setMode(mode: SessionMode) {
this.mode = mode
editor.colorsScheme.setColor(EditorColors.CARET_COLOR, mode.caretColor)
updateHint()
}
private fun updateHint() {
val hintArray = mode.getHint(acceptedTag, state?.currentProcessor.let { it != null && it.query.rawText.isNotEmpty() }) ?: return
val hintText = hintArray
.joinToString("\n")
.replace("<f>", "<span style=\"font-family:'${editor.colorsScheme.editorFontName}';font-weight:bold\">")
.replace("</f>", "</span>")
val hint = LightweightHint(HintUtil.createInformationLabel(hintText))
val pos = acceptedTag?.let(editor::offsetToLogicalPosition) ?: editor.caretModel.logicalPosition
val point = HintManagerImpl.getHintPosition(hint, editor, pos, HintManager.ABOVE)
val info = HintManagerImpl.createHintHint(editor, point, hint, HintManager.ABOVE).setShowImmediately(true)
val flags = HintManager.UPDATE_BY_SCROLLING or HintManager.HIDE_BY_ESCAPE
HintManagerImpl.getInstanceImpl().showEditorHint(hint, editor, point, flags, 0, true, info)
}
fun cycleMode() {
if (!this::mode.isInitialized) {
setMode(JumpMode())
state = SessionStateImpl(editor, tagger)
return
}
restart()
setMode(when (mode) {
is JumpMode -> BetweenPointsMode()
else -> JumpMode()
})
state = SessionStateImpl(editor, tagger)
}
/**
* Starts a regular expression search. If a search was already active, it will be reset alongside its tags and highlights.
*/
fun startRegexSearch(pattern: String, boundaries: Boundaries) {
if (!this::mode.isInitialized) {
setMode(JumpMode())
}
tagger = Tagger(editor)
tagCanvas.setMarkers(emptyList())
val processor = SearchProcessor.fromRegex(editor, pattern, boundaries).also { state = SessionStateImpl(editor, tagger, it) }
updateSearch(processor, markImmediately = true)
}
/**
* Starts a regular expression search. If a search was already active, it will be reset alongside its tags and highlights.
*/
fun startRegexSearch(pattern: Pattern, boundaries: Boundaries) {
startRegexSearch(pattern.regex, boundaries)
}
fun tagImmediately() {
val state = state ?: return
val processor = state.currentProcessor
if (processor != null) {
updateSearch(processor, markImmediately = true)
}
else if (mode is JumpMode) {
val offset = editor.caretModel.offset
val result = editor.immutableText.getOrNull(offset)?.let(state::type)
if (result is TypeResult.UpdateResults) {
acceptedTag = offset
textHighlighter.renderFinal(offset, result.processor.query)
updateHint()
}
}
}
/**
* Ends this session.
*/
fun end() {
SessionManager.end(editor)
}
/**
* Clears any currently active search, tags, and highlights.
*/
fun restart() {
state = null
tagger = Tagger(editor)
acceptedTag = null
tagCanvas.removeMarkers()
textHighlighter.reset()
HintManagerImpl.getInstanceImpl().hideAllHints()
editorSettings.onTagUnaccepted(editor)
editor.colorsScheme.setColor(EditorColors.CARET_COLOR, mode.caretColor)
editor.contentComponent.repaint()
}
/**
* Should only be used from [SessionManager] to dispose a successfully ended session.
*/
internal fun dispose() {
tagger = Tagger(editor)
tagCanvas.unbind()
textHighlighter.reset()
EditorKeyListener.detach(editor)
if (!editor.isDisposed) {
HintManagerImpl.getInstanceImpl().hideAllHints()
editorSettings.restore(editor)
editor.colorsScheme.setColor(EditorColors.CARET_COLOR, AbstractColorsScheme.INHERITED_COLOR_MARKER)
editor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE)
}
}
}

View File

@@ -0,0 +1,41 @@
package org.acejump.session
import com.intellij.openapi.editor.Editor
/**
* Manages active [Session]s in [Editor]s. There may only be one [Session] per [Editor], but multiple [Session]s across multiple [Editor]s
* may be active at once.
*
* It is possible for an [Editor] to be disposed with an active [Session]. In such case, the reference to both will remain until a new
* [Session] starts, at which point the [SessionManager.cleanup] method will purge disposed [Editor]s.
*/
object SessionManager {
private val sessions = HashMap<Editor, Session>(4)
/**
* Starts a new [Session], or returns an existing [Session] if the specified [Editor] already has one.
*/
fun start(editor: Editor): Session {
return sessions.getOrPut(editor) { cleanup(); Session(editor) }
}
/**
* Returns the active [Session] in the specified [Editor], or null if the [Editor] has no active session.
*/
operator fun get(editor: Editor): Session? {
return sessions[editor]
}
/**
* Ends the active [Session] in the specified [Editor], or does nothing if the [Editor] has no active session.
*/
fun end(editor: Editor) {
sessions.remove(editor)?.dispose()
}
private fun cleanup() {
for (disposedEditor in sessions.keys.filter { it.isDisposed }) {
sessions.remove(disposedEditor)?.dispose()
}
}
}

View File

@@ -0,0 +1,10 @@
package org.acejump.session
import com.intellij.openapi.editor.Editor
import org.acejump.action.AceTagAction
interface SessionState {
val editor: Editor
fun type(char: Char): TypeResult
fun act(action: AceTagAction, offset: Int, shiftMode: Boolean)
}

View File

@@ -0,0 +1,30 @@
package org.acejump.session
import com.intellij.openapi.editor.Editor
import org.acejump.action.AceTagAction
import org.acejump.boundaries.StandardBoundaries
import org.acejump.search.SearchProcessor
import org.acejump.search.Tagger
internal class SessionStateImpl(override val editor: Editor, private val tagger: Tagger, processor: SearchProcessor? = null) : SessionState {
internal var currentProcessor: SearchProcessor? = processor
override fun type(char: Char): TypeResult {
val processor = currentProcessor
if (processor == null) {
val newProcessor = SearchProcessor.fromChar(editor, char, StandardBoundaries.VISIBLE_ON_SCREEN)
return TypeResult.UpdateResults(newProcessor.also { currentProcessor = it })
}
if (processor.type(char, tagger)) {
return TypeResult.UpdateResults(processor)
}
return TypeResult.Nothing
}
override fun act(action: AceTagAction, offset: Int, shiftMode: Boolean) {
currentProcessor?.let { action(editor, it, offset, shiftMode) }
}
}

View File

@@ -0,0 +1,12 @@
package org.acejump.session
import org.acejump.modes.SessionMode
import org.acejump.search.SearchProcessor
sealed class TypeResult {
object Nothing : TypeResult()
object RestartSearch : TypeResult()
class UpdateResults(val processor: SearchProcessor) : TypeResult()
class ChangeMode(val mode: SessionMode) : TypeResult()
object EndSession : TypeResult()
}

View File

@@ -1,69 +0,0 @@
package org.acejump.view
import org.acejump.search.getLineEndOffset
import org.acejump.search.getLineStartOffset
import org.acejump.view.Model.editor
import org.acejump.view.Model.editorText
import org.acejump.view.Model.viewBounds
import kotlin.math.max
import kotlin.math.min
/**
* Interface which defines the boundary inside the file to be searched
*/
enum class Boundary: ClosedRange<Int> {
// Search the complete file
FULL_FILE_BOUNDARY {
override val start: Int
get() = 0
override val endInclusive: Int
get() = editorText.length
},
// Search only on the screen
SCREEN_BOUNDARY {
override val start: Int
get() = Model.viewBounds.first
override val endInclusive: Int
get() = Model.viewBounds.last
},
// Search from the start of the screen to the caret
BEFORE_CARET_BOUNDARY {
override val start: Int
get() = max(0, viewBounds.first)
override val endInclusive: Int
get() = min(editor.caretModel.offset, viewBounds.last)
},
// Search from the caret to the end of the screen
AFTER_CARET_BOUNDARY {
override val start: Int
get() = max(editor.caretModel.offset, viewBounds.first)
override val endInclusive: Int
get() = viewBounds.last
},
// Search on the current line
CURRENT_LINE_BOUNDARY {
override val start: Int
get() = editor.getLineStartOffset(editor.caretModel.logicalPosition.line)
override val endInclusive: Int
get() = editor.getLineEndOffset(editor.caretModel.logicalPosition.line)
},
// Search after caret within line
CURRENT_LINE_AFTER_CARET_BOUNDARY {
override val start: Int
get() = max(editor.caretModel.offset, viewBounds.first)
override val endInclusive: Int
get() = editor.getLineEndOffset(editor.caretModel.logicalPosition.line)
},
// Search before caret within line
CURRENT_LINE_BEFORE_CARET_BOUNDARY {
override val start: Int
get() = editor.getLineStartOffset(editor.caretModel.logicalPosition.line)
override val endInclusive: Int
get() = min(editor.caretModel.offset, viewBounds.last)
};
fun intRange() = IntRange(start, endInclusive)
override fun toString() = super.toString() + " (${intRange()}) "
}

View File

@@ -1,67 +0,0 @@
package org.acejump.view
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import org.acejump.search.*
import org.acejump.view.Model.fontWidth
import org.acejump.view.Model.viewBounds
import java.awt.Graphics
import java.awt.Point
import javax.swing.JComponent
import javax.swing.SwingUtilities.convertPoint
/**
* Overlay composed of all tag [Marker]s. Maintains a registry of tags' visual
* positions once assigned, so that we do not paint two tags to the same space.
*/
object Canvas: JComponent(), Resettable {
private val logger = Logger.getInstance(Canvas::class.java)
private val occupied = hashSetOf<Point>()
@Volatile
var jumpLocations: Collection<Marker> = emptyList()
set(value) {
field = value
runLater { repaint() }
}
fun Editor.bindCanvas() {
reset()
storeBounds()
contentComponent.add(Canvas)
setBounds(0, 0, contentComponent.width, contentComponent.height)
if (ApplicationInfo.getInstance().build.components.first() < 173) {
val loc = convertPoint(Canvas, location, component.rootPane)
setLocation(-loc.x, -loc.y)
}
}
private fun Editor.storeBounds() {
viewBounds = getView()
// TODO: Fix reference, cf. https://github.com/acejump/AceJump/issues/200
this::offsetToLogicalPosition.let {
logger.info("View bounds: $viewBounds (lines " +
"${it(viewBounds.first).line}..${it(viewBounds.last).line})")
}
}
override fun paint(graphics: Graphics) {
jumpLocations.ifEmpty { return }
super.paint(graphics)
occupied.clear()
jumpLocations.forEach { it.paintMe(graphics) }
}
fun registerTag(p: Point, tag: String) =
(-1..tag.length).forEach { occupied.add(Point(p.x + it * fontWidth, p.y)) }
fun isFree(point: Point) = point !in occupied
override fun reset() {
jumpLocations = emptyList()
occupied.clear()
}
}

View File

@@ -1,230 +0,0 @@
package org.acejump.view
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.markup.CustomHighlighterRenderer
import com.intellij.openapi.editor.markup.RangeHighlighter
import org.acejump.config.AceConfig
import org.acejump.control.Selector
import org.acejump.label.Tagger.regex
import org.acejump.search.*
import org.acejump.search.JumpMode.TARGET
import org.acejump.view.Marker.Alignment.*
import org.acejump.view.Model.arcD
import org.acejump.view.Model.editor
import org.acejump.view.Model.fontHeight
import org.acejump.view.Model.fontWidth
import org.acejump.view.Model.naturalCaretColor
import org.acejump.view.Model.rectHeight
import org.acejump.view.Model.rectVOffset
import java.awt.*
import java.awt.AlphaComposite.SRC_OVER
import java.awt.AlphaComposite.getInstance
import java.awt.RenderingHints.KEY_ANTIALIASING
import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
import org.acejump.view.Model.editorText as text
/**
* Represents the visual component of a tag, which gets painted to the screen.
* All functionality related to tag highlighting including visual appearance,
* alignment and painting should be handled here. Tags are "captioned" with two
* or fewer characters. To select a tag, a user will type the tag's assigned
* "caption", which will move the caret to a known index in the document.
*
* TODO: Clean up this class.
*/
class Marker {
val index: Int
private val query: String
val tag: String?
private val tagUpperCase: String?
private var srcPoint: Point
private var queryLength: Int
private var trueOffset: Int
private val searchWidth: Int
private val tgIdx: Int
private val start: Point
private val startY: Int
private var tagPoint: Point
private var yPos: Int
private var alignment: Alignment
private val endsWith: Boolean
constructor(query: String, tag: String?, index: Int) {
this.query = query
this.tag = tag
this.tagUpperCase = tag?.toUpperCase()
this.index = index
val lastQueryChar = query.last()
endsWith = tag != null && lastQueryChar == tag[0]
queryLength = query.length - if(endsWith) 1 else 0
trueOffset = query.length - 1
searchWidth = if (regex) 0 else queryLength * fontWidth
var i = 1
while (i < query.length && index + i < text.length &&
query[i].equals(text[index + i], ignoreCase = true)) i++
trueOffset = i - 1
queryLength = i
tgIdx = index + trueOffset
editor.run {
start = getPoint(index)
srcPoint = getPointRelative(start, contentComponent)
tagPoint =
if (index == tgIdx) srcPoint
else getPointRelative(getPoint(tgIdx), contentComponent)
}
startY = start.y + rectVOffset
yPos = tagPoint.y + rectVOffset
alignment = RIGHT
}
enum class Alignment { /*TOP, BOTTOM,*/ LEFT, RIGHT, NONE }
/**
* Called by AceJump as a renderable element of [Canvas]. Paints [tag]s.
*/
fun paintMe(graphics: Graphics) = (graphics as Graphics2D).run {
setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
if (!Finder.skim)
tag?.alignTag(Canvas)
?.apply { Canvas.registerTag(this, tag) }
?.let {
if (alignment == NONE) return
highlightTag(it); drawTagForeground(it)
}
}
/**
* Called by IntelliJ as a [CustomHighlighterRenderer]. Paints [highlight]s.
*/
companion object: CustomHighlighterRenderer {
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) =
(g as Graphics2D).run {
setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
color = AceConfig.textHighlightColor
val start = editor.getPoint(highlighter.startOffset)
val startY = start.y + rectVOffset
if (regex) {
highlightRegex(start.x, startY)
}
else {
val queryLength = highlighter.endOffset - highlighter.startOffset
val searchWidth = queryLength * fontWidth
fillRoundRect(start.x, startY, searchWidth, rectHeight, arcD, arcD)
if (JumpMode.equals(TARGET)) {
surroundTargetWord(highlighter.startOffset, startY)
}
}
// TODO this is a lag-fest, results should be cached before this gets turned back on
//if (highlighter.startOffset == Selector.nearestVisibleMatches().firstOrNull()) {
// color = naturalCaretColor
// drawLine(start.x, startY, start.x, startY + rectHeight)
//}
}
private fun Graphics2D.highlightRegex(x: Int, y: Int) {
composite = getInstance(SRC_OVER, 0.40.toFloat())
fillRoundRect(x, y, fontWidth, rectHeight, arcD, arcD)
}
private fun Graphics2D.surroundTargetWord(index: Int, startY: Int) {
if (!text[index].isLetterOrDigit()) {
return
}
color = AceConfig.targetModeColor
val (wordStart, wordEnd) = text.wordBounds(index)
val xPos = editor.getPoint(wordStart).x
val wordWidth = (wordEnd - wordStart) * fontWidth
drawRoundRect(xPos, startY, wordWidth, rectHeight, arcD, arcD)
}
}
private fun Graphics2D.drawTagForeground(tagPosition: Point) {
font = Model.font
color = AceConfig.tagForegroundColor
composite = getInstance(SRC_OVER, 1.toFloat())
drawString(tagUpperCase!!, tagPosition.x, tagPosition.y + fontHeight)
}
// TODO: Fix tag alignment and visibility issues
// https://github.com/acejump/AceJump/issues/233
// https://github.com/acejump/AceJump/issues/228
private fun String.alignTag(canvas: Canvas): Point {
val xPos = tagPoint.x + fontWidth
// val top = Point(x - fontWidth, y - fontHeight)
// val bottom = Point(x - fontWidth, y + fontHeight)
val left = Point(srcPoint.x - fontWidth * length, yPos)
val right = Point(xPos, yPos)
val nextCharIsWhiteSpace = text.length <= index + 1 ||
text[index + 1].isWhitespace()
val canAlignRight = canvas.isFree(right)
val isFirstCharacterOfLine = editor.isFirstCharacterOfLine(index)
val canAlignLeft = !isFirstCharacterOfLine && canvas.isFree(left)
alignment = when {
nextCharIsWhiteSpace -> RIGHT
isFirstCharacterOfLine -> RIGHT
canAlignLeft -> LEFT
canAlignRight -> RIGHT
else -> NONE
}
return when (alignment) {
// TOP -> top
LEFT -> left
RIGHT -> right
// BOTTOM -> bottom
NONE -> Point(-1, -1)
}
}
private fun Graphics2D.highlightTag(point: Point?) {
if (query.isEmpty() || alignment == NONE) return
var tagX = point!!.x
var tagWidth = tag?.length?.times(fontWidth) ?: 0
val charIndex = index + query.length - 1
val beforeEnd = charIndex < text.length
val textChar = if (beforeEnd) text[charIndex].toLowerCase() else 0.toChar()
fun highlightFirst() {
composite = getInstance(SRC_OVER, 0.40.toFloat())
color = AceConfig.textHighlightColor
if (endsWith && query.last() != textChar) {
fillRoundRect(tagX, yPos, fontWidth, rectHeight, arcD, arcD)
tagX += fontWidth
tagWidth -= fontWidth
}
}
fun highlightLast() {
color = AceConfig.tagBackgroundColor
if (alignment != RIGHT || text.hasSpaceRight(index) || regex)
composite = getInstance(SRC_OVER, 1.toFloat())
fillRoundRect(tagX, yPos, tagWidth, rectHeight, arcD, arcD)
}
highlightFirst()
highlightLast()
}
infix fun inside(viewBounds: IntRange) = index in viewBounds
}

View File

@@ -1,73 +0,0 @@
package org.acejump.view
import com.github.promeg.pinyinhelper.Pinyin
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColors.CARET_COLOR
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.project.ProjectManager
import org.acejump.config.AceConfig
import org.acejump.search.defaultEditor
import org.acejump.view.Boundary.FULL_FILE_BOUNDARY
import java.awt.Color.BLACK
import java.awt.Color.YELLOW
import java.awt.Font
import java.awt.Font.BOLD
/**
* Data holder for all settings and IDE components needed by AceJump.
*
* TODO: Integrate this class with [org.acejump.config.AceSettings]
*/
object Model {
var editor: Editor = defaultEditor()
val markup
get() = editor.markupModel
val project
get() = editor.project ?: ProjectManager.getInstance().defaultProject
val caretOffset
get() = editor.caretModel.offset
val editorText: String
get() = editor.document.text
.let { if (AceConfig.supportPinyin) it.mapToPinyin() else it }
private fun String.mapToPinyin() =
map { Pinyin.toPinyin(it).first() }.joinToString("")
var naturalBlock = false
var naturalBlink = true
var naturalCaretColor = BLACK
var naturalHighlight = YELLOW
val scheme
get() = editor.colorsScheme
val font
get() = Font(scheme.editorFontName, BOLD, scheme.editorFontSize)
val fontWidth
get() = editor.component.getFontMetrics(font).stringWidth("w")
val fontHeight
get() = editor.colorsScheme.editorFontSize
val lineHeight
get() = editor.lineHeight
val rectHeight
get() = fontHeight + 3
val rectVOffset
get() = lineHeight - (editor as EditorImpl).descent - fontHeight
val arcD
get() = if (AceConfig.roundedTagCorners) rectHeight - 6 else 1
var viewBounds = 0..0
const val LONG_DOCUMENT_LENGTH = 100000
val LONG_DOCUMENT
get() = LONG_DOCUMENT_LENGTH < editorText.length
const val MAX_TAG_RESULTS = 300
val defaultBoundary = FULL_FILE_BOUNDARY
var boundaries: Boundary = defaultBoundary
fun Editor.setupCaret() {
settings.isBlockCursor = true
settings.isBlinkCaret = false
colorsScheme.setColor(CARET_COLOR, AceConfig.jumpModeColor)
}
}

View File

@@ -0,0 +1,130 @@
package org.acejump.view
import com.intellij.openapi.editor.Editor
import com.intellij.ui.ColorUtil
import com.intellij.ui.scale.JBUIScale
import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries
import org.acejump.config.AceConfig
import org.acejump.countMatchingCharacters
import org.acejump.immutableText
import java.awt.Color
import java.awt.Graphics2D
import java.awt.Point
import java.awt.Rectangle
import kotlin.math.max
/**
* Describes a 1 or 2 character shortcut that points to a specific character in the editor.
*/
internal class Tag(
private val tag: String,
val offsetL: Int,
val offsetR: Int,
private val shiftR: Int,
private val hasSpaceRight: Boolean
) {
private val length = tag.length
companion object {
private const val ARC = 1
/**
* Creates a new tag, precomputing some information about the nearby characters to reduce rendering overhead. If the last typed
* character ([literalQueryText]) matches the first [tag] character, only the second [tag] character is displayed.
*/
fun create(editor: Editor, tag: String, offset: Int, literalQueryText: String?): Tag {
val chars = editor.immutableText
val matching = literalQueryText?.let { chars.countMatchingCharacters(offset, it) } ?: 0
val hasSpaceRight = offset + 1 >= chars.length || chars[offset + 1].isWhitespace()
val displayedTag = if (literalQueryText != null && literalQueryText.last().equals(tag.first(), ignoreCase = true))
tag.drop(1).toUpperCase()
else
tag.toUpperCase()
return Tag(displayedTag, offset, offset + max(0, matching - 1), tag.length - displayedTag.length, hasSpaceRight)
}
/**
* Renders the tag background.
*/
private fun drawHighlight(g: Graphics2D, rect: Rectangle, color: Color) {
g.color = color
g.fillRoundRect(rect.x, rect.y, rect.width, rect.height + 1, ARC, ARC)
}
/**
* Renders the tag text.
*/
private fun drawForeground(g: Graphics2D, font: TagFont, point: Point, text: String) {
val x = point.x + 2
val y = point.y + font.baselineDistance
g.font = font.tagFont
if (!ColorUtil.isDark(AceConfig.tagForegroundColor)) {
g.color = Color(0F, 0F, 0F, 0.35F)
g.drawString(text, x + 1, y + 1)
}
g.color = AceConfig.tagForegroundColor
g.drawString(text, x, y)
}
}
/**
* Returns true if the left-aligned offset is in the range. Use to cull tags outside visible range.
* Only the left offset is checked, because if the tag was right-aligned on the last index of the range, it would not be visible anyway.
*/
fun isOffsetInRange(range: IntRange): Boolean {
return offsetL in range
}
/**
* Paints the tag, taking into consideration visual space around characters in the editor, as well as all other previously painted tags.
* Returns a rectangle indicating the area where the tag was rendered, or null if the tag could not be rendered due to overlap.
*/
fun paint(g: Graphics2D, editor: Editor, cache: EditorOffsetCache, font: TagFont, occupied: MutableList<Rectangle>): Rectangle? {
val rect = alignTag(editor, cache, font, occupied) ?: return null
drawHighlight(g, rect, AceConfig.tagBackgroundColor)
drawForeground(g, font, rect.location, tag)
occupied.add(JBUIScale.scale(2).let { Rectangle(rect.x - it, rect.y, rect.width + (2 * it), rect.height) })
return rect
}
private fun alignTag(editor: Editor, cache: EditorOffsetCache, font: TagFont, occupied: List<Rectangle>): Rectangle? {
val boundaries = StandardBoundaries.VISIBLE_ON_SCREEN
if (hasSpaceRight || offsetL == 0 || editor.immutableText[offsetL - 1].let { it == '\n' || it == '\r' }) {
val rectR = createRightAlignedTagRect(editor, cache, font)
return rectR.takeIf { boundaries.isOffsetInside(editor, offsetR, cache) && occupied.none(rectR::intersects) }
}
val rectL = createLeftAlignedTagRect(editor, cache, font)
if (occupied.none(rectL::intersects)) {
return rectL.takeIf { boundaries.isOffsetInside(editor, offsetL, cache) }
}
val rectR = createRightAlignedTagRect(editor, cache, font)
if (occupied.none(rectR::intersects)) {
return rectR.takeIf { boundaries.isOffsetInside(editor, offsetR, cache) }
}
return null
}
private fun createRightAlignedTagRect(editor: Editor, cache: EditorOffsetCache, font: TagFont): Rectangle {
val pos = cache.offsetToXY(editor, offsetR)
val shift = font.editorFontMetrics.charWidth(editor.immutableText[offsetR]) + (font.tagCharWidth * shiftR)
return Rectangle(pos.x + shift, pos.y, (font.tagCharWidth * length) + 4, font.lineHeight)
}
private fun createLeftAlignedTagRect(editor: Editor, cache: EditorOffsetCache, font: TagFont): Rectangle {
val pos = cache.offsetToXY(editor, offsetL)
val shift = -(font.tagCharWidth * length)
return Rectangle(pos.x + shift - 4, pos.y, (font.tagCharWidth * length) + 4, font.lineHeight)
}
}

View File

@@ -0,0 +1,91 @@
package org.acejump.view
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.event.CaretEvent
import com.intellij.openapi.editor.event.CaretListener
import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Rectangle
import java.awt.RenderingHints
import javax.swing.JComponent
import javax.swing.SwingUtilities
/**
* Holds all active tag markers and renders them on top of the editor.
*/
internal class TagCanvas(private val editor: Editor) : JComponent(), CaretListener {
private var markers: List<Tag>? = null
init {
val contentComponent = editor.contentComponent
contentComponent.add(this)
setBounds(0, 0, contentComponent.width, contentComponent.height)
if (ApplicationInfo.getInstance().build.components.first() < 173) {
SwingUtilities.convertPoint(this, location, editor.component.rootPane).let { setLocation(-it.x, -it.y) }
}
editor.caretModel.addCaretListener(this)
}
fun unbind() {
markers = null
editor.contentComponent.remove(this)
editor.caretModel.removeCaretListener(this)
}
/**
* Ensures that all tags and the outline around the selected tag are repainted. It should not be necessary to repaint the entire tag
* canvas, but the cost of repainting visible tags is negligible.
*/
override fun caretPositionChanged(event: CaretEvent) {
repaint()
}
fun setMarkers(markers: List<Tag>) {
this.markers = markers
repaint()
}
fun removeMarkers() {
this.markers = emptyList()
}
override fun paint(g: Graphics) {
if (!markers.isNullOrEmpty()) {
super.paint(g)
}
}
override fun paintChildren(g: Graphics) {
super.paintChildren(g)
val markers = markers ?: return
val font = TagFont(editor)
val cache = EditorOffsetCache.new()
val viewRange = StandardBoundaries.VISIBLE_ON_SCREEN.getOffsetRange(editor, cache)
val occupied = mutableListOf<Rectangle>()
(g as Graphics2D).setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
// If there is a tag at the caret location, prioritize its rendering over all other tags. This is helpful for seeing which tag is
// currently selected while navigating highly clustered tags, although it does end up rearranging nearby tags which can be confusing.
// TODO instead of immediately painting, we could calculate the layout of everything first, and then remove tags that interfere with
// the caret tag to avoid changing the alignment of the caret tag
val caretOffset = editor.caretModel.offset
val caretMarker = markers.find { it.offsetL == caretOffset || it.offsetR == caretOffset }
caretMarker?.paint(g, editor, cache, font, occupied)
for (marker in markers) {
if (marker.isOffsetInRange(viewRange) && marker !== caretMarker) {
marker.paint(g, editor, cache, font, occupied)
}
}
}
}

View File

@@ -0,0 +1,18 @@
package org.acejump.view
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorFontType
import java.awt.Font
import java.awt.FontMetrics
/**
* Stores font metrics for aligning and rendering [Tag]s.
*/
internal class TagFont(editor: Editor) {
val tagFont: Font = editor.colorsScheme.getFont(EditorFontType.BOLD)
val tagCharWidth = editor.component.getFontMetrics(tagFont).charWidth('W')
val editorFontMetrics: FontMetrics = editor.component.getFontMetrics(editor.colorsScheme.getFont(EditorFontType.PLAIN))
val lineHeight = editor.lineHeight
val baselineDistance = editor.ascent
}

View File

@@ -0,0 +1,130 @@
package org.acejump.view
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorFontType
import com.intellij.openapi.editor.markup.CustomHighlighterRenderer
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.ints.IntList
import org.acejump.boundaries.EditorOffsetCache
import org.acejump.config.AceConfig
import org.acejump.immutableText
import org.acejump.search.SearchQuery
import java.awt.Color
import java.awt.Graphics
/**
* Renders highlights for search occurrences.
*/
internal class TextHighlighter(private val editor: Editor) {
private var previousHighlights: Array<RangeHighlighter>? = null
/**
* Removes all current highlights and re-creates them from scratch. Must be called whenever any of the method parameters change.
*/
fun renderOccurrences(offsets: IntList, query: SearchQuery) {
render(offsets, when (query) {
is SearchQuery.RegularExpression -> RegexRenderer
else -> SearchedWordRenderer
}, query::getHighlightLength)
}
/**
* Removes all current highlights and re-adds a single highlight at the position of the accepted tag with a different color.
*/
fun renderFinal(offset: Int, query: SearchQuery) {
render(IntArrayList(intArrayOf(offset)), AcceptedTagRenderer, query::getHighlightLength)
}
private inline fun render(offsets: IntList, renderer: CustomHighlighterRenderer, getHighlightLength: (CharSequence, Int) -> Int) {
val markup = editor.markupModel
val chars = editor.immutableText
val modifications = (previousHighlights?.size ?: 0) + offsets.size
val enableBulkEditing = modifications > 1000
val document = editor.document
try {
if (enableBulkEditing) {
document.isInBulkUpdate = true
}
previousHighlights?.forEach(markup::removeHighlighter)
previousHighlights = Array(offsets.size) { index ->
val start = offsets.getInt(index)
val end = start + getHighlightLength(chars, start)
markup.addRangeHighlighter(start, end, LAYER, null, HighlighterTargetArea.EXACT_RANGE).apply {
customRenderer = renderer
}
}
} finally {
if (enableBulkEditing) {
document.isInBulkUpdate = false
}
}
}
fun reset() {
editor.markupModel.removeAllHighlighters()
previousHighlights = null
}
/**
* Renders a filled highlight in the background of a searched text occurrence.
*/
private object SearchedWordRenderer : CustomHighlighterRenderer {
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) {
drawFilled(g, editor, highlighter.startOffset, highlighter.endOffset, AceConfig.textHighlightColor)
}
}
/**
* Renders a filled highlight in the background of the first highlighted position. Used for regex search queries.
*/
private object RegexRenderer : CustomHighlighterRenderer {
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) {
drawSingle(g, editor, highlighter.startOffset, AceConfig.textHighlightColor)
}
}
/**
* Renders a filled highlight in the background of the accepted tag position and search query.
*/
private object AcceptedTagRenderer : CustomHighlighterRenderer {
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) {
drawFilled(g, editor, highlighter.startOffset, highlighter.endOffset, AceConfig.acceptedTagColor)
}
}
private companion object {
private const val LAYER = HighlighterLayer.LAST + 1
private fun drawFilled(g: Graphics, editor: Editor, startOffset: Int, endOffset: Int, color: Color) {
val start = EditorOffsetCache.Uncached.offsetToXY(editor, startOffset)
val end = EditorOffsetCache.Uncached.offsetToXY(editor, endOffset)
g.color = color
g.fillRect(start.x, start.y + 1, end.x - start.x, editor.lineHeight - 1)
g.color = AceConfig.tagBackgroundColor
g.drawRect(start.x, start.y, end.x - start.x, editor.lineHeight)
}
private fun drawSingle(g: Graphics, editor: Editor, offset: Int, color: Color) {
val pos = EditorOffsetCache.Uncached.offsetToXY(editor, offset)
val char = editor.immutableText.getOrNull(offset)?.takeUnless { it == '\n' || it == '\t' } ?: ' '
val font = editor.colorsScheme.getFont(EditorFontType.PLAIN)
val lastCharWidth = editor.component.getFontMetrics(font).charWidth(char)
g.color = color
g.fillRect(pos.x, pos.y + 1, lastCharWidth, editor.lineHeight - 1)
g.color = AceConfig.tagBackgroundColor
g.drawRect(pos.x, pos.y, lastCharWidth, editor.lineHeight)
}
}
}

View File

@@ -1,26 +0,0 @@
charactersAndLayoutHeading=Characters and Layout
tagCharsToBeUsedLabel=Allowed characters:
keyboardLayoutLabel=Keyboard layout:
keyboardDesignLabel=Keyboard design:
modesHeading=Modes
cycleModeOrderLabel=Cycle order:
jumpModeDisabled=(Skip)
jumpModeJump=Jump
jumpModeJumpEnd=Jump to End
jumpModeTarget=Target
jumpModeDefine=Definition
colorsHeading=Colors
jumpModeColorLabel=Jump mode caret background:
jumpEndModeColorLabel=Jump to End mode caret background:
targetModeColorLabel=Target mode caret background:
definitionModeColorLabel=Definition mode caret background:
textHighlightColorLabel=Searched text background:
tagForegroundColorLabel=Tag foreground:
tagBackgroundColorLabel=Tag background:
appearanceHeading=Appearance
displayQueryLabel=Display search query
roundedTagCornersLabel=Rounded tag corners
languagesHeading=Language
behaviorHeading=Behavior
searchWholeFileLabel=Search whole file
supportPinyin=Support Pinyin selection

View File

@@ -18,51 +18,57 @@
<applicationConfigurable groupId="tools" displayName="AceJump" <applicationConfigurable groupId="tools" displayName="AceJump"
instance="org.acejump.config.AceConfigurable" instance="org.acejump.config.AceConfigurable"
id="preferences.AceConfigurable" dynamic="true"/> id="preferences.AceConfigurable" dynamic="true"/>
<editorActionHandler action="EditorEscape" order="first"
implementationClass="org.acejump.action.AceEditorAction$Reset"/>
<editorActionHandler action="EditorBackSpace" order="first"
implementationClass="org.acejump.action.AceEditorAction$ClearSearch"/>
<editorActionHandler action="EditorEnter" order="first"
implementationClass="org.acejump.action.AceEditorAction$TagImmediately"/>
<editorActionHandler action="EditorUp" order="first"
implementationClass="org.acejump.action.AceEditorAction$SearchLineStarts"/>
<editorActionHandler action="EditorLeft" order="first"
implementationClass="org.acejump.action.AceEditorAction$SearchLineIndents"/>
<editorActionHandler action="EditorLineStart" order="first"
implementationClass="org.acejump.action.AceEditorAction$SearchLineIndents"/>
<editorActionHandler action="EditorRight" order="first"
implementationClass="org.acejump.action.AceEditorAction$SearchLineEnds"/>
<editorActionHandler action="EditorLineEnd" order="first"
implementationClass="org.acejump.action.AceEditorAction$SearchLineEnds"/>
</extensions> </extensions>
<actions> <actions>
<action id="AceAction" <action id="AceAction"
class="org.acejump.control.AceAction$ActivateOrCycleMode" class="org.acejump.action.AceKeyboardAction$ActivateAceJump"
text="Activate / Cycle AceJump Mode"> text="Activate AceJump Mode">
<keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl SEMICOLON"/> <keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl SEMICOLON"/>
<keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl SEMICOLON"/> <keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl SEMICOLON"/>
<keyboard-shortcut keymap="$default" first-keystroke="ctrl SEMICOLON"/> <keyboard-shortcut keymap="$default" first-keystroke="ctrl SEMICOLON"/>
</action> </action>
<action id="AceWordStartAction"
class="org.acejump.control.AceAction$ToggleJumpMode"
text="Start AceJump in Word Start Mode"/>
<action id="AceWordEndAction"
class="org.acejump.control.AceAction$ToggleJumpEndMode"
text="Start AceJump in Word End Mode"/>
<action id="AceTargetAction"
class="org.acejump.control.AceAction$ToggleSelectWordMode"
text="Start AceJump in Word Select Mode">
<keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl alt SEMICOLON"/>
<keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl alt SEMICOLON"/>
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt SEMICOLON"/>
</action>
<action id="AceDeclarationAction"
class="org.acejump.control.AceAction$ToggleDefinitionMode"
text="Start AceJump in Declaration Mode"/>
<action id="AceLineAction" <action id="AceLineAction"
class="org.acejump.control.AceAction$ToggleAllLinesMode" class="org.acejump.action.AceKeyboardAction$StartAllLineMarksMode"
text="Start AceJump in All Lines Mode" text="Start AceJump in All Line Marks Mode">
description="Targets line markers in AceJump">
<keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl shift SEMICOLON"/> <keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl shift SEMICOLON"/>
<keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl shift SEMICOLON"/> <keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl shift SEMICOLON"/>
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift SEMICOLON"/> <keyboard-shortcut keymap="$default" first-keystroke="ctrl shift SEMICOLON"/>
</action> </action>
<action id="AceLineStartsAction"
class="org.acejump.action.AceKeyboardAction$StartAllLineStartsMode"
text="Start AceJump in All Line Starts Mode"/>
<action id="AceLineEndsAction"
class="org.acejump.action.AceKeyboardAction$StartAllLineEndsMode"
text="Start AceJump in All Line Ends Mode"/>
<action id="AceLineIndentsAction"
class="org.acejump.action.AceKeyboardAction$StartAllLineIndentsMode"
text="Start AceJump in All Line Indents Mode"/>
<action id="AceWordAction" <action id="AceWordAction"
class="org.acejump.control.AceAction$ToggleAllWordsMode" class="org.acejump.action.AceKeyboardAction$StartAllWordsMode"
text="Start AceJump in All Words Mode"/> text="Start AceJump in All Words Mode"/>
<action id="AceWordForwardAction" <action id="AceWordForwardAction"
class="org.acejump.control.AceAction$ToggleAllWordsForwardMode" class="org.acejump.action.AceKeyboardAction$StartAllWordsForwardMode"
text="Start in All Words After Caret Mode"/> text="Start AceJump in All Words After Caret Mode"/>
<action id="AceWordBackwardsAction" <action id="AceWordBackwardsAction"
class="org.acejump.control.AceAction$ToggleAllWordsBackwardsMode" class="org.acejump.action.AceKeyboardAction$StartAllWordsBackwardsMode"
text="Start in All Words Before Caret Mode"/> text="Start AceJump in All Words Before Caret Mode"/>
<action id="AceActOnHighlightedWords"
class="org.acejump.control.AceAction$ActOnHighlightedWords"
text="Act on Highlighted Words"/>
</actions> </actions>
</idea-plugin> </idea-plugin>

View File

@@ -1,17 +1,7 @@
import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_ENTER
import com.intellij.openapi.actionSystem.IdeActions.* import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_START_NEW_LINE
import com.intellij.openapi.components.ServiceManager import org.acejump.action.AceKeyboardAction
import com.intellij.openapi.fileTypes.PlainTextFileType import org.acejump.test.util.BaseTest
import com.intellij.psi.PsiFile
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import org.acejump.config.AceConfig
import org.acejump.control.*
import org.acejump.label.Tagger
import org.acejump.view.Canvas
import kotlin.random.Random
import kotlin.system.measureTimeMillis
import java.io.File
/** /**
* Functional test cases and end-to-end performance tests. * Functional test cases and end-to-end performance tests.
@@ -19,7 +9,7 @@ import java.io.File
* TODO: Add more structure to test cases, use test resources to define files. * TODO: Add more structure to test cases, use test resources to define files.
*/ */
class AceTest: BasePlatformTestCase() { class AceTest : BaseTest() {
fun `test that scanner finds all occurrences of single character`() = fun `test that scanner finds all occurrences of single character`() =
assertEquals("test test test".search("t"), setOf(0, 3, 5, 8, 10, 13)) assertEquals("test test test".search("t"), setOf(0, 3, 5, 8, 10, 13))
@@ -65,7 +55,8 @@ class AceTest: BasePlatformTestCase() {
fun `test tag selection`() { fun `test tag selection`() {
"<caret>testing 1234".search("g") "<caret>testing 1234".search("g")
typeAndWaitForResults(Canvas.jumpLocations.first().tag!!) typeAndWaitForResults(session.tags[0].key)
typeAndWaitForResults("j")
myFixture.checkResult("testin<caret>g 1234") myFixture.checkResult("testin<caret>g 1234")
} }
@@ -73,7 +64,8 @@ class AceTest: BasePlatformTestCase() {
fun `test shift selection`() { fun `test shift selection`() {
"<caret>testing 1234".search("4") "<caret>testing 1234".search("4")
typeAndWaitForResults(Canvas.jumpLocations.first().tag!!.toUpperCase()) typeAndWaitForResults(session.tags[0].key)
typeAndWaitForResults("J")
myFixture.checkResult("<selection>testing 123<caret></selection>4") myFixture.checkResult("<selection>testing 123<caret></selection>4")
} }
@@ -81,27 +73,28 @@ class AceTest: BasePlatformTestCase() {
fun `test words before caret action`() { fun `test words before caret action`() {
makeEditor("test words <caret> before caret is two") makeEditor("test words <caret> before caret is two")
takeAction(AceWordBackwardsAction()) takeAction(AceKeyboardAction.StartAllWordsBackwardsMode)
assertEquals(2, Tagger.markers.size) assertEquals(2, session.tags.size)
} }
fun `test words after caret action`() { fun `test words after caret action`() {
makeEditor("test words <caret> after caret is four") makeEditor("test words <caret> after caret is four")
takeAction(AceWordForwardAction()) takeAction(AceKeyboardAction.StartAllWordsForwardMode)
assertEquals(4, Tagger.markers.size) assertEquals(4, session.tags.size)
} }
fun `test word mode`() { fun `test word mode`() {
makeEditor("test word action") makeEditor("test word action")
takeAction(AceWordAction()) takeAction(AceKeyboardAction.StartAllWordsMode)
assertEquals(3, Tagger.markers.size) assertEquals(3, session.tags.size)
typeAndWaitForResults(Canvas.jumpLocations.toList()[1].tag!!) typeAndWaitForResults(session.tags[1].key)
typeAndWaitForResults("j")
myFixture.checkResult("test <caret>word action") myFixture.checkResult("test <caret>word action")
} }
@@ -109,123 +102,17 @@ class AceTest: BasePlatformTestCase() {
fun `test target mode`() { fun `test target mode`() {
"<caret>test target action".search("target") "<caret>test target action".search("target")
takeAction(AceTargetAction()) typeAndWaitForResults(session.tags[0].key)
typeAndWaitForResults(Canvas.jumpLocations.first().tag!!) typeAndWaitForResults("s")
myFixture.checkResult("test <selection>target<caret></selection> action") myFixture.checkResult("test <selection>target<caret></selection> action")
} }
fun `test line mode`() { fun `test line mode`() {
makeEditor(" test\n three\n lines") makeEditor(" test\n three\n lines\n")
takeAction(AceLineAction()) takeAction(AceKeyboardAction.StartAllLineMarksMode)
assertEquals(9, Tagger.markers.size) assertEquals(9, session.tags.size)
} }
}
fun `test pinyin selection`() {
getSettings().supportPinyin = true
"test 拼音 selection".search("py")
takeAction(AceTargetAction())
typeAndWaitForResults(Canvas.jumpLocations.first().tag!!)
myFixture.checkResult("test <selection>拼音<caret></selection> selection")
}
fun `test tag latency`(editorText: String) {
var time = 0L
editorText.toCharArray().distinct().filter { !it.isWhitespace() }
.forEach { query ->
repeat(10) {
makeEditor(editorText)
myFixture.testAction(AceAction())
time += measureTimeMillis { typeAndWaitForResults("$query") }
assert(Tagger.markers.isNotEmpty()) { "Should be tagged: $query" }
resetEditor()
}
}
println("Average time to tag results: ${time / 100}ms")
}
fun `test random text latency`() =
`test tag latency`(
generateSequence {
generateSequence {
generateSequence {
('a'..'z').random(Random(0))
}.take(5).joinToString("")
}.take(20).joinToString(" ")
}.take(100).joinToString("\n")
)
fun `test lorem ipsum latency`() =
`test tag latency`(
File(
javaClass.classLoader.getResource("lipsum.txt")!!.file
).readText()
)
fun getSettings() =
ServiceManager.getService(AceConfig::class.java).aceSettings
// Enforces the results are available in less than 100ms
private fun String.search(query: String) =
myFixture.run {
maybeWarmUp(this@search, query)
val queryTime = measureTimeMillis { this@search.executeQuery(query) }
// assert(queryTime < 100) { "Query exceeded time limit! ($queryTime ms)" }
this@search.replace(Regex("<[^>]*>"), "").assertCorrectNumberOfTags(query)
editor.markupModel.allHighlighters.map { it.startOffset }.toSet()
}
// Ensures that the correct number of locations are tagged
private fun String.assertCorrectNumberOfTags(query: String) =
assertEquals(split(query.fold("") { prefix, char ->
if ((prefix + char) in this) prefix + char else return
}).size - 1, myFixture.editor.markupModel.allHighlighters.size)
private var shouldWarmup = true
// Should be run exactly once to warm up the JVM
private fun maybeWarmUp(text: String, query: String) {
if (shouldWarmup) {
text.executeQuery(query)
takeAction(ACTION_EDITOR_ESCAPE)
UIUtil.dispatchAllInvocationEvents()
// Now the JVM is warm, never run this method again
shouldWarmup = false
}
}
fun makeEditor(contents: String): PsiFile =
myFixture.configureByText(PlainTextFileType.INSTANCE, contents)
fun takeAction(action: String) = myFixture.performEditorAction(action)
fun takeAction(action: AnAction) = myFixture.testAction(action)
// Just does a query without enforcing any time limit
private fun String.executeQuery(query: String) {
myFixture.run {
makeEditor(this@executeQuery)
testAction(AceAction())
typeAndWaitForResults(query)
}
}
fun resetEditor() {
takeAction(ACTION_EDITOR_ESCAPE)
UIUtil.dispatchAllInvocationEvents()
assertEmpty(myFixture.editor.markupModel.allHighlighters)
}
override fun tearDown() = resetEditor().also { super.tearDown() }
private fun typeAndWaitForResults(string: String) =
myFixture.type(string).also { UIUtil.dispatchAllInvocationEvents() }
}

View File

@@ -0,0 +1,45 @@
import org.acejump.action.AceKeyboardAction
import org.acejump.test.util.BaseTest
import org.junit.Ignore
import java.io.File
import kotlin.random.Random
import kotlin.system.measureTimeMillis
@Ignore
class LatencyTest : BaseTest() {
private fun `test tag latency`(editorText: String) {
val chars = editorText.toCharArray().distinct().filter { !it.isWhitespace() }
val avg = averageTimeWithWarmup(warmupRuns = 10, timedRuns = 10) {
var time = 0L
for (query in chars) {
makeEditor(editorText)
myFixture.testAction(AceKeyboardAction.ActivateAceJump)
time += measureTimeMillis { typeAndWaitForResults("$query") }
// TODO assert(Tagger.markers.isNotEmpty()) { "Should be tagged: $query" }
resetEditor()
}
time
}
println("Average time to tag results: ${String.format("%.1f", avg.toDouble() / chars.size)} ms")
}
fun `test random text latency`() = `test tag latency`(
generateSequence {
generateSequence {
generateSequence {
('a'..'z').random(Random(0))
}.take(5).joinToString("")
}.take(20).joinToString(" ")
}.take(100).joinToString("\n")
)
fun `test lorem ipsum latency`() = `test tag latency`(
File(
javaClass.classLoader.getResource("lipsum.txt")!!.file
).readText()
)
}

View File

@@ -0,0 +1,75 @@
package org.acejump.test.util
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.psi.PsiFile
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import org.acejump.action.AceKeyboardAction
import org.acejump.session.SessionManager
abstract class BaseTest : BasePlatformTestCase() {
companion object {
inline fun averageTimeWithWarmup(warmupRuns: Int, timedRuns: Int, action: () -> Long): Long {
repeat(warmupRuns) {
action()
}
var time = 0L
repeat(timedRuns) {
time += action()
}
return time / timedRuns
}
}
protected val session
get() = SessionManager[myFixture.editor]!!
override fun tearDown() {
resetEditor()
super.tearDown()
}
fun takeAction(action: String) = myFixture.performEditorAction(action)
fun takeAction(action: AnAction) = myFixture.testAction(action)
fun makeEditor(contents: String): PsiFile {
return myFixture.configureByText(PlainTextFileType.INSTANCE, contents)
}
fun resetEditor() {
takeAction(IdeActions.ACTION_EDITOR_ESCAPE)
UIUtil.dispatchAllInvocationEvents()
assertEmpty(myFixture.editor.markupModel.allHighlighters)
}
fun typeAndWaitForResults(string: String) {
myFixture.type(string)
UIUtil.dispatchAllInvocationEvents()
}
fun String.executeQuery(query: String) {
myFixture.run {
makeEditor(this@executeQuery)
testAction(AceKeyboardAction.ActivateAceJump)
typeAndWaitForResults(query)
}
}
fun String.search(query: String): Set<Int> {
this@search.executeQuery(query)
this@search.replace(Regex("<[^>]*>"), "").assertCorrectNumberOfTags(query)
return myFixture.editor.markupModel.allHighlighters.map { it.startOffset }.toSet()
}
private fun String.assertCorrectNumberOfTags(query: String) {
assertEquals(split(query.fold("") { prefix, char ->
if ((prefix + char) in this) prefix + char else return
}).size - 1, myFixture.editor.markupModel.allHighlighters.size)
}
}