1
0
mirror of https://github.com/chylex/IntelliJ-AceJump.git synced 2024-10-19 03:42:46 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
2b5b9b080a
Update Gradle to 8.5 and IntelliJ to 2023.3 2023-12-12 18:42:54 +01:00
27 changed files with 906 additions and 324 deletions

View File

@ -8,7 +8,7 @@ plugins {
} }
group = "org.acejump" group = "org.acejump"
version = "chylex-15" version = "chylex-14"
repositories { repositories {
mavenCentral() mavenCentral()

Binary file not shown.

View File

@ -13,35 +13,40 @@ import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.playback.commands.ActionCommand import com.intellij.openapi.ui.playback.commands.ActionCommand
import org.acejump.search.Tag import org.acejump.search.SearchProcessor
/** /**
* Base class for actions available after typing a tag. * Base class for actions available after typing a tag.
*/ */
sealed class AceTagAction { sealed class AceTagAction {
abstract operator fun invoke(tag: Tag, shiftMode: Boolean, isFinal: Boolean) abstract operator fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean, isFinal: Boolean)
abstract class BaseJumpAction : AceTagAction() { abstract class BaseJumpAction : AceTagAction() {
override fun invoke(tag: Tag, shiftMode: Boolean, isFinal: Boolean) { override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean, isFinal: Boolean) {
val editor = tag.editor
val caretModel = editor.caretModel val caretModel = editor.caretModel
val oldCarets = if (shiftMode) caretModel.caretsAndSelections else emptyList() val oldCarets = if (shiftMode) caretModel.caretsAndSelections else emptyList()
editor.project?.let { addCurrentPositionToHistory(it, editor.document) } recordCaretPosition(editor)
if (isFinal) { if (isFinal) {
ensureEditorFocused(editor) ensureEditorFocused(editor)
} }
moveCaretTo(editor, tag.offset) moveCaretTo(editor, getCaretOffset(editor, searchProcessor, offset))
if (shiftMode) { if (shiftMode) {
caretModel.caretsAndSelections = oldCarets + caretModel.caretsAndSelections caretModel.caretsAndSelections = oldCarets + caretModel.caretsAndSelections
} }
} }
abstract fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int
} }
private companion object { private companion object {
fun recordCaretPosition(editor: Editor) = with(editor) {
project?.let { addCurrentPositionToHistory(it, document) }
}
fun moveCaretTo(editor: Editor, offset: Int) = with(editor) { fun moveCaretTo(editor: Editor, offset: Int) = with(editor) {
selectionModel.removeSelection(true) selectionModel.removeSelection(true)
caretModel.removeSecondaryCarets() caretModel.removeSecondaryCarets()
@ -83,7 +88,11 @@ sealed class AceTagAction {
* On default action, places the caret at the first character of the search query. * On default action, places the caret at the first character of the search query.
* On shift action, adds the new caret to existing carets. * On shift action, adds the new caret to existing carets.
*/ */
object JumpToSearchStart : BaseJumpAction() object JumpToSearchStart : BaseJumpAction() {
override fun getCaretOffset(editor: Editor, searchProcessor: SearchProcessor, offset: Int): Int {
return offset
}
}
/** /**
* On default action, performs the Go To Declaration action, available via `Navigate | Declaration or Usages`. * On default action, performs the Go To Declaration action, available via `Navigate | Declaration or Usages`.
@ -91,8 +100,8 @@ sealed class AceTagAction {
* Always places the caret at the start of the word. * Always places the caret at the start of the word.
*/ */
object GoToDeclaration : AceTagAction() { object GoToDeclaration : AceTagAction() {
override fun invoke(tag: Tag, shiftMode: Boolean, isFinal: Boolean) { override fun invoke(editor: Editor, searchProcessor: SearchProcessor, offset: Int, shiftMode: Boolean, isFinal: Boolean) {
JumpToSearchStart.invoke(tag, shiftMode = false, isFinal = isFinal) JumpToSearchStart(editor, searchProcessor, offset, shiftMode = false, isFinal = isFinal)
ApplicationManager.getApplication().invokeLater { performAction(if (shiftMode) IdeActions.ACTION_GOTO_TYPE_DECLARATION else IdeActions.ACTION_GOTO_DECLARATION) } ApplicationManager.getApplication().invokeLater { performAction(if (shiftMode) IdeActions.ACTION_GOTO_TYPE_DECLARATION else IdeActions.ACTION_GOTO_DECLARATION) }
} }
} }

View File

@ -44,8 +44,8 @@ sealed class AceVimAction : DumbAwareAction() {
session.defaultBoundary = mode.boundaries session.defaultBoundary = mode.boundaries
session.startJumpMode { session.startJumpMode {
object : JumpMode() { object : JumpMode() {
override fun accept(state: SessionState, acceptedTag: Tag) { override fun accept(state: SessionState, acceptedTag: Tag): Boolean {
AceTagAction.JumpToSearchStart.invoke(acceptedTag, shiftMode = wasUpperCase, isFinal = true) state.act(AceTagAction.JumpToSearchStart, acceptedTag, wasUpperCase, isFinal = true)
if (selectionStart != null) { if (selectionStart != null) {
caret.vim.vimSetSelection(selectionStart, caret.offset, moveCaretToSelectionEnd = true) caret.vim.vimSetSelection(selectionStart, caret.offset, moveCaretToSelectionEnd = true)
@ -88,6 +88,7 @@ sealed class AceVimAction : DumbAwareAction() {
injector.scroll.scrollCaretIntoView(editor.vim) injector.scroll.scrollCaretIntoView(editor.vim)
mode.finishSession(editor, session) mode.finishSession(editor, session)
return true
} }
} }
} }
@ -175,8 +176,9 @@ sealed class AceVimAction : DumbAwareAction() {
session.defaultBoundary = VISIBLE_ON_SCREEN session.defaultBoundary = VISIBLE_ON_SCREEN
session.startJumpMode { session.startJumpMode {
object : JumpMode() { object : JumpMode() {
override fun accept(state: SessionState, acceptedTag: Tag) { override fun accept(state: SessionState, acceptedTag: Tag): Boolean {
AceTagAction.GoToDeclaration.invoke(acceptedTag, shiftMode = wasUpperCase, isFinal = true) state.act(AceTagAction.GoToDeclaration, acceptedTag, shiftMode = wasUpperCase, isFinal = true)
return true
} }
} }
} }

View File

@ -21,8 +21,10 @@ class AceConfig : PersistentStateComponent<AceSettings> {
val layout get() = settings.layout val layout get() = settings.layout
val minQueryLength get() = settings.minQueryLength val minQueryLength get() = settings.minQueryLength
val jumpModeColor get() = settings.jumpModeColor val jumpModeColor get() = settings.jumpModeColor
val textHighlightColor get() = settings.textHighlightColor
val tagForegroundColor get() = settings.tagForegroundColor val tagForegroundColor get() = settings.tagForegroundColor
val searchHighlightColor get() = settings.searchHighlightColor val tagBackgroundColor get() = settings.tagBackgroundColor
val acceptedTagColor get() = settings.acceptedTagColor
} }
override fun getState(): AceSettings { override fun getState(): AceSettings {

View File

@ -16,16 +16,20 @@ class AceConfigurable : Configurable {
panel.keyboardLayout != settings.layout || panel.keyboardLayout != settings.layout ||
panel.minQueryLengthInt != settings.minQueryLength || panel.minQueryLengthInt != settings.minQueryLength ||
panel.jumpModeColor != settings.jumpModeColor || panel.jumpModeColor != settings.jumpModeColor ||
panel.textHighlightColor != settings.textHighlightColor ||
panel.tagForegroundColor != settings.tagForegroundColor || panel.tagForegroundColor != settings.tagForegroundColor ||
panel.searchHighlightColor != settings.searchHighlightColor panel.tagBackgroundColor != settings.tagBackgroundColor ||
panel.acceptedTagColor != settings.acceptedTagColor
override fun apply() { override fun apply() {
settings.allowedChars = panel.allowedChars settings.allowedChars = panel.allowedChars
settings.layout = panel.keyboardLayout settings.layout = panel.keyboardLayout
settings.minQueryLength = panel.minQueryLengthInt ?: settings.minQueryLength settings.minQueryLength = panel.minQueryLengthInt ?: settings.minQueryLength
panel.jumpModeColor?.let { settings.jumpModeColor = it } panel.jumpModeColor?.let { settings.jumpModeColor = it }
panel.textHighlightColor?.let { settings.textHighlightColor = it }
panel.tagForegroundColor?.let { settings.tagForegroundColor = it } panel.tagForegroundColor?.let { settings.tagForegroundColor = it }
panel.searchHighlightColor?.let { settings.searchHighlightColor = it } panel.tagBackgroundColor?.let { settings.tagBackgroundColor = it }
panel.acceptedTagColor?.let { settings.acceptedTagColor = it }
KeyLayoutCache.reset(settings) KeyLayoutCache.reset(settings)
} }

View File

@ -13,9 +13,15 @@ data class AceSettings(
@OptionTag("jumpModeRGB", converter = ColorConverter::class) @OptionTag("jumpModeRGB", converter = ColorConverter::class)
var jumpModeColor: Color = Color(0xFFFFFF), var jumpModeColor: Color = Color(0xFFFFFF),
@OptionTag("textHighlightRGB", converter = ColorConverter::class)
var textHighlightColor: Color = Color(0x394B58),
@OptionTag("tagForegroundRGB", converter = ColorConverter::class) @OptionTag("tagForegroundRGB", converter = ColorConverter::class)
var tagForegroundColor: Color = Color(0xFFFFFF), var tagForegroundColor: Color = Color(0xFFFFFF),
@OptionTag("searchHighlightRGB", converter = ColorConverter::class) @OptionTag("tagBackgroundRGB", converter = ColorConverter::class)
var searchHighlightColor: Color = Color(0x008299), var tagBackgroundColor: Color = Color(0x008299),
@OptionTag("acceptedTagRGB", converter = ColorConverter::class)
var acceptedTagColor: Color = Color(0x394B58)
) )

View File

@ -27,8 +27,10 @@ internal class AceSettingsPanel {
private val keyboardLayoutArea = JBTextArea().apply { isEditable = false } private val keyboardLayoutArea = JBTextArea().apply { isEditable = false }
private val minQueryLengthField = JBTextField() private val minQueryLengthField = JBTextField()
private val jumpModeColorWheel = ColorPanel() private val jumpModeColorWheel = ColorPanel()
private val textHighlightColorWheel = ColorPanel()
private val tagForegroundColorWheel = ColorPanel() private val tagForegroundColorWheel = ColorPanel()
private val searchHighlightColorWheel = ColorPanel() private val tagBackgroundColorWheel = ColorPanel()
private val acceptedTagColorWheel = ColorPanel()
init { init {
tagCharsField.apply { font = Font("monospaced", font.style, font.size) } tagCharsField.apply { font = Font("monospaced", font.style, font.size) }
@ -52,8 +54,10 @@ internal class AceSettingsPanel {
titledRow("Colors") { titledRow("Colors") {
row("Caret background:") { short(jumpModeColorWheel) } row("Caret background:") { short(jumpModeColorWheel) }
row("Searched text background:") { short(textHighlightColorWheel) }
row("Tag foreground:") { short(tagForegroundColorWheel) } row("Tag foreground:") { short(tagForegroundColorWheel) }
row("Search highlight:") { short(searchHighlightColorWheel) } row("Tag background:") { short(tagBackgroundColorWheel) }
row("Accepted tag position background:") { short(acceptedTagColorWheel) }
} }
} }
@ -63,8 +67,10 @@ internal class AceSettingsPanel {
internal var keyChars by keyboardLayoutArea internal var keyChars by keyboardLayoutArea
internal var minQueryLength by minQueryLengthField internal var minQueryLength by minQueryLengthField
internal var jumpModeColor by jumpModeColorWheel internal var jumpModeColor by jumpModeColorWheel
internal var textHighlightColor by textHighlightColorWheel
internal var tagForegroundColor by tagForegroundColorWheel internal var tagForegroundColor by tagForegroundColorWheel
internal var searchHighlightColor by searchHighlightColorWheel internal var tagBackgroundColor by tagBackgroundColorWheel
internal var acceptedTagColor by acceptedTagColorWheel
internal var minQueryLengthInt internal var minQueryLengthInt
get() = minQueryLength.toIntOrNull()?.coerceIn(1, 10) get() = minQueryLength.toIntOrNull()?.coerceIn(1, 10)
@ -75,8 +81,10 @@ internal class AceSettingsPanel {
keyboardLayout = settings.layout keyboardLayout = settings.layout
minQueryLength = settings.minQueryLength.toString() minQueryLength = settings.minQueryLength.toString()
jumpModeColor = settings.jumpModeColor jumpModeColor = settings.jumpModeColor
textHighlightColor = settings.textHighlightColor
tagForegroundColor = settings.tagForegroundColor tagForegroundColor = settings.tagForegroundColor
searchHighlightColor = settings.searchHighlightColor tagBackgroundColor = settings.tagBackgroundColor
acceptedTagColor = settings.acceptedTagColor
} }
// Removal pending support for https://youtrack.jetbrains.com/issue/KT-8575 // Removal pending support for https://youtrack.jetbrains.com/issue/KT-8575

View File

@ -1,5 +1,10 @@
package org.acejump.input package org.acejump.input
import it.unimi.dsi.fastutil.objects.Object2IntMap
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap
import java.awt.geom.Point2D
import kotlin.math.floor
/** /**
* Defines common keyboard layouts. Each layout has a key priority order, based on each key's distance from the home row and how * 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. * ergonomically difficult they are to press.
@ -18,7 +23,38 @@ enum class KeyLayout(internal val rows: Array<String>, priority: String) {
internal val allChars = rows.joinToString("").toCharArray().apply(CharArray::sort).joinToString("") internal val allChars = rows.joinToString("").toCharArray().apply(CharArray::sort).joinToString("")
internal val allPriorities = priority.mapIndexed { index, char -> char to index }.toMap() internal val allPriorities = priority.mapIndexed { index, char -> char to index }.toMap()
private val keyDistances: Map<Char, Object2IntMap<Char>> by lazy {
val keyDistanceMap = mutableMapOf<Char, Object2IntMap<Char>>()
val keyLocations = mutableMapOf<Char, Point2D>()
for ((rowIndex, rowChars) in rows.withIndex()) {
val keyY = rowIndex * 1.2F // Slightly increase cost of traveling between rows.
for ((columnIndex, char) in rowChars.withIndex()) {
val keyX = columnIndex + (0.25F * rowIndex) // Assume a 1/4-key uniform stagger.
keyLocations[char] = Point2D.Float(keyX, keyY)
}
}
for (fromChar in allChars) {
val distances = Object2IntOpenHashMap<Char>()
val fromLocation = keyLocations.getValue(fromChar)
for (toChar in allChars) {
distances[toChar] = floor(2F * fromLocation.distanceSq(keyLocations.getValue(toChar))).toInt()
}
keyDistanceMap[fromChar] = distances
}
keyDistanceMap
}
internal inline fun priority(crossinline tagToChar: (String) -> Char): (String) -> Int? { internal inline fun priority(crossinline tagToChar: (String) -> Char): (String) -> Int? {
return { allPriorities[tagToChar(it)] } return { allPriorities[tagToChar(it)] }
} }
internal fun distanceBetweenKeys(char1: Char, char2: Char): Int {
return keyDistances.getValue(char1).getValue(char2)
}
} }

View File

@ -34,17 +34,18 @@ internal object KeyLayoutCache {
*/ */
fun reset(settings: AceSettings) { fun reset(settings: AceSettings) {
tagOrder = compareBy( tagOrder = compareBy(
String::length, { it[0].isDigit() || it[1].isDigit() },
settings.layout.priority(String::last) { settings.layout.distanceBetweenKeys(it[0], it[1]) },
settings.layout.priority { it[0] }
) )
@Suppress("ConvertLambdaToReference")
val allPossibleChars = settings.allowedChars val allPossibleChars = settings.allowedChars
.toCharArray() .toCharArray()
.filter(Char::isLetterOrDigit) .filter(Char::isLetterOrDigit)
.distinct() .distinct()
.ifEmpty { settings.layout.allChars.toCharArray().toList() } .joinToString("")
.ifEmpty(settings.layout::allChars)
allPossibleTags = allPossibleChars.flatMap { listOf("$it", ";$it") }.sortedWith(tagOrder) allPossibleTags = allPossibleChars.flatMap { a -> allPossibleChars.map { b -> "$a$b".intern() } }.sortedWith(tagOrder)
} }
} }

View File

@ -18,7 +18,8 @@ open class JumpMode : SessionMode {
return state.type(charTyped) return state.type(charTyped)
} }
override fun accept(state: SessionState, acceptedTag: Tag) { override fun accept(state: SessionState, acceptedTag: Tag): Boolean {
AceTagAction.JumpToSearchStart.invoke(acceptedTag, shiftMode = wasUpperCase, isFinal = true) state.act(AceTagAction.JumpToSearchStart, acceptedTag, wasUpperCase, isFinal = true)
return true
} }
} }

View File

@ -9,5 +9,5 @@ interface SessionMode {
val caretColor: Color val caretColor: Color
fun type(state: SessionState, charTyped: Char, acceptedTag: Tag?): TypeResult fun type(state: SessionState, charTyped: Char, acceptedTag: Tag?): TypeResult
fun accept(state: SessionState, acceptedTag: Tag) fun accept(state: SessionState, acceptedTag: Tag): Boolean
} }

View File

@ -3,18 +3,17 @@ package org.acejump.search
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.IntArrayList import it.unimi.dsi.fastutil.ints.IntArrayList
import org.acejump.boundaries.Boundaries import org.acejump.boundaries.Boundaries
import org.acejump.clone
import org.acejump.config.AceConfig
import org.acejump.immutableText import org.acejump.immutableText
import org.acejump.isWordPart
import org.acejump.matchesAt import org.acejump.matchesAt
/** /**
* Searches editor text for matches of a [SearchQuery], and updates previous results when the user [refineQuery]s a character. * Searches editor text for matches of a [SearchQuery], and updates previous results when the user [type]s a character.
*/ */
class SearchProcessor private constructor(query: SearchQuery, private val results: MutableMap<Editor, IntArrayList>) { class SearchProcessor private constructor(query: SearchQuery, results: MutableMap<Editor, IntArrayList>) {
companion object { companion object {
fun fromString(editors: List<Editor>, query: String, boundaries: Boundaries): SearchProcessor { fun fromChar(editors: List<Editor>, char: Char, boundaries: Boundaries): SearchProcessor {
return SearchProcessor(editors, SearchQuery.Literal(query), boundaries) return SearchProcessor(editors, SearchQuery.Literal(char.toString()), boundaries)
} }
fun fromRegex(editors: List<Editor>, pattern: String, boundaries: Boundaries): SearchProcessor { fun fromRegex(editors: List<Editor>, pattern: String, boundaries: Boundaries): SearchProcessor {
@ -54,40 +53,97 @@ class SearchProcessor private constructor(query: SearchQuery, private val result
internal var query = query internal var query = query
private set private set
val resultsCopy internal var results = results
get() = results.clone() private set
val isQueryFinished /**
get() = query.rawText.length >= AceConfig.minQueryLength * 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 canMatchTag = tagger.canQueryMatchAnyTag(newQuery)
fun refineQuery(char: Char): Boolean { // If the typed character is not compatible with any existing tag or as a continuation of any previous occurrence, reject the query
if (char == '\n') { // change and return false to indicate that nothing else should happen.
return true
if (newQuery.length > 1 && !canMatchTag && !isContinuation(newQuery)) {
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()
for ((editor, offsets) in results) {
val chars = editor.immutableText
val iter = offsets.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 { else {
query = SearchQuery.Literal(query.rawText + char) removeObsoleteResults(newQuery, tagger)
removeObsoleteResults() query = SearchQuery.Literal(newQuery)
return isQueryFinished
} }
return true
}
/**
* Returns true if the new query is a continuation of any remaining search query.
*/
private fun isContinuation(newQuery: String): Boolean {
for ((editor, offsets) in results) {
val chars = editor.immutableText
if (offsets.any { chars.matchesAt(it, newQuery, ignoreCase = true) }) {
return true
}
}
return false
} }
/** /**
* After updating the query, removes all results that no longer match the search query. * After updating the query, removes all results that no longer match the search query.
*/ */
private fun removeObsoleteResults() { private fun removeObsoleteResults(newQuery: String, tagger: Tagger) {
val query = query.rawText val lastCharOffset = newQuery.lastIndex
val lastChar = newQuery[lastCharOffset]
val ignoreCase = newQuery[0].isLowerCase()
for (entry in results) { for ((editor, offsets) in results.entries.toList()) {
val editor = entry.key val chars = editor.immutableText
val offsetIter = entry.value.iterator()
while (offsetIter.hasNext()) { val remaining = IntArrayList()
val offset = offsetIter.nextInt() val iter = offsets.iterator()
if (!editor.immutableText.matchesAt(offset, query, ignoreCase = true)) { while (iter.hasNext()) {
offsetIter.remove() val offset = iter.nextInt()
val endOffset = offset + lastCharOffset
val lastTypedCharMatches = endOffset < chars.length && chars[endOffset].equals(lastChar, ignoreCase)
if (lastTypedCharMatches || tagger.isQueryCompatibleWithTagAt(newQuery, Tag(editor, offset))) {
remaining.add(offset)
} }
} }
results[editor] = remaining
} }
} }
} }

View File

@ -0,0 +1,231 @@
package org.acejump.search
import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.IntList
import it.unimi.dsi.fastutil.ints.IntOpenHashSet
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.IdentityHashMap
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 editorPriority: List<Editor>,
private val queryLength: Int,
private val newResults: Map<Editor, IntList>,
private val allResults: Map<Editor, IntList>,
) {
companion object {
fun solve(
editorPriority: List<Editor>,
query: SearchQuery,
newResults: Map<Editor, IntList>,
allResults: Map<Editor, IntList>,
tags: List<String>,
caches: Map<Editor, EditorOffsetCache>,
): Map<String, Tag> {
return Solver(editorPriority, max(1, query.rawText.length), newResults, allResults).map(tags, caches)
}
}
private var newTags = HashMap<String, Tag>(KeyLayoutCache.allPossibleTags.size)
private val newTagIndices = newResults.keys.associateWith { IntOpenHashSet() }
private var allWordFragments = HashSet<String>(allResults.values.sumBy(IntList::size)).apply {
for ((editor, offsets) in allResults) {
val chars = editor.immutableText
val iter = offsets.iterator()
while (iter.hasNext()) {
forEachWordFragment(chars, iter.nextInt(), this::add)
}
}
}
private fun generateEligibleSites(availableTags: List<String>): Map<String, MutableList<Tag>> {
val eligibleSitesByTag = HashMap<String, MutableList<Tag>>(100)
val tagsByFirstLetter = availableTags.groupBy { it[0] }
for ((editor, offsets) in newResults) {
val chars = editor.immutableText
val iter = offsets.iterator()
while (iter.hasNext()) {
val site = iter.nextInt()
for ((firstLetter, tags) in tagsByFirstLetter.entries) {
if (canTagBeginWithChar(chars, site, firstLetter)) {
for (tag in tags) {
eligibleSitesByTag.getOrPut(tag, ::mutableListOf).add(Tag(editor, site))
}
}
}
}
}
return eligibleSitesByTag
}
private fun generateMatchingSites(
eligibleSites: Map<String, MutableList<Tag>>,
caches: Map<Editor, EditorOffsetCache>,
): Map<String, Iterator<Tag>> {
val matchingSites = HashMap<MutableList<Tag>, Iterator<Tag>>()
val matchingSitesSorted = IdentityHashMap<String, Iterator<Tag>>() // Keys are guaranteed to be from a single collection.
val siteOrder = siteOrder(caches)
for ((mark, tags) in eligibleSites.entries) {
matchingSitesSorted[mark] = matchingSites.getOrPut(tags) {
@Suppress("ConvertLambdaToReference")
tags.toMutableList().apply { sortWith(siteOrder) }.iterator()
}
}
return matchingSitesSorted
}
fun map(availableTags: List<String>, caches: Map<Editor, EditorOffsetCache>): Map<String, Tag> {
val eligibleSitesByTag = generateEligibleSites(availableTags)
val matchingSitesSorted = generateMatchingSites(eligibleSitesByTag, caches)
val tagOrder = KeyLayoutCache.tagOrder
.thenComparingInt { eligibleSitesByTag.getValue(it).size }
.thenBy(AceConfig.layout.priority(String::last))
val sortedTags = eligibleSitesByTag.keys.toMutableList().apply {
sortWith(tagOrder)
}
var totalAssigned = 0
val totalResults = newResults.values.sumBy(IntList::size)
for (tag in sortedTags) {
if (totalAssigned == totalResults) {
break
}
if (tryToAssignTag(tag, matchingSitesSorted.getValue(tag))) {
totalAssigned++
}
}
return newTags
}
private fun tryToAssignTag(mark: String, tags: Iterator<Tag>): Boolean {
if (newTags.containsKey(mark)) {
return false
}
while (tags.hasNext()) {
val tag = tags.next()
val assigned = newTagIndices.getValue(tag.editor)
if (tag.offset !in assigned) {
newTags[mark] = tag
assigned.add(tag.offset)
return true
}
}
return false
}
private fun siteOrder(caches: Map<Editor, EditorOffsetCache>) = Comparator<Tag> { a, b ->
val aEditor = a.editor
val bEditor = b.editor
if (aEditor !== bEditor) {
val aEditorIndex = editorPriority.indexOf(aEditor)
val bEditorIndex = editorPriority.indexOf(bEditor)
// For multiple editors, prioritize them based on the provided order.
return@Comparator if (aEditorIndex < bEditorIndex) -1 else 1
}
val aIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(aEditor, a.offset, caches.getValue(aEditor))
val bIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(bEditor, b.offset, caches.getValue(bEditor))
if (aIsVisible != bIsVisible) {
// Sites in immediate view should come first.
return@Comparator if (aIsVisible) -1 else 1
}
val aIsNotWordStart = aEditor.immutableText[max(0, a.offset - 1)].isWordPart
val bIsNotWordStart = bEditor.immutableText[max(0, b.offset - 1)].isWordPart
if (aIsNotWordStart != bIsNotWordStart) {
// Ensure that the first letter of a word is prioritized for tagging.
return@Comparator if (bIsNotWordStart) -1 else 1
}
when {
a.offset < b.offset -> -1
a.offset > b.offset -> 1
else -> 0
}
}
private fun canTagBeginWithChar(chars: CharSequence, site: Int, char: Char): Boolean {
if (char.toString() in allWordFragments) {
return false
}
forEachWordFragment(chars, site) {
if (it + char in allWordFragments) {
return false
}
}
return true
}
private inline fun forEachWordFragment(chars: CharSequence, site: Int, callback: (String) -> Unit) {
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

@ -1,117 +1,229 @@
package org.acejump.search package org.acejump.search
import com.google.common.collect.ArrayListMultimap import com.google.common.collect.ArrayListMultimap
import com.google.common.collect.HashBiMap
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.ints.IntList import it.unimi.dsi.fastutil.ints.IntList
import org.acejump.ExternalUsage
import org.acejump.boundaries.EditorOffsetCache import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries.VISIBLE_ON_SCREEN import org.acejump.boundaries.StandardBoundaries
import org.acejump.immutableText import org.acejump.immutableText
import org.acejump.input.KeyLayoutCache import org.acejump.input.KeyLayoutCache.allPossibleTags
import org.acejump.isWordPart import org.acejump.isWordPart
import org.acejump.matchesAt
import org.acejump.view.TagMarker import org.acejump.view.TagMarker
import java.util.AbstractMap.SimpleImmutableEntry
import kotlin.collections.component1 import kotlin.collections.component1
import kotlin.collections.component2 import kotlin.collections.component2
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
/** /**
* Assigns tags to search occurrences. * Assigns tags to search occurrences, updates them when the search query changes, and requests a jump if the search query matches a tag.
* The ordering of [editors] may be used to prioritize tagging editors earlier in the list in case of conflicts. * The ordering of [editors] may be used to prioritize tagging editors earlier in the list in case of conflicts.
*/ */
class Tagger(private val editors: List<Editor>, results: Map<Editor, IntList>) { class Tagger(private val editors: List<Editor>) {
private var tagMap: Map<String, Tag> private var tagMap = HashBiMap.create<String, Tag>()
private var typedTag = ""
internal val markers: Map<Editor, Collection<TagMarker>> val hasTags
get() { get() = tagMap.isNotEmpty()
val markers = ArrayListMultimap.create<Editor, TagMarker>(editors.size, min(tagMap.values.size, 40))
for ((mark, tag) in tagMap) { @ExternalUsage
val marker = TagMarker.create(mark, tag.offset, typedTag) internal val tags
markers.put(tag.editor, marker) get() = tagMap.map { SimpleImmutableEntry(it.key, it.value) }.sortedBy { it.value.offset }
/**
* 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: Map<Editor, 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)
}
} }
return markers.asMap() if (queryText.length == 1) {
} for ((editor, offsets) in results) {
removeResultsWithOverlappingTags(editor, offsets)
init {
val caches = results.keys.associateWith { EditorOffsetCache.new() }
sortResults(results, caches)
val tagSites = results
.flatMap { (editor, sites) -> sites.map { site -> Tag(editor, site) } }
.sortedWith(siteOrder(editors, caches))
tagMap = KeyLayoutCache.allPossibleTags.zip(tagSites).toMap()
}
internal fun type(char: Char): TaggingResult {
val newTypedTag = typedTag + char
val matchingTag = tagMap[newTypedTag]
if (matchingTag != null) {
return TaggingResult.Accept(matchingTag)
}
val newTagMap = tagMap.filter { it.key.startsWith(newTypedTag, ignoreCase = true) }
if (newTagMap.isEmpty()) {
return TaggingResult.Nothing
}
typedTag = newTypedTag
tagMap = newTagMap
return TaggingResult.Mark(markers)
}
private companion object {
private fun sortResults(results: Map<Editor, IntList>, caches: Map<Editor, EditorOffsetCache>) {
for ((editor, offsets) in results) {
val cache = caches.getValue(editor)
offsets.sort { a, b ->
val aIsVisible = VISIBLE_ON_SCREEN.isOffsetInside(editor, a, cache)
val bIsVisible = VISIBLE_ON_SCREEN.isOffsetInside(editor, b, cache)
when {
aIsVisible && !bIsVisible -> -1
bIsVisible && !aIsVisible -> 1
else -> 0
}
} }
} }
} }
private fun siteOrder(editorPriority: List<Editor>, caches: Map<Editor, EditorOffsetCache>) = Comparator<Tag> { a, b -> if (!isRegex || tagMap.isEmpty()) {
val aEditor = a.editor tagMap = assignTagsAndMerge(results, availableTags, query, queryText)
val bEditor = b.editor }
if (aEditor !== bEditor) { val resultTags = results.flatMap { (editor, offsets) -> offsets.map { Tag(editor, it) } }
val aEditorIndex = editorPriority.indexOf(aEditor) return TaggingResult.Mark(createTagMarkers(resultTags, query.rawText.ifEmpty { null }))
val bEditorIndex = editorPriority.indexOf(bEditor) }
// For multiple editors, prioritize them based on the provided order.
return@Comparator if (aEditorIndex < bEditorIndex) -1 else 1
}
val aIsVisible = VISIBLE_ON_SCREEN.isOffsetInside(aEditor, a.offset, caches.getValue(aEditor)) /**
val bIsVisible = VISIBLE_ON_SCREEN.isOffsetInside(bEditor, b.offset, caches.getValue(bEditor)) * Assigns as many unassigned tags as possible, and merges them with the existing compatible tags.
if (aIsVisible != bIsVisible) { */
// Sites in immediate view should come first. private fun assignTagsAndMerge(
return@Comparator if (aIsVisible) -1 else 1 results: Map<Editor, IntList>, availableTags: List<String>, query: SearchQuery, queryText: String,
} ): HashBiMap<String, Tag> {
val caches = results.keys.associateWith { EditorOffsetCache.new() }
val aIsNotWordStart = aEditor.immutableText[max(0, a.offset - 1)].isWordPart for ((editor, offsets) in results) {
val bIsNotWordStart = bEditor.immutableText[max(0, b.offset - 1)].isWordPart val cache = caches.getValue(editor)
if (aIsNotWordStart != bIsNotWordStart) {
// Ensure that the first letter of a word is prioritized for tagging.
return@Comparator if (bIsNotWordStart) -1 else 1
}
when { offsets.sort { a, b ->
a.offset < b.offset -> -1 val aIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, a, cache)
a.offset > b.offset -> 1 val bIsVisible = StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, b, cache)
else -> 0
when {
aIsVisible && !bIsVisible -> -1
bIsVisible && !aIsVisible -> 1
else -> 0
}
} }
} }
val allAssignedTags = mutableMapOf<String, Tag>()
val oldCompatibleTags = tagMap.filter { (mark, tag) ->
isTagCompatibleWithQuery(mark, tag, queryText) || results[tag.editor]?.contains(tag.offset) == true
}
val vacantResults: Map<Editor, IntList>
if (oldCompatibleTags.isEmpty()) {
vacantResults = results
}
else {
val vacant = mutableMapOf<Editor, IntList>()
for ((editor, offsets) in results) {
val list = IntArrayList()
val iter = offsets.iterator()
while (iter.hasNext()) {
val tag = Tag(editor, iter.nextInt())
if (tag !in oldCompatibleTags.values) {
list.add(tag.offset)
}
}
vacant[editor] = list
}
vacantResults = vacant
}
allAssignedTags.putAll(oldCompatibleTags)
allAssignedTags.putAll(Solver.solve(editors, query, vacantResults, results, availableTags, caches))
val assignedMarkers = allAssignedTags.keys.groupBy { it[0] }
return allAssignedTags.mapKeysTo(HashBiMap.create(allAssignedTags.size)) { (tag, _) ->
if (canShortenTag(tag, assignedMarkers, queryText))
tag[0].toString()
else
tag
}
}
private infix fun Map.Entry<String, Tag>.solves(query: String): Boolean {
return query.endsWith(key, true) && isTagCompatibleWithQuery(key, value, query)
}
private fun isTagCompatibleWithQuery(marker: String, tag: Tag, query: String): Boolean {
return tag.editor.immutableText.matchesAt(tag.offset, getPlaintextPortion(query, marker), ignoreCase = true)
}
fun isQueryCompatibleWithTagAt(query: String, tag: Tag): Boolean {
return tagMap.inverse()[tag].let { it != null && isTagCompatibleWithQuery(it, tag, 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(editor: Editor, offsets: IntList) {
val iter = offsets.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(tags: Collection<Tag>, literalQueryText: String?): MutableMap<Editor, Collection<TagMarker>> {
val tagMapInv = tagMap.inverse()
val markers = ArrayListMultimap.create<Editor, TagMarker>(editors.size, min(tags.size, 50))
for (tag in tags) {
val mark = tagMapInv[tag] ?: continue
val editor = tag.editor
val marker = TagMarker.create(editor, mark, tag.offset, literalQueryText)
markers.put(editor, marker)
}
return markers.asMap()
}
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, marker: String) = when {
query.endsWith(marker, true) -> query.dropLast(marker.length)
query.endsWith(marker.first(), true) -> query.dropLast(1)
else -> query
}
private fun getTagPortion(query: String, marker: String) = when {
query.endsWith(marker, true) -> query.takeLast(marker.length)
query.endsWith(marker.first(), true) -> query.takeLast(1)
else -> ""
}
private fun canShortenTag(marker: String, markers: Map<Char, List<String>>, queryText: String): Boolean {
// Avoid matching query - will trigger a jump.
// TODO: lift this constraint.
val queryEndsWith = queryText.endsWith(marker[0]) || queryText.endsWith(marker)
if (queryEndsWith) {
return false
}
val startingWithSameLetter = markers[marker[0]]
return startingWithSameLetter == null || startingWithSameLetter.singleOrNull() == marker
}
} }
} }

View File

@ -4,7 +4,6 @@ import com.intellij.openapi.editor.Editor
import org.acejump.view.TagMarker import org.acejump.view.TagMarker
internal sealed class TaggingResult { internal sealed class TaggingResult {
object Nothing : TaggingResult()
class Accept(val tag: Tag) : TaggingResult() class Accept(val tag: Tag) : TaggingResult()
class Mark(val markers: Map<Editor, Collection<TagMarker>>) : TaggingResult() class Mark(val markers: MutableMap<Editor, Collection<TagMarker>>) : TaggingResult()
} }

View File

@ -1,29 +1,23 @@
package org.acejump.session package org.acejump.session
import com.intellij.codeInsight.hint.HintManagerImpl import com.intellij.codeInsight.hint.HintManagerImpl
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ScrollType 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.EditorColors
import com.intellij.openapi.editor.colors.impl.AbstractColorsScheme import com.intellij.openapi.editor.colors.impl.AbstractColorsScheme
import it.unimi.dsi.fastutil.ints.IntList import org.acejump.ExternalUsage
import org.acejump.boundaries.Boundaries import org.acejump.boundaries.Boundaries
import org.acejump.boundaries.StandardBoundaries import org.acejump.boundaries.StandardBoundaries
import org.acejump.clone
import org.acejump.config.AceConfig import org.acejump.config.AceConfig
import org.acejump.input.EditorKeyListener import org.acejump.input.EditorKeyListener
import org.acejump.input.KeyLayoutCache import org.acejump.input.KeyLayoutCache
import org.acejump.modes.JumpMode import org.acejump.modes.JumpMode
import org.acejump.modes.SessionMode import org.acejump.modes.SessionMode
import org.acejump.search.Pattern import org.acejump.search.*
import org.acejump.search.SearchProcessor
import org.acejump.search.SearchQuery
import org.acejump.search.Tag
import org.acejump.session.TypeResult.AcceptTag
import org.acejump.session.TypeResult.ChangeMode
import org.acejump.session.TypeResult.ChangeState
import org.acejump.session.TypeResult.EndSession
import org.acejump.session.TypeResult.Nothing
import org.acejump.view.TagCanvas import org.acejump.view.TagCanvas
import org.acejump.view.TagMarker
import org.acejump.view.TextHighlighter import org.acejump.view.TextHighlighter
/** /**
@ -33,7 +27,8 @@ class Session(private val mainEditor: Editor, private val jumpEditors: List<Edit
private val editorSettings = EditorSettings.setup(mainEditor) private val editorSettings = EditorSettings.setup(mainEditor)
private lateinit var mode: SessionMode private lateinit var mode: SessionMode
private var state: SessionState? = null private var state: SessionStateImpl? = null
private var tagger = Tagger(jumpEditors)
private var acceptedTag: Tag? = null private var acceptedTag: Tag? = null
set(value) { set(value) {
@ -48,48 +43,69 @@ class Session(private val mainEditor: Editor, private val jumpEditors: List<Edit
private val textHighlighter = TextHighlighter() private val textHighlighter = TextHighlighter()
private val tagCanvases = jumpEditors.associateWith(::TagCanvas) private val tagCanvases = jumpEditors.associateWith(::TagCanvas)
@ExternalUsage
val tags
get() = tagger.tags
var defaultBoundary: Boundaries = StandardBoundaries.VISIBLE_ON_SCREEN var defaultBoundary: Boundaries = StandardBoundaries.VISIBLE_ON_SCREEN
private val actions = object : SessionActions {
override fun showHighlights(results: Map<Editor, IntList>, query: SearchQuery) {
textHighlighter.renderOccurrences(results, query)
}
override fun hideHighlights() {
textHighlighter.reset()
}
override fun setTagMarkers(markers: Map<Editor, Collection<TagMarker>>) {
for ((editor, canvas) in tagCanvases) {
canvas.setMarkers(markers[editor].orEmpty())
}
}
}
init { init {
KeyLayoutCache.ensureInitialized(AceConfig.settings) KeyLayoutCache.ensureInitialized(AceConfig.settings)
EditorKeyListener.attach(mainEditor) { editor, charTyped, _ -> typeCharacter(editor, charTyped) }
EditorKeyListener.attach(mainEditor, 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 -> return;
is TypeResult.UpdateResults -> updateSearch(result.processor, markImmediately = hadTags)
is TypeResult.ChangeMode -> setMode(result.mode)
TypeResult.RestartSearch -> restart().also {
this@Session.state = SessionStateImpl(jumpEditors, tagger, defaultBoundary)
}
TypeResult.EndSession -> end()
}
}
})
} }
private fun typeCharacter(editor: Editor, charTyped: Char) { /**
val state = state ?: return * 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
editorSettings.startEditing(editor) if (!markImmediately && query.rawText.let { it.length < AceConfig.minQueryLength && it.all(Char::isLetterOrDigit) }) {
val result = mode.type(state, charTyped, acceptedTag) textHighlighter.renderOccurrences(results, query)
editorSettings.stopEditing(editor) return
}
when (result) { when (val result = tagger.update(query, results.clone())) {
Nothing -> return is TaggingResult.Accept -> {
is ChangeState -> this.state = result.state
is ChangeMode -> setMode(result.mode)
is AcceptTag -> {
acceptedTag = result.tag acceptedTag = result.tag
mode.accept(state, result.tag) textHighlighter.renderFinal(result.tag, processor.query)
end()
if (state?.let { mode.accept(it, result.tag) } == true) {
end()
return
}
} }
EndSession -> end() is TaggingResult.Mark -> {
for ((editor, canvas) in tagCanvases) {
canvas.setMarkers(result.markers[editor].orEmpty())
}
textHighlighter.renderOccurrences(results, query)
}
} }
} }
@ -113,7 +129,7 @@ class Session(private val mainEditor: Editor, private val jumpEditors: List<Edit
} }
setMode(mode()) setMode(mode())
state = SessionState.WaitForKey(actions, jumpEditors, defaultBoundary) state = SessionStateImpl(jumpEditors, tagger, defaultBoundary)
} }
/** /**
@ -124,18 +140,22 @@ class Session(private val mainEditor: Editor, private val jumpEditors: List<Edit
setMode(JumpMode()) setMode(JumpMode())
} }
for (canvas in tagCanvases.values) { tagger = Tagger(jumpEditors)
canvas.setMarkers(emptyList()) tagCanvases.values.forEach { it.setMarkers(emptyList()) }
}
val processor = SearchProcessor.fromRegex(jumpEditors, pattern.regex, defaultBoundary) val processor = SearchProcessor.fromRegex(jumpEditors, pattern.regex, defaultBoundary)
textHighlighter.renderOccurrences(processor.resultsCopy, processor.query) state = SessionStateImpl(jumpEditors, tagger, defaultBoundary, processor)
state = SessionState.SelectTag(actions, jumpEditors, processor) updateSearch(processor, markImmediately = true)
} }
fun tagImmediately() { fun tagImmediately() {
typeCharacter(mainEditor, '\n') val state = state ?: return
val processor = state.currentProcessor
if (processor != null) {
updateSearch(processor, markImmediately = true)
}
} }
/** /**
@ -150,6 +170,7 @@ class Session(private val mainEditor: Editor, private val jumpEditors: List<Edit
*/ */
fun restart() { fun restart() {
state = null state = null
tagger = Tagger(jumpEditors)
acceptedTag = null acceptedTag = null
tagCanvases.values.forEach(TagCanvas::removeMarkers) tagCanvases.values.forEach(TagCanvas::removeMarkers)
textHighlighter.reset() textHighlighter.reset()
@ -164,6 +185,7 @@ class Session(private val mainEditor: Editor, private val jumpEditors: List<Edit
* Should only be used from [SessionManager] to dispose a successfully ended session. * Should only be used from [SessionManager] to dispose a successfully ended session.
*/ */
internal fun dispose() { internal fun dispose() {
tagger = Tagger(jumpEditors)
tagCanvases.values.forEach(TagCanvas::unbind) tagCanvases.values.forEach(TagCanvas::unbind)
textHighlighter.reset() textHighlighter.reset()
EditorKeyListener.detach(mainEditor) EditorKeyListener.detach(mainEditor)

View File

@ -1,13 +0,0 @@
package org.acejump.session
import com.intellij.openapi.editor.Editor
import it.unimi.dsi.fastutil.ints.IntList
import org.acejump.search.SearchQuery
import org.acejump.view.TagMarker
internal interface SessionActions {
fun showHighlights(results: Map<Editor, IntList>, query: SearchQuery)
fun hideHighlights()
fun setTagMarkers(markers: Map<Editor, Collection<TagMarker>>)
}

View File

@ -1,72 +1,9 @@
package org.acejump.session package org.acejump.session
import com.intellij.openapi.editor.Editor import org.acejump.action.AceTagAction
import org.acejump.boundaries.Boundaries import org.acejump.search.Tag
import org.acejump.search.SearchProcessor
import org.acejump.search.Tagger
import org.acejump.search.TaggingResult
sealed interface SessionState { interface SessionState {
fun type(char: Char): TypeResult fun type(char: Char): TypeResult
fun act(action: AceTagAction, tag: Tag, shiftMode: Boolean, isFinal: Boolean)
class WaitForKey internal constructor(
private val actions: SessionActions,
private val jumpEditors: List<Editor>,
private val defaultBoundary: Boundaries,
) : SessionState {
override fun type(char: Char): TypeResult {
val searchProcessor = SearchProcessor.fromString(jumpEditors, char.toString(), defaultBoundary)
return if (searchProcessor.isQueryFinished) {
TypeResult.ChangeState(SelectTag(actions, jumpEditors, searchProcessor))
}
else {
TypeResult.ChangeState(RefineSearchQuery(actions, jumpEditors, searchProcessor))
}
}
}
class RefineSearchQuery internal constructor(
private val actions: SessionActions,
private val jumpEditors: List<Editor>,
private val searchProcessor: SearchProcessor,
) : SessionState {
init {
actions.showHighlights(searchProcessor.resultsCopy, searchProcessor.query)
}
override fun type(char: Char): TypeResult {
return if (searchProcessor.refineQuery(char)) {
actions.hideHighlights()
TypeResult.ChangeState(SelectTag(actions, jumpEditors, searchProcessor))
}
else {
actions.showHighlights(searchProcessor.resultsCopy, searchProcessor.query)
TypeResult.Nothing
}
}
}
class SelectTag internal constructor(
private val actions: SessionActions,
jumpEditors: List<Editor>,
searchProcessor: SearchProcessor,
) : SessionState {
private val tagger = Tagger(jumpEditors, searchProcessor.resultsCopy)
init {
actions.setTagMarkers(tagger.markers)
}
override fun type(char: Char): TypeResult {
return when (val result = tagger.type(char)) {
is TaggingResult.Nothing -> TypeResult.Nothing
is TaggingResult.Accept -> TypeResult.AcceptTag(result.tag)
is TaggingResult.Mark -> {
actions.setTagMarkers(result.markers)
TypeResult.Nothing
}
}
}
}
} }

View File

@ -0,0 +1,36 @@
package org.acejump.session
import com.intellij.openapi.editor.Editor
import org.acejump.action.AceTagAction
import org.acejump.boundaries.Boundaries
import org.acejump.search.SearchProcessor
import org.acejump.search.Tag
import org.acejump.search.Tagger
internal class SessionStateImpl(
private val jumpEditors: List<Editor>,
private val tagger: Tagger,
private val defaultBoundary: Boundaries,
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(jumpEditors, char, defaultBoundary)
return TypeResult.UpdateResults(newProcessor.also { currentProcessor = it })
}
if (processor.type(char, tagger)) {
return TypeResult.UpdateResults(processor)
}
return TypeResult.Nothing
}
override fun act(action: AceTagAction, tag: Tag, shiftMode: Boolean, isFinal: Boolean) {
currentProcessor?.let { action(tag.editor, it, tag.offset, shiftMode, isFinal) }
}
}

View File

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

View File

@ -2,7 +2,8 @@ package org.acejump.view
import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.ui.ColorUtil import com.intellij.openapi.editor.event.CaretEvent
import com.intellij.openapi.editor.event.CaretListener
import org.acejump.boundaries.EditorOffsetCache import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries import org.acejump.boundaries.StandardBoundaries
import java.awt.Graphics import java.awt.Graphics
@ -15,9 +16,8 @@ import javax.swing.SwingUtilities
/** /**
* Holds all active tag markers and renders them on top of the editor. * Holds all active tag markers and renders them on top of the editor.
*/ */
internal class TagCanvas(private val editor: Editor) : JComponent() { internal class TagCanvas(private val editor: Editor) : JComponent(), CaretListener {
private var markers: Collection<TagMarker> = emptyList() private var markers: Collection<TagMarker>? = null
private var visible = false
init { init {
val contentComponent = editor.contentComponent val contentComponent = editor.contentComponent
@ -27,38 +27,43 @@ internal class TagCanvas(private val editor: Editor) : JComponent() {
if (ApplicationInfo.getInstance().build.components.first() < 173) { if (ApplicationInfo.getInstance().build.components.first() < 173) {
SwingUtilities.convertPoint(this, location, editor.component.rootPane).let { setLocation(-it.x, -it.y) } SwingUtilities.convertPoint(this, location, editor.component.rootPane).let { setLocation(-it.x, -it.y) }
} }
editor.caretModel.addCaretListener(this)
} }
fun unbind() { fun unbind() {
markers = null
editor.contentComponent.remove(this) editor.contentComponent.remove(this)
editor.contentComponent.repaint() 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: Collection<TagMarker>) { fun setMarkers(markers: Collection<TagMarker>) {
this.markers = markers this.markers = markers
this.visible = true
repaint() repaint()
} }
fun removeMarkers() { fun removeMarkers() {
this.markers = emptyList() this.markers = emptyList()
this.visible = false }
repaint()
override fun paint(g: Graphics) {
if (!markers.isNullOrEmpty()) {
super.paint(g)
}
} }
override fun paintChildren(g: Graphics) { override fun paintChildren(g: Graphics) {
super.paintChildren(g) super.paintChildren(g)
if (!visible) { val markers = markers ?: return
return
}
g.color = ColorUtil.withAlpha(editor.colorsScheme.defaultBackground, 0.6)
g.fillRect(0, 0, width - 1, height - 1)
if (markers.isEmpty()) {
return
}
(g as Graphics2D).setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) (g as Graphics2D).setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
@ -68,8 +73,18 @@ internal class TagCanvas(private val editor: Editor) : JComponent() {
val viewRange = StandardBoundaries.VISIBLE_ON_SCREEN.getOffsetRange(editor, cache) val viewRange = StandardBoundaries.VISIBLE_ON_SCREEN.getOffsetRange(editor, cache)
val occupied = mutableListOf<Rectangle>() val occupied = mutableListOf<Rectangle>()
// 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) { for (marker in markers) {
if (marker.offset in viewRange) { if (marker.isOffsetInRange(viewRange) && marker !== caretMarker) {
marker.paint(g, editor, cache, font, occupied) marker.paint(g, editor, cache, font, occupied)
} }
} }

View File

@ -2,18 +2,23 @@ package org.acejump.view
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorFontType import com.intellij.openapi.editor.colors.EditorFontType
import com.intellij.ui.ColorUtil
import org.acejump.config.AceConfig import org.acejump.config.AceConfig
import java.awt.Font import java.awt.Font
import java.awt.FontMetrics
/** /**
* Stores font metrics for aligning and rendering [TagMarker]s. * Stores font metrics for aligning and rendering [TagMarker]s.
*/ */
internal class TagFont(editor: Editor) { internal class TagFont(editor: Editor) {
val tagFont: Font = editor.colorsScheme.getFont(EditorFontType.PLAIN) val tagFont: Font = editor.colorsScheme.getFont(EditorFontType.BOLD)
val tagCharWidth = editor.component.getFontMetrics(tagFont).charWidth('W') val tagCharWidth = editor.component.getFontMetrics(tagFont).charWidth('W')
val foregroundColor = AceConfig.tagForegroundColor val foregroundColor = AceConfig.tagForegroundColor
var backgroundColor = AceConfig.tagBackgroundColor
val isForegroundDark = ColorUtil.isDark(foregroundColor)
val editorFontMetrics: FontMetrics = editor.component.getFontMetrics(editor.colorsScheme.getFont(EditorFontType.PLAIN))
val lineHeight = editor.lineHeight val lineHeight = editor.lineHeight
val baselineDistance = editor.ascent val baselineDistance = editor.ascent
} }

View File

@ -2,38 +2,54 @@ package org.acejump.view
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.SystemInfo
import com.intellij.ui.scale.JBUIScale
import org.acejump.boundaries.EditorOffsetCache import org.acejump.boundaries.EditorOffsetCache
import org.acejump.boundaries.StandardBoundaries
import org.acejump.countMatchingCharacters
import org.acejump.immutableText
import java.awt.Color import java.awt.Color
import java.awt.Graphics2D import java.awt.Graphics2D
import java.awt.Point import java.awt.Point
import java.awt.Rectangle import java.awt.Rectangle
import kotlin.math.max
/** /**
* Describes a 1 or 2 character shortcut that points to a specific character in the editor. * Describes a 1 or 2 character shortcut that points to a specific character in the editor.
*/ */
internal class TagMarker( internal class TagMarker(
private val tag: String, private val tag: String,
val offset: Int val offsetL: Int,
val offsetR: Int,
private val shiftR: Int,
private val hasSpaceRight: Boolean
) { ) {
private val length = tag.length private val length = tag.length
companion object { companion object {
private const val ARC = 1
/** /**
* TODO This might be due to DPI settings. * TODO This might be due to DPI settings.
*/ */
private val HIGHLIGHT_OFFSET = if (SystemInfo.isMac) -0.5 else 0.0 private val HIGHLIGHT_OFFSET = if (SystemInfo.isMac) -0.5 else 0.0
private val SHADOW_COLOR = Color(0F, 0F, 0F, 0.35F)
/** /**
* Creates a new tag, precomputing some information about the nearby characters to reduce rendering overhead. If the last typed * Creates a new tag, precomputing some information about the nearby characters to reduce rendering overhead. If the last typed
* character ([typedTag]) matches the first [tag] character, only the second [tag] character is displayed. * character ([literalQueryText]) matches the first [tag] character, only the second [tag] character is displayed.
*/ */
fun create(tag: String, offset: Int, typedTag: String): TagMarker { fun create(editor: Editor, tag: String, offset: Int, literalQueryText: String?): TagMarker {
val displayedTag = if (typedTag.isNotEmpty() && typedTag.last().equals(tag.first(), ignoreCase = true)) val chars = editor.immutableText
tag.drop(1).uppercase() val matching = literalQueryText?.let { chars.countMatchingCharacters(offset, it) } ?: 0
else val hasSpaceRight = offset + 1 >= chars.length || chars[offset + 1].isWhitespace()
tag.uppercase()
return TagMarker(displayedTag, offset) val displayedTag = if (literalQueryText != null && literalQueryText.last().equals(tag.first(), ignoreCase = true))
tag.drop(1).toUpperCase()
else
tag.toUpperCase()
return TagMarker(displayedTag, offset, offset + max(0, matching - 1), tag.length - displayedTag.length, hasSpaceRight)
} }
/** /**
@ -42,7 +58,7 @@ internal class TagMarker(
private fun drawHighlight(g: Graphics2D, rect: Rectangle, color: Color) { private fun drawHighlight(g: Graphics2D, rect: Rectangle, color: Color) {
g.color = color g.color = color
g.translate(0.0, HIGHLIGHT_OFFSET) g.translate(0.0, HIGHLIGHT_OFFSET)
g.fillRect(rect.x, rect.y, rect.width, rect.height + 1) g.fillRoundRect(rect.x, rect.y, rect.width, rect.height + 1, ARC, ARC)
g.translate(0.0, -HIGHLIGHT_OFFSET) g.translate(0.0, -HIGHLIGHT_OFFSET)
} }
@ -50,12 +66,29 @@ internal class TagMarker(
* Renders the tag text. * Renders the tag text.
*/ */
private fun drawForeground(g: Graphics2D, font: TagFont, point: Point, text: String) { private fun drawForeground(g: Graphics2D, font: TagFont, point: Point, text: String) {
g.color = font.foregroundColor val x = point.x + 2
val y = point.y + font.baselineDistance
g.font = font.tagFont g.font = font.tagFont
g.drawString(text, point.x, point.y + font.baselineDistance)
if (!font.isForegroundDark) {
g.color = SHADOW_COLOR
g.drawString(text, x + 1, y + 1)
}
g.color = font.foregroundColor
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. * 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. * Returns a rectangle indicating the area where the tag was rendered, or null if the tag could not be rendered due to overlap.
@ -63,16 +96,43 @@ internal class TagMarker(
fun paint(g: Graphics2D, editor: Editor, cache: EditorOffsetCache, font: TagFont, occupied: MutableList<Rectangle>): Rectangle? { fun paint(g: Graphics2D, editor: Editor, cache: EditorOffsetCache, font: TagFont, occupied: MutableList<Rectangle>): Rectangle? {
val rect = alignTag(editor, cache, font, occupied) ?: return null val rect = alignTag(editor, cache, font, occupied) ?: return null
drawHighlight(g, rect, editor.colorsScheme.defaultBackground) drawHighlight(g, rect, font.backgroundColor)
drawForeground(g, font, rect.location, tag) drawForeground(g, font, rect.location, tag)
occupied.add(rect) occupied.add(JBUIScale.scale(2).let { Rectangle(rect.x - it, rect.y, rect.width + (2 * it), rect.height) })
return rect return rect
} }
private fun alignTag(editor: Editor, cache: EditorOffsetCache, font: TagFont, occupied: List<Rectangle>): Rectangle? { private fun alignTag(editor: Editor, cache: EditorOffsetCache, font: TagFont, occupied: List<Rectangle>): Rectangle? {
val pos = cache.offsetToXY(editor, offset) val boundaries = StandardBoundaries.VISIBLE_ON_SCREEN
val rect = Rectangle(pos.x, pos.y, font.tagCharWidth * length, font.lineHeight)
return rect.takeIf { occupied.none(it::intersects) } 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

@ -6,12 +6,13 @@ import com.intellij.openapi.editor.markup.CustomHighlighterRenderer
import com.intellij.openapi.editor.markup.HighlighterLayer import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.ui.ColorUtil import it.unimi.dsi.fastutil.ints.IntArrayList
import it.unimi.dsi.fastutil.ints.IntList import it.unimi.dsi.fastutil.ints.IntList
import org.acejump.boundaries.EditorOffsetCache import org.acejump.boundaries.EditorOffsetCache
import org.acejump.config.AceConfig import org.acejump.config.AceConfig
import org.acejump.immutableText import org.acejump.immutableText
import org.acejump.search.SearchQuery import org.acejump.search.SearchQuery
import org.acejump.search.Tag
import java.awt.Color import java.awt.Color
import java.awt.Graphics import java.awt.Graphics
@ -31,6 +32,13 @@ internal class TextHighlighter {
}, query::getHighlightLength) }, 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(tag: Tag, query: SearchQuery) {
render(mutableMapOf(tag.editor to IntArrayList(intArrayOf(tag.offset))), AcceptedTagRenderer, query::getHighlightLength)
}
private inline fun render(results: Map<Editor, IntList>, renderer: CustomHighlighterRenderer, getHighlightLength: (CharSequence, Int) -> Int) { private inline fun render(results: Map<Editor, IntList>, renderer: CustomHighlighterRenderer, getHighlightLength: (CharSequence, Int) -> Int) {
for ((editor, offsets) in results) { for ((editor, offsets) in results) {
val highlights = previousHighlights[editor] val highlights = previousHighlights[editor]
@ -80,7 +88,7 @@ internal class TextHighlighter {
*/ */
private object SearchedWordRenderer : CustomHighlighterRenderer { private object SearchedWordRenderer : CustomHighlighterRenderer {
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) { override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) {
drawFilled(g, editor, highlighter.startOffset, highlighter.endOffset, AceConfig.searchHighlightColor) drawFilled(g, editor, highlighter.startOffset, highlighter.endOffset, AceConfig.textHighlightColor)
} }
} }
@ -89,7 +97,16 @@ internal class TextHighlighter {
*/ */
private object RegexRenderer : CustomHighlighterRenderer { private object RegexRenderer : CustomHighlighterRenderer {
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) { override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) {
drawSingle(g, editor, highlighter.startOffset, AceConfig.searchHighlightColor) 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)
} }
} }
@ -100,10 +117,10 @@ internal class TextHighlighter {
val start = EditorOffsetCache.Uncached.offsetToXY(editor, startOffset) val start = EditorOffsetCache.Uncached.offsetToXY(editor, startOffset)
val end = EditorOffsetCache.Uncached.offsetToXY(editor, endOffset) val end = EditorOffsetCache.Uncached.offsetToXY(editor, endOffset)
g.color = ColorUtil.withAlpha(AceConfig.searchHighlightColor, 0.2) g.color = color
g.fillRect(start.x, start.y + 1, end.x - start.x, editor.lineHeight - 1) g.fillRect(start.x, start.y + 1, end.x - start.x, editor.lineHeight - 1)
g.color = color g.color = AceConfig.tagBackgroundColor
g.drawRect(start.x, start.y, end.x - start.x, editor.lineHeight) g.drawRect(start.x, start.y, end.x - start.x, editor.lineHeight)
} }
@ -113,10 +130,10 @@ internal class TextHighlighter {
val font = editor.colorsScheme.getFont(EditorFontType.PLAIN) val font = editor.colorsScheme.getFont(EditorFontType.PLAIN)
val lastCharWidth = editor.component.getFontMetrics(font).charWidth(char) val lastCharWidth = editor.component.getFontMetrics(font).charWidth(char)
g.color = ColorUtil.withAlpha(AceConfig.searchHighlightColor, 0.2) g.color = color
g.fillRect(pos.x, pos.y + 1, lastCharWidth, editor.lineHeight - 1) g.fillRect(pos.x, pos.y + 1, lastCharWidth, editor.lineHeight - 1)
g.color = color g.color = AceConfig.tagBackgroundColor
g.drawRect(pos.x, pos.y, lastCharWidth, editor.lineHeight) g.drawRect(pos.x, pos.y, lastCharWidth, editor.lineHeight)
} }
} }

View File

@ -24,7 +24,7 @@
implementationClass="org.acejump.action.AceEditorAction$Reset"/> implementationClass="org.acejump.action.AceEditorAction$Reset"/>
<editorActionHandler action="EditorBackSpace" order="first" <editorActionHandler action="EditorBackSpace" order="first"
implementationClass="org.acejump.action.AceEditorAction$ClearSearch"/> implementationClass="org.acejump.action.AceEditorAction$ClearSearch"/>
<editorActionHandler action="EditorEnter" order="first, before terminalEnter" <editorActionHandler action="EditorEnter" order="first"
implementationClass="org.acejump.action.AceEditorAction$TagImmediately"/> implementationClass="org.acejump.action.AceEditorAction$TagImmediately"/>
</extensions> </extensions>

View File

@ -0,0 +1,36 @@
import org.acejump.test.util.BaseTest
/**
* Functional test cases and end-to-end performance tests.
*
* TODO: Add more structure to test cases, use test resources to define files.
*/
class AceTest : BaseTest() {
fun `test that scanner finds all occurrences of single character`() =
assertEquals("test test test".search("t"), setOf(0, 3, 5, 8, 10, 13))
fun `test empty results for an absent query`() =
assertEmpty("test test test".search("best"))
fun `test sticky results on a query with extra characters`() =
assertEquals("test test test".search("testz"), setOf(0, 5, 10))
fun `test a query inside text with some variations`() =
assertEquals("abcd dabc cdab".search("cd"), setOf(2, 10))
fun `test a query containing a space character`() =
assertEquals("abcd dabc cdab".search("cd "), setOf(2))
fun `test a query containing a { character`() =
assertEquals("abcd{dabc cdab".search("cd{"), setOf(2))
fun `test tag selection`() {
"<caret>testing 1234".search("g")
typeAndWaitForResults(session.tags[0].key)
myFixture.checkResult("testin<caret>g 1234")
}
}