mirror of
https://github.com/chylex/IntelliJ-AceJump.git
synced 2025-09-15 14:32:08 +02:00
Compare commits
155 Commits
experiment
...
9921409aff
Author | SHA1 | Date | |
---|---|---|---|
9921409aff
|
|||
![]() |
3039b5fbb3 | ||
cb3d4559f9
|
|||
![]() |
d905909e31 | ||
![]() |
64c6956b88 | ||
![]() |
b1a044dbf8 | ||
![]() |
e46cbe1d06 | ||
![]() |
f64e25a0a9 | ||
![]() |
eba043dfb2 | ||
![]() |
a746b0a516 | ||
![]() |
209b6c97b7 | ||
![]() |
3c89cd07b9 | ||
![]() |
2ffcc2c57a | ||
![]() |
56b1dfa9a9 | ||
![]() |
48cadfef4c | ||
![]() |
6d47331c71 | ||
![]() |
3339f6bdb8 | ||
![]() |
e7e85a6944 | ||
![]() |
1839efb59f | ||
![]() |
da913f61b6 | ||
![]() |
7dd12a0457 | ||
![]() |
dae7bd8fa4 | ||
![]() |
7d39ea2fef | ||
![]() |
64ad5039f2 | ||
![]() |
fb3bfa068e | ||
![]() |
c52f046a6f | ||
![]() |
ba739501e8 | ||
![]() |
e939479368 | ||
![]() |
1c604ce03c | ||
![]() |
099c852e32 | ||
![]() |
6642eff1e0 | ||
![]() |
73ba06b296 | ||
![]() |
9507496092 | ||
![]() |
99e0a50884 | ||
![]() |
c070b211e3 | ||
![]() |
6a2b5dd422 | ||
![]() |
b7024842ba | ||
![]() |
4831f38b34 | ||
![]() |
07014455e6 | ||
![]() |
ab6c6a702b | ||
![]() |
8b9e8bb89b | ||
![]() |
6c6544cf47 | ||
![]() |
7fe9d13f48 | ||
![]() |
f1f9ec5974 | ||
![]() |
5271d97c00 | ||
![]() |
872a99c84c | ||
![]() |
f8b2db5090 | ||
![]() |
5f0965c921 | ||
![]() |
5ee0245f0b | ||
![]() |
176d450855 | ||
![]() |
1341c6aff4 | ||
![]() |
0b0622dcdf | ||
![]() |
929b08e9d0 | ||
![]() |
54ea6ac9c1 | ||
![]() |
e477a487a2 | ||
![]() |
f56dc3d457 | ||
![]() |
0befd7895e | ||
![]() |
df94f4ce2a | ||
![]() |
978edff59e | ||
![]() |
f9938590fb | ||
![]() |
c534192902 | ||
![]() |
6deac53165 | ||
![]() |
2c4d4431bc | ||
![]() |
2185da7cb4 | ||
![]() |
c2a71a2c43 | ||
![]() |
d29dd3fa34 | ||
![]() |
fdbcea0b4f | ||
![]() |
c14ae702ba | ||
![]() |
8de4495885 | ||
![]() |
dd75e1ddea | ||
![]() |
02f9ecec5a | ||
f17f4ef8c4
|
|||
![]() |
c9034bced6 | ||
35003b0bab
|
|||
![]() |
dc137e4d23 | ||
![]() |
6858b745e6 | ||
![]() |
1505e98d66 | ||
![]() |
2b7015474e | ||
![]() |
8d62b0d130 | ||
828940a53a
|
|||
41071dbaf9
|
|||
![]() |
cb10ff7789 | ||
![]() |
71f8d7150d | ||
![]() |
e619f5feb0 | ||
![]() |
9dd85c1815 | ||
![]() |
194dc3a14e | ||
![]() |
f1488f7fdf | ||
![]() |
952a9af9ad | ||
![]() |
86a7ad751f | ||
![]() |
650a00f491 | ||
![]() |
9573315eff | ||
![]() |
eef4c2e08f | ||
![]() |
d1277dcb8a | ||
![]() |
7ef147c941 | ||
![]() |
4504da02ba | ||
![]() |
7cce1b7034 | ||
![]() |
16fd472132 | ||
![]() |
3c231327d0 | ||
![]() |
9284514ce3 | ||
![]() |
767a72a97c | ||
![]() |
d1ad283c8c | ||
![]() |
a169e3c751 | ||
![]() |
0ee9f3e1cb | ||
![]() |
a05e92f3cb | ||
17a762e4d8
|
|||
aaa3f5d922
|
|||
d407fd5333
|
|||
12ae15c0f7
|
|||
![]() |
5fd99c7f9b | ||
![]() |
d0cd15ef2c | ||
![]() |
e6184ca6d3 | ||
![]() |
020b6c1c7c | ||
![]() |
9839a43da4 | ||
![]() |
a7ebbcadb4 | ||
![]() |
4622fbb20b | ||
![]() |
b3df72ef81 | ||
![]() |
00ed423faa | ||
![]() |
615ac0bc7a | ||
![]() |
2c0a236ed8 | ||
![]() |
d6553cd358 | ||
![]() |
5bb9a98723 | ||
![]() |
85163d4fb9 | ||
![]() |
02fee84923 | ||
![]() |
89c3f88fbf | ||
![]() |
8fe050dc15 | ||
![]() |
61f0e3cf77 | ||
![]() |
27b897848d | ||
![]() |
e17abbdabb | ||
![]() |
5da53c603e | ||
![]() |
59e0082236 | ||
![]() |
d2b1fb96dd | ||
![]() |
d5dfa95896 | ||
![]() |
a109ff7fce | ||
![]() |
b33ae482b0 | ||
![]() |
c6b70109da | ||
![]() |
beb39c95c8 | ||
![]() |
f675efbc12 | ||
d2ae335de1 | |||
324296b55a | |||
a4ebeffe05 | |||
7716242e55 | |||
eb70ef5097 | |||
4d5a0b3e6a | |||
eb1bbb2e03 | |||
74a65a6510 | |||
2c08494a71 | |||
b61abee04d | |||
![]() |
89af38422a | ||
![]() |
80f25c39b2 | ||
![]() |
8e09ab83d7 | ||
![]() |
a9df9b7970 | ||
![]() |
b1d69bf251 | ||
![]() |
e37e1d92b3 | ||
![]() |
c5008ab26e | ||
ee1ce3c37e |
@@ -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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: breandan
|
|
246
CHANGES.md
246
CHANGES.md
@@ -1,224 +1,322 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
### 3.6.4
|
## Unreleased
|
||||||
|
|
||||||
- Improvements to tag latency. Thanks to @chylex for [the PR](https://github.com/acejump/AceJump/pull/339)!
|
- Add buttons to reset colors to default values in Settings, [#411](https://github.com/acejump/AceJump/issues/411)
|
||||||
|
|
||||||
### 3.6.3
|
## 3.8.16
|
||||||
|
|
||||||
|
- Fix issue with unselectable tags, [#446](https://github.com/acejump/AceJump/issues/446)
|
||||||
|
|
||||||
|
## 3.8.15
|
||||||
|
|
||||||
|
- Forbid jumping to offscreen tags, [#442](https://github.com/acejump/AceJump/issues/442)
|
||||||
|
|
||||||
|
## 3.8.14
|
||||||
|
|
||||||
|
- Fixes NoSuchFieldError: Companion on older platform versions, [#432](https://github.com/acejump/AceJump/issues/432), [#434](https://github.com/acejump/AceJump/issues/434), [#435](https://github.com/acejump/AceJump/issues/432), [#437](https://github.com/acejump/AceJump/issues/437), [#438](https://github.com/acejump/AceJump/issues/438), thanks to [@wuruofan](https://github.com/wuruofan)
|
||||||
|
|
||||||
|
## 3.8.13
|
||||||
|
|
||||||
|
- Fixes color settings not being persisted, [#431](https://github.com/acejump/AceJump/issues/431)
|
||||||
|
|
||||||
|
## 3.8.12
|
||||||
|
|
||||||
|
- Fixes tag cycling issue with Enter/Shift+Enter, [#429](https://github.com/acejump/AceJump/issues/429)
|
||||||
|
|
||||||
|
## 3.8.11
|
||||||
|
|
||||||
|
- Fixes UI issue affecting mode cycling order, [#426](https://github.com/acejump/AceJump/issues/426)
|
||||||
|
|
||||||
|
## 3.8.10
|
||||||
|
|
||||||
|
- Fixes regression in 3.8.9 breaking cross-tab selection, [#417](https://github.com/acejump/AceJump/issues/417)
|
||||||
|
|
||||||
|
## 3.8.9
|
||||||
|
|
||||||
|
- Add ids to editor action handlers, [#410](https://github.com/acejump/AceJump/pull/410), thanks to [@AlexPl292](https://github.com/AlexPl292)
|
||||||
|
- Update API to IJ-2022.3 and JDK to 17
|
||||||
|
|
||||||
|
## 3.8.8
|
||||||
|
|
||||||
|
- Add AZERTY keyboard layout, [#398](https://github.com/acejump/AceJump/pull/398), thanks to [@delphinaubin](https://github.com/delphinaubin)
|
||||||
|
- Add bounded toggle mode to start jump mode before or after the caret, [#401](https://github.com/acejump/AceJump/pull/401), thanks to [@colossatr0n](https://github.com/colossatr0n)
|
||||||
|
- Remove only the highlighters added by AceJump when jump session ends, [#407](https://github.com/acejump/AceJump/pull/407), thanks to [@huoguangjin](https://github.com/huoguangjin)
|
||||||
|
|
||||||
|
## 3.8.7
|
||||||
|
|
||||||
|
- Fixes Unicode-ASCII regression, [#399](https://github.com/acejump/AceJump/issues/399)
|
||||||
|
|
||||||
|
## 3.8.6
|
||||||
|
|
||||||
|
- Adds AZERTY keyboard layout, [#398](https://github.com/acejump/AceJump/pull/398), thanks to [@delphinaubin](https://github.com/delphinaubin)
|
||||||
|
|
||||||
|
## 3.8.5
|
||||||
|
|
||||||
|
- Improves tag order for non-QWERTY layouts, [#385](https://github.com/acejump/AceJump/issues/385)
|
||||||
|
- Restores <kbd>Tab</kbd>/<kbd>Shift</kbd>+<kbd>Tab</kbd> functionality, [#356](https://github.com/acejump/AceJump/issues/356)
|
||||||
|
- Fixes tag cycling with <kbd>Enter</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd>, [#380](https://github.com/acejump/AceJump/issues/380), thanks [@AlexPl292](https://github.com/AlexPl292)
|
||||||
|
|
||||||
|
## 3.8.4
|
||||||
|
|
||||||
|
- Fixes Declaration Mode in Rider, [#379](https://github.com/acejump/AceJump/issues/379), thanks to [@igor-akhmetov](https://github.com/igor-akhmetov) for helping diagnose!
|
||||||
|
- Fixes highlight offset on high-DPI screens, [#362](https://github.com/acejump/AceJump/issues/362), thanks to [@chylex](https://github.com/chylex) for [the PR](https://github.com/acejump/AceJump/pull/384)!
|
||||||
|
|
||||||
|
## 3.8.3
|
||||||
|
|
||||||
|
- Displays regular expression for regex-based queries
|
||||||
|
- Fixes a bug when current search text was enabled causing word and line mode tags to not be displayed, [#376](https://github.com/acejump/AceJump/issues/376)
|
||||||
|
|
||||||
|
## 3.8.2
|
||||||
|
|
||||||
|
- Add option to display current search text, [#375](https://github.com/acejump/AceJump/issues/375)
|
||||||
|
- Fixes a bug where editor was not focused, [#374](https://github.com/acejump/AceJump/issues/374)
|
||||||
|
- Thanks to [@SaiKai](https://github.com/SaiKai) for the PRs!
|
||||||
|
|
||||||
|
## 3.8.1
|
||||||
|
|
||||||
|
- Hotfix for stale cache, [#373](https://github.com/acejump/AceJump/issues/373)
|
||||||
|
|
||||||
|
## 3.8.0
|
||||||
|
- Allow jumping between splitters in the editor, [#371](https://github.com/acejump/AceJump/pull/371)
|
||||||
|
- Adds support for unicode search and selection, [#368](https://github.com/acejump/AceJump/issues/368)
|
||||||
|
|
||||||
|
## 3.7.1
|
||||||
|
- Fix settings display issue, [#363](https://github.com/acejump/AceJump/issues/363)
|
||||||
|
- Update AceJump extension API to include tag information, [#357](https://github.com/acejump/AceJump/pull/357)
|
||||||
|
- Allow defining jump mode with boundaries, [#358](https://github.com/acejump/AceJump/pull/358)
|
||||||
|
- Use Kotlin classes for actions, [#359](https://github.com/acejump/AceJump/pull/359)
|
||||||
|
- Thanks to [@AlexPl292](https://github.com/AlexPl292) for the PRs!
|
||||||
|
|
||||||
|
## 3.7.0
|
||||||
|
- Improvements to tag latency
|
||||||
|
- Redesign settings panel
|
||||||
|
- Add missing configuration for definition mode color
|
||||||
|
- Adds option to switch between straight and rounded tag corners
|
||||||
|
- Adds option to only consider visible area
|
||||||
|
- Add customizable jump mode cycling
|
||||||
|
- Jump-to-End mode jumps to the end of a word
|
||||||
|
- Fixes toggle keys not resetting mode when pressed twice
|
||||||
|
- Increase limit for what is considered a large file
|
||||||
|
- Major refactoring, [#350](https://github.com/acejump/AceJump/pull/353)
|
||||||
|
- [Many bug fixes](https://github.com/acejump/AceJump/issues/348#issuecomment-739454920): [#338](https://github.com/acejump/AceJump/issues/338), [#336](https://github.com/acejump/AceJump/issues/336), [#329](https://github.com/acejump/AceJump/issues/329), [#327](https://github.com/acejump/AceJump/issues/327), [#310](https://github.com/acejump/AceJump/issues/310), [#233](https://github.com/acejump/AceJump/issues/233), [#228](https://github.com/acejump/AceJump/issues/228), [#187](https://github.com/acejump/AceJump/issues/187), [#147](https://github.com/acejump/AceJump/issues/147), [#132](https://github.com/acejump/AceJump/issues/132), [#71](https://github.com/acejump/AceJump/issues/71)
|
||||||
|
- Huge thanks to [@chylex](https://github.com/chylex) for [all the PRs](https://github.com/acejump/AceJump/pulls?q=is%3Apr+author%3Achylex)!
|
||||||
|
|
||||||
|
## 3.6.3
|
||||||
|
|
||||||
- Vote for your favorite <a href="https://twitter.com/breandan/status/1274169810411274241">AceJump logo</a>!
|
- Vote for your favorite <a href="https://twitter.com/breandan/status/1274169810411274241">AceJump logo</a>!
|
||||||
- Fixes potential bug.
|
- Fixes potential bug.
|
||||||
- Increases test coverage.
|
- Increases test coverage.
|
||||||
|
|
||||||
### 3.6.2
|
## 3.6.2
|
||||||
|
|
||||||
- Fixes [#226](https://github.com/acejump/AceJump/issues/226). Thanks @AlexPl292!
|
- Fixes [#226](https://github.com/acejump/AceJump/issues/226). Thanks [@AlexPl292](https://github.com/AlexPl292)!
|
||||||
- Update Pinyin engine.
|
- Update Pinyin engine.
|
||||||
|
|
||||||
### 3.6.1
|
## 3.6.1
|
||||||
|
|
||||||
- Fixes [#324](https://github.com/acejump/AceJump/issues/324). Thanks @AlexPl292!
|
- Fixes [#324](https://github.com/acejump/AceJump/issues/324). Thanks [@AlexPl292](https://github.com/AlexPl292)!
|
||||||
- Fixes [#325](https://github.com/acejump/AceJump/issues/325).
|
- Fixes [#325](https://github.com/acejump/AceJump/issues/325).
|
||||||
- Fixes Pinyin support.
|
- Fixes Pinyin support.
|
||||||
|
|
||||||
### 3.6.0
|
## 3.6.0
|
||||||
|
|
||||||
- Adds support for Chinese [#314](https://github.com/acejump/AceJump/issues/314).
|
- Adds support for Chinese [#314](https://github.com/acejump/AceJump/issues/314).
|
||||||
- Fixes constantly loading settings page [#303](https://github.com/acejump/AceJump/issues/303).
|
- Fixes constantly loading settings page [#303](https://github.com/acejump/AceJump/issues/303).
|
||||||
- Honor camel humps [#315](https://github.com/acejump/AceJump/issues/315). Thanks to @clojj.
|
- Honor camel humps [#315](https://github.com/acejump/AceJump/issues/315). Thanks to [@clojj](https://github.com/clojj).
|
||||||
- Support dynamic application reloading [#322](https://github.com/acejump/AceJump/issues/322).
|
- Support dynamic application reloading [#322](https://github.com/acejump/AceJump/issues/322).
|
||||||
|
|
||||||
### 3.5.9
|
## 3.5.9
|
||||||
|
|
||||||
- Fix a build configuration error affecting plugins which depend on AceJump. Fixes [#305](https://github.com/acejump/AceJump/issues/305).
|
- Fix a build configuration error affecting plugins which depend on AceJump. Fixes [#305](https://github.com/acejump/AceJump/issues/305).
|
||||||
|
|
||||||
### 3.5.8
|
## 3.5.8
|
||||||
|
|
||||||
- Tagging improvements
|
- Tagging improvements
|
||||||
- Support for external plugin integration
|
- Support for external plugin integration
|
||||||
- Fixes [#304](https://github.com/acejump/AceJump/issues/304), [#255](https://github.com/acejump/AceJump/issues/255)
|
- Fixes [#304](https://github.com/acejump/AceJump/issues/304), [#255](https://github.com/acejump/AceJump/issues/255)
|
||||||
|
|
||||||
### 3.5.7
|
## 3.5.7
|
||||||
|
|
||||||
- <kbd>Tab</kbd>/<kbd>Enter</kbd> will now scroll horizontally if results are not visible.
|
- <kbd>Tab</kbd>/<kbd>Enter</kbd> will now scroll horizontally if results are not visible.
|
||||||
- Fixes [#294](https://github.com/acejump/AceJump/issues/294) "Access is allowed from event dispatch thread only" error
|
- Fixes [#294](https://github.com/acejump/AceJump/issues/294) "Access is allowed from event dispatch thread only" error
|
||||||
|
|
||||||
### 3.5.6
|
## 3.5.6
|
||||||
|
|
||||||
- Key prioritization for most common keyboard layouts and fixes for a number of minor issues.
|
- Key prioritization for most common keyboard layouts and fixes for a number of minor issues.
|
||||||
- Fixes: Index OOB [#242](https://github.com/acejump/AceJump/issues/242), Missing editor [#249](https://github.com/acejump/AceJump/issues/249), [#275](https://github.com/acejump/AceJump/issues/275), Forgotten block caret [#278](https://github.com/acejump/AceJump/issues/278), QWERTZ layout [#273](https://github.com/acejump/AceJump/issues/273)
|
- Fixes: Index OOB [#242](https://github.com/acejump/AceJump/issues/242), Missing editor [#249](https://github.com/acejump/AceJump/issues/249), [#275](https://github.com/acejump/AceJump/issues/275), Forgotten block caret [#278](https://github.com/acejump/AceJump/issues/278), QWERTZ layout [#273](https://github.com/acejump/AceJump/issues/273)
|
||||||
|
|
||||||
### 3.5.5
|
## 3.5.5
|
||||||
|
|
||||||
- <kbd>Enter</kbd> will now escape exit from AceJump when there is a single visible tag. [#274](https://github.com/acejump/AceJump/issues/274)
|
- <kbd>Enter</kbd> will now escape exit from AceJump when there is a single visible tag. [#274](https://github.com/acejump/AceJump/issues/274)
|
||||||
- <kbd>Shift</kbd>+<kbd>Tab</kbd> to scroll to previous occurrences now works properly. [#179](https://github.com/acejump/AceJump/issues/179)
|
- <kbd>Shift</kbd>+<kbd>Tab</kbd> to scroll to previous occurrences now works properly. [#179](https://github.com/acejump/AceJump/issues/179)
|
||||||
- Fixes an error with sticky block caret mode. [#269](https://github.com/acejump/AceJump/issues/269)
|
- Fixes an error with sticky block caret mode. [#269](https://github.com/acejump/AceJump/issues/269)
|
||||||
|
|
||||||
### 3.5.4
|
## 3.5.4
|
||||||
|
|
||||||
- Introduces cyclical selection: press Enter or Shift + Enter to cycle through tags on the screen. Press Escape to return to the editor.
|
- Introduces cyclical selection: press Enter or Shift + Enter to cycle through tags on the screen. Press Escape to return to the editor.
|
||||||
|
|
||||||
### 3.5.3
|
## 3.5.3
|
||||||
|
|
||||||
- Fixes for two regressions affecting caret color and shift-selection.
|
- Fixes for two regressions affecting caret color and shift-selection.
|
||||||
|
|
||||||
### 3.5.2
|
## 3.5.2
|
||||||
|
|
||||||
- Various improvements to settings page, including a keyboard layout selector.
|
- Various improvements to settings page, including a keyboard layout selector.
|
||||||
- Shorter tags on average, AceJump tries to use a single-character tag more often.
|
- Shorter tags on average, AceJump tries to use a single-character tag more often.
|
||||||
- Tag characters are now prioritized by user-defined order from the settings page.
|
- Tag characters are now prioritized by user-defined order from the settings page.
|
||||||
- Fixes an issue when running the plugin on platform version 2018.3 and above.
|
- Fixes an issue when running the plugin on platform version 2018.3 and above.
|
||||||
|
|
||||||
### 3.5.1
|
## 3.5.1
|
||||||
|
|
||||||
- Now supports searching for CaPiTaLiZeD letters (typing capital letters in the query will force a case-sensitive search).
|
- Now supports searching for CaPiTaLiZeD letters (typing capital letters in the query will force a case-sensitive search).
|
||||||
- **Declaration Mode**: Press the AceJump shortcut a second time to activate Declaration Mode, which will jump to the declaration of a variable in the editor.
|
- **Declaration Mode**: Press the AceJump shortcut a second time to activate Declaration Mode, which will jump to the declaration of a variable in the editor.
|
||||||
- Keep hitting the AceJump shortcut to cycle between modes (default, declaration, target, disabled).
|
- Keep hitting the AceJump shortcut to cycle between modes (default, declaration, target, disabled).
|
||||||
- Bug fix: AceJump settings should now properly persist after restarting the IDE.
|
- Bug fix: AceJump settings should now properly persist after restarting the IDE.
|
||||||
|
|
||||||
### 3.5.0
|
## 3.5.0
|
||||||
|
|
||||||
- Adds two new features. "**Word-Mode**" and quick tag selection.
|
- Adds two new features. "**Word-Mode**" and quick tag selection.
|
||||||
- **Word Mode** removes search and addresses latency issues raised in [#161](https://github.com/acejump/AceJump/issues/161). To learn more about **Word Mode**, see the [readme](https://github.com/johnlindquist/AceJump#tips).
|
- **Word Mode** removes search and addresses latency issues raised in [#161](https://github.com/acejump/AceJump/issues/161). To learn more about **Word Mode**, see the [readme](https://github.com/johnlindquist/AceJump#tips).
|
||||||
- Pressing <kbd>Enter</kbd> during a search will jump to the next visible match (or closest match, if next is not visible), as per [#133](https://github.com/acejump/AceJump/issues/133).
|
- Pressing <kbd>Enter</kbd> during a search will jump to the next visible match (or closest match, if next is not visible), as per [#133](https://github.com/acejump/AceJump/issues/133).
|
||||||
|
|
||||||
### 3.4.3
|
## 3.4.3
|
||||||
|
|
||||||
- Stability improvements and tagging optimizations. Fixes [#206](https://github.com/acejump/AceJump/issues/206), [#202](https://github.com/acejump/AceJump/issues/202).
|
- Stability improvements and tagging optimizations. Fixes [#206](https://github.com/acejump/AceJump/issues/206), [#202](https://github.com/acejump/AceJump/issues/202).
|
||||||
|
|
||||||
### 3.4.2
|
## 3.4.2
|
||||||
|
|
||||||
- Fixes [a regression](https://github.com/johnlindquist/AceJump/issues/197) affecting older platform versions.
|
- Fixes [a regression](https://github.com/johnlindquist/AceJump/issues/197) affecting older platform versions.
|
||||||
|
|
||||||
### 3.4.1
|
## 3.4.1
|
||||||
|
|
||||||
- Fixes a regression affecting tag alignment when line spacing is greater than 1.0. Minor speed improvements.
|
- Fixes a regression affecting tag alignment when line spacing is greater than 1.0. Minor speed improvements.
|
||||||
|
|
||||||
### 3.4.0
|
## 3.4.0
|
||||||
|
|
||||||
- Restores original scroll position if tab search cancelled. Minor improvements to latency and tag painting.
|
- Restores original scroll position if tab search cancelled. Minor improvements to latency and tag painting.
|
||||||
|
|
||||||
### 3.3.6
|
## 3.3.6
|
||||||
|
|
||||||
- Fix for [#129](https://github.com/acejump/AceJump/issues/129).
|
- Fix for [#129](https://github.com/acejump/AceJump/issues/129).
|
||||||
|
|
||||||
### 3.3.5
|
## 3.3.5
|
||||||
|
|
||||||
- Minor bugfix release. Improve handling of window resizing.
|
- Minor bugfix release. Improve handling of window resizing.
|
||||||
|
|
||||||
### 3.3.4
|
## 3.3.4
|
||||||
|
|
||||||
- Add a settings page. (Settings > Tools > AceJump)
|
- Add a settings page. (Settings > Tools > AceJump)
|
||||||
|
|
||||||
### 3.3.3
|
## 3.3.3
|
||||||
|
|
||||||
- Improve latency and fix a bug in line selection mode.
|
- Improve latency and fix a bug in line selection mode.
|
||||||
|
|
||||||
### 3.3.2
|
## 3.3.2
|
||||||
|
|
||||||
- AceJump now persists target mode state when scrolling or tabbing.
|
- AceJump now persists target mode state when scrolling or tabbing.
|
||||||
|
|
||||||
### 3.3.1
|
## 3.3.1
|
||||||
|
|
||||||
- Fixes a minor regression where tags are not displaying correctly.
|
- Fixes a minor regression where tags are not displaying correctly.
|
||||||
|
|
||||||
### 3.3.0
|
## 3.3.0
|
||||||
|
|
||||||
- AceJump now searches the entire document. Press TAB to get the next set of results!
|
- AceJump now searches the entire document. Press TAB to get the next set of results!
|
||||||
|
|
||||||
### 3.2.7
|
## 3.2.7
|
||||||
|
|
||||||
- Minor fixes and stability improvements.
|
- Minor fixes and stability improvements.
|
||||||
|
|
||||||
### 3.2.6
|
## 3.2.6
|
||||||
|
|
||||||
- Fixes an error affecting older versions of the IntelliJ Platform.
|
- Fixes an error affecting older versions of the IntelliJ Platform.
|
||||||
|
|
||||||
### 3.2.5
|
## 3.2.5
|
||||||
|
|
||||||
- AceJump 3 now supports older IntelliJ Platform and Kotlin versions.
|
- AceJump 3 now supports older IntelliJ Platform and Kotlin versions.
|
||||||
|
|
||||||
### 3.2.4
|
## 3.2.4
|
||||||
|
|
||||||
- Tagging improvements (tags now shorter on average) and visual updates.
|
- Tagging improvements (tags now shorter on average) and visual updates.
|
||||||
|
|
||||||
### 3.2.3
|
## 3.2.3
|
||||||
|
|
||||||
- Fixes a critical issue affecting users with multiple editor windows open.
|
- Fixes a critical issue affecting users with multiple editor windows open.
|
||||||
|
|
||||||
### 3.2.2
|
## 3.2.2
|
||||||
|
|
||||||
- Adds scrolling support and fixes some line spacing issues.
|
- Adds scrolling support and fixes some line spacing issues.
|
||||||
|
|
||||||
### 3.2.1
|
## 3.2.1
|
||||||
|
|
||||||
- AceJump now synchronizes font style changes in real-time.
|
- AceJump now synchronizes font style changes in real-time.
|
||||||
|
|
||||||
### 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
- Support Back/Forward navigation in the IntelliJ Platform.
|
- Support Back/Forward navigation in the IntelliJ Platform.
|
||||||
|
|
||||||
### 3.1.8
|
## 3.1.8
|
||||||
|
|
||||||
- Fixes some errors that occur when the user closes an editor prematurely.
|
- Fixes some errors that occur when the user closes an editor prematurely.
|
||||||
|
|
||||||
### 3.1.6
|
## 3.1.6
|
||||||
|
|
||||||
- Fixes a rare tag collision scenario and UninitializedPropertyAccess exception
|
- Fixes a rare tag collision scenario and UninitializedPropertyAccess exception
|
||||||
|
|
||||||
### 3.1.5
|
## 3.1.5
|
||||||
|
|
||||||
- Allow users to enter target mode directly by pressing Ctrl+Alt+;
|
- Allow users to enter target mode directly by pressing Ctrl+Alt+;
|
||||||
|
|
||||||
### 3.1.4
|
## 3.1.4
|
||||||
|
|
||||||
- Fixes the "Assertion Failed" exception popup
|
- Fixes the "Assertion Failed" exception popup
|
||||||
|
|
||||||
### 3.1.3
|
## 3.1.3
|
||||||
|
|
||||||
- Fixes an error affecting some users during startup.
|
- Fixes an error affecting some users during startup.
|
||||||
|
|
||||||
### 3.1.2
|
## 3.1.2
|
||||||
|
|
||||||
- Fixes an Android Studio regression.
|
- Fixes an Android Studio regression.
|
||||||
|
|
||||||
### 3.1.1
|
## 3.1.1
|
||||||
|
|
||||||
- Hotfix for broken target mode.
|
- Hotfix for broken target mode.
|
||||||
|
|
||||||
### 3.1.0
|
## 3.1.0
|
||||||
|
|
||||||
- Removes the search box, lots of small usability improvements.
|
- Removes the search box, lots of small usability improvements.
|
||||||
|
|
||||||
### 3.0.7
|
## 3.0.7
|
||||||
|
|
||||||
- No longer tags "folded" regions and minor alignment adjustments.
|
- No longer tags "folded" regions and minor alignment adjustments.
|
||||||
|
|
||||||
### 3.0.6
|
## 3.0.6
|
||||||
|
|
||||||
- Fixes alignment issues, removes top and bottom alignments until there is a better way to visually differentiate adjacent tags.
|
- Fixes alignment issues, removes top and bottom alignments until there is a better way to visually differentiate adjacent tags.
|
||||||
|
|
||||||
### 3.0.5
|
## 3.0.5
|
||||||
|
|
||||||
- Hotfix for target mode.
|
- Hotfix for target mode.
|
||||||
|
|
||||||
### 3.0.4
|
## 3.0.4
|
||||||
|
|
||||||
- Adds *Line Mode* - press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>;</kbd> to activate.
|
- Adds *Line Mode* - press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>;</kbd> to activate.
|
||||||
|
|
||||||
### 3.0.3
|
## 3.0.3
|
||||||
|
|
||||||
- Updates to tag placement and performance improvements.
|
- Updates to tag placement and performance improvements.
|
||||||
|
|
||||||
### 3.0.2
|
## 3.0.2
|
||||||
|
|
||||||
- Fixes target mode and default shortcut activation for Mac users.
|
- Fixes target mode and default shortcut activation for Mac users.
|
||||||
|
|
||||||
### 3.0.1
|
## 3.0.1
|
||||||
|
|
||||||
- Fixes target-mode issues affecting users with non-default shortcuts and adds support for Home/End.
|
- Fixes target-mode issues affecting users with non-default shortcuts and adds support for Home/End.
|
||||||
|
|
||||||
### 3.0.0
|
## 3.0.0
|
||||||
|
|
||||||
- Major rewrite of AceJump. Introducing:
|
- Major rewrite of AceJump. Introducing:
|
||||||
* Realtime search: Just type the word where you want to jump and AceJump will do the rest.
|
* Realtime search: Just type the word where you want to jump and AceJump will do the rest.
|
||||||
@@ -226,82 +324,82 @@
|
|||||||
* Keyboard-aware tagging: Tries to minimize finger travel distance on QWERTY keyboards.
|
* Keyboard-aware tagging: Tries to minimize finger travel distance on QWERTY keyboards.
|
||||||
* Colorful highlighting: AceJump will now highlight the editor text, as you type.
|
* Colorful highlighting: AceJump will now highlight the editor text, as you type.
|
||||||
|
|
||||||
### 2.0.13
|
## 2.0.13
|
||||||
|
|
||||||
- Fix a regression affecting *Target Mode* and line-based navigation.
|
- Fix a regression affecting *Target Mode* and line-based navigation.
|
||||||
|
|
||||||
### 2.0.12
|
## 2.0.12
|
||||||
|
|
||||||
- Fix ClassCastException when input letter not present: [#73](https://github.com/acejump/AceJump/issues/73)
|
- Fix ClassCastException when input letter not present: [#73](https://github.com/acejump/AceJump/issues/73)
|
||||||
|
|
||||||
### 2.0.11
|
## 2.0.11
|
||||||
|
|
||||||
- One hundred percent all natural Kotlin.
|
- One hundred percent all natural Kotlin.
|
||||||
|
|
||||||
### 2.0.10
|
## 2.0.10
|
||||||
|
|
||||||
- Support 2016.2, remove upper version limit, update internal Kotlin version
|
- Support 2016.2, remove upper version limit, update internal Kotlin version
|
||||||
|
|
||||||
### 2.0.9
|
## 2.0.9
|
||||||
|
|
||||||
- Compile on Java 7 to address: [#61](https://github.com/acejump/AceJump/issues/61)
|
- Compile on Java 7 to address: [#61](https://github.com/acejump/AceJump/issues/61)
|
||||||
|
|
||||||
### 2.0.8
|
## 2.0.8
|
||||||
|
|
||||||
- Compile on Java 6 to address: [#59](https://github.com/acejump/AceJump/issues/59)
|
- Compile on Java 6 to address: [#59](https://github.com/acejump/AceJump/issues/59)
|
||||||
|
|
||||||
### 2.0.7
|
## 2.0.7
|
||||||
|
|
||||||
- Language update for Kotlin 1.0 release.
|
- Language update for Kotlin 1.0 release.
|
||||||
|
|
||||||
### 2.0.6
|
## 2.0.6
|
||||||
|
|
||||||
- Fixing "lost focus" bugs mentioned here: [#41](https://github.com/acejump/AceJump/issues/41)
|
- Fixing "lost focus" bugs mentioned here: [#41](https://github.com/acejump/AceJump/issues/41)
|
||||||
|
|
||||||
### 2.0.5
|
## 2.0.5
|
||||||
|
|
||||||
- Fixing "backspace" bugs mentioned here: [#20](https://github.com/acejump/AceJump/issues/20)
|
- Fixing "backspace" bugs mentioned here: [#20](https://github.com/acejump/AceJump/issues/20)
|
||||||
|
|
||||||
### 2.0.4
|
## 2.0.4
|
||||||
|
|
||||||
- Fixing "code folding" bugs mentioned here: [#24](https://github.com/acejump/AceJump/issues/24)
|
- Fixing "code folding" bugs mentioned here: [#24](https://github.com/acejump/AceJump/issues/24)
|
||||||
|
|
||||||
### 2.0.3
|
## 2.0.3
|
||||||
|
|
||||||
- More work on Ubuntu focus bug
|
- More work on Ubuntu focus bug
|
||||||
|
|
||||||
### 2.0.2
|
## 2.0.2
|
||||||
|
|
||||||
- Fixed bug when there's only 1 search result
|
- Fixed bug when there's only 1 search result
|
||||||
|
|
||||||
### 2.0.1
|
## 2.0.1
|
||||||
|
|
||||||
- Fixing Ubuntu focus bug
|
- Fixing Ubuntu focus bug
|
||||||
|
|
||||||
### 2.0.0
|
## 2.0.0
|
||||||
|
|
||||||
- Major release: Added "target mode", many speed increases, multi-char search implemented
|
- Major release: Added "target mode", many speed increases, multi-char search implemented
|
||||||
|
|
||||||
### 1.1.0
|
## 1.1.0
|
||||||
|
|
||||||
- Switching to Kotlin for the code base
|
- Switching to Kotlin for the code base
|
||||||
|
|
||||||
### 1.0.4
|
## 1.0.4
|
||||||
|
|
||||||
- Fixing [#9](https://github.com/acejump/AceJump/issues/9) and [#6](https://github.com/acejump/AceJump/issues/6)
|
- Fixing [#9](https://github.com/acejump/AceJump/issues/9) and [#6](https://github.com/acejump/AceJump/issues/6)
|
||||||
|
|
||||||
### 1.0.3
|
## 1.0.3
|
||||||
|
|
||||||
- Fixed minor visual lag when removing the "jumpers" from the editor
|
- Fixed minor visual lag when removing the "jumpers" from the editor
|
||||||
|
|
||||||
### 1.0.2
|
## 1.0.2
|
||||||
|
|
||||||
- Cleaning up minor bugs (npe when editor not in focus, not removing layers)
|
- Cleaning up minor bugs (npe when editor not in focus, not removing layers)
|
||||||
|
|
||||||
### 1.0.1
|
## 1.0.1
|
||||||
|
|
||||||
- Adding a new jump: "Enter" will take you to the first non-whitespace char in a new line (compare to "Home" which takes you to a new line)
|
- Adding a new jump: "Enter" will take you to the first non-whitespace char in a new line (compare to "Home" which takes you to a new line)
|
||||||
|
|
||||||
### 1.0.0
|
## 1.0.0
|
||||||
|
|
||||||
- Cleaned up code base for release
|
- Cleaned up code base for release
|
103
README.md
103
README.md
@@ -1,13 +1,20 @@
|
|||||||
<p align="center"><a href="https://plugins.jetbrains.com/plugin/7086"> <img src="logo.png" alt="AceJumpLogo"></a></p>
|
<p align="center"><a href="https://plugins.jetbrains.com/plugin/7086"> <img src="logo.png" alt="AceJumpLogo"></a></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub" title="JetBrains on GitHub"><img src="http://jb.gg/badges/team.svg"></a>
|
<a href="https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub" title="JetBrains on GitHub"><img src="https://jb.gg/badges/team.svg"></a>
|
||||||
<a href="https://teamcity.jetbrains.com/viewType.html?buildTypeId=acejump_buildplugin&guest=1" title="Build Plugin"><img src="https://teamcity.jetbrains.com/app/rest/builds/buildType:acejump_buildplugin/statusIcon.svg"></a>
|
<a href="https://teamcity.jetbrains.com/viewType.html?buildTypeId=acejump_buildplugin&guest=1" title="Build Plugin"><img src="https://teamcity.jetbrains.com/app/rest/builds/buildType:acejump_buildplugin/statusIcon.svg"></a>
|
||||||
<a href="https://plugins.jetbrains.com/plugin/7086-acejump" title="Jetbrains Plugin"><img src="https://img.shields.io/jetbrains/plugin/v/7086-acejump.svg"></a>
|
<a href="https://plugins.jetbrains.com/plugin/7086-acejump" title="Jetbrains Plugin"><img src="https://img.shields.io/jetbrains/plugin/v/7086-acejump.svg"></a>
|
||||||
<a href="LICENSE" title="License"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
<a href="LICENSE" title="License"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
||||||
<a href="https://twitter.com/search?q=AceJump&f=live" title="Twitter"><img src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
|
<a href="https://twitter.com/search?q=AceJump&f=live" title="Twitter"><img src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
> **Note**: There is currently an outstanding issue with [settings deserialization](https://github.com/acejump/AceJump/issues/445). If the AceJump settings were changed in the past, they can become corrupted and may need to be manually deleted. The location varies depending on the operating system and platform version used. For IntelliJ IDEA, the plugin settings file is located in either `options` or `plugins` in the following directories:
|
||||||
|
> * Mac: `~/Library/Application Support/JetBrains/IntelliJIdea<VERSION>`
|
||||||
|
> * Windows: `%APPDATA%\JetBrains\IntelliJIdea<VERSION>`
|
||||||
|
> * Linux: `~/.local/share/JetBrains/IntelliJIdea<VERSION>`
|
||||||
|
>
|
||||||
|
> If not found, you can locate this file via the command, `find . | grep AceJump.xml` under the `JetBrains` directory. We apologize for any inconvenience this may have caused.
|
||||||
|
|
||||||
[AceJump](https://plugins.jetbrains.com/plugin/7086) is a plugin for the [IntelliJ Platform](https://github.com/JetBrains/intellij-community/) that lets you jump to any symbol in the editor with just a few keystrokes. Press the keyboard shortcut for `AceAction` (<kbd>Ctrl</kbd>+<kbd>;</kbd> by default) to activate AceJump. Type any string in the editor, followed by one of the illustrated tags, to jump its position:
|
[AceJump](https://plugins.jetbrains.com/plugin/7086) is a plugin for the [IntelliJ Platform](https://github.com/JetBrains/intellij-community/) that lets you jump to any symbol in the editor with just a few keystrokes. Press the keyboard shortcut for `AceAction` (<kbd>Ctrl</kbd>+<kbd>;</kbd> by default) to activate AceJump. Type any string in the editor, followed by one of the illustrated tags, to jump its position:
|
||||||
|
|
||||||

|

|
||||||
@@ -54,8 +61,6 @@ AceJump search is [smart case](http://ideavim.sourceforge.net/vim/usr_27.html#vi
|
|||||||
|
|
||||||
AceJump can be [installed directly from the IDE](https://www.jetbrains.com/help/idea/managing-plugins.html#install), via **Settings | Plugins | Browse Repositories... | 🔍 "AceJump"**.
|
AceJump can be [installed directly from the IDE](https://www.jetbrains.com/help/idea/managing-plugins.html#install), via **Settings | Plugins | Browse Repositories... | 🔍 "AceJump"**.
|
||||||
|
|
||||||
[Canary builds](https://teamcity.jetbrains.com/repository/download/acejump_buildplugin/.lastSuccessful/AceJump.zip?guest=1) are provided courtesy of [TeamCity](https://www.jetbrains.com/teamcity/). These can be downloaded and [installed from disk](https://www.jetbrains.com/help/idea/managing-plugins.html#install_plugin_from_disk).
|
|
||||||
|
|
||||||
## Configuring
|
## Configuring
|
||||||
|
|
||||||
[IdeaVim](https://plugins.jetbrains.com/plugin/164) users can choose to activate AceJump with a single keystroke (<kbd>f</kbd>, <kbd>F</kbd> and <kbd>g</kbd> are arbitrary) by running:
|
[IdeaVim](https://plugins.jetbrains.com/plugin/164) users can choose to activate AceJump with a single keystroke (<kbd>f</kbd>, <kbd>F</kbd> and <kbd>g</kbd> are arbitrary) by running:
|
||||||
@@ -64,15 +69,25 @@ AceJump can be [installed directly from the IDE](https://www.jetbrains.com/help/
|
|||||||
echo -e '
|
echo -e '
|
||||||
|
|
||||||
" Press `f` to activate AceJump
|
" Press `f` to activate AceJump
|
||||||
map f :action AceAction<CR>
|
map f <Action>(AceAction)
|
||||||
" Press `F` to activate Target Mode
|
" Press `F` to activate Target Mode
|
||||||
map F :action AceTargetAction<CR>
|
map F <Action>(AceTargetAction)
|
||||||
" Press `g` to activate Line Mode
|
" Press `g` to activate Line Mode
|
||||||
map g :action AceLineAction<CR>
|
map g <Action>(AceLineAction)
|
||||||
|
|
||||||
' >> ~/.ideavimrc
|
' >> ~/.ideavimrc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To customize AceJump's behavior further with additional actions, see the `<action>` tags in [plugin.xml](src/main/resources/META-INF/plugin.xml). The following example shows how to activate AceJump before or after the caret.
|
||||||
|
|
||||||
|
```
|
||||||
|
" Press `S` in normal mode to activate AceJump mode before the caret
|
||||||
|
nmap S <Action>(AceBackwardAction)
|
||||||
|
|
||||||
|
" Press `s` in normal mode to activate AceJump mode after the caret
|
||||||
|
nmap s <Action>(AceForwardAction)
|
||||||
|
```
|
||||||
|
|
||||||
To change the default keyboard shortcuts, open **File \| Settings \| Keymap \| 🔍 "AceJump" \| AceJump \|** <kbd>Enter⏎</kbd>.
|
To change the default keyboard shortcuts, open **File \| Settings \| Keymap \| 🔍 "AceJump" \| AceJump \|** <kbd>Enter⏎</kbd>.
|
||||||
|
|
||||||

|

|
||||||
@@ -91,21 +106,56 @@ The build artifact will be placed in `build/distributions/`.
|
|||||||
|
|
||||||
*Miscellaneous: AceJump is built using [Gradle](https://gradle.com/) with the [Gradle Kotlin DSL](https://docs.gradle.org/5.1/userguide/kotlin_dsl.html) and the [gradle-intellij-plugin](https://github.com/JetBrains/gradle-intellij-plugin).*
|
*Miscellaneous: AceJump is built using [Gradle](https://gradle.com/) with the [Gradle Kotlin DSL](https://docs.gradle.org/5.1/userguide/kotlin_dsl.html) and the [gradle-intellij-plugin](https://github.com/JetBrains/gradle-intellij-plugin).*
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
AceJump can be used by other [IntelliJ Platform](https://plugins.jetbrains.com/docs/intellij/welcome.html) plugins. To do so, add the following snippet to your `build.gradle.kts` file:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
intellij {
|
||||||
|
plugins.set("AceJump:<LATEST_VERSION>")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Callers who pass an instance of [`Editor`](https://github.com/JetBrains/intellij-community/blob/master/platform/editor-ui-api/src/com/intellij/openapi/editor/Editor.java) into `SessionManager.start(editor)` will receive a [`Session`](src/main/kotlin/org/acejump/session/Session.kt) instance in return. Sessions are disposed after use.
|
||||||
|
|
||||||
|
To use AceJump externally, please see the following example:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import org.acejump.session.SessionManager
|
||||||
|
import org.acejump.session.AceJumpListener
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.*
|
||||||
|
import org.acejump.search.Pattern.*
|
||||||
|
|
||||||
|
val aceJumpSession = SessionManager.start(editorInstance)
|
||||||
|
|
||||||
|
aceJumpSession.addAceJumpListener(object: AceJumpListener {
|
||||||
|
override fun finished() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sessions provide these endpoints for external consumers:
|
||||||
|
|
||||||
|
/*1.*/ aceJumpSession.markResults(sortedSetOf(/*...*/)) // Pass a set of offsets
|
||||||
|
/*2.*/ aceJumpSession.startRegexSearch("[aeiou]+", WHOLE_FILE) // Search for regex
|
||||||
|
/*3.*/ aceJumpSession.startRegexSearch(ALL_WORDS, VISIBLE_ON_SCREEN) // Search for Pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom boundaries for search (i.e. current line before caret etc.) can also be defined using the [Boundaries](src/main/kotlin/org/acejump/boundaries/Boundaries.kt) interface.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
AceJump is supported by community members like you. Contributions are highly welcome!
|
AceJump is supported by community members like you. Contributions are highly welcome!
|
||||||
|
|
||||||
If you would like to [contribute](https://github.com/acejump/AceJump/pulls), here are a few of the ways you can help improve AceJump:
|
If you would like to [contribute](https://github.com/acejump/AceJump/pulls?q=is%3Apr), here are a few of the ways you can help improve AceJump:
|
||||||
|
|
||||||
* [Improve test coverage](https://github.com/acejump/AceJump/issues/139)
|
* [Improve test coverage](https://github.com/acejump/AceJump/issues/139)
|
||||||
* [Add option to place the caret after the search text](https://github.com/acejump/AceJump/issues/225)
|
* [Add action to repeat last search](https://github.com/acejump/AceJump/issues/316)
|
||||||
* [Support user-configurable keyboard layouts](https://github.com/acejump/AceJump/issues/172)
|
* [Add configurable RegEx modes](https://github.com/acejump/AceJump/issues/215)
|
||||||
* [Speed up tagging on large files](https://github.com/acejump/AceJump/issues/217)
|
* [Add font family and size options](https://github.com/acejump/AceJump/issues/192)
|
||||||
|
* [Tag placement and visibility improvements](https://github.com/acejump/AceJump/issues/323)
|
||||||
* [Animated documentation](https://github.com/acejump/AceJump/issues/145)
|
* [Animated documentation](https://github.com/acejump/AceJump/issues/145)
|
||||||
* [Display current search text](https://github.com/acejump/AceJump/issues/227)
|
|
||||||
* [Support for full screen tagging](https://github.com/acejump/AceJump/issues/144)
|
|
||||||
* [Fold text between matches](https://github.com/acejump/AceJump/issues/255)
|
* [Fold text between matches](https://github.com/acejump/AceJump/issues/255)
|
||||||
* [Multi-platform support](https://github.com/acejump/AceJump/issues/229)
|
|
||||||
|
|
||||||
To start [IntelliJ IDEA CE](https://github.com/JetBrains/intellij-community) with AceJump installed, run `./gradlew runIde -PluginDev [-x test]`.
|
To start [IntelliJ IDEA CE](https://github.com/JetBrains/intellij-community) with AceJump installed, run `./gradlew runIde -PluginDev [-x test]`.
|
||||||
|
|
||||||
@@ -128,7 +178,7 @@ AceJump is inspired by prior work, but adds several improvements, including:
|
|||||||
* **Line Mode**: Jump to the first, last, or first non-whitespace character of any line on-screen (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>;</kbd>).
|
* **Line Mode**: Jump to the first, last, or first non-whitespace character of any line on-screen (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>;</kbd>).
|
||||||
* **Word Mode**: Jump to the first character of any visible word on-screen in two keystrokes or less.
|
* **Word Mode**: Jump to the first character of any visible word on-screen in two keystrokes or less.
|
||||||
* **Declaration Mode**: Jump to the declaration of a token (if it is available) rather than the token itself.
|
* **Declaration Mode**: Jump to the declaration of a token (if it is available) rather than the token itself.
|
||||||
* **Pinyin support**: Pinyin search and selection, e.g. to search for "拼音", activate AceJump and type: <kbd>p</kbd><kbd>y</kbd>
|
* **Unicode support**: Unicode search and selection, e.g. to search for "拼音", activate AceJump and type: <kbd>p</kbd><kbd>y</kbd>
|
||||||
|
|
||||||
The following plugins have a similar UI for navigating text and web browsing:
|
The following plugins have a similar UI for navigating text and web browsing:
|
||||||
|
|
||||||
@@ -136,30 +186,37 @@ The following plugins have a similar UI for navigating text and web browsing:
|
|||||||
|:----------------------------------------------------------------------|:-------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------:|:-------------------:|:------------------------------------------------------------------------:|
|
|:----------------------------------------------------------------------|:-------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------:|:-------------------:|:------------------------------------------------------------------------:|
|
||||||
| AceJump | [⬇](https://plugins.jetbrains.com/plugin/7086-acejump) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
| AceJump | [⬇](https://plugins.jetbrains.com/plugin/7086-acejump) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
||||||
| [IdeaVim-EasyMotion](https://github.com/AlexPl292/IdeaVim-EasyMotion) | [⬇](https://github.com/AlexPl292/IdeaVim-EasyMotion) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
| [IdeaVim-EasyMotion](https://github.com/AlexPl292/IdeaVim-EasyMotion) | [⬇](https://github.com/AlexPl292/IdeaVim-EasyMotion) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
||||||
| [AceJump-Lite](https://github.com/EeeMt/AceJump-Lite) | [⬇](https://plugins.jetbrains.com/plugin/9803-acejump-lite) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Java](https://www.java.com) |
|
| [KJump](https://github.com/a690700752/KJump) | [⬇](https://plugins.jetbrains.com/plugin/10149-kjump) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
||||||
| [KJump](https://github.com/a690700752/KJump) | [⬇](https://plugins.jetbrains.com/plugin/10149-kjump) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Java](https://www.java.com) |
|
| [AceJump-Lite](https://github.com/EeeMt/AceJump-Lite) | [⬇](https://plugins.jetbrains.com/plugin/9803-acejump-lite) | [IntelliJ Platform](https://jetbrains.com) | :x: | [Java](https://www.java.com) |
|
||||||
| [emacsIDEAs](https://github.com/whunmr/emacsIDEAs) | [⬇](https://plugins.jetbrains.com/plugin/7163-emacsideas) | [IntelliJ Platform](https://jetbrains.com) | :heavy_check_mark: | [Java](https://www.java.com) |
|
| [emacsIDEAs](https://github.com/whunmr/emacsIDEAs) | [⬇](https://plugins.jetbrains.com/plugin/7163-emacsideas) | [IntelliJ Platform](https://jetbrains.com) | :x: | [Java](https://www.java.com) |
|
||||||
| [TraceJump](https://github.com/acejump/tracejump) | [⬇](https://github.com/acejump/tracejump) | Desktop | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
| [TraceJump](https://github.com/acejump/tracejump) | [⬇](https://github.com/acejump/tracejump) | Desktop | :heavy_check_mark: | [Kotlin](http://kotlinlang.org/) |
|
||||||
| [ace-jump-mode](https://github.com/winterTTr/ace-jump-mode) | [⬇](https://melpa.org/#/ace-jump-mode) | [emacs](https://www.gnu.org/software/emacs/) | :x: | [Emacs Lisp](https://www.gnu.org/software/emacs/manual/eintr.html) |
|
| [ace-jump-mode](https://github.com/winterTTr/ace-jump-mode) | [⬇](https://melpa.org/#/ace-jump-mode) | [emacs](https://www.gnu.org/software/emacs/) | :x: | [Emacs Lisp](https://www.gnu.org/software/emacs/manual/eintr.html) |
|
||||||
| [avy](https://github.com/abo-abo/avy) | [⬇](https://melpa.org/#/avy) | [emacs](https://www.gnu.org/software/emacs/) | :heavy_check_mark: | [Emacs Lisp](https://www.gnu.org/software/emacs/manual/eintr.html) |
|
| [avy](https://github.com/abo-abo/avy) | [⬇](https://melpa.org/#/avy) | [emacs](https://www.gnu.org/software/emacs/) | :heavy_check_mark: | [Emacs Lisp](https://www.gnu.org/software/emacs/manual/eintr.html) |
|
||||||
| [EasyMotion](https://github.com/easymotion/vim-easymotion) | [⬇](https://vimawesome.com/plugin/easymotion) | [Vim](http://www.vim.org/) | :x: | [Vimscript](http://learnvimscriptthehardway.stevelosh.com/) |
|
| [EasyMotion](https://github.com/easymotion/vim-easymotion) | [⬇](https://vimawesome.com/plugin/easymotion) | [Vim](http://www.vim.org/) | :x: | [Vimscript](http://learnvimscriptthehardway.stevelosh.com/) |
|
||||||
|
| [Hop](https://github.com/phaazon/hop.nvim) | [⬇](https://github.com/phaazon/hop.nvim#installation) | [NeoVim](https://neovim.io/) | :heavy_check_mark: | [Lua](https://www.lua.org/) |
|
||||||
|
| [leap.nvim](https://github.com/ggandor/leap.nvim) | [⬇](https://github.com/ggandor/leap.nvim#installation) | [NeoVim](https://neovim.io/) | :heavy_check_mark: | [Fennel](https://fennel-lang.org) |
|
||||||
|
| [lightspeed.nvim](https://github.com/ggandor/lightspeed.nvim) | [⬇](https://github.com/ggandor/lightspeed.nvim#installation) | [NeoVim](https://neovim.io/) | :x: | [Fennel](https://fennel-lang.org) |
|
||||||
| [Sublime EasyMotion](https://github.com/tednaleid/sublime-EasyMotion) | [⬇](https://packagecontrol.io/packages/EasyMotion) | [Sublime](https://www.sublimetext.com/) | :x: | [Python](https://www.python.org/) |
|
| [Sublime EasyMotion](https://github.com/tednaleid/sublime-EasyMotion) | [⬇](https://packagecontrol.io/packages/EasyMotion) | [Sublime](https://www.sublimetext.com/) | :x: | [Python](https://www.python.org/) |
|
||||||
| [AceJump](https://github.com/ice9js/ace-jump-sublime) | [⬇](https://packagecontrol.io/packages/AceJump) | [Sublime](https://www.sublimetext.com/) | :heavy_check_mark: | [Python](https://www.python.org/) |
|
| [AceJump](https://github.com/ice9js/ace-jump-sublime) | [⬇](https://packagecontrol.io/packages/AceJump) | [Sublime](https://www.sublimetext.com/) | :x: | [Python](https://www.python.org/) |
|
||||||
| [Jumpy](https://github.com/DavidLGoldberg/jumpy) | [⬇](https://atom.io/packages/jumpy) | [Atom](https://atom.io/) | :heavy_check_mark: | [CoffeeScript](http://coffeescript.org/) |
|
| [Jumpy](https://github.com/DavidLGoldberg/jumpy) | [⬇](https://atom.io/packages/jumpy) | [Atom](https://atom.io/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
|
| [Jumpy2](https://github.com/DavidLGoldberg/jumpy2) | [⬇](https://marketplace.visualstudio.com/items?itemName=DavidLGoldberg.jumpy2) | [Visual Studio Code](https://code.visualstudio.com/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| [Find-Jump](https://github.com/msafi/xvsc/tree/master/findJump) | [⬇](https://marketplace.visualstudio.com/items?itemName=mksafi.find-jump) | [Visual Studio Code](https://code.visualstudio.com/) | :x: | [TypeScript](https://www.typescriptlang.org/) |
|
| [Find-Jump](https://github.com/msafi/xvsc/tree/master/findJump) | [⬇](https://marketplace.visualstudio.com/items?itemName=mksafi.find-jump) | [Visual Studio Code](https://code.visualstudio.com/) | :x: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| [MetaGo](https://github.com/metaseed/metaGo) | [⬇](https://marketplace.visualstudio.com/items?itemName=metaseed.metago) | [Visual Studio Code](https://code.visualstudio.com/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
| [MetaGo](https://github.com/metaseed/metaGo) | [⬇](https://marketplace.visualstudio.com/items?itemName=metaseed.metago) | [Visual Studio Code](https://code.visualstudio.com/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| [VSCodeVim](https://github.com/VSCodeVim/Vim) | [⬇](https://marketplace.visualstudio.com/items?itemName=vscodevim.vim) | [Visual Studio Code](https://code.visualstudio.com/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
| [VSCodeVim](https://github.com/VSCodeVim/Vim) | [⬇](https://marketplace.visualstudio.com/items?itemName=vscodevim.vim) | [Visual Studio Code](https://code.visualstudio.com/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| [CodeAceJumper](https://github.com/lucax88x/CodeAceJumper) | [⬇](https://marketplace.visualstudio.com/items?itemName=lucax88x.codeacejumper) | [Visual Studio Code](https://code.visualstudio.com/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
| [CodeAceJumper](https://github.com/lucax88x/CodeAceJumper) | [⬇](https://marketplace.visualstudio.com/items?itemName=lucax88x.codeacejumper) | [Visual Studio Code](https://code.visualstudio.com/) | :x: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| [AceJump](https://github.com/jsturtevant/ace-jump) | [⬇](https://marketplace.visualstudio.com/items?itemName=jsturtevant.AceJump) | [Visual Studio](https://www.visualstudio.com/) | :x: | [C#](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/) |
|
| [AceJump](https://github.com/jsturtevant/ace-jump) | [⬇](https://marketplace.visualstudio.com/items?itemName=jsturtevant.AceJump) | [Visual Studio](https://www.visualstudio.com/) | :x: | [C#](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/) |
|
||||||
| [EasyMotion](https://github.com/jaredpar/EasyMotion) | [⬇](https://marketplace.visualstudio.com/items?itemName=JaredParMSFT.EasyMotion) | [Visual Studio](https://www.visualstudio.com/) | :x: | [C#](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/) |
|
| [EasyMotion](https://github.com/jaredpar/EasyMotion) | [⬇](https://marketplace.visualstudio.com/items?itemName=JaredParMSFT.EasyMotion) | [Visual Studio](https://www.visualstudio.com/) | :x: | [C#](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/) |
|
||||||
| [cVim](https://github.com/1995eaton/chromium-vim) | [⬇](https://chrome.google.com/webstore/detail/cvim/ihlenndgcmojhcghmfjfneahoeklbjjh) | [Chrome](https://www.google.com/chrome) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
| [tmux-fingers](https://github.com/Morantron/tmux-fingers) | [⬇](https://github.com/Morantron/tmux-fingers#using-tmux-plugin-manager) | [tmux](https://github.com/tmux/tmux) | :heavy_check_mark: | [Bash](https://www.gnu.org/software/bash/) |
|
||||||
|
| [tmux-thumb](https://github.com/Morantron/tmux-fingers) | [⬇](https://github.com/fcsonline/tmux-thumbs#using-tmux-plugin-manager) | [tmux](https://github.com/tmux/tmux) | :heavy_check_mark: | [Rust](https://www.rust-lang.org/) |
|
||||||
|
| [tmux-jump](https://github.com/schasse/tmux-jump) | [⬇](https://github.com/schasse/tmux-jump#installation-via-tpm) | [tmux](https://github.com/tmux/tmux) | :heavy_check_mark: | [Ruby](https://www.ruby-lang.org) |
|
||||||
|
| [cVim](https://github.com/1995eaton/chromium-vim) | [⬇](https://chrome.google.com/webstore/detail/cvim/ihlenndgcmojhcghmfjfneahoeklbjjh) | [Chrome](https://www.google.com/chrome) | :x: | [JavaScript](https://www.javascript.com/) |
|
||||||
| [SurfingKeys](https://github.com/brookhong/Surfingkeys) | [⬇](https://chrome.google.com/webstore/detail/surfingkeys/gfbliohnnapiefjpjlpjnehglfpaknnc) | [Chrome](https://www.google.com/chrome) / [Firefox](https://www.mozilla.org/firefox) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
| [SurfingKeys](https://github.com/brookhong/Surfingkeys) | [⬇](https://chrome.google.com/webstore/detail/surfingkeys/gfbliohnnapiefjpjlpjnehglfpaknnc) | [Chrome](https://www.google.com/chrome) / [Firefox](https://www.mozilla.org/firefox) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
||||||
| [Vimium](https://github.com/philc/vimium) | [⬇](https://chrome.google.com/webstore/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb) | [Chrome](https://www.google.com/chrome) | :heavy_check_mark: | [CoffeeScript](http://coffeescript.org/) |
|
| [Vimium](https://github.com/philc/vimium) | [⬇](https://chrome.google.com/webstore/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb) | [Chrome](https://www.google.com/chrome) | :heavy_check_mark: | [CoffeeScript](http://coffeescript.org/) |
|
||||||
| [Vrome](https://github.com/jinzhu/vrome) | [⬇](https://chrome.google.com/webstore/detail/vrome/godjoomfiimiddapohpmfklhgmbfffjj) | [Chrome](https://www.google.com/chrome) | :x: | [CoffeeScript](http://coffeescript.org/) |
|
| [Vrome](https://github.com/jinzhu/vrome) | [⬇](https://chrome.google.com/webstore/detail/vrome/godjoomfiimiddapohpmfklhgmbfffjj) | [Chrome](https://www.google.com/chrome) | :x: | [CoffeeScript](http://coffeescript.org/) |
|
||||||
| [ViChrome](https://github.com/k2nr/ViChrome) | [⬇](https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi) | [Chrome](https://www.google.com/chrome) | :x: | [CoffeeScript](http://coffeescript.org/) |
|
| [ViChrome](https://github.com/k2nr/ViChrome) | [⬇](https://chrome.google.com/webstore/detail/vichrome/gghkfhpblkcmlkmpcpgaajbbiikbhpdi) | [Chrome](https://www.google.com/chrome) | :x: | [CoffeeScript](http://coffeescript.org/) |
|
||||||
| [VimFx](https://github.com/akhodakivskiy/VimFx) | [⬇](https://github.com/akhodakivskiy/VimFx/releases) | [Firefox](https://www.mozilla.org/firefox) | :heavy_check_mark: | [CoffeeScript](http://coffeescript.org/) |
|
| [VimFx](https://github.com/akhodakivskiy/VimFx) | [⬇](https://github.com/akhodakivskiy/VimFx/releases) | [Firefox](https://www.mozilla.org/firefox) | :heavy_check_mark: | [CoffeeScript](http://coffeescript.org/) |
|
||||||
| [Vimperator](https://github.com/vimperator/vimperator-labs/) | [⬇](https://github.com/vimperator/vimperator-labs/releases) | [Firefox](https://www.mozilla.org/firefox) | :x: | [JavaScript](https://www.javascript.com/) |
|
| [Vimperator](https://github.com/vimperator/vimperator-labs/) | [⬇](https://github.com/vimperator/vimperator-labs/releases) | [Firefox](https://www.mozilla.org/firefox) | :x: | [JavaScript](https://www.javascript.com/) |
|
||||||
| [Pentadactyl](https://github.com/5digits/dactyl) | [⬇](http://bug.5digits.org/pentadactyl/#sect-download) | [Firefox](https://www.mozilla.org/firefox) | :x: | [JavaScript](https://www.javascript.com/) |
|
| [Pentadactyl](https://github.com/5digits/dactyl) | [⬇](http://bug.5digits.org/pentadactyl/#sect-download) | [Firefox](https://www.mozilla.org/firefox) | :x: | [JavaScript](https://www.typescriptlang.org/) |
|
||||||
| [Vim Vixen](https://github.com/ueokande/vim-vixen) | [⬇](https://addons.mozilla.org/firefox/addon/vim-vixen/) | [Firefox 57+](https://blog.mozilla.org/addons/2017/09/28/webextensions-in-firefox-57/) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
| [Vim Vixen](https://github.com/ueokande/vim-vixen) | [⬇](https://addons.mozilla.org/firefox/addon/vim-vixen/) | [Firefox 57+](https://blog.mozilla.org/addons/2017/09/28/webextensions-in-firefox-57/) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
||||||
| [Tridactyl](https://github.com/tridactyl/tridactyl) | [⬇](https://addons.mozilla.org/firefox/addon/tridactyl-vim/) | [Firefox 57+](https://blog.mozilla.org/addons/2017/09/28/webextensions-in-firefox-57/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
| [Tridactyl](https://github.com/tridactyl/tridactyl) | [⬇](https://addons.mozilla.org/firefox/addon/tridactyl-vim/) | [Firefox 57+](https://blog.mozilla.org/addons/2017/09/28/webextensions-in-firefox-57/) | :heavy_check_mark: | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| [Vimari](https://github.com/guyht/vimari) | [⬇](https://github.com/guyht/vimari/releases) | [Safari](https://www.apple.com/safari/) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
| [Vimari](https://github.com/guyht/vimari) | [⬇](https://github.com/guyht/vimari/releases) | [Safari](https://www.apple.com/safari/) | :heavy_check_mark: | [JavaScript](https://www.javascript.com/) |
|
||||||
@@ -170,8 +227,8 @@ The following individuals have significantly improved AceJump through their cont
|
|||||||
|
|
||||||
* [John Lindquist](https://github.com/johnlindquist) for creating AceJump and supporting it for many years.
|
* [John Lindquist](https://github.com/johnlindquist) for creating AceJump and supporting it for many years.
|
||||||
* [Breandan Considine](https://github.com/breandan) for maintaining the project and adding some new features.
|
* [Breandan Considine](https://github.com/breandan) for maintaining the project and adding some new features.
|
||||||
|
* [chylex](https://github.com/chylex) for numerous [performance optimizations](https://github.com/acejump/AceJump/pulls?q=is%3Apr+author%3Achylex), [bug fixes](https://github.com/acejump/AceJump/issues/348#issuecomment-739454920) and [refactoring](https://github.com/acejump/AceJump/pull/353).
|
||||||
* [Alex Plate](https://github.com/AlexPl292) for submitting [several PRs](https://github.com/acejump/AceJump/pulls?q=is%3Apr+author%3AAlexPl292).
|
* [Alex Plate](https://github.com/AlexPl292) for submitting [several PRs](https://github.com/acejump/AceJump/pulls?q=is%3Apr+author%3AAlexPl292).
|
||||||
* [Daniel Chýlek](https://github.com/chylex) for several [performance optimizations](https://github.com/acejump/AceJump/pull/339).
|
|
||||||
* [Sven Speckmaier](https://github.com/svensp) for [improving](https://github.com/acejump/AceJump/pull/214) search latency.
|
* [Sven Speckmaier](https://github.com/svensp) for [improving](https://github.com/acejump/AceJump/pull/214) search latency.
|
||||||
* [Stefan Monnier](https://www.iro.umontreal.ca/~monnier/) for algorithmic advice and maintaining Emacs for several years.
|
* [Stefan Monnier](https://www.iro.umontreal.ca/~monnier/) for algorithmic advice and maintaining Emacs for several years.
|
||||||
* [Fool's Mate](https://www.fools-mate.de/) for the [icon](https://github.com/acejump/AceJump/issues/313) and graphic design.
|
* [Fool's Mate](https://www.fools-mate.de/) for the [icon](https://github.com/acejump/AceJump/issues/313) and graphic design.
|
||||||
|
@@ -1,66 +1,85 @@
|
|||||||
import org.jetbrains.changelog.closure
|
import org.jetbrains.changelog.Changelog.OutputType.HTML
|
||||||
import org.jetbrains.intellij.tasks.*
|
import org.jetbrains.changelog.date
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
|
||||||
import org.jetbrains.intellij.tasks.PatchPluginXmlTask
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
idea apply true
|
idea
|
||||||
kotlin("jvm") version "1.3.72"
|
kotlin("jvm") version "1.8.20" // https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#kotlin-standard-library
|
||||||
id("org.jetbrains.intellij") version "0.6.4"
|
id("org.jetbrains.intellij") version "1.15.0"
|
||||||
id("org.jetbrains.changelog") version "0.6.2"
|
id("org.jetbrains.changelog") version "2.2.0"
|
||||||
id("com.github.ben-manes.versions") version "0.36.0"
|
id("com.github.ben-manes.versions") version "0.48.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks {
|
tasks {
|
||||||
withType<KotlinCompile> {
|
|
||||||
kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
kotlinOptions.freeCompilerArgs += "-progressive"
|
|
||||||
}
|
|
||||||
|
|
||||||
named<Zip>("buildPlugin") {
|
named<Zip>("buildPlugin") {
|
||||||
dependsOn("test")
|
dependsOn("test")
|
||||||
archiveFileName.set("AceJump.zip")
|
archiveFileName = "AceJump.zip"
|
||||||
}
|
}
|
||||||
|
|
||||||
withType<RunIdeTask> {
|
runIde {
|
||||||
dependsOn("test")
|
dependsOn("test")
|
||||||
findProperty("luginDev")?.let { args = listOf(projectDir.absolutePath) }
|
findProperty("luginDev")?.let { args = listOf(projectDir.absolutePath) }
|
||||||
}
|
}
|
||||||
|
|
||||||
withType<PublishTask> {
|
publishPlugin {
|
||||||
val intellijPublishToken: String? by project
|
val intellijPublishToken: String? by project
|
||||||
token(intellijPublishToken)
|
token = intellijPublishToken
|
||||||
}
|
}
|
||||||
|
|
||||||
withType<PatchPluginXmlTask> {
|
patchPluginXml {
|
||||||
sinceBuild("201.6668.0")
|
sinceBuild = "223.7571.182"
|
||||||
changeNotes({ changelog.getLatest().toHTML() })
|
changeNotes = provider {
|
||||||
|
changelog.renderItem(changelog.getAll().values.take(2).last(), HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runPluginVerifier {
|
||||||
|
ideVersions = listOf("2023.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove pending: https://youtrack.jetbrains.com/issue/IDEA-278926
|
||||||
|
val test by getting(Test::class) {
|
||||||
|
isScanForTestClasses = false
|
||||||
|
// Only run tests from classes that end with "Test"
|
||||||
|
include("**/AceTest.class")
|
||||||
|
include("**/ExternalUsageTest.class")
|
||||||
|
include("**/LatencyTest.class")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
sourceSets.all {
|
||||||
|
languageSettings.apply {
|
||||||
|
languageVersion = "2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val acejumpVersion = "3.8.16"
|
||||||
|
|
||||||
changelog {
|
changelog {
|
||||||
|
version = acejumpVersion
|
||||||
path = "${project.projectDir}/CHANGES.md"
|
path = "${project.projectDir}/CHANGES.md"
|
||||||
header = closure { "${project.version}" }
|
header = provider { "[${project.version}] - ${date()}" }
|
||||||
}
|
itemPrefix = "-"
|
||||||
|
unreleasedTerm = "Unreleased"
|
||||||
dependencies {
|
|
||||||
// gradle-intellij-plugin doesn't attach sources properly for Kotlin :(
|
|
||||||
compileOnly(kotlin("stdlib-jdk8"))
|
|
||||||
// https://github.com/promeG/TinyPinyin
|
|
||||||
implementation("com.github.promeg:tinypinyin:2.0.3")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
jcenter()
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// https://github.com/anyascii/anyascii
|
||||||
|
implementation("com.anyascii:anyascii:0.3.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
intellij {
|
intellij {
|
||||||
version = "2020.2"
|
version = "2023.2.1"
|
||||||
pluginName = "AceJump"
|
pluginName = "AceJump"
|
||||||
updateSinceUntilBuild = false
|
updateSinceUntilBuild = false
|
||||||
setPlugins("java")
|
plugins = listOf("java")
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "org.acejump"
|
group = "org.acejump"
|
||||||
version = "3.6.4"
|
version = acejumpVersion
|
||||||
|
4
gradle.properties
Normal file
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
kotlin.stdlib.default.dependency=false
|
||||||
|
kotlin.incremental.useClasspathSnapshot=false
|
||||||
|
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
2
gradlew
vendored
2
gradlew
vendored
@@ -82,6 +82,7 @@ esac
|
|||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
@@ -129,6 +130,7 @@ fi
|
|||||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
# We build the pattern for arguments to be converted via cygpath
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
22
gradlew.bat
vendored
22
gradlew.bat
vendored
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
|||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if "%ERRORLEVEL%" == "0" goto init
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
@@ -54,7 +54,7 @@ goto fail
|
|||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto init
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
@@ -64,28 +64,14 @@ echo location of your Java installation.
|
|||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:init
|
|
||||||
@rem Get command-line arguments, handling Windows variants
|
|
||||||
|
|
||||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
|
||||||
|
|
||||||
:win9xME_args
|
|
||||||
@rem Slurp the command line arguments.
|
|
||||||
set CMD_LINE_ARGS=
|
|
||||||
set _SKIP=2
|
|
||||||
|
|
||||||
:win9xME_args_slurp
|
|
||||||
if "x%~1" == "x" goto execute
|
|
||||||
|
|
||||||
set CMD_LINE_ARGS=%*
|
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
254
src/main/kotlin/org/acejump/AceUtil.kt
Normal file
254
src/main/kotlin/org/acejump/AceUtil.kt
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package org.acejump
|
||||||
|
|
||||||
|
import com.anyascii.AnyAscii
|
||||||
|
import com.intellij.diff.util.DiffUtil.getLineCount
|
||||||
|
import com.intellij.openapi.application.ApplicationManager
|
||||||
|
import com.intellij.openapi.editor.*
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntArrayList
|
||||||
|
import org.acejump.config.AceConfig
|
||||||
|
import java.awt.Point
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This annotation is a marker which means that the annotated function is
|
||||||
|
* used in external plugins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Retention(AnnotationRetention.SOURCE)
|
||||||
|
annotation class ExternalUsage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an immutable version of the currently edited document.
|
||||||
|
*/
|
||||||
|
val Editor.immutableText get() = EditorsCache.getText(this)
|
||||||
|
|
||||||
|
object EditorsCache {
|
||||||
|
private var stale = true
|
||||||
|
fun invalidate() {
|
||||||
|
stale = true
|
||||||
|
editorTexts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val editorTexts = mutableMapOf<Editor, CharSequence>()
|
||||||
|
|
||||||
|
fun getText(editor: Editor) =
|
||||||
|
if (stale || editor !in editorTexts)
|
||||||
|
editor.document.immutableCharSequence
|
||||||
|
.let { if (AceConfig.mapToASCII) it.mapToASCII() else it }
|
||||||
|
.also { editorTexts[editor] = it; stale = false }
|
||||||
|
else editorTexts[editor]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CharSequence.mapToASCII() =
|
||||||
|
map { AnyAscii.transliterate("$it").first() }.joinToString("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if [this] contains [otherText] at the specified offset.
|
||||||
|
*/
|
||||||
|
fun CharSequence.matchesAt(selfOffset: Int, otherText: String, ignoreCase: Boolean) =
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MutableMap<Editor, IntArrayList>.clone(): MutableMap<Editor, IntArrayList> {
|
||||||
|
val clone = HashMap<Editor, IntArrayList>(size)
|
||||||
|
|
||||||
|
for ((editor, offsets) in this) {
|
||||||
|
clone[editor] = offsets.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borrowed from Editor.calculateVisibleRange() but only available after 232.6095.10
|
||||||
|
fun Editor.getView(): IntRange {
|
||||||
|
ApplicationManager.getApplication().assertIsDispatchThread()
|
||||||
|
val rect = scrollingModel.visibleArea
|
||||||
|
val startPosition = xyToLogicalPosition(Point(rect.x, rect.y))
|
||||||
|
val visibleStart = logicalPositionToOffset(startPosition)
|
||||||
|
val endPosition = xyToLogicalPosition(Point(rect.x + rect.width, rect.y + rect.height))
|
||||||
|
val visibleEnd = logicalPositionToOffset(LogicalPosition(endPosition.line + 1, 0))
|
||||||
|
return visibleStart..visibleEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 < 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(document) -> 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 < 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(document) -> getFileSize(allowEnd)
|
||||||
|
else -> document.getLineEndOffset(line) - if (allowEnd) 0 else 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
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(document) - 1))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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))
|
||||||
|
|
120
src/main/kotlin/org/acejump/action/AceAction.kt
Normal file
120
src/main/kotlin/org/acejump/action/AceAction.kt
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package org.acejump.action
|
||||||
|
|
||||||
|
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||||
|
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||||
|
import com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR
|
||||||
|
import com.intellij.openapi.actionSystem.PlatformDataKeys.LAST_ACTIVE_FILE_EDITOR
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||||
|
import com.intellij.openapi.fileEditor.TextEditor
|
||||||
|
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
|
||||||
|
import com.intellij.openapi.project.DumbAwareAction
|
||||||
|
import com.intellij.util.IncorrectOperationException
|
||||||
|
import org.acejump.boundaries.Boundaries
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.AFTER_CARET
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.BEFORE_CARET
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.WHOLE_FILE
|
||||||
|
import org.acejump.input.JumpMode
|
||||||
|
import org.acejump.input.JumpMode.DECLARATION
|
||||||
|
import org.acejump.input.JumpMode.JUMP
|
||||||
|
import org.acejump.input.JumpMode.JUMP_END
|
||||||
|
import org.acejump.input.JumpMode.TARGET
|
||||||
|
import org.acejump.search.Pattern
|
||||||
|
import org.acejump.search.Pattern.ALL_WORDS
|
||||||
|
import org.acejump.search.Pattern.LINE_ALL_MARKS
|
||||||
|
import org.acejump.search.Pattern.LINE_ENDS
|
||||||
|
import org.acejump.search.Pattern.LINE_INDENTS
|
||||||
|
import org.acejump.search.Pattern.LINE_STARTS
|
||||||
|
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 AceAction: DumbAwareAction() {
|
||||||
|
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||||
|
|
||||||
|
final override fun update(action: AnActionEvent) {
|
||||||
|
action.presentation.isEnabled =
|
||||||
|
(action.getData(EDITOR) ?: (action.getData(LAST_ACTIVE_FILE_EDITOR) as? TextEditor)?.editor) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun actionPerformed(e: AnActionEvent) {
|
||||||
|
val editor = e.getData(EDITOR) ?: (e.getData(LAST_ACTIVE_FILE_EDITOR) as? TextEditor)?.editor ?: return
|
||||||
|
val project = e.project
|
||||||
|
|
||||||
|
if (project != null) {
|
||||||
|
try {
|
||||||
|
val fem = FileEditorManager.getInstance(project) as FileEditorManagerEx
|
||||||
|
val openEditors = fem.splitters.getSelectedEditors()
|
||||||
|
.mapNotNull { (it as? TextEditor)?.editor }
|
||||||
|
.sortedBy { if (it === editor) 0 else 1 }
|
||||||
|
invoke(SessionManager.start(editor, openEditors))
|
||||||
|
} catch (e: IncorrectOperationException) {
|
||||||
|
invoke(SessionManager.start(editor))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
invoke(SessionManager.start(editor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract operator fun invoke(session: Session)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic action type that toggles a specific [JumpMode].
|
||||||
|
*/
|
||||||
|
abstract class BaseToggleJumpModeAction(private val mode: JumpMode): AceAction() {
|
||||||
|
final override fun invoke(session: Session) = session.toggleJumpMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic action type that toggles a specific [JumpMode] with [Boundaries].
|
||||||
|
*/
|
||||||
|
abstract class BaseToggleBoundedJumpModeAction(private val mode: JumpMode, private val boundaries: Boundaries): AceAction() {
|
||||||
|
final override fun invoke(session: Session) = session.toggleJumpMode(mode, boundaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic action type that starts a regex search.
|
||||||
|
*/
|
||||||
|
abstract class BaseRegexSearchAction(private val pattern: Pattern, private val boundaries: Boundaries): AceAction() {
|
||||||
|
override fun invoke(session: Session) = session.startRegexSearch(pattern, boundaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates an AceJump session in the first [JumpMode], or cycles to the next [JumpMode] as defined in configuration.
|
||||||
|
*/
|
||||||
|
class ActivateOrCycleMode: AceAction() {
|
||||||
|
override fun invoke(session: Session) = session.cycleNextJumpMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates an AceJump session in the last [JumpMode], or cycles to the previous [JumpMode] as defined in configuration.
|
||||||
|
*/
|
||||||
|
class ActivateOrReverseCycleMode: AceAction() {
|
||||||
|
override fun invoke(session: Session) = session.cyclePreviousJumpMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
|
||||||
|
// Unbounded Toggle Modes
|
||||||
|
class ToggleJumpMode : BaseToggleJumpModeAction(JUMP)
|
||||||
|
class ToggleJumpEndMode : BaseToggleJumpModeAction(JUMP_END)
|
||||||
|
class ToggleTargetMode : BaseToggleJumpModeAction(TARGET)
|
||||||
|
class ToggleDeclarationMode : BaseToggleJumpModeAction(DECLARATION)
|
||||||
|
|
||||||
|
// Bounded Toggle Modes
|
||||||
|
class ToggleBackwardJumpMode : BaseToggleBoundedJumpModeAction(JUMP, BEFORE_CARET)
|
||||||
|
class ToggleForwardJumpMode : BaseToggleBoundedJumpModeAction(JUMP, AFTER_CARET)
|
||||||
|
|
||||||
|
// Regex Modes
|
||||||
|
class StartAllWordsMode : BaseRegexSearchAction(ALL_WORDS, WHOLE_FILE)
|
||||||
|
class StartAllWordsBackwardsMode : BaseRegexSearchAction(ALL_WORDS, BEFORE_CARET)
|
||||||
|
class StartAllWordsForwardMode : BaseRegexSearchAction(ALL_WORDS, AFTER_CARET)
|
||||||
|
class StartAllLineStartsMode : BaseRegexSearchAction(LINE_STARTS, WHOLE_FILE)
|
||||||
|
class StartAllLineEndsMode : BaseRegexSearchAction(LINE_ENDS, WHOLE_FILE)
|
||||||
|
class StartAllLineIndentsMode : BaseRegexSearchAction(LINE_INDENTS, WHOLE_FILE)
|
||||||
|
class StartAllLineMarksMode : BaseRegexSearchAction(LINE_ALL_MARKS, WHOLE_FILE)
|
||||||
|
|
||||||
|
// @formatter:on
|
||||||
|
}
|
66
src/main/kotlin/org/acejump/action/AceEditorAction.kt
Normal file
66
src/main/kotlin/org/acejump/action/AceEditorAction.kt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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 =
|
||||||
|
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 SelectBackward(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) = session.visitPreviousTag()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectForward(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) = session.visitNextTag()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollToNextScreenful(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) { session.scrollToNextScreenful() }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollToPreviousScreenful(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) { session.scrollToPreviousScreenful() }
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchLineStarts(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) = session.startRegexSearch(LINE_STARTS, WHOLE_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchLineEnds(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) = session.startRegexSearch(LINE_ENDS, WHOLE_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchLineIndents(originalHandler: EditorActionHandler): AceEditorAction(originalHandler) {
|
||||||
|
override fun run(session: Session) = session.startRegexSearch(LINE_INDENTS, WHOLE_FILE)
|
||||||
|
}
|
||||||
|
}
|
134
src/main/kotlin/org/acejump/action/TagJumper.kt
Normal file
134
src/main/kotlin/org/acejump/action/TagJumper.kt
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package org.acejump.action
|
||||||
|
|
||||||
|
import com.intellij.openapi.actionSystem.ActionManager
|
||||||
|
import com.intellij.openapi.actionSystem.AnAction
|
||||||
|
import com.intellij.openapi.actionSystem.IdeActions.ACTION_GOTO_DECLARATION
|
||||||
|
import com.intellij.openapi.actionSystem.IdeActions.ACTION_GOTO_TYPE_DECLARATION
|
||||||
|
import com.intellij.openapi.command.CommandProcessor
|
||||||
|
import com.intellij.openapi.command.UndoConfirmationPolicy
|
||||||
|
import com.intellij.openapi.editor.Document
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.editor.actionSystem.DocCommandGroupId
|
||||||
|
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||||
|
import com.intellij.openapi.fileEditor.TextEditor
|
||||||
|
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
|
||||||
|
import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory
|
||||||
|
import com.intellij.openapi.project.Project
|
||||||
|
import com.intellij.openapi.ui.playback.commands.ActionCommand
|
||||||
|
import org.acejump.countMatchingCharacters
|
||||||
|
import org.acejump.immutableText
|
||||||
|
import org.acejump.input.JumpMode
|
||||||
|
import org.acejump.input.JumpMode.DECLARATION
|
||||||
|
import org.acejump.input.JumpMode.JUMP_END
|
||||||
|
import org.acejump.input.JumpMode.TARGET
|
||||||
|
import org.acejump.isWordPart
|
||||||
|
import org.acejump.search.SearchProcessor
|
||||||
|
import org.acejump.search.Tag
|
||||||
|
import org.acejump.wordEnd
|
||||||
|
import org.acejump.wordStart
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs [JumpMode] navigation and actions.
|
||||||
|
*/
|
||||||
|
internal class TagJumper(private val mode: JumpMode, private val searchProcessor: SearchProcessor?) {
|
||||||
|
/**
|
||||||
|
* Moves caret to a specific offset in the editor according to the positioning and selection rules of the current [JumpMode].
|
||||||
|
*/
|
||||||
|
fun visit(tag: Tag) {
|
||||||
|
val editor = tag.editor
|
||||||
|
val offset = tag.offset
|
||||||
|
|
||||||
|
if (mode === JUMP_END || mode === TARGET) {
|
||||||
|
val chars = editor.immutableText
|
||||||
|
val matchingChars = searchProcessor?.let { chars.countMatchingCharacters(offset, it.query.rawText) } ?: 0
|
||||||
|
val targetOffset = offset + matchingChars
|
||||||
|
val isInsideWord = matchingChars > 0 && chars[targetOffset - 1].isWordPart && chars[targetOffset].isWordPart
|
||||||
|
val finalTargetOffset = if (isInsideWord) chars.wordEnd(targetOffset) + 1 else targetOffset
|
||||||
|
|
||||||
|
if (mode === JUMP_END) {
|
||||||
|
moveCaretTo(editor, finalTargetOffset)
|
||||||
|
} else if (mode === TARGET) {
|
||||||
|
if (isInsideWord) {
|
||||||
|
selectRange(editor, chars.wordStart(targetOffset), finalTargetOffset)
|
||||||
|
} else {
|
||||||
|
selectRange(editor, offset, finalTargetOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moveCaretTo(editor, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates caret and selection by [visit]ing a specific offset in the editor, and applying session-finalizing [JumpMode] actions such as
|
||||||
|
* using the Go To Declaration action, or selecting text between caret and target offset/word if Shift was held during the jump.
|
||||||
|
*/
|
||||||
|
fun jump(tag: Tag, shiftMode: Boolean, isCrossEditor: Boolean) {
|
||||||
|
val editor = tag.editor
|
||||||
|
val oldOffset = editor.caretModel.offset
|
||||||
|
|
||||||
|
visit(tag)
|
||||||
|
|
||||||
|
if (mode === DECLARATION) {
|
||||||
|
performAction(ActionManager.getInstance().getAction(if (shiftMode) ACTION_GOTO_TYPE_DECLARATION else ACTION_GOTO_DECLARATION))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftMode && !isCrossEditor) {
|
||||||
|
val newOffset = editor.caretModel.offset
|
||||||
|
|
||||||
|
if (mode === TARGET) {
|
||||||
|
selectRange(
|
||||||
|
editor, oldOffset, when {
|
||||||
|
newOffset < oldOffset -> editor.selectionModel.selectionStart
|
||||||
|
else -> editor.selectionModel.selectionEnd
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
selectRange(editor, oldOffset, newOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private fun moveCaretTo(editor: Editor, offset: Int) = with(editor) {
|
||||||
|
ensureEditorFocused(this)
|
||||||
|
project?.let { addCurrentPositionToHistory(it, document) }
|
||||||
|
selectionModel.removeSelection(true)
|
||||||
|
caretModel.moveToOffset(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectRange(editor: Editor, fromOffset: Int, toOffset: Int) = with(editor) {
|
||||||
|
ensureEditorFocused(this)
|
||||||
|
selectionModel.removeSelection(true)
|
||||||
|
selectionModel.setSelection(fromOffset, toOffset)
|
||||||
|
caretModel.moveToOffset(toOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureEditorFocused(editor: Editor) {
|
||||||
|
val project = editor.project ?: return
|
||||||
|
val fem = FileEditorManager.getInstance(project) as FileEditorManagerEx
|
||||||
|
|
||||||
|
val window = fem.windows.firstOrNull { (it.getSelectedComposite(false)?.selectedWithProvider?.fileEditor as? TextEditor)?.editor === editor }
|
||||||
|
if (window != null && window !== fem.currentWindow) {
|
||||||
|
fem.currentWindow = window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performAction(action: AnAction) = ActionManager.getInstance()
|
||||||
|
.tryToExecute(action, ActionCommand.getInputEvent(null), null, null, true)
|
||||||
|
}
|
||||||
|
}
|
64
src/main/kotlin/org/acejump/action/TagScroller.kt
Normal file
64
src/main/kotlin/org/acejump/action/TagScroller.kt
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package org.acejump.action
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.*
|
||||||
|
import org.acejump.*
|
||||||
|
import org.acejump.search.SearchProcessor
|
||||||
|
|
||||||
|
internal class TagScroller(private val editor: Editor, private val searchProcessor: SearchProcessor) {
|
||||||
|
fun scroll(
|
||||||
|
forward: Boolean = true,
|
||||||
|
position: LogicalPosition? = if (forward) findNextPosition() else findPreviousPosition()
|
||||||
|
) = if (position != null) true.also { scrollTo(position) } else false
|
||||||
|
|
||||||
|
private fun scrollTo(position: LogicalPosition) = editor.run {
|
||||||
|
scrollingModel.disableAnimation()
|
||||||
|
scrollingModel.scrollTo(position, ScrollType.CENTER)
|
||||||
|
|
||||||
|
val firstInView = textMatches.first { it in editor.getView() }
|
||||||
|
val horizontalOffset = offsetToLogicalPosition(firstInView).column
|
||||||
|
if (horizontalOffset > scrollingModel.visibleArea.width)
|
||||||
|
scrollingModel.scrollHorizontally(horizontalOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
val textMatches by lazy { searchProcessor.results[editor]!! }
|
||||||
|
|
||||||
|
private fun findPreviousPosition(): LogicalPosition? {
|
||||||
|
val prevIndex = textMatches.toList().dropLastWhile { it > editor.getView().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 <= editor.getView().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()
|
||||||
|
}
|
||||||
|
}
|
69
src/main/kotlin/org/acejump/action/TagVisitor.kt
Normal file
69
src/main/kotlin/org/acejump/action/TagVisitor.kt
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package org.acejump.action
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.*
|
||||||
|
import com.intellij.openapi.editor.ScrollType.*
|
||||||
|
import org.acejump.search.SearchProcessor
|
||||||
|
import org.acejump.search.Tag
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables navigation between currently active tags.
|
||||||
|
*/
|
||||||
|
internal class TagVisitor(private val editor: Editor, private val searchProcessor: SearchProcessor, private val tagJumper: TagJumper) {
|
||||||
|
/**
|
||||||
|
* Places caret at the closest tag following the caret position, according
|
||||||
|
* to the rules of the current jump mode (see [TagJumper.visit]).
|
||||||
|
* If the caret is at or past the last tag, it moves to the first tag instead.
|
||||||
|
* If there is only one tag, it immediately performs the jump action as described in [TagJumper.jump].
|
||||||
|
*/
|
||||||
|
fun visitNext(): Boolean =
|
||||||
|
visit(SelectionModel::getSelectionEnd) { if (it < 0) -it - 1 else it + 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places caret at the closest tag preceding the caret position, according to
|
||||||
|
* the rules of the current jump mode (see [TagJumper.visit]).
|
||||||
|
* If the caret is at or before the first tag, it moves to the last tag instead.
|
||||||
|
* If there is only one tag, it immediately performs the jump action as described in [TagJumper.jump].
|
||||||
|
*/
|
||||||
|
fun visitPrevious(): Boolean =
|
||||||
|
visit(SelectionModel::getSelectionStart) { if (it < 0) -it - 2 else it - 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to the closest result to the caret.
|
||||||
|
*/
|
||||||
|
fun scrollToClosest() {
|
||||||
|
val caret = editor.caretModel.offset
|
||||||
|
val results = searchProcessor.results[editor].takeUnless { it.isNullOrEmpty() } ?: return
|
||||||
|
val index = results.binarySearch(caret).let { if (it < 0) -it - 1 else it }
|
||||||
|
|
||||||
|
val targetOffset = listOfNotNull(
|
||||||
|
results.getOrNull(index - 1),
|
||||||
|
results.getOrNull(index)
|
||||||
|
).minByOrNull { abs(it - caret) }
|
||||||
|
|
||||||
|
if (targetOffset != null)
|
||||||
|
editor.scrollingModel.scrollTo(editor.offsetToLogicalPosition(targetOffset), RELATIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun visit(caretPosition: SelectionModel.() -> Int, indexModifier: (Int) -> Int): Boolean {
|
||||||
|
val results = searchProcessor.results[editor].takeUnless { it.isNullOrEmpty() } ?: return false
|
||||||
|
val nextIndex = indexModifier(results.binarySearch(caretPosition(editor.selectionModel)))
|
||||||
|
|
||||||
|
val targetOffset = results.getInt(
|
||||||
|
when {
|
||||||
|
nextIndex < 0 -> results.lastIndex
|
||||||
|
nextIndex > results.lastIndex -> 0
|
||||||
|
else -> nextIndex
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val onlyResult = results.size == 1
|
||||||
|
|
||||||
|
if (onlyResult) tagJumper.jump(Tag(editor, targetOffset), shiftMode = false, isCrossEditor = false)
|
||||||
|
else tagJumper.visit(Tag(editor, targetOffset))
|
||||||
|
|
||||||
|
editor.scrollingModel.scrollToCaret(RELATIVE)
|
||||||
|
return onlyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
44
src/main/kotlin/org/acejump/boundaries/Boundaries.kt
Normal file
44
src/main/kotlin/org/acejump/boundaries/Boundaries.kt
Normal 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 =
|
||||||
|
StandardBoundaries.VISIBLE_ON_SCREEN.getOffsetRange(editor, cache)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the editor offset is included within the boundary.
|
||||||
|
*/
|
||||||
|
fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache = EditorOffsetCache.Uncached): Boolean =
|
||||||
|
StandardBoundaries.VISIBLE_ON_SCREEN.isOffsetInside(editor, offset, cache)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) this
|
||||||
|
else 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 =
|
||||||
|
this@Boundaries.isOffsetInside(editor, offset, cache) && other.isOffsetInside(editor, offset, cache)
|
||||||
|
}
|
||||||
|
}
|
71
src/main/kotlin/org/acejump/boundaries/EditorOffsetCache.kt
Normal file
71
src/main/kotlin/org/acejump/boundaries/EditorOffsetCache.kt
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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 = 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> =
|
||||||
|
visibleArea ?: Uncached.visibleArea(editor).also { visibleArea = it }
|
||||||
|
|
||||||
|
override fun xyToOffset(editor: Editor, pos: Point): Int =
|
||||||
|
pointToOffset.getInt(pos).let { offset ->
|
||||||
|
if (offset != -1) offset
|
||||||
|
else Uncached.xyToOffset(editor, pos)
|
||||||
|
.also { pointToOffset.put(pos, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun offsetToXY(editor: Editor, offset: Int) =
|
||||||
|
offsetToPoint.get(offset) ?: Uncached.offsetToXY(editor, offset)
|
||||||
|
.also { offsetToPoint.put(offset, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
object Uncached: EditorOffsetCache() {
|
||||||
|
override fun visibleArea(editor: Editor): Pair<Point, Point> =
|
||||||
|
editor.scrollingModel.visibleArea.let { visibleRect ->
|
||||||
|
Pair(
|
||||||
|
visibleRect.location, visibleRect.location.apply {
|
||||||
|
translate(visibleRect.width, visibleRect.height)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun xyToOffset(editor: Editor, pos: Point): Int =
|
||||||
|
editor.logicalPositionToOffset(editor.xyToLogicalPosition(pos))
|
||||||
|
|
||||||
|
override fun offsetToXY(editor: Editor, offset: Int): Point =
|
||||||
|
editor.offsetToXY(offset, true, false)
|
||||||
|
}
|
||||||
|
}
|
58
src/main/kotlin/org/acejump/boundaries/StandardBoundaries.kt
Normal file
58
src/main/kotlin/org/acejump/boundaries/StandardBoundaries.kt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package org.acejump.boundaries
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
|
||||||
|
enum class StandardBoundaries : Boundaries {
|
||||||
|
WHOLE_FILE {
|
||||||
|
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache) =
|
||||||
|
0..editor.document.textLength
|
||||||
|
|
||||||
|
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache) =
|
||||||
|
offset in (0..editor.document.textLength)
|
||||||
|
},
|
||||||
|
|
||||||
|
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) =
|
||||||
|
0..(editor.caretModel.offset)
|
||||||
|
|
||||||
|
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean =
|
||||||
|
offset <= editor.caretModel.offset
|
||||||
|
},
|
||||||
|
|
||||||
|
AFTER_CARET {
|
||||||
|
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache) =
|
||||||
|
editor.caretModel.offset until editor.document.textLength
|
||||||
|
|
||||||
|
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean =
|
||||||
|
offset >= editor.caretModel.offset
|
||||||
|
}
|
||||||
|
}
|
@@ -1,114 +1,44 @@
|
|||||||
package org.acejump.config
|
package org.acejump.config
|
||||||
|
|
||||||
import com.intellij.openapi.components.PersistentStateComponent
|
import com.intellij.openapi.components.PersistentStateComponent
|
||||||
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 com.intellij.util.application
|
||||||
import org.acejump.label.Pattern
|
import org.acejump.input.KeyLayoutCache
|
||||||
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 consistency 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() = application.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 cycleMode1: JumpMode get() = settings.cycleMode1
|
|
||||||
val cycleMode2: JumpMode get() = settings.cycleMode2
|
|
||||||
val cycleMode3: JumpMode get() = settings.cycleMode3
|
|
||||||
val cycleMode4: JumpMode get() = settings.cycleMode4
|
|
||||||
val jumpModeColor: Color get() = settings.jumpModeColor
|
|
||||||
val targetModeColor: Color get() = settings.targetModeColor
|
|
||||||
val definitionModeColor: Color get() = settings.definitionModeColor
|
|
||||||
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(
|
// @formatter:off
|
||||||
// Values are QWERTY keys sorted by physical proximity to the map key
|
val layout get() = settings.layout
|
||||||
'j' to "jikmnhuolbgypvftcdrxsezawq8796054321",
|
val cycleModes get() = settings.let { arrayOf(it.cycleMode1, it.cycleMode2, it.cycleMode3, it.cycleMode4) }
|
||||||
'f' to "ftgvcdryhbxseujnzawqikmolp5463728190",
|
val minQueryLength get() = settings.minQueryLength
|
||||||
'k' to "kolmjipnhubgyvftcdrxsezawq9807654321",
|
val jumpModeColor get() = settings.getJumpModeJBC()
|
||||||
'd' to "drfcxsetgvzawyhbqujnikmolp4352617890",
|
val jumpEndModeColor get() = settings.getJumpEndModeJBC()
|
||||||
'l' to "lkopmjinhubgyvftcdrxsezawq0987654321",
|
val targetModeColor get() = settings.getTargetModeJBC()
|
||||||
's' to "sedxzawrfcqtgvyhbujnikmolp3241567890",
|
val definitionModeColor get() = settings.getDefinitionModeJBC()
|
||||||
'a' to "aqwszedxrfctgvyhbujnikmolp1234567890",
|
val textHighlightColor get() = settings.getTextHighlightJBC()
|
||||||
'h' to "hujnbgyikmvftolcdrpxsezawq6758493021",
|
val tagForegroundColor get() = settings.getTagForegroundJBC()
|
||||||
'g' to "gyhbvftujncdrikmxseolzawpq5647382910",
|
val tagBackgroundColor get() = settings.getTagBackgroundJBC()
|
||||||
'y' to "yuhgtijnbvfrokmcdeplxswzaq6758493021",
|
val searchWholeFile get() = settings.searchWholeFile
|
||||||
't' to "tygfruhbvcdeijnxswokmzaqpl5647382910",
|
val mapToASCII get() = settings.mapToASCII
|
||||||
'u' to "uijhyokmnbgtplvfrcdexswzaq7869504321",
|
val showSearchNotification get() = settings.showSearchNotification
|
||||||
'r' to "rtfdeygvcxswuhbzaqijnokmpl4536271890",
|
// @formatter:on
|
||||||
'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
|
||||||
|
|
||||||
override fun loadState(state: AceSettings) {
|
override fun loadState(state: AceSettings) {
|
||||||
logger.info("Loaded AceConfig settings: $aceSettings")
|
|
||||||
aceSettings = state
|
aceSettings = state
|
||||||
|
KeyLayoutCache.reset(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,14 +1,11 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -21,42 +18,37 @@ class AceConfigurable: Configurable {
|
|||||||
panel.cycleMode2 != settings.cycleMode2 ||
|
panel.cycleMode2 != settings.cycleMode2 ||
|
||||||
panel.cycleMode3 != settings.cycleMode3 ||
|
panel.cycleMode3 != settings.cycleMode3 ||
|
||||||
panel.cycleMode4 != settings.cycleMode4 ||
|
panel.cycleMode4 != settings.cycleMode4 ||
|
||||||
panel.jumpModeColor != settings.jumpModeColor ||
|
panel.minQueryLengthInt != settings.minQueryLength ||
|
||||||
panel.targetModeColor != settings.targetModeColor ||
|
panel.jumpModeColor?.rgb != settings.jumpModeColor ||
|
||||||
panel.definitionModeColor != settings.definitionModeColor ||
|
panel.jumpEndModeColor?.rgb != settings.jumpEndModeColor ||
|
||||||
panel.textHighlightColor != settings.textHighlightColor ||
|
panel.targetModeColor?.rgb != settings.targetModeColor ||
|
||||||
panel.tagForegroundColor != settings.tagForegroundColor ||
|
panel.definitionModeColor?.rgb != settings.definitionModeColor ||
|
||||||
panel.tagBackgroundColor != settings.tagBackgroundColor ||
|
panel.textHighlightColor?.rgb != settings.textHighlightColor ||
|
||||||
panel.roundedTagCorners != settings.roundedTagCorners ||
|
panel.tagForegroundColor?.rgb != settings.tagForegroundColor ||
|
||||||
|
panel.tagBackgroundColor?.rgb != settings.tagBackgroundColor ||
|
||||||
panel.searchWholeFile != settings.searchWholeFile ||
|
panel.searchWholeFile != settings.searchWholeFile ||
|
||||||
panel.supportPinyin != settings.supportPinyin
|
panel.mapToASCII != settings.mapToASCII ||
|
||||||
|
panel.showSearchNotification != settings.showSearchNotification
|
||||||
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.cycleMode1 = panel.cycleMode1
|
||||||
settings.cycleMode2 = panel.cycleMode2
|
settings.cycleMode2 = panel.cycleMode2
|
||||||
settings.cycleMode3 = panel.cycleMode3
|
settings.cycleMode3 = panel.cycleMode3
|
||||||
settings.cycleMode4 = panel.cycleMode4
|
settings.cycleMode4 = panel.cycleMode4
|
||||||
panel.jumpModeColor ?.let { settings.jumpModeColor = it }
|
settings.minQueryLength = panel.minQueryLengthInt ?: settings.minQueryLength
|
||||||
panel.targetModeColor ?.let { settings.targetModeColor = it }
|
panel.jumpModeColor?.let { settings.jumpModeColor = it.rgb }
|
||||||
panel.definitionModeColor ?.let { settings.definitionModeColor = it }
|
panel.jumpEndModeColor?.let { settings.jumpEndModeColor = it.rgb }
|
||||||
panel.textHighlightColor ?.let { settings.textHighlightColor = it }
|
panel.targetModeColor?.let { settings.targetModeColor = it.rgb }
|
||||||
panel.tagForegroundColor ?.let { settings.tagForegroundColor = it }
|
panel.definitionModeColor?.let { settings.definitionModeColor = it.rgb }
|
||||||
panel.tagBackgroundColor ?.let { settings.tagBackgroundColor = it }
|
panel.textHighlightColor?.let { settings.textHighlightColor = it.rgb }
|
||||||
settings.roundedTagCorners = panel.roundedTagCorners
|
panel.tagForegroundColor?.let { settings.tagForegroundColor = it.rgb }
|
||||||
|
panel.tagBackgroundColor?.let { settings.tagBackgroundColor = it.rgb }
|
||||||
settings.searchWholeFile = panel.searchWholeFile
|
settings.searchWholeFile = panel.searchWholeFile
|
||||||
settings.supportPinyin = panel.supportPinyin
|
settings.mapToASCII = panel.mapToASCII
|
||||||
|
settings.showSearchNotification = panel.showSearchNotification
|
||||||
logger.info("User applied new settings: $settings")
|
KeyLayoutCache.reset(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reset() = panel.reset(settings)
|
override fun reset() = panel.reset(settings)
|
||||||
|
@@ -1,44 +1,43 @@
|
|||||||
package org.acejump.config
|
package org.acejump.config
|
||||||
|
|
||||||
import com.intellij.util.xmlb.annotations.OptionTag
|
import com.intellij.ui.JBColor
|
||||||
import org.acejump.label.Pattern.Companion.KeyLayout
|
import org.acejump.input.*
|
||||||
import org.acejump.label.Pattern.Companion.KeyLayout.QWERTY
|
import org.acejump.input.KeyLayout.QWERTY
|
||||||
import org.acejump.search.JumpMode
|
|
||||||
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 cycleMode1: JumpMode = JumpMode.JUMP,
|
||||||
var cycleMode2: JumpMode = JumpMode.DEFINE,
|
var cycleMode2: JumpMode = JumpMode.DECLARATION,
|
||||||
var cycleMode3: JumpMode = JumpMode.TARGET,
|
var cycleMode3: JumpMode = JumpMode.TARGET,
|
||||||
var cycleMode4: JumpMode = JumpMode.DISABLED,
|
var cycleMode4: JumpMode = JumpMode.JUMP_END,
|
||||||
|
var minQueryLength: Int = 1,
|
||||||
|
|
||||||
@OptionTag("jumpModeRGB", converter = ColorConverter::class)
|
var jumpModeColor: Int = 0xFFFFFF,
|
||||||
var jumpModeColor: Color = Color.BLUE,
|
|
||||||
|
|
||||||
@OptionTag("targetModeRGB", converter = ColorConverter::class)
|
var jumpEndModeColor: Int = 0x33E78A,
|
||||||
var targetModeColor: Color = Color.RED,
|
|
||||||
|
|
||||||
@OptionTag("definitionModeRGB", converter = ColorConverter::class)
|
var targetModeColor: Int = 0xFFB700,
|
||||||
var definitionModeColor: Color = Color.MAGENTA,
|
|
||||||
|
|
||||||
@OptionTag("textHighlightRGB", converter = ColorConverter::class)
|
var definitionModeColor: Int = 0x6FC5FF,
|
||||||
var textHighlightColor: Color = Color.GREEN,
|
|
||||||
|
|
||||||
@OptionTag("tagForegroundRGB", converter = ColorConverter::class)
|
var textHighlightColor: Int = 0x394B58,
|
||||||
var tagForegroundColor: Color = Color.BLACK,
|
|
||||||
|
|
||||||
@OptionTag("tagBackgroundRGB", converter = ColorConverter::class)
|
var tagForegroundColor: Int = 0xFFFFFF,
|
||||||
var tagBackgroundColor: Color = Color.YELLOW,
|
|
||||||
|
var tagBackgroundColor: Int = 0x008299,
|
||||||
|
|
||||||
var displayQuery: Boolean = false,
|
|
||||||
var roundedTagCorners: Boolean = true,
|
|
||||||
var searchWholeFile: Boolean = true,
|
var searchWholeFile: Boolean = true,
|
||||||
var supportPinyin: Boolean = false
|
|
||||||
)
|
var mapToASCII: Boolean = false,
|
||||||
|
|
||||||
|
var showSearchNotification: Boolean = false
|
||||||
|
) {
|
||||||
|
fun getJumpModeJBC() = JBColor.namedColor("jumpModeRGB", jumpModeColor)
|
||||||
|
fun getJumpEndModeJBC() = JBColor.namedColor("jumpEndModeRGB", jumpEndModeColor)
|
||||||
|
fun getTargetModeJBC() = JBColor.namedColor("targetModeRGB", targetModeColor)
|
||||||
|
fun getDefinitionModeJBC() = JBColor.namedColor("definitionModeRGB", definitionModeColor)
|
||||||
|
fun getTextHighlightJBC() = JBColor.namedColor("textHighlightRGB", textHighlightColor)
|
||||||
|
fun getTagForegroundJBC() = JBColor.namedColor("tagForegroundRGB", tagForegroundColor)
|
||||||
|
fun getTagBackgroundJBC() = JBColor.namedColor("tagBackgroundRGB", tagBackgroundColor)
|
||||||
|
}
|
||||||
|
@@ -1,124 +1,89 @@
|
|||||||
package org.acejump.config
|
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.components.*
|
||||||
import com.intellij.ui.components.JBCheckBox
|
import com.intellij.ui.dsl.builder.*
|
||||||
import com.intellij.ui.components.JBTextArea
|
import org.acejump.input.JumpMode
|
||||||
import com.intellij.ui.components.JBTextField
|
import org.acejump.input.KeyLayout
|
||||||
import com.intellij.ui.layout.Cell
|
import java.awt.*
|
||||||
import com.intellij.ui.layout.GrowPolicy.MEDIUM_TEXT
|
import javax.swing.*
|
||||||
import com.intellij.ui.layout.GrowPolicy.SHORT_TEXT
|
|
||||||
import com.intellij.ui.layout.panel
|
|
||||||
import org.acejump.label.Pattern.Companion.KeyLayout
|
|
||||||
import org.acejump.search.JumpMode
|
|
||||||
import org.acejump.search.aceString
|
|
||||||
import java.awt.Color
|
|
||||||
import java.awt.Font
|
|
||||||
import javax.swing.JCheckBox
|
|
||||||
import javax.swing.JComponent
|
|
||||||
import javax.swing.JPanel
|
|
||||||
import javax.swing.text.JTextComponent
|
import javax.swing.text.JTextComponent
|
||||||
import kotlin.reflect.KProperty
|
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 defaults = AceSettings()
|
||||||
|
|
||||||
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 cycleModeCombo1 = ComboBox<JumpMode>()
|
||||||
private val cycleModeCombo2 = ComboBox<JumpMode>()
|
private val cycleModeCombo2 = ComboBox<JumpMode>()
|
||||||
private val cycleModeCombo3 = ComboBox<JumpMode>()
|
private val cycleModeCombo3 = ComboBox<JumpMode>()
|
||||||
private val cycleModeCombo4 = ComboBox<JumpMode>()
|
private val cycleModeCombo4 = ComboBox<JumpMode>()
|
||||||
private val jumpModeColorWheel = ColorPanel()
|
private val minQueryLengthField = JBTextField()
|
||||||
private val targetModeColorWheel = ColorPanel()
|
private val jumpModeColorWheel = ResettableColorPicker(defaults.getJumpModeJBC())
|
||||||
private val definitionModeColorWheel = ColorPanel()
|
private val jumpEndModeColorWheel = ResettableColorPicker(defaults.getJumpEndModeJBC())
|
||||||
private val textHighlightColorWheel = ColorPanel()
|
private val targetModeColorWheel = ResettableColorPicker(defaults.getTargetModeJBC())
|
||||||
private val tagForegroundColorWheel = ColorPanel()
|
private val definitionModeColorWheel = ResettableColorPicker(defaults.getDefinitionModeJBC())
|
||||||
private val tagBackgroundColorWheel = ColorPanel()
|
private val textHighlightColorWheel = ResettableColorPicker(defaults.getTextHighlightJBC())
|
||||||
private val displayQueryCheckBox = JBCheckBox().apply { isEnabled = false }
|
private val tagForegroundColorWheel = ResettableColorPicker(defaults.getTagForegroundJBC())
|
||||||
private val roundedTagCornersCheckBox = JBCheckBox()
|
private val tagBackgroundColorWheel = ResettableColorPicker(defaults.getTagBackgroundJBC())
|
||||||
private val searchWholeFileCheckBox = JBCheckBox()
|
private val searchWholeFileCheckBox = JBCheckBox()
|
||||||
private val supportPinyinCheckBox = JBCheckBox()
|
private val mapToASCIICheckBox = JBCheckBox()
|
||||||
|
private val showSearchNotificationCheckBox = 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
|
cycleModeCombo1.setupEnumItems { cycleMode1 = it }
|
||||||
|
cycleModeCombo2.setupEnumItems { cycleMode2 = it }
|
||||||
|
cycleModeCombo3.setupEnumItems { cycleMode3 = it }
|
||||||
|
cycleModeCombo4.setupEnumItems { cycleMode4 = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
group("Characters and Layout") {
|
||||||
fun Cell.medium(component: JComponent) = component(growPolicy = MEDIUM_TEXT)
|
row("Allowed characters in tags:") { cell(tagCharsField).columns(COLUMNS_LARGE) }
|
||||||
|
row("Keyboard layout:") { cell(keyboardLayoutCombo).columns(COLUMNS_SHORT) }
|
||||||
titledRow(aceString("charactersAndLayoutHeading")) {
|
row("Keyboard design:") { cell(keyboardLayoutArea).columns(COLUMNS_SHORT) }
|
||||||
row(aceString("tagCharsToBeUsedLabel")) { medium(tagCharsField) }
|
|
||||||
row(aceString("keyboardLayoutLabel")) { short(keyboardLayoutCombo) }
|
|
||||||
row(aceString("keyboardDesignLabel")) { short(keyboardLayoutArea) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
titledRow(aceString("modesHeading")) {
|
group("Modes") {
|
||||||
row(aceString("cycleModeOrderLabel")) {
|
row("Cycle order:") {
|
||||||
cell(isVerticalFlow = false, isFullWidth = false) {
|
cell(cycleModeCombo1).columns(10)
|
||||||
cycleModeCombo1()
|
cell(cycleModeCombo2).columns(10)
|
||||||
cycleModeCombo2()
|
cell(cycleModeCombo3).columns(10)
|
||||||
cycleModeCombo3()
|
cell(cycleModeCombo4).columns(10)
|
||||||
cycleModeCombo4()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
titledRow(aceString("colorsHeading")) {
|
group("Colors") {
|
||||||
row(aceString("jumpModeColorLabel")) { short(jumpModeColorWheel) }
|
row("Jump mode caret background:") { cell(jumpModeColorWheel) }
|
||||||
row(aceString("targetModeColorLabel")) { short(targetModeColorWheel) }
|
row("Jump to End mode caret background:") { cell(jumpEndModeColorWheel) }
|
||||||
row(aceString("definitionModeColorLabel")) { short(definitionModeColorWheel) }
|
row("Target mode caret background:") { cell(targetModeColorWheel) }
|
||||||
row(aceString("textHighlightColorLabel")) { short(textHighlightColorWheel) }
|
row("Definition mode caret background:") { cell(definitionModeColorWheel) }
|
||||||
row(aceString("tagForegroundColorLabel")) { short(tagForegroundColorWheel) }
|
row("Searched text background:") { cell(textHighlightColorWheel) }
|
||||||
row(aceString("tagBackgroundColorLabel")) { short(tagBackgroundColorWheel) }
|
row("Tag foreground:") { cell(tagForegroundColorWheel) }
|
||||||
|
row("Tag background:") { cell(tagBackgroundColorWheel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
titledRow(aceString("appearanceHeading")) {
|
group("Behavior") {
|
||||||
row { short(displayQueryCheckBox.apply { text = aceString("displayQueryLabel") }) }
|
row { cell(searchWholeFileCheckBox.apply { text = "Search whole file" }) }
|
||||||
row { short(roundedTagCornersCheckBox.apply { text = aceString("roundedTagCornersLabel") }) }
|
row("Minimum typed characters (1-10):") { cell(minQueryLengthField) }
|
||||||
}
|
}
|
||||||
|
group("Language Settings") {
|
||||||
titledRow(aceString("behaviorHeading")) {
|
row { cell(mapToASCIICheckBox.apply { text = "Map unicode to ASCII" }) }
|
||||||
row { short(searchWholeFileCheckBox.apply { text = aceString("searchWholeFileLabel") }) }
|
}
|
||||||
row { short(supportPinyinCheckBox.apply { text = aceString("supportPinyin") }) }
|
group("Visual") {
|
||||||
|
row { cell(showSearchNotificationCheckBox.apply { text = "Show hint with search text" }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -127,16 +92,23 @@ internal class AceSettingsPanel {
|
|||||||
internal var cycleMode2 by cycleModeCombo2
|
internal var cycleMode2 by cycleModeCombo2
|
||||||
internal var cycleMode3 by cycleModeCombo3
|
internal var cycleMode3 by cycleModeCombo3
|
||||||
internal var cycleMode4 by cycleModeCombo4
|
internal var cycleMode4 by cycleModeCombo4
|
||||||
|
internal var minQueryLength by minQueryLengthField
|
||||||
internal var jumpModeColor by jumpModeColorWheel
|
internal var jumpModeColor by jumpModeColorWheel
|
||||||
|
internal var jumpEndModeColor by jumpEndModeColorWheel
|
||||||
internal var targetModeColor by targetModeColorWheel
|
internal var targetModeColor by targetModeColorWheel
|
||||||
internal var definitionModeColor by definitionModeColorWheel
|
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 roundedTagCorners by roundedTagCornersCheckBox
|
|
||||||
internal var searchWholeFile by searchWholeFileCheckBox
|
internal var searchWholeFile by searchWholeFileCheckBox
|
||||||
internal var supportPinyin by supportPinyinCheckBox
|
internal var mapToASCII by mapToASCIICheckBox
|
||||||
|
internal var showSearchNotification by showSearchNotificationCheckBox
|
||||||
|
|
||||||
|
internal var minQueryLengthInt
|
||||||
|
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
|
||||||
@@ -145,28 +117,35 @@ internal class AceSettingsPanel {
|
|||||||
cycleMode2 = settings.cycleMode2
|
cycleMode2 = settings.cycleMode2
|
||||||
cycleMode3 = settings.cycleMode3
|
cycleMode3 = settings.cycleMode3
|
||||||
cycleMode4 = settings.cycleMode4
|
cycleMode4 = settings.cycleMode4
|
||||||
jumpModeColor = settings.jumpModeColor
|
minQueryLength = settings.minQueryLength.toString()
|
||||||
targetModeColor = settings.targetModeColor
|
jumpModeColor = settings.getJumpModeJBC()
|
||||||
definitionModeColor = settings.definitionModeColor
|
jumpEndModeColor = settings.getJumpEndModeJBC()
|
||||||
textHighlightColor = settings.textHighlightColor
|
targetModeColor = settings.getTargetModeJBC()
|
||||||
tagForegroundColor = settings.tagForegroundColor
|
definitionModeColor = settings.getDefinitionModeJBC()
|
||||||
tagBackgroundColor = settings.tagBackgroundColor
|
textHighlightColor = settings.getTextHighlightJBC()
|
||||||
displayQuery = settings.displayQuery
|
tagForegroundColor = settings.getTagForegroundJBC()
|
||||||
roundedTagCorners = settings.roundedTagCorners
|
tagBackgroundColor = settings.getTagBackgroundJBC()
|
||||||
searchWholeFile = settings.searchWholeFile
|
searchWholeFile = settings.searchWholeFile
|
||||||
supportPinyin = settings.supportPinyin
|
mapToASCII = settings.mapToASCII
|
||||||
|
showSearchNotification = settings.showSearchNotification
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.lowercase()
|
||||||
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 ResettableColorPicker.getValue(a: AceSettingsPanel, p: KProperty<*>) = getSelectedColor()
|
||||||
private operator fun ColorPanel.setValue(a: AceSettingsPanel, p: KProperty<*>, c: Color?) = setSelectedColor(c)
|
private operator fun ResettableColorPicker.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 inline operator fun <reified 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) }
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,14 +0,0 @@
|
|||||||
package org.acejump.config
|
|
||||||
|
|
||||||
import com.intellij.util.xmlb.Converter
|
|
||||||
import java.awt.Color
|
|
||||||
|
|
||||||
class ColorConverter : Converter<Color>() {
|
|
||||||
override fun toString(value: Color): String {
|
|
||||||
return value.rgb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fromString(value: String): Color? {
|
|
||||||
return value.toIntOrNull()?.let(::Color)
|
|
||||||
}
|
|
||||||
}
|
|
47
src/main/kotlin/org/acejump/config/ResettableColorPicker.kt
Normal file
47
src/main/kotlin/org/acejump/config/ResettableColorPicker.kt
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package org.acejump.config
|
||||||
|
|
||||||
|
import com.intellij.icons.AllIcons
|
||||||
|
import com.intellij.openapi.actionSystem.*
|
||||||
|
import com.intellij.openapi.actionSystem.impl.ActionButton
|
||||||
|
import com.intellij.ui.ColorPanel
|
||||||
|
import com.intellij.ui.JBColor
|
||||||
|
import java.awt.*
|
||||||
|
import javax.swing.*
|
||||||
|
|
||||||
|
internal class ResettableColorPicker(private val defaultColor: JBColor) : JPanel(FlowLayout()) {
|
||||||
|
private val resetAction = object : AnAction({ "Reset to Default" }, AllIcons.General.Reset) {
|
||||||
|
override fun getActionUpdateThread(): ActionUpdateThread {
|
||||||
|
return ActionUpdateThread.EDT
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(e: AnActionEvent) {
|
||||||
|
e.presentation.isEnabled = colorPanel.selectedColor != defaultColor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun actionPerformed(e: AnActionEvent) {
|
||||||
|
setSelectedColor(defaultColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val colorPanel = ColorPanel()
|
||||||
|
private val resetButton = ActionButton(resetAction, null, ActionPlaces.UNKNOWN, ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE)
|
||||||
|
|
||||||
|
init {
|
||||||
|
add(colorPanel)
|
||||||
|
add(resetButton)
|
||||||
|
setSelectedColor(defaultColor)
|
||||||
|
|
||||||
|
colorPanel.addActionListener {
|
||||||
|
resetButton.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSelectedColor(): Color? {
|
||||||
|
return colorPanel.selectedColor
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelectedColor(color: Color?) {
|
||||||
|
colorPanel.selectedColor = color
|
||||||
|
resetButton.update()
|
||||||
|
}
|
||||||
|
}
|
@@ -1,90 +0,0 @@
|
|||||||
package org.acejump.control
|
|
||||||
|
|
||||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
|
||||||
import com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR
|
|
||||||
import com.intellij.openapi.diagnostic.Logger
|
|
||||||
import com.intellij.openapi.project.DumbAwareAction
|
|
||||||
import org.acejump.control.Handler.regexSearch
|
|
||||||
import org.acejump.label.Pattern
|
|
||||||
import org.acejump.label.Pattern.ALL_WORDS
|
|
||||||
import org.acejump.search.JumpMode
|
|
||||||
import org.acejump.search.Jumper
|
|
||||||
import org.acejump.search.getNameOfFileInEditor
|
|
||||||
import org.acejump.view.Boundary.*
|
|
||||||
import org.acejump.view.Model.boundaries
|
|
||||||
import org.acejump.view.Model.defaultBoundary
|
|
||||||
import org.acejump.view.Model.editor
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entry point for all actions. The IntelliJ Platform calls AceJump here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
open class AceAction: DumbAwareAction() {
|
|
||||||
open val logger = Logger.getInstance(javaClass)
|
|
||||||
override fun update(action: AnActionEvent) {
|
|
||||||
action.presentation.isEnabled = action.getData(EDITOR) != null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun actionPerformed(e: AnActionEvent) {
|
|
||||||
editor = e.getData(EDITOR) ?: return
|
|
||||||
boundaries = defaultBoundary
|
|
||||||
val textLength = editor.document.textLength
|
|
||||||
logger.info("Invoked on ${editor.getNameOfFileInEditor()} ($textLength)")
|
|
||||||
Handler.activate()
|
|
||||||
customize()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun customize() = Jumper.cycleMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When target mode is activated, selecting a tag will highlight an entire word.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AceTargetAction: AceAction() {
|
|
||||||
override fun customize() = Jumper.toggleMode(JumpMode.TARGET)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* When line mode is activated, we will tag the beginning and end of each line.
|
|
||||||
*
|
|
||||||
* TODO: https://github.com/acejump/AceJump/issues/327
|
|
||||||
* TODO: https://github.com/acejump/AceJump/issues/340
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AceLineAction: AceAction() {
|
|
||||||
override fun customize() = regexSearch(Pattern.LINE_MARK)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When declaration mode is activated, selecting a tag will take us to the
|
|
||||||
* definition (i.e. declaration) of the token in the editor, if it exists.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AceDefinitionAction: AceAction() {
|
|
||||||
override fun customize() = Jumper.toggleMode(JumpMode.DEFINE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When word mode is activated, we will tag all words on the screen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AceWordAction: AceAction() {
|
|
||||||
override fun customize() = regexSearch(ALL_WORDS, SCREEN_BOUNDARY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for words from the start of the screen to the caret
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AceWordForwardAction: AceAction() {
|
|
||||||
override fun customize() = regexSearch(ALL_WORDS, AFTER_CARET_BOUNDARY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for words from the caret position to the start of the screen
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AceWordBackwardsAction: AceAction() {
|
|
||||||
override fun customize() = regexSearch(ALL_WORDS, BEFORE_CARET_BOUNDARY)
|
|
||||||
}
|
|
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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() {}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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() }
|
|
||||||
}
|
|
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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 }
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
39
src/main/kotlin/org/acejump/input/EditorKeyListener.kt
Normal file
39
src/main/kotlin/org/acejump/input/EditorKeyListener.kt
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
79
src/main/kotlin/org/acejump/input/JumpMode.kt
Normal file
79
src/main/kotlin/org/acejump/input/JumpMode.kt
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package org.acejump.input
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.colors.impl.AbstractColorsScheme
|
||||||
|
import org.acejump.config.AceConfig
|
||||||
|
import java.awt.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes modes that determine the behavior of a "jump" to a tag.
|
||||||
|
* Most modes have two variations:
|
||||||
|
* - **Default jump** happens when jumping without holding the Shift key
|
||||||
|
* - **Shift jump** happens when jumping while holding the Shift key
|
||||||
|
*/
|
||||||
|
enum class JumpMode {
|
||||||
|
/**
|
||||||
|
* Default value at the start of a session. If the session does not get
|
||||||
|
* assigned a proper [JumpMode] by the time the user requests a jump, the
|
||||||
|
* results of the jump are undefined.
|
||||||
|
*/
|
||||||
|
DISABLED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On default jump, places the caret at the first character of the search
|
||||||
|
* query. On shift jump, does the above but also selects all text between
|
||||||
|
* the original and new caret positions.
|
||||||
|
*/
|
||||||
|
JUMP,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On default jump, 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 first
|
||||||
|
* character of the search query, then the caret is placed after the last
|
||||||
|
* character of the search query.
|
||||||
|
*
|
||||||
|
* On shift jump, does the above but also selects all text between
|
||||||
|
* the original and new caret positions.
|
||||||
|
*/
|
||||||
|
JUMP_END,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On default jump, 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 first 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 jump, does the above but also selects all text between the
|
||||||
|
* start of the original caret position and the end of the new selection.
|
||||||
|
*/
|
||||||
|
TARGET,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On default jump, performs the Go To Declaration action, available
|
||||||
|
* via `Navigate | Declaration or Usages`.
|
||||||
|
*
|
||||||
|
* On shift jump, performs the Go To Type Declaration action, available
|
||||||
|
* via `Navigate | Type Declaration`.
|
||||||
|
*
|
||||||
|
* Always places the caret at the first character of the search query.
|
||||||
|
*/
|
||||||
|
DECLARATION;
|
||||||
|
|
||||||
|
val caretColor: Color get() = when (this) {
|
||||||
|
DISABLED -> AbstractColorsScheme.INHERITED_COLOR_MARKER
|
||||||
|
JUMP -> AceConfig.jumpModeColor
|
||||||
|
JUMP_END -> AceConfig.jumpEndModeColor
|
||||||
|
TARGET -> AceConfig.targetModeColor
|
||||||
|
DECLARATION -> AceConfig.definitionModeColor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = when (this) {
|
||||||
|
DISABLED -> "(Skip)"
|
||||||
|
JUMP -> "Jump"
|
||||||
|
JUMP_END -> "Jump to End"
|
||||||
|
TARGET -> "Target"
|
||||||
|
DECLARATION -> "Definition"
|
||||||
|
}
|
||||||
|
}
|
56
src/main/kotlin/org/acejump/input/JumpModeTracker.kt
Normal file
56
src/main/kotlin/org/acejump/input/JumpModeTracker.kt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package org.acejump.input
|
||||||
|
|
||||||
|
import org.acejump.config.AceConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers the current [JumpMode] for a session. Allows cycling
|
||||||
|
* [JumpMode]s according to the order defined in configuration, or
|
||||||
|
* toggling one specific [JumpMode] on or off.
|
||||||
|
*/
|
||||||
|
internal class JumpModeTracker {
|
||||||
|
private var currentMode = JumpMode.DISABLED
|
||||||
|
private var currentIndex = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the next/previous [JumpMode] defined in configuration,
|
||||||
|
* skipping any [JumpMode]s that are not assigned. If at least two
|
||||||
|
* [JumpMode]s are assigned in the cycle order, then cycling will
|
||||||
|
* wrap around. If only one [JumpMode] is assigned, then cycling will
|
||||||
|
* toggle that one mode.
|
||||||
|
*/
|
||||||
|
fun cycle(forward: Boolean): JumpMode {
|
||||||
|
val cycleModes = AceConfig.cycleModes
|
||||||
|
val direction = if (forward) 1 else -1
|
||||||
|
val start = if (currentIndex == 0 && !forward) 0 else currentIndex - 1
|
||||||
|
|
||||||
|
for (offset in 1 until cycleModes.size) {
|
||||||
|
val index = (start + cycleModes.size + (offset * direction)) % cycleModes.size
|
||||||
|
|
||||||
|
if (cycleModes[index] != JumpMode.DISABLED) {
|
||||||
|
currentMode = cycleModes[index]
|
||||||
|
currentIndex = index + 1
|
||||||
|
return currentMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMode = JumpMode.DISABLED
|
||||||
|
currentIndex = 0
|
||||||
|
return currentMode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the specified [JumpMode]. If the current mode already
|
||||||
|
* equals the specified one, it resets to [JumpMode.DISABLED].
|
||||||
|
*/
|
||||||
|
fun toggle(newMode: JumpMode): JumpMode {
|
||||||
|
if (currentMode == newMode) {
|
||||||
|
currentMode = JumpMode.DISABLED
|
||||||
|
currentIndex = 0
|
||||||
|
} else {
|
||||||
|
currentMode = newMode
|
||||||
|
currentIndex = AceConfig.cycleModes.indexOfFirst { it == newMode } + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentMode
|
||||||
|
}
|
||||||
|
}
|
62
src/main/kotlin/org/acejump/input/KeyLayout.kt
Normal file
62
src/main/kotlin/org/acejump/input/KeyLayout.kt
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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 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"),
|
||||||
|
AZERTY(arrayOf("1234567890", "azertyuiop", "qsdfghjklm", "wxcvbn"), priority = "fjghdkslqvncmbxwrutyeizoap5849673210");
|
||||||
|
|
||||||
|
internal val allChars = rows.joinToString("").toCharArray().apply(CharArray::sort).joinToString("")
|
||||||
|
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? {
|
||||||
|
return { allPriorities[tagToChar(it)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun distanceBetweenKeys(char1: Char, char2: Char): Int {
|
||||||
|
return keyDistances.getValue(char1).getValue(char2)
|
||||||
|
}
|
||||||
|
}
|
50
src/main/kotlin/org/acejump/input/KeyLayoutCache.kt
Normal file
50
src/main/kotlin/org/acejump/input/KeyLayoutCache.kt
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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 {
|
||||||
|
/**
|
||||||
|
* 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) else Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-initializes cached data according to updated settings.
|
||||||
|
*/
|
||||||
|
fun reset(settings: AceSettings) {
|
||||||
|
tagOrder = compareBy(
|
||||||
|
{ it[0].isDigit() || it[1].isDigit() },
|
||||||
|
{ settings.layout.distanceBetweenKeys(it[0], 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -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()
|
|
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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)
|
|
||||||
}
|
|
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,293 +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) = 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 < 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 < 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
|
|
@@ -1,229 +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 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()
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,87 +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, TARGET, DEFINE;
|
|
||||||
|
|
||||||
companion object: Resettable {
|
|
||||||
private var modeIndex = 0
|
|
||||||
private var mode: JumpMode = DISABLED
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
setCaretColor(when (field) {
|
|
||||||
JUMP -> AceConfig.jumpModeColor
|
|
||||||
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")
|
|
||||||
TARGET -> aceString("jumpModeTarget")
|
|
||||||
DEFINE -> aceString("jumpModeDefine")
|
|
||||||
} ?: "Unknown"
|
|
||||||
}
|
|
@@ -1,105 +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.search.JumpMode.DEFINE
|
|
||||||
import org.acejump.search.JumpMode.TARGET
|
|
||||||
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
|
|
||||||
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 moveCaretTo(offset: Int) = editor.run {
|
|
||||||
appendCaretPositionToEditorNavigationHistory()
|
|
||||||
selectionModel.removeSelection()
|
|
||||||
caretModel.moveToOffset(offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
|
9
src/main/kotlin/org/acejump/search/Pattern.kt
Normal file
9
src/main/kotlin/org/acejump/search/Pattern.kt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package org.acejump.search
|
||||||
|
|
||||||
|
enum class Pattern(val regex: String) {
|
||||||
|
LINE_STARTS("^.|^\\n|(?<!.)\\Z"),
|
||||||
|
LINE_ENDS("\\n|\\Z"),
|
||||||
|
LINE_INDENTS("[^\\s].*|^\\n|(?<!.)\\Z"),
|
||||||
|
LINE_ALL_MARKS(listOf(LINE_ENDS, LINE_STARTS, LINE_INDENTS).flatMap { it.regex.split("|") }.distinct().joinToString("|")),
|
||||||
|
ALL_WORDS("(?<=[^a-zA-Z0-9_]|\\A)[a-zA-Z0-9_]");
|
||||||
|
}
|
@@ -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 }
|
|
||||||
}
|
|
161
src/main/kotlin/org/acejump/search/SearchProcessor.kt
Normal file
161
src/main/kotlin/org/acejump/search/SearchProcessor.kt
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
internal class SearchProcessor private constructor(
|
||||||
|
private val editors: List<Editor>,
|
||||||
|
query: SearchQuery,
|
||||||
|
results: MutableMap<Editor, IntArrayList>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromChar(editors: List<Editor>, char: Char, boundaries: Boundaries) =
|
||||||
|
SearchProcessor(editors, SearchQuery.Literal(char.toString()), boundaries)
|
||||||
|
|
||||||
|
fun fromRegex(editors: List<Editor>, pattern: String, boundaries: Boundaries) =
|
||||||
|
SearchProcessor(editors, SearchQuery.RegularExpression(pattern), boundaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(editors: List<Editor>, query: SearchQuery, boundaries: Boundaries) : this(editors, query, mutableMapOf()) {
|
||||||
|
val regex = query.toRegex()
|
||||||
|
|
||||||
|
if (regex != null) {
|
||||||
|
for (editor in editors) {
|
||||||
|
val offsets = IntArrayList()
|
||||||
|
|
||||||
|
val offsetRange = boundaries.getOffsetRange(editor)
|
||||||
|
var result = regex.find(editor.immutableText, offsetRange.first)
|
||||||
|
|
||||||
|
while (result != null) {
|
||||||
|
// For some reason regex matches can be out of bounds, but
|
||||||
|
// boundary check prevents an exception.
|
||||||
|
val index = result.range.first
|
||||||
|
val highlightEnd = index + query.getHighlightLength("", index)
|
||||||
|
|
||||||
|
if (highlightEnd > offsetRange.last) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
else if (boundaries.isOffsetInside(editor, index)) {
|
||||||
|
offsets.add(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
results[editor] = offsets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var query: SearchQuery = query
|
||||||
|
private set
|
||||||
|
|
||||||
|
var results: MutableMap<Editor, IntArrayList> = results
|
||||||
|
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 is invalid 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.canQueryMatchAnyVisibleTag(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 && !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 {
|
||||||
|
removeObsoleteResults(newQuery, tagger)
|
||||||
|
query = SearchQuery.Literal(newQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
private fun removeObsoleteResults(newQuery: String, tagger: Tagger) {
|
||||||
|
val lastCharOffset = newQuery.lastIndex
|
||||||
|
val lastChar = newQuery[lastCharOffset]
|
||||||
|
val ignoreCase = newQuery[0].isLowerCase()
|
||||||
|
|
||||||
|
for ((editor, offsets) in results.entries.toList()) {
|
||||||
|
val chars = editor.immutableText
|
||||||
|
val remaining = IntArrayList()
|
||||||
|
val iter = offsets.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, Tag(editor, offset))) {
|
||||||
|
remaining.add(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[editor] = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
59
src/main/kotlin/org/acejump/search/SearchQuery.kt
Normal file
59
src/main/kotlin/org/acejump/search/SearchQuery.kt
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 var rawText: String): SearchQuery() {
|
||||||
|
init {
|
||||||
|
require(rawText.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getHighlightLength(text: CharSequence, offset: Int): Int =
|
||||||
|
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 var pattern: String): SearchQuery() {
|
||||||
|
override val rawText = ""
|
||||||
|
|
||||||
|
override fun getHighlightLength(text: CharSequence, offset: Int) = 0
|
||||||
|
|
||||||
|
override fun toRegex(): Regex =
|
||||||
|
Regex(pattern, setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
|
||||||
|
}
|
||||||
|
}
|
201
src/main/kotlin/org/acejump/search/Solver.kt
Normal file
201
src/main/kotlin/org/acejump/search/Solver.kt
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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.VISIBLE_ON_SCREEN
|
||||||
|
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.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> =
|
||||||
|
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.sumOf(IntList::size)).apply {
|
||||||
|
for ((editor, offsets) in allResults) {
|
||||||
|
val iter = offsets.iterator()
|
||||||
|
while (iter.hasNext()) forEachWordFragment(editor, iter.nextInt()) { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun map(availableTags: List<String>, caches: Map<Editor, EditorOffsetCache>): Map<String, Tag> {
|
||||||
|
val eligibleSitesByTag = HashMap<String, MutableList<Tag>>(100)
|
||||||
|
val tagsByFirstLetter = availableTags.groupBy { it[0] }
|
||||||
|
|
||||||
|
for ((editor, offsets) in newResults) {
|
||||||
|
val iter = offsets.iterator()
|
||||||
|
while (iter.hasNext()) {
|
||||||
|
val site = iter.nextInt()
|
||||||
|
|
||||||
|
for ((firstLetter, tags) in tagsByFirstLetter.entries) {
|
||||||
|
if (canTagBeginWithChar(editor, site, firstLetter)) {
|
||||||
|
for (tag in tags) {
|
||||||
|
eligibleSitesByTag.getOrPut(tag, ::mutableListOf).add(Tag(editor, site))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val matchingSites = HashMap<MutableList<Tag>, MutableList<Tag>>()
|
||||||
|
// Keys are guaranteed to be from a single collection.
|
||||||
|
val matchingSitesAsArrays = IdentityHashMap<String, MutableList<Tag>>()
|
||||||
|
|
||||||
|
val siteOrder = siteOrder(caches)
|
||||||
|
val tagOrder = KeyLayoutCache.tagOrder
|
||||||
|
.thenComparingInt { eligibleSitesByTag.getValue(it).size }
|
||||||
|
.thenBy(AceConfig.layout.priority(String::last))
|
||||||
|
|
||||||
|
val sortedTags = eligibleSitesByTag.keys.toMutableList().apply {
|
||||||
|
sortWith(tagOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((mark, tags) in eligibleSitesByTag.entries) {
|
||||||
|
matchingSitesAsArrays[mark] = matchingSites.getOrPut(tags) {
|
||||||
|
tags.toMutableList().apply { sortWith(siteOrder) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalAssigned = 0
|
||||||
|
val totalResults = newResults.values.sumOf(IntList::size)
|
||||||
|
|
||||||
|
for (tag in sortedTags) {
|
||||||
|
if (totalAssigned == totalResults) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tryToAssignTag(tag, matchingSitesAsArrays.getValue(tag))) {
|
||||||
|
totalAssigned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryToAssignTag(mark: String, tags: List<Tag>): Boolean {
|
||||||
|
if (newTags.containsKey(mark)) return false
|
||||||
|
|
||||||
|
val tag = tags.firstOrNull { it.offset !in newTagIndices.getValue(it.editor) } ?: return false
|
||||||
|
@Suppress("ReplacePutWithAssignment")
|
||||||
|
newTags.put(mark, tag)
|
||||||
|
newTagIndices.getValue(tag.editor).add(tag.offset)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = VISIBLE_ON_SCREEN.isOffsetInside(aEditor, a.offset, caches.getValue(aEditor))
|
||||||
|
val bIsVisible = 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(editor: Editor, site: Int, char: Char): Boolean {
|
||||||
|
if (char.toString() in allWordFragments) return false
|
||||||
|
|
||||||
|
forEachWordFragment(editor, site) { if (it + char in allWordFragments) return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun forEachWordFragment(editor: Editor, site: Int, callback: (String) -> Unit) {
|
||||||
|
val chars = editor.immutableText
|
||||||
|
val left = max(0, site + queryLength - 1)
|
||||||
|
val right = chars.wordEndPlus(site)
|
||||||
|
if (right >= chars.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = StringBuilder(1 + right - left)
|
||||||
|
|
||||||
|
for (i in left..right) {
|
||||||
|
builder.append(chars[i].lowercase())
|
||||||
|
callback(builder.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
src/main/kotlin/org/acejump/search/Tag.kt
Normal file
5
src/main/kotlin/org/acejump/search/Tag.kt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package org.acejump.search
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
|
||||||
|
data class Tag(val editor: Editor, val offset: Int)
|
224
src/main/kotlin/org/acejump/search/Tagger.kt
Normal file
224
src/main/kotlin/org/acejump/search/Tagger.kt
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package org.acejump.search
|
||||||
|
|
||||||
|
import com.google.common.collect.ArrayListMultimap
|
||||||
|
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.*
|
||||||
|
import org.acejump.boundaries.EditorOffsetCache
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.VISIBLE_ON_SCREEN
|
||||||
|
import org.acejump.input.KeyLayoutCache.allPossibleTags
|
||||||
|
import org.acejump.view.TagMarker
|
||||||
|
import java.util.AbstractMap.SimpleImmutableEntry
|
||||||
|
import kotlin.collections.component1
|
||||||
|
import kotlin.collections.component2
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
internal class Tagger(private val editors: List<Editor>) {
|
||||||
|
private var tagMap = HashBiMap.create<String, Tag>()
|
||||||
|
val hasTags
|
||||||
|
get() = tagMap.isNotEmpty()
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
val tags
|
||||||
|
get() = tagMap.map { SimpleImmutableEntry(it.key, it.value) }.sortedBy { it.value.offset }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all markers, allowing them to be regenerated from scratch.
|
||||||
|
*/
|
||||||
|
fun unmark() {
|
||||||
|
tagMap = HashBiMap.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns tags to as many results as possible, keeping previously assigned
|
||||||
|
* tags. Returns a [TaggingResult.Jump] 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.
|
||||||
|
*/
|
||||||
|
fun markOrJump(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).lowercase()
|
||||||
|
|
||||||
|
val availableTags = allPossibleTags.filter { !queryText.endsWith(it[0]) && it !in tagMap }
|
||||||
|
|
||||||
|
if (!isRegex) {
|
||||||
|
for (entry in tagMap.entries) {
|
||||||
|
if (entry solves queryText) {
|
||||||
|
return TaggingResult.Jump(query = queryText.substringBefore(entry.key), mark = entry.key, tag = entry.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryText.length == 1) {
|
||||||
|
for ((editor, offsets) in results) {
|
||||||
|
removeResultsWithOverlappingTags(editor, offsets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegex || tagMap.isEmpty())
|
||||||
|
tagMap = assignTagsAndMerge(results, availableTags, query, queryText)
|
||||||
|
|
||||||
|
val resultTags = results.flatMap { (editor, offsets) -> offsets.map { Tag(editor, it) } }
|
||||||
|
return TaggingResult.Mark(createTagMarkers(resultTags, query.rawText.ifEmpty { null }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns as many unassigned tags as possible, and merges them with
|
||||||
|
* the existing compatible tags.
|
||||||
|
*/
|
||||||
|
private fun assignTagsAndMerge(
|
||||||
|
results: Map<Editor, IntList>,
|
||||||
|
availableTags: List<String>,
|
||||||
|
query: SearchQuery,
|
||||||
|
queryText: String
|
||||||
|
): HashBiMap<String, Tag> {
|
||||||
|
val caches = results.keys.associateWith { EditorOffsetCache.new() }
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
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.keys))
|
||||||
|
tag[0].toString()
|
||||||
|
else
|
||||||
|
tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private infix fun Map.Entry<String, Tag>.solves(query: String): Boolean =
|
||||||
|
query.endsWith(key, true) && isTagCompatibleWithQuery(key, value, query)
|
||||||
|
|
||||||
|
private fun isTagCompatibleWithQuery(marker: String, tag: Tag, query: String): Boolean =
|
||||||
|
tag.editor.immutableText.matchesAt(tag.offset, getPlaintextPortion(query, marker), ignoreCase = true)
|
||||||
|
|
||||||
|
fun isQueryCompatibleWithTagAt(query: String, tag: Tag): Boolean =
|
||||||
|
tagMap.inverse()[tag].let { it != null && isTagCompatibleWithQuery(it, tag, query) }
|
||||||
|
|
||||||
|
fun canQueryMatchAnyVisibleTag(query: String): Boolean =
|
||||||
|
tagMap.any { (label, tag) ->
|
||||||
|
val tagPortion = getTagPortion(query, label)
|
||||||
|
tagPortion.isNotEmpty()
|
||||||
|
&& label.startsWith(tagPortion, ignoreCase = true)
|
||||||
|
&& isTagCompatibleWithQuery(label, tag, query)
|
||||||
|
&& tag.offset in tag.editor.getView()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) =
|
||||||
|
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: Collection<String>): Boolean {
|
||||||
|
for (other in markers)
|
||||||
|
if (marker != other && marker[0] == other[0])
|
||||||
|
return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
src/main/kotlin/org/acejump/search/TaggingResult.kt
Normal file
9
src/main/kotlin/org/acejump/search/TaggingResult.kt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package org.acejump.search
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import org.acejump.view.TagMarker
|
||||||
|
|
||||||
|
sealed class TaggingResult {
|
||||||
|
class Jump(val query: String, val mark: String, val tag: Tag): TaggingResult()
|
||||||
|
class Mark(val markers: MutableMap<Editor, Collection<TagMarker>>): TaggingResult()
|
||||||
|
}
|
5
src/main/kotlin/org/acejump/session/AceJumpListener.kt
Normal file
5
src/main/kotlin/org/acejump/session/AceJumpListener.kt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package org.acejump.session
|
||||||
|
|
||||||
|
interface AceJumpListener {
|
||||||
|
fun finished(mark: String?, query: String?)
|
||||||
|
}
|
38
src/main/kotlin/org/acejump/session/EditorSettings.kt
Normal file
38
src/main/kotlin/org/acejump/session/EditorSettings.kt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun setup(editor: Editor): EditorSettings {
|
||||||
|
val settings = editor.settings
|
||||||
|
val document = editor.document
|
||||||
|
|
||||||
|
val original = EditorSettings(
|
||||||
|
isBlockCursor = settings.isBlockCursor,
|
||||||
|
isBlinkCaret = settings.isBlinkCaret,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.isBlockCursor = true
|
||||||
|
settings.isBlinkCaret = false
|
||||||
|
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restore(editor: Editor) {
|
||||||
|
val settings = editor.settings
|
||||||
|
val document = editor.document
|
||||||
|
|
||||||
|
settings.isBlockCursor = isBlockCursor
|
||||||
|
settings.isBlinkCaret = isBlinkCaret
|
||||||
|
}
|
||||||
|
}
|
306
src/main/kotlin/org/acejump/session/Session.kt
Normal file
306
src/main/kotlin/org/acejump/session/Session.kt
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package org.acejump.session
|
||||||
|
|
||||||
|
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.CARET_COLOR
|
||||||
|
import com.intellij.util.containers.ContainerUtil
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntArrayList
|
||||||
|
import org.acejump.*
|
||||||
|
import org.acejump.action.TagScroller
|
||||||
|
import org.acejump.action.TagJumper
|
||||||
|
import org.acejump.action.TagVisitor
|
||||||
|
import org.acejump.boundaries.Boundaries
|
||||||
|
import org.acejump.boundaries.EditorOffsetCache
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.VISIBLE_ON_SCREEN
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.WHOLE_FILE
|
||||||
|
import org.acejump.config.AceConfig
|
||||||
|
import org.acejump.input.EditorKeyListener
|
||||||
|
import org.acejump.input.JumpMode
|
||||||
|
import org.acejump.input.JumpModeTracker
|
||||||
|
import org.acejump.input.KeyLayoutCache
|
||||||
|
import org.acejump.search.Pattern
|
||||||
|
import org.acejump.search.SearchProcessor
|
||||||
|
import org.acejump.search.Tagger
|
||||||
|
import org.acejump.search.TaggingResult
|
||||||
|
import org.acejump.view.TagCanvas
|
||||||
|
import org.acejump.view.TextHighlighter
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages an AceJump session for one or more [Editor]s.
|
||||||
|
*/
|
||||||
|
class Session(private val mainEditor: Editor, private val jumpEditors: List<Editor>) {
|
||||||
|
private val listeners: MutableList<AceJumpListener> =
|
||||||
|
ContainerUtil.createLockFreeCopyOnWriteList()
|
||||||
|
|
||||||
|
private var boundaries: Boundaries = defaultBoundaries
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val defaultBoundaries
|
||||||
|
get() = if (AceConfig.searchWholeFile) WHOLE_FILE else VISIBLE_ON_SCREEN
|
||||||
|
}
|
||||||
|
|
||||||
|
private val originalSettings = EditorSettings.setup(mainEditor)
|
||||||
|
|
||||||
|
private val jumpModeTracker = JumpModeTracker()
|
||||||
|
private var jumpMode = JumpMode.DISABLED
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
|
||||||
|
if (value === JumpMode.DISABLED) {
|
||||||
|
end()
|
||||||
|
} else {
|
||||||
|
searchProcessor?.let { textHighlighter.render(it.results, it.query, jumpMode) }
|
||||||
|
mainEditor.colorsScheme.setColor(CARET_COLOR, value.caretColor)
|
||||||
|
mainEditor.contentComponent.repaint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchProcessor: SearchProcessor? = null
|
||||||
|
private var tagger = Tagger(jumpEditors)
|
||||||
|
|
||||||
|
private val tagJumper
|
||||||
|
get() = TagJumper(jumpMode, searchProcessor)
|
||||||
|
|
||||||
|
private val tagVisitor
|
||||||
|
get() = searchProcessor?.let { TagVisitor(mainEditor, it, tagJumper) }
|
||||||
|
|
||||||
|
private val tagScroller
|
||||||
|
get() = searchProcessor?.let { TagScroller(mainEditor, it) }
|
||||||
|
|
||||||
|
private val textHighlighter = TextHighlighter()
|
||||||
|
private val tagCanvases = jumpEditors.associateWith(::TagCanvas)
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
val tags
|
||||||
|
get() = tagger.tags
|
||||||
|
|
||||||
|
init {
|
||||||
|
KeyLayoutCache.ensureInitialized(AceConfig.settings)
|
||||||
|
|
||||||
|
EditorKeyListener.attach(mainEditor, object: TypedActionHandler {
|
||||||
|
override fun execute(editor: Editor, charTyped: Char, context: DataContext) {
|
||||||
|
var processor = searchProcessor
|
||||||
|
val hadTags = tagger.hasTags
|
||||||
|
|
||||||
|
if (processor == null) {
|
||||||
|
processor = SearchProcessor.fromChar(
|
||||||
|
jumpEditors, charTyped, boundaries
|
||||||
|
).also { searchProcessor = it }
|
||||||
|
} else if (!processor.type(charTyped, tagger)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearch(
|
||||||
|
processor, markImmediately = hadTags,
|
||||||
|
shiftMode = charTyped.isUpperCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates text highlights and tag markers according to the current
|
||||||
|
* search state. Dispatches jumps if the search query matches a tag.
|
||||||
|
* If all tags are outside view, scrolls to the closest one.
|
||||||
|
*/
|
||||||
|
private fun updateSearch(
|
||||||
|
processor: SearchProcessor,
|
||||||
|
markImmediately: Boolean,
|
||||||
|
shiftMode: Boolean = false
|
||||||
|
) {
|
||||||
|
val query = processor.query
|
||||||
|
val results = processor.results
|
||||||
|
|
||||||
|
textHighlighter.render(results, query, jumpMode)
|
||||||
|
|
||||||
|
if (!markImmediately &&
|
||||||
|
query.rawText.let {
|
||||||
|
it.length < AceConfig.minQueryLength &&
|
||||||
|
it.all(Char::isLetterOrDigit)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val result = tagger.markOrJump(query, results.clone())) {
|
||||||
|
is TaggingResult.Jump -> {
|
||||||
|
tagJumper.jump(result.tag, shiftMode, isCrossEditor = mainEditor !== result.tag.editor)
|
||||||
|
tagCanvases.values.forEach(TagCanvas::removeMarkers)
|
||||||
|
end(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TaggingResult.Mark -> {
|
||||||
|
val markers = result.markers
|
||||||
|
|
||||||
|
for ((editor, canvas) in tagCanvases) {
|
||||||
|
canvas.setMarkers(markers[editor].orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jumpEditors.all { editor ->
|
||||||
|
val cache = EditorOffsetCache.new()
|
||||||
|
markers[editor].let { it == null || it.none { marker ->
|
||||||
|
VISIBLE_ON_SCREEN.isOffsetInside(editor, marker.offsetL, cache) ||
|
||||||
|
VISIBLE_ON_SCREEN.isOffsetInside(editor, marker.offsetR, cache) } }
|
||||||
|
}) {
|
||||||
|
tagVisitor?.scrollToClosest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun markResults(resultsToMark: SortedSet<Int>) {
|
||||||
|
val jumpEditor = jumpEditors.singleOrNull() ?: return
|
||||||
|
markResults(mapOf(jumpEditor to resultsToMark))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun markResults(resultsToMark: Map<Editor, Collection<Int>>) {
|
||||||
|
tagger = Tagger(jumpEditors)
|
||||||
|
tagCanvases.values.forEach { it.setMarkers(emptyList()) }
|
||||||
|
|
||||||
|
val processor = SearchProcessor.fromRegex(jumpEditors, "", defaultBoundaries)
|
||||||
|
.apply {
|
||||||
|
results.clear()
|
||||||
|
for ((editor, offsets) in resultsToMark) {
|
||||||
|
if (editor in jumpEditors) {
|
||||||
|
results[editor] = IntArrayList(offsets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearch(processor, markImmediately = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a regular expression search. If a search was already active,
|
||||||
|
* it will be reset alongside its tags and highlights.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun startRegexSearch(pattern: String, boundaries: Boundaries) {
|
||||||
|
tagger = Tagger(jumpEditors)
|
||||||
|
tagCanvases.values.forEach { it.setMarkers(emptyList()) }
|
||||||
|
|
||||||
|
val processor = SearchProcessor.fromRegex(
|
||||||
|
jumpEditors, pattern,
|
||||||
|
boundaries.intersection(defaultBoundaries)
|
||||||
|
).also { searchProcessor = 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun startRegexSearch(pattern: Pattern, boundaries: Boundaries) =
|
||||||
|
startRegexSearch(pattern.regex, boundaries)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [JumpModeTracker.cycle].
|
||||||
|
*/
|
||||||
|
fun cycleNextJumpMode() {
|
||||||
|
jumpMode = jumpModeTracker.cycle(forward = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [JumpModeTracker.cycle].
|
||||||
|
*/
|
||||||
|
fun cyclePreviousJumpMode() {
|
||||||
|
jumpMode = jumpModeTracker.cycle(forward = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [JumpModeTracker.toggle]
|
||||||
|
*/
|
||||||
|
fun toggleJumpMode(newMode: JumpMode) {
|
||||||
|
jumpMode = jumpModeTracker.toggle(newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun toggleJumpMode(newMode: JumpMode, boundaries: Boundaries) {
|
||||||
|
this.boundaries = this.boundaries.intersection(boundaries)
|
||||||
|
jumpMode = jumpModeTracker.toggle(newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [TagVisitor.visitPrevious]. If there are no tags, nothing happens.
|
||||||
|
*/
|
||||||
|
fun visitPreviousTag() =
|
||||||
|
if (tagVisitor?.visitPrevious() == true) end() else Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [TagVisitor.visitNext]. If there are no tags, nothing happens.
|
||||||
|
*/
|
||||||
|
fun visitNextTag() =
|
||||||
|
if (tagVisitor?.visitNext() == true) end() else Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [TagVisitor.visitPrevious]. If there are no tags, nothing happens.
|
||||||
|
*/
|
||||||
|
fun scrollToNextScreenful() = tagScroller?.scroll(true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See [TagVisitor.visitNext]. If there are no tags, nothing happens.
|
||||||
|
*/
|
||||||
|
fun scrollToPreviousScreenful() = tagScroller?.scroll(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends this session.
|
||||||
|
*/
|
||||||
|
fun end(taggingResult: TaggingResult? = null) =
|
||||||
|
SessionManager.end(mainEditor, taggingResult)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears any currently active search, tags, and highlights.
|
||||||
|
* Does not reset [JumpMode].
|
||||||
|
*/
|
||||||
|
fun restart() {
|
||||||
|
tagger = Tagger(jumpEditors)
|
||||||
|
searchProcessor = null
|
||||||
|
tagCanvases.values.forEach(TagCanvas::removeMarkers)
|
||||||
|
textHighlighter.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should only be used from [SessionManager] to dispose a
|
||||||
|
* successfully ended session.
|
||||||
|
*/
|
||||||
|
internal fun dispose(taggingResult: TaggingResult?) {
|
||||||
|
tagger = Tagger(jumpEditors)
|
||||||
|
EditorKeyListener.detach(mainEditor)
|
||||||
|
tagCanvases.values.forEach(TagCanvas::unbind)
|
||||||
|
textHighlighter.reset()
|
||||||
|
EditorsCache.invalidate()
|
||||||
|
|
||||||
|
val jumpResult = taggingResult as? TaggingResult.Jump
|
||||||
|
val mark = jumpResult?.mark
|
||||||
|
val query = jumpResult?.query
|
||||||
|
listeners.forEach { it.finished(mark, query) }
|
||||||
|
|
||||||
|
if (!mainEditor.isDisposed) {
|
||||||
|
originalSettings.restore(mainEditor)
|
||||||
|
mainEditor.colorsScheme.setColor(CARET_COLOR, JumpMode.DISABLED.caretColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
val focusedEditor = jumpResult?.tag?.editor ?: mainEditor
|
||||||
|
if (!focusedEditor.isDisposed) {
|
||||||
|
focusedEditor.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun addAceJumpListener(listener: AceJumpListener) {
|
||||||
|
listeners += listener
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
fun removeAceJumpListener(listener: AceJumpListener) {
|
||||||
|
listeners -= listener
|
||||||
|
}
|
||||||
|
}
|
53
src/main/kotlin/org/acejump/session/SessionManager.kt
Normal file
53
src/main/kotlin/org/acejump/session/SessionManager.kt
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package org.acejump.session
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import org.acejump.ExternalUsage
|
||||||
|
import org.acejump.search.TaggingResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@ExternalUsage
|
||||||
|
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 = start(editor, listOf(editor))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new multi-editor [Session], or returns an existing [Session] if the specified main [Editor] already has one.
|
||||||
|
* The [mainEditor] is used for typing the search query and tag.
|
||||||
|
* The [jumpEditors] are all editors that will be searched and tagged. The list is ordered so that editors earlier in the list will be
|
||||||
|
* prioritized for tagging in case of conflicts.
|
||||||
|
*/
|
||||||
|
fun start(mainEditor: Editor, jumpEditors: List<Editor>): Session {
|
||||||
|
return sessions.getOrPut(mainEditor) { cleanup(); Session(mainEditor, jumpEditors) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active [Session] in the specified [Editor],
|
||||||
|
* or null if the [Editor] has no active session.
|
||||||
|
*/
|
||||||
|
operator fun get(editor: Editor): Session? = sessions[editor]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the active [Session] in the specified [Editor],
|
||||||
|
* or does nothing if the [Editor] has no active session.
|
||||||
|
*/
|
||||||
|
fun end(editor: Editor, taggingResult: TaggingResult?) =
|
||||||
|
sessions.remove(editor)?.dispose(taggingResult) ?: Unit
|
||||||
|
|
||||||
|
private fun cleanup() = sessions.keys.filter { it.isDisposed }
|
||||||
|
.forEach { disposedEditor -> sessions.remove(disposedEditor)?.dispose(null) }
|
||||||
|
}
|
@@ -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()}) "
|
|
||||||
}
|
|
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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
|
|
||||||
}
|
|
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
88
src/main/kotlin/org/acejump/view/TagCanvas.kt
Normal file
88
src/main/kotlin/org/acejump/view/TagCanvas.kt
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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.VISIBLE_ON_SCREEN
|
||||||
|
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: Collection<TagMarker>? = 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: Collection<TagMarker>) {
|
||||||
|
this.markers = markers
|
||||||
|
repaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeMarkers() {
|
||||||
|
markers = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun paint(g: Graphics) =
|
||||||
|
if (!markers.isNullOrEmpty()) super.paint(g) else Unit
|
||||||
|
|
||||||
|
override fun paintChildren(g: Graphics) {
|
||||||
|
super.paintChildren(g)
|
||||||
|
|
||||||
|
val markers = markers ?: return
|
||||||
|
|
||||||
|
(g as Graphics2D).setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||||
|
|
||||||
|
val font = TagFont(editor)
|
||||||
|
|
||||||
|
val cache = EditorOffsetCache.new()
|
||||||
|
val viewRange = VISIBLE_ON_SCREEN.getOffsetRange(editor, cache)
|
||||||
|
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)
|
||||||
|
if (marker.isOffsetInRange(viewRange) && marker !== caretMarker)
|
||||||
|
marker.paint(g, editor, cache, font, occupied)
|
||||||
|
}
|
||||||
|
}
|
20
src/main/kotlin/org/acejump/view/TagFont.kt
Normal file
20
src/main/kotlin/org/acejump/view/TagFont.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package org.acejump.view
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.editor.colors.EditorFontType.BOLD
|
||||||
|
import com.intellij.openapi.editor.colors.EditorFontType.PLAIN
|
||||||
|
import java.awt.Font
|
||||||
|
import java.awt.FontMetrics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores font metrics for aligning and rendering [TagMarker]s.
|
||||||
|
*/
|
||||||
|
class TagFont(editor: Editor) {
|
||||||
|
val tagFont: Font = editor.colorsScheme.getFont(BOLD)
|
||||||
|
val tagCharWidth = editor.component.getFontMetrics(tagFont).charWidth('W')
|
||||||
|
|
||||||
|
val editorFontMetrics: FontMetrics =
|
||||||
|
editor.component.getFontMetrics(editor.colorsScheme.getFont(PLAIN))
|
||||||
|
val lineHeight = editor.lineHeight
|
||||||
|
val baselineDistance = editor.ascent
|
||||||
|
}
|
148
src/main/kotlin/org/acejump/view/TagMarker.kt
Normal file
148
src/main/kotlin/org/acejump/view/TagMarker.kt
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package org.acejump.view
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.ui.ColorUtil
|
||||||
|
import com.intellij.ui.JreHiDpiUtil
|
||||||
|
import com.intellij.ui.scale.JBUIScale
|
||||||
|
import org.acejump.boundaries.EditorOffsetCache
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.VISIBLE_ON_SCREEN
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
class TagMarker(
|
||||||
|
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?): TagMarker {
|
||||||
|
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).uppercase()
|
||||||
|
else
|
||||||
|
tag.uppercase()
|
||||||
|
|
||||||
|
return TagMarker(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
|
||||||
|
|
||||||
|
// Workaround for misalignment on high DPI screens.
|
||||||
|
if (JreHiDpiUtil.isJreHiDPI(g)) {
|
||||||
|
g.translate(0.0, -0.5)
|
||||||
|
g.fillRoundRect(rect.x, rect.y, rect.width, rect.height + 1, ARC, ARC)
|
||||||
|
g.translate(0.0, 0.5)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLineEnding(char: Char): Boolean {
|
||||||
|
return char == '\n' || char == '\r'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = 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 = VISIBLE_ON_SCREEN
|
||||||
|
|
||||||
|
if (hasSpaceRight || offsetL !in 1 until editor.document.textLength || isLineEnding(editor.immutableText[offsetL - 1])) {
|
||||||
|
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 char = if (offsetR >= editor.document.textLength)
|
||||||
|
' ' // Use the width of a space on the last line.
|
||||||
|
else editor.immutableText[offsetR].let {
|
||||||
|
// Use the width of a space on empty lines.
|
||||||
|
if (isLineEnding(it)) ' ' else it
|
||||||
|
}
|
||||||
|
|
||||||
|
val shift = font.editorFontMetrics.charWidth(char) + (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)
|
||||||
|
}
|
||||||
|
}
|
225
src/main/kotlin/org/acejump/view/TextHighlighter.kt
Normal file
225
src/main/kotlin/org/acejump/view/TextHighlighter.kt
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package org.acejump.view
|
||||||
|
|
||||||
|
import com.intellij.codeInsight.CodeInsightBundle
|
||||||
|
import com.intellij.codeInsight.hint.*
|
||||||
|
import com.intellij.codeInsight.hint.HintManagerImpl.HIDE_BY_ESCAPE
|
||||||
|
import com.intellij.codeInsight.hint.HintManagerImpl.HIDE_BY_TEXT_CHANGE
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.editor.colors.EditorFontType
|
||||||
|
import com.intellij.openapi.editor.markup.*
|
||||||
|
import com.intellij.openapi.editor.markup.HighlighterTargetArea.EXACT_RANGE
|
||||||
|
import com.intellij.ui.*
|
||||||
|
import com.intellij.util.DocumentUtil
|
||||||
|
import com.intellij.util.ui.*
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntList
|
||||||
|
import org.acejump.*
|
||||||
|
import org.acejump.boundaries.EditorOffsetCache
|
||||||
|
import org.acejump.config.AceConfig
|
||||||
|
import org.acejump.input.JumpMode
|
||||||
|
import org.acejump.search.SearchQuery
|
||||||
|
import java.awt.*
|
||||||
|
import javax.swing.*
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders highlights for search occurrences.
|
||||||
|
*/
|
||||||
|
internal class TextHighlighter {
|
||||||
|
private companion object { private const val LAYER = HighlighterLayer.LAST + 1 }
|
||||||
|
private var previousHighlights = mutableMapOf<Editor, Array<RangeHighlighter>>()
|
||||||
|
private var previousHint: LightweightHint? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label for the search notification.
|
||||||
|
*/
|
||||||
|
private class NotificationLabel(text: String?): JLabel(text) {
|
||||||
|
init {
|
||||||
|
background = HintUtil.getInformationColor()
|
||||||
|
foreground = JBColor.foreground()
|
||||||
|
this.isOpaque = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all current highlights and re-creates them from scratch.
|
||||||
|
* Must be called whenever any of the method parameters change.
|
||||||
|
*/
|
||||||
|
fun render(results: Map<Editor, IntList>, query: SearchQuery, jumpMode: JumpMode) {
|
||||||
|
|
||||||
|
val renderer = when {
|
||||||
|
query is SearchQuery.RegularExpression -> RegexRenderer
|
||||||
|
jumpMode === JumpMode.TARGET -> SearchedWordWithOutlineRenderer
|
||||||
|
else -> SearchedWordRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((editor, offsets) in results) {
|
||||||
|
val highlights = previousHighlights[editor]
|
||||||
|
|
||||||
|
val markup = editor.markupModel
|
||||||
|
val document = editor.document
|
||||||
|
val chars = editor.immutableText
|
||||||
|
|
||||||
|
val modifications = (highlights?.size ?: 0) + offsets.size
|
||||||
|
val enableBulkEditing = modifications > 1000
|
||||||
|
|
||||||
|
DocumentUtil.executeInBulk(document, enableBulkEditing) {
|
||||||
|
highlights?.forEach(markup::removeHighlighter)
|
||||||
|
previousHighlights[editor] = Array(offsets.size) { index ->
|
||||||
|
val start = offsets.getInt(index)
|
||||||
|
val end = start + query.getHighlightLength(chars, start)
|
||||||
|
|
||||||
|
markup.addRangeHighlighter(start, end, LAYER, null, EXACT_RANGE)
|
||||||
|
.apply { customRenderer = renderer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AceConfig.showSearchNotification)
|
||||||
|
showSearchNotification(results, query, jumpMode)
|
||||||
|
|
||||||
|
for (editor in previousHighlights.keys.toList()) {
|
||||||
|
if (!results.containsKey(editor))
|
||||||
|
previousHighlights.remove(editor)
|
||||||
|
?.forEach(editor.markupModel::removeHighlighter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a notification with the current search text.
|
||||||
|
*/
|
||||||
|
private fun showSearchNotification(results: Map<Editor, IntList>,
|
||||||
|
query: SearchQuery, jumpMode: JumpMode) {
|
||||||
|
// clear previous hint
|
||||||
|
previousHint?.hide()
|
||||||
|
|
||||||
|
// add notification hint to first editor
|
||||||
|
val editor = results.keys.first()
|
||||||
|
val component: JComponent = editor.component
|
||||||
|
|
||||||
|
val label1 = NotificationLabel(
|
||||||
|
" " +
|
||||||
|
CodeInsightBundle.message("incremental.search.tooltip.prefix")
|
||||||
|
).apply { font = UIUtil.getLabelFont().deriveFont(Font.BOLD) }
|
||||||
|
|
||||||
|
val queryText = " " +
|
||||||
|
if (query is SearchQuery.RegularExpression) query.toRegex().toString()
|
||||||
|
else query.rawText[0] + query.rawText.drop(1).lowercase()
|
||||||
|
val label2 = NotificationLabel(queryText)
|
||||||
|
|
||||||
|
val label3 = NotificationLabel(
|
||||||
|
"Found ${results.values.flatMap { it.asIterable() }.size}" +
|
||||||
|
" results in ${results.keys.size}" +
|
||||||
|
" editor" + if (1 != results.keys.size) "s" else "."
|
||||||
|
)
|
||||||
|
|
||||||
|
val panel = JPanel(BorderLayout()).apply {
|
||||||
|
add(label1, BorderLayout.WEST)
|
||||||
|
add(label2, BorderLayout.CENTER)
|
||||||
|
add(label3, BorderLayout.EAST)
|
||||||
|
border = BorderFactory.createLineBorder(
|
||||||
|
if (jumpMode == JumpMode.DISABLED) JBColor.BLACK else jumpMode.caretColor
|
||||||
|
)
|
||||||
|
|
||||||
|
preferredSize = Dimension(
|
||||||
|
editor.contentComponent.width +
|
||||||
|
label1.preferredSize.width, preferredSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hint = LightweightHint(panel)
|
||||||
|
|
||||||
|
val x = SwingUtilities.convertPoint(component, 0, 0, component).x
|
||||||
|
val y: Int = -hint.component.preferredSize.height
|
||||||
|
val p = SwingUtilities.convertPoint(
|
||||||
|
component, x, y,
|
||||||
|
component.rootPane.layeredPane
|
||||||
|
)
|
||||||
|
|
||||||
|
HintManagerImpl.getInstanceImpl().showEditorHint(
|
||||||
|
hint,
|
||||||
|
editor,
|
||||||
|
p,
|
||||||
|
HIDE_BY_ESCAPE or HIDE_BY_TEXT_CHANGE,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
HintHint(editor, p).setAwtTooltip(false)
|
||||||
|
)
|
||||||
|
previousHint = hint
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
previousHighlights.forEach { (editor, highlighters) ->
|
||||||
|
highlighters.forEach(editor.markupModel::removeHighlighter)
|
||||||
|
}
|
||||||
|
previousHighlights.clear()
|
||||||
|
previousHint?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
private fun drawFilled(g: Graphics, editor: Editor, startOffset: Int, endOffset: Int) {
|
||||||
|
val start = EditorOffsetCache.Uncached.offsetToXY(editor, startOffset)
|
||||||
|
val end = EditorOffsetCache.Uncached.offsetToXY(editor, endOffset)
|
||||||
|
|
||||||
|
g.color = AceConfig.textHighlightColor
|
||||||
|
g.fillRect(start.x, start.y, end.x - start.x, editor.lineHeight)
|
||||||
|
|
||||||
|
g.color = AceConfig.tagBackgroundColor
|
||||||
|
g.drawRect(start.x, start.y, end.x - start.x, editor.lineHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a filled highlight in the background of a searched
|
||||||
|
* text occurrence, as well as an outline indicating the range
|
||||||
|
* of characters that will be selected by [JumpMode.TARGET].
|
||||||
|
*/
|
||||||
|
private object SearchedWordWithOutlineRenderer: CustomHighlighterRenderer {
|
||||||
|
override fun paint(editor: Editor, highlighter: RangeHighlighter, g: Graphics) {
|
||||||
|
SearchedWordRenderer.paint(editor, highlighter, g)
|
||||||
|
|
||||||
|
val chars = editor.immutableText
|
||||||
|
val startOffset = highlighter.startOffset
|
||||||
|
|
||||||
|
if (chars.getOrNull(startOffset)?.isWordPart == true)
|
||||||
|
drawOutline(g, editor, chars.wordStart(startOffset), chars.wordEnd(startOffset) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawOutline(g: Graphics, editor: Editor, startOffset: Int, endOffset: Int) {
|
||||||
|
val start = EditorOffsetCache.Uncached.offsetToXY(editor, startOffset)
|
||||||
|
val end = EditorOffsetCache.Uncached.offsetToXY(editor, endOffset)
|
||||||
|
|
||||||
|
g.color = AceConfig.targetModeColor
|
||||||
|
g.drawRect(max(0, start.x - JBUI.scale(1)), start.y,
|
||||||
|
end.x - start.x + JBUI.scale(2), editor.lineHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
private fun drawSingle(g: Graphics, editor: Editor, offset: Int) {
|
||||||
|
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 = AceConfig.textHighlightColor
|
||||||
|
g.fillRect(pos.x, pos.y, lastCharWidth, editor.lineHeight)
|
||||||
|
|
||||||
|
g.color = AceConfig.tagBackgroundColor
|
||||||
|
g.drawRect(pos.x, pos.y, lastCharWidth, editor.lineHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,24 +0,0 @@
|
|||||||
charactersAndLayoutHeading=Characters and Layout
|
|
||||||
tagCharsToBeUsedLabel=Allowed characters:
|
|
||||||
keyboardLayoutLabel=Keyboard layout:
|
|
||||||
keyboardDesignLabel=Keyboard design:
|
|
||||||
modesHeading=Modes
|
|
||||||
cycleModeOrderLabel=Cycle order:
|
|
||||||
jumpModeDisabled=(Skip)
|
|
||||||
jumpModeJump=Jump
|
|
||||||
jumpModeTarget=Target
|
|
||||||
jumpModeDefine=Definition
|
|
||||||
colorsHeading=Colors
|
|
||||||
jumpModeColorLabel=Jump 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
|
|
@@ -18,48 +18,100 @@
|
|||||||
<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"
|
||||||
|
id="AceHandlerEscape"/>
|
||||||
|
<editorActionHandler action="EditorBackSpace" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$ClearSearch"
|
||||||
|
id="AceHandlerBackSpace"/>
|
||||||
|
<editorActionHandler action="EditorStartNewLine" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SelectBackward"
|
||||||
|
id="AceHandlerStartNewLine"/>
|
||||||
|
<editorActionHandler action="EditorEnter" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SelectForward"
|
||||||
|
id="AceHandlerEnter"/>
|
||||||
|
<editorActionHandler action="EditorTab" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$ScrollToNextScreenful"
|
||||||
|
id="AceHandlerTab"/>
|
||||||
|
<editorActionHandler action="EditorUnindentSelection" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$ScrollToPreviousScreenful"
|
||||||
|
id="AceHandlerUnindentSelection"/>
|
||||||
|
<editorActionHandler action="EditorUp" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SearchLineStarts"
|
||||||
|
id="AceHandlerUp"/>
|
||||||
|
<editorActionHandler action="EditorLeft" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SearchLineIndents"
|
||||||
|
id="AceHandlerLeft"/>
|
||||||
|
<editorActionHandler action="EditorLineStart" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SearchLineIndents"
|
||||||
|
id="AceHandlerLineStart"/>
|
||||||
|
<editorActionHandler action="EditorRight" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SearchLineEnds"
|
||||||
|
id="AceHandlerRight"/>
|
||||||
|
<editorActionHandler action="EditorLineEnd" order="first"
|
||||||
|
implementationClass="org.acejump.action.AceEditorAction$SearchLineEnds"
|
||||||
|
id="AceHandlerLineEnd"/>
|
||||||
|
|
||||||
</extensions>
|
</extensions>
|
||||||
|
|
||||||
<actions>
|
<actions>
|
||||||
<action id="AceAction"
|
<action id="AceAction"
|
||||||
class="org.acejump.control.AceAction"
|
class="org.acejump.action.AceAction$ActivateOrCycleMode"
|
||||||
text="Activate AceJump Mode"
|
text="Activate / Cycle AceJump Mode">
|
||||||
description="Targets a character in AceJump">
|
|
||||||
<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="AceLineAction"
|
<action id="AceReverseAction"
|
||||||
class="org.acejump.control.AceLineAction"
|
class="org.acejump.action.AceAction$ActivateOrReverseCycleMode"
|
||||||
text="Display Line Markers"
|
text="Activate / Reverse Cycle AceJump Mode"/>
|
||||||
description="Targets line markers in AceJump">
|
<action id="AceForwardAction"
|
||||||
<keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl shift SEMICOLON"/>
|
class="org.acejump.action.AceAction$ToggleForwardJumpMode"
|
||||||
<keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl shift SEMICOLON"/>
|
text="Start AceJump in Jump After Caret Mode"/>
|
||||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift SEMICOLON"/>
|
<action id="AceBackwardAction"
|
||||||
</action>
|
class="org.acejump.action.AceAction$ToggleBackwardJumpMode"
|
||||||
|
text="Start AceJump in Jump Before Caret Mode"/>
|
||||||
|
<action id="AceWordStartAction"
|
||||||
|
class="org.acejump.action.AceAction$ToggleJumpMode"
|
||||||
|
text="Start AceJump in Jump Mode"/>
|
||||||
|
<action id="AceWordEndAction"
|
||||||
|
class="org.acejump.action.AceAction$ToggleJumpEndMode"
|
||||||
|
text="Start AceJump in Jump End Mode"/>
|
||||||
<action id="AceTargetAction"
|
<action id="AceTargetAction"
|
||||||
class="org.acejump.control.AceTargetAction"
|
class="org.acejump.action.AceAction$ToggleTargetMode"
|
||||||
text="Start in Target Mode"
|
text="Start AceJump in Target Mode">
|
||||||
description="Targets a whole word in AceJump">
|
|
||||||
<keyboard-shortcut keymap="Mac OS X" first-keystroke="ctrl alt SEMICOLON"/>
|
<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="Mac OS X 10.5+" first-keystroke="ctrl alt SEMICOLON"/>
|
||||||
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt SEMICOLON"/>
|
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt SEMICOLON"/>
|
||||||
</action>
|
</action>
|
||||||
<action id="AceWordAction"
|
|
||||||
class="org.acejump.control.AceWordAction"
|
|
||||||
text="Start in Word Mode"
|
|
||||||
description="Searches for all words on the screen in AceJump"/>
|
|
||||||
<action id="AceWordForwardAction"
|
|
||||||
class="org.acejump.control.AceWordForwardAction"
|
|
||||||
text="Start in Word Forward Mode"
|
|
||||||
description="Searches for all visible words after the caret in AceJump"/>
|
|
||||||
<action id="AceWordBackwardsAction"
|
|
||||||
class="org.acejump.control.AceWordBackwardsAction"
|
|
||||||
text="Start in Word Backward Mode"
|
|
||||||
description="Searches for all visible words before the caret in AceJump"/>
|
|
||||||
<action id="AceDeclarationAction"
|
<action id="AceDeclarationAction"
|
||||||
class="org.acejump.control.AceDefinitionAction"
|
class="org.acejump.action.AceAction$ToggleDeclarationMode"
|
||||||
text="Start in Declaration Mode"
|
text="Start AceJump in Declaration Mode"/>
|
||||||
description="AceJump will invoke the 'Navigate To' action after jumping"/>
|
<action id="AceLineAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllLineMarksMode"
|
||||||
|
text="Start AceJump in All Line Marks Mode">
|
||||||
|
<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="$default" first-keystroke="ctrl shift SEMICOLON"/>
|
||||||
|
</action>
|
||||||
|
<action id="AceLineStartsAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllLineStartsMode"
|
||||||
|
text="Start AceJump in All Line Starts Mode"/>
|
||||||
|
<action id="AceLineEndsAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllLineEndsMode"
|
||||||
|
text="Start AceJump in All Line Ends Mode"/>
|
||||||
|
<action id="AceLineIndentsAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllLineIndentsMode"
|
||||||
|
text="Start AceJump in All Line Indents Mode"/>
|
||||||
|
<action id="AceWordAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllWordsMode"
|
||||||
|
text="Start AceJump in All Words Mode"/>
|
||||||
|
<action id="AceWordForwardAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllWordsForwardMode"
|
||||||
|
text="Start AceJump in All Words After Caret Mode"/>
|
||||||
|
<action id="AceWordBackwardsAction"
|
||||||
|
class="org.acejump.action.AceAction$StartAllWordsBackwardsMode"
|
||||||
|
text="Start AceJump in All Words Before Caret Mode"/>
|
||||||
</actions>
|
</actions>
|
||||||
</idea-plugin>
|
</idea-plugin>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg width="100%" height="100%" viewBox="0 0 40 41" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
<svg width="100%" height="100%" viewBox="0 0 40 41" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
<g transform="matrix(1,0,0,1,0,-635)">
|
<g transform="matrix(1,0,0,1,0,-635)">
|
||||||
<g id="pluginIcon" transform="matrix(0.154082,0,0,0.154082,-39.7746,553.645)">
|
<g id="pluginIcon" transform="matrix(0.154082,0,0,0.154082,-39.7746,553.645)">
|
||||||
<rect x="258.139" y="529.264" width="259.602" height="259.602" style="fill:none;"/>
|
<rect x="258.139" y="529.264" width="259.602" height="259.602" style="fill:none;"/>
|
||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -1,17 +1,9 @@
|
|||||||
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 com.intellij.openapi.editor.actions.EnterAction
|
||||||
import com.intellij.openapi.fileTypes.PlainTextFileType
|
import org.acejump.action.AceAction
|
||||||
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.config.AceConfig
|
||||||
import org.acejump.control.*
|
import org.acejump.test.util.BaseTest
|
||||||
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 +11,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 +57,7 @@ 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)
|
||||||
|
|
||||||
myFixture.checkResult("testin<caret>g 1234")
|
myFixture.checkResult("testin<caret>g 1234")
|
||||||
}
|
}
|
||||||
@@ -73,7 +65,7 @@ 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.uppercase())
|
||||||
|
|
||||||
myFixture.checkResult("<selection>testing 123<caret></selection>4")
|
myFixture.checkResult("<selection>testing 123<caret></selection>4")
|
||||||
}
|
}
|
||||||
@@ -81,27 +73,27 @@ 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(AceAction.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(AceAction.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(AceAction.StartAllWordsMode())
|
||||||
|
|
||||||
assertEquals(3, Tagger.markers.size)
|
assertEquals(3, session.tags.size)
|
||||||
|
|
||||||
typeAndWaitForResults(Canvas.jumpLocations.toList()[1].tag!!)
|
typeAndWaitForResults(session.tags[1].key)
|
||||||
|
|
||||||
myFixture.checkResult("test <caret>word action")
|
myFixture.checkResult("test <caret>word action")
|
||||||
}
|
}
|
||||||
@@ -109,123 +101,65 @@ 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())
|
takeAction(AceAction.ToggleTargetMode())
|
||||||
typeAndWaitForResults(Canvas.jumpLocations.first().tag!!)
|
typeAndWaitForResults(session.tags[0].key)
|
||||||
|
|
||||||
myFixture.checkResult("test <selection>target<caret></selection> action")
|
myFixture.checkResult("test <selection>target<caret></selection> action")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun `test cache invalidation`() {
|
||||||
|
"first line".search("first")
|
||||||
|
typeAndWaitForResults(session.tags[0].key)
|
||||||
|
|
||||||
|
repeat(3) { takeAction(EnterAction()) }
|
||||||
|
|
||||||
|
takeAction(AceAction.ToggleTargetMode())
|
||||||
|
typeAndWaitForResults("first")
|
||||||
|
typeAndWaitForResults(session.tags[0].key)
|
||||||
|
|
||||||
|
myFixture.checkResult("\n\n\n<selection>first<caret></selection> line")
|
||||||
|
}
|
||||||
|
|
||||||
fun `test line mode`() {
|
fun `test line mode`() {
|
||||||
makeEditor(" test\n three\n lines")
|
makeEditor(" test\n three\n lines")
|
||||||
|
|
||||||
takeAction(AceLineAction())
|
takeAction(AceAction.StartAllLineMarksMode())
|
||||||
|
|
||||||
assertEquals(9, Tagger.markers.size)
|
assertEquals(9, session.tags.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun `test pinyin selection`() {
|
fun `test chinese selection`() {
|
||||||
getSettings().supportPinyin = true
|
AceConfig.settings.mapToASCII = true
|
||||||
|
|
||||||
"test 拼音 selection".search("py")
|
"test 拼音 selection".search("py")
|
||||||
|
|
||||||
takeAction(AceTargetAction())
|
takeAction(AceAction.ToggleTargetMode())
|
||||||
typeAndWaitForResults(Canvas.jumpLocations.first().tag!!)
|
|
||||||
|
typeAndWaitForResults(session.tags[0].key)
|
||||||
|
|
||||||
myFixture.checkResult("test <selection>拼音<caret></selection> selection")
|
myFixture.checkResult("test <selection>拼音<caret></selection> selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun `test tag latency`(editorText: String) {
|
fun `test japanese selection`() {
|
||||||
var time = 0L
|
AceConfig.settings.mapToASCII = true
|
||||||
|
|
||||||
editorText.toCharArray().distinct().filter { !it.isWhitespace() }
|
"あみだにょらい".search("am")
|
||||||
.forEach { query ->
|
|
||||||
repeat(10) {
|
|
||||||
makeEditor(editorText)
|
|
||||||
|
|
||||||
myFixture.testAction(AceAction())
|
takeAction(AceAction.ToggleTargetMode())
|
||||||
time += measureTimeMillis { typeAndWaitForResults("$query") }
|
|
||||||
assert(Tagger.markers.isNotEmpty()) { "Should be tagged: $query" }
|
typeAndWaitForResults(session.tags[0].key)
|
||||||
resetEditor()
|
|
||||||
}
|
myFixture.checkResult("<selection>あみだにょらい<caret></selection>")
|
||||||
}
|
}
|
||||||
|
|
||||||
println("Average time to tag results: ${time / 100}ms")
|
// https://github.com/acejump/AceJump/issues/355
|
||||||
|
fun `ignore test a word that is difficult to tag`() {
|
||||||
|
makeEditor("aaCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||||
|
|
||||||
|
takeAction(AceAction.ActivateOrCycleMode())
|
||||||
|
|
||||||
|
typeAndWaitForResults("c")
|
||||||
|
|
||||||
|
assertEquals(2, session.tags.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
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() }
|
|
||||||
}
|
}
|
188
src/test/kotlin/ExternalUsageTest.kt
Normal file
188
src/test/kotlin/ExternalUsageTest.kt
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import com.intellij.mock.MockVirtualFile
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||||
|
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||||
|
import com.intellij.openapi.fileEditor.TextEditor
|
||||||
|
import com.intellij.util.ui.UIUtil
|
||||||
|
import it.unimi.dsi.fastutil.ints.IntArrayList
|
||||||
|
import junit.framework.TestCase
|
||||||
|
import org.acejump.action.AceAction
|
||||||
|
import org.acejump.boundaries.Boundaries
|
||||||
|
import org.acejump.boundaries.EditorOffsetCache
|
||||||
|
import org.acejump.boundaries.StandardBoundaries.WHOLE_FILE
|
||||||
|
import org.acejump.input.JumpMode
|
||||||
|
import org.acejump.search.Pattern.ALL_WORDS
|
||||||
|
import org.acejump.session.AceJumpListener
|
||||||
|
import org.acejump.session.SessionManager
|
||||||
|
import org.acejump.test.util.BaseTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test [org.acejump.ExternalUsage] endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ExternalUsageTest: BaseTest() {
|
||||||
|
fun `test externally tagged results and listener notification`() {
|
||||||
|
makeEditor("test externally tagged results")
|
||||||
|
|
||||||
|
SessionManager.start(myFixture.editor).markResults(sortedSetOf(4, 10, 15))
|
||||||
|
|
||||||
|
TestCase.assertEquals(3, session.tags.size)
|
||||||
|
|
||||||
|
var shouldBeTrueAfterFinished = false
|
||||||
|
session.addAceJumpListener(object: AceJumpListener {
|
||||||
|
override fun finished(mark: String?, query: String?) {
|
||||||
|
shouldBeTrueAfterFinished = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
typeAndWaitForResults(session.tags[0].key)
|
||||||
|
|
||||||
|
TestCase.assertTrue(shouldBeTrueAfterFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test externally tagged results with multiple editors`() {
|
||||||
|
val fileA = MockVirtualFile("a.txt", "first file")
|
||||||
|
val fileB = MockVirtualFile("b.txt", "second file with more markers")
|
||||||
|
manager?.openFile(fileA, true)
|
||||||
|
manager?.openFile(fileB, false)
|
||||||
|
|
||||||
|
val mainEditor = (manager?.selectedEditor as TextEditor).editor
|
||||||
|
val editorA = (manager?.getEditors(fileA)?.single() as TextEditor).editor
|
||||||
|
val editorB = (manager?.getEditors(fileB)?.single() as TextEditor).editor
|
||||||
|
|
||||||
|
val session = SessionManager.start(mainEditor, listOf(editorA, editorB))
|
||||||
|
|
||||||
|
session.markResults(mapOf(
|
||||||
|
editorA to IntArrayList(intArrayOf(0, 6)),
|
||||||
|
editorB to IntArrayList(intArrayOf(0, 7, 22))
|
||||||
|
))
|
||||||
|
|
||||||
|
TestCase.assertEquals(5, session.tags.size)
|
||||||
|
TestCase.assertEquals(2, session.tags.count { it.value.editor === editorA })
|
||||||
|
TestCase.assertEquals(3, session.tags.count { it.value.editor === editorB })
|
||||||
|
|
||||||
|
TestCase.assertEquals(listOf(0, 6), session.tags
|
||||||
|
.filter { it.value.editor === editorA }
|
||||||
|
.map { it.value.offset }
|
||||||
|
.sorted())
|
||||||
|
|
||||||
|
TestCase.assertEquals(listOf(0, 7, 22), session.tags
|
||||||
|
.filter { it.value.editor === editorB }
|
||||||
|
.map { it.value.offset }
|
||||||
|
.sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test external pattern usage`() {
|
||||||
|
makeEditor("test external pattern usage")
|
||||||
|
|
||||||
|
SessionManager.start(myFixture.editor)
|
||||||
|
.startRegexSearch(ALL_WORDS, WHOLE_FILE)
|
||||||
|
|
||||||
|
TestCase.assertEquals(4, session.tags.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test external regex usage`() {
|
||||||
|
makeEditor("test external regex usage")
|
||||||
|
|
||||||
|
SessionManager.start(myFixture.editor)
|
||||||
|
.startRegexSearch("[aeiou]+", WHOLE_FILE)
|
||||||
|
|
||||||
|
TestCase.assertEquals(9, session.tags.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test external jump with bounds`() {
|
||||||
|
makeEditor("test word and word usage")
|
||||||
|
|
||||||
|
SessionManager.start(myFixture.editor)
|
||||||
|
.toggleJumpMode(JumpMode.JUMP, object : Boundaries {
|
||||||
|
override fun getOffsetRange(editor: Editor, cache: EditorOffsetCache): IntRange {
|
||||||
|
return 14..18
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean {
|
||||||
|
return offset in 14..18
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
typeAndWaitForResults("word")
|
||||||
|
|
||||||
|
TestCase.assertEquals(1, session.tags.size)
|
||||||
|
TestCase.assertEquals(14, session.tags.single().value.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test listener query and mark`() {
|
||||||
|
"<caret>testing 1234".search("g")
|
||||||
|
|
||||||
|
var detectedMark: String? = null
|
||||||
|
var detectedQuery: String? = null
|
||||||
|
session.addAceJumpListener(object: AceJumpListener {
|
||||||
|
override fun finished(mark: String?, query: String?) {
|
||||||
|
detectedMark = mark
|
||||||
|
detectedQuery = query
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
val mark = session.tags[0].key
|
||||||
|
typeAndWaitForResults(mark)
|
||||||
|
|
||||||
|
TestCase.assertEquals(mark, detectedMark)
|
||||||
|
TestCase.assertEquals("g", detectedQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test listener after escape`() {
|
||||||
|
"<caret>testing 1234".search("g")
|
||||||
|
|
||||||
|
var detectedMark: String? = null
|
||||||
|
var detectedQuery: String? = null
|
||||||
|
session.addAceJumpListener(object: AceJumpListener {
|
||||||
|
override fun finished(mark: String?, query: String?) {
|
||||||
|
detectedMark = mark
|
||||||
|
detectedQuery = query
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
myFixture.performEditorAction("EditorEscape")
|
||||||
|
UIUtil.dispatchAllInvocationEvents()
|
||||||
|
|
||||||
|
TestCase.assertEquals(null, detectedMark)
|
||||||
|
TestCase.assertEquals(null, detectedQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test listener for word motion`() {
|
||||||
|
makeEditor("test word action")
|
||||||
|
|
||||||
|
takeAction(AceAction.StartAllWordsMode())
|
||||||
|
|
||||||
|
var detectedMark: String? = null
|
||||||
|
var detectedQuery: String? = null
|
||||||
|
session.addAceJumpListener(object: AceJumpListener {
|
||||||
|
override fun finished(mark: String?, query: String?) {
|
||||||
|
detectedMark = mark
|
||||||
|
detectedQuery = query
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
val mark = session.tags[1].key
|
||||||
|
typeAndWaitForResults(mark)
|
||||||
|
|
||||||
|
TestCase.assertEquals(mark, detectedMark)
|
||||||
|
TestCase.assertEquals("", detectedQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun `test do not remove other highlights when the session ends`() {
|
||||||
|
makeEditor("test do not remove other highlights when the session ends")
|
||||||
|
|
||||||
|
val markupModel = myFixture.editor.markupModel
|
||||||
|
val layer = HighlighterLayer.SELECTION - 1
|
||||||
|
val existedHighlighter = markupModel.addRangeHighlighter(0, 1, layer, null, HighlighterTargetArea.EXACT_RANGE)
|
||||||
|
|
||||||
|
takeAction(AceAction.StartAllWordsMode())
|
||||||
|
val mark = session.tags[0].key
|
||||||
|
typeAndWaitForResults(mark)
|
||||||
|
|
||||||
|
TestCase.assertEquals("last session should be disposed", null, SessionManager[myFixture.editor])
|
||||||
|
TestCase.assertTrue("existed highlighter should not be removed", existedHighlighter.isValid)
|
||||||
|
|
||||||
|
existedHighlighter.dispose()
|
||||||
|
}
|
||||||
|
}
|
42
src/test/kotlin/LatencyTest.kt
Normal file
42
src/test/kotlin/LatencyTest.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import org.acejump.action.AceAction
|
||||||
|
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(AceAction.ActivateOrCycleMode())
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
65
src/test/kotlin/org/acejump/test/util/BaseTest.kt
Normal file
65
src/test/kotlin/org/acejump/test/util/BaseTest.kt
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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.FileEditorManagerTestCase
|
||||||
|
import com.intellij.util.ui.UIUtil
|
||||||
|
import org.acejump.action.AceAction
|
||||||
|
import org.acejump.session.SessionManager
|
||||||
|
|
||||||
|
abstract class BaseTest: FileEditorManagerTestCase() {
|
||||||
|
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 =
|
||||||
|
myFixture.configureByText(PlainTextFileType.INSTANCE, contents)
|
||||||
|
|
||||||
|
fun resetEditor() {
|
||||||
|
myFixture.editor?.let {
|
||||||
|
takeAction(IdeActions.ACTION_EDITOR_ESCAPE)
|
||||||
|
UIUtil.dispatchAllInvocationEvents()
|
||||||
|
assertEmpty(it.markupModel.allHighlighters)
|
||||||
|
}
|
||||||
|
manager?.closeAllFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun typeAndWaitForResults(string: String) {
|
||||||
|
myFixture.type(string)
|
||||||
|
UIUtil.dispatchAllInvocationEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.executeQuery(query: String) = myFixture.run {
|
||||||
|
makeEditor(this@executeQuery)
|
||||||
|
testAction(AceAction.ActivateOrCycleMode())
|
||||||
|
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)
|
||||||
|
}
|
Reference in New Issue
Block a user