Skip to content
This repository was archived by the owner on Dec 15, 2022. It is now read-only.

Commit 3855240

Browse files
Merge pull request #312 from savetheclocktower/apd-15-adjacent-tab-stops-rev-two
Fix behavior when inserting content between two adjacent tab stops
2 parents 36054ba + 072362a commit 3855240

File tree

5 files changed

+249
-42
lines changed

5 files changed

+249
-42
lines changed

lib/insertion.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ function transformText (str, flags) {
4545
}
4646

4747
class Insertion {
48-
constructor ({ range, substitution }) {
48+
constructor ({ range, substitution, references }) {
4949
this.range = range
5050
this.substitution = substitution
51+
this.references = references
5152
if (substitution) {
5253
if (substitution.replace === undefined) {
5354
substitution.replace = ''

lib/snippet-expansion.js

Lines changed: 173 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,29 @@ module.exports = class SnippetExpansion {
1010
this.cursor = cursor
1111
this.snippets = snippets
1212
this.subscriptions = new CompositeDisposable
13-
this.tabStopMarkers = []
1413
this.selections = [this.cursor.selection]
1514

15+
// Holds the `Insertion` instance corresponding to each tab stop marker. We
16+
// don't use the tab stop's own numbering here; we renumber them
17+
// consecutively starting at 0 in the order in which they should be
18+
// visited. So `$1` (if present) will always be at index `0`, and `$0` (if
19+
// present) will always be the last index.
20+
this.insertionsByIndex = []
21+
22+
// Each insertion has a corresponding marker. We keep them in a map so we
23+
// can easily reassociate an insertion with its new marker when we destroy
24+
// its old one.
25+
this.markersForInsertions = new Map()
26+
27+
// The index of the active tab stop.
28+
this.tabStopIndex = null
29+
30+
// If, say, tab stop 4's placeholder references tab stop 2, then tab stop
31+
// 4's insertion goes into this map as a "related" insertion to tab stop 2.
32+
// We need to keep track of this because tab stop 4's marker will need to
33+
// be replaced while 2 is the active index.
34+
this.relatedInsertionsByIndex = new Map()
35+
1636
const startPosition = this.cursor.selection.getBufferRange().start
1737
let {body, tabStopList} = this.snippet
1838
let tabStops = tabStopList.toArray()
@@ -28,8 +48,11 @@ module.exports = class SnippetExpansion {
2848
this.editor.transact(() => {
2949
this.ignoringBufferChanges(() => {
3050
this.editor.transact(() => {
51+
// Insert the snippet body at the cursor.
3152
const newRange = this.cursor.selection.insertText(body, {autoIndent: false})
3253
if (this.snippet.tabStopList.length > 0) {
54+
// Listen for cursor changes so we can decide whether to keep the
55+
// snippet active or terminate it.
3356
this.subscriptions.add(this.cursor.onDidChangePosition(event => this.cursorMoved(event)))
3457
this.subscriptions.add(this.cursor.onDidDestroy(() => this.cursorDestroyed()))
3558
this.placeTabStopMarkers(startPosition, tabStops)
@@ -49,9 +72,12 @@ module.exports = class SnippetExpansion {
4972

5073
cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) {
5174
if (this.settingTabStop || textChanged) { return }
52-
const itemWithCursor = this.tabStopMarkers[this.tabStopIndex].find(item => item.marker.getBufferRange().containsPoint(newBufferPosition))
75+
const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find(insertion => {
76+
let marker = this.markersForInsertions.get(insertion)
77+
return marker.getBufferRange().containsPoint(newBufferPosition)
78+
})
5379

54-
if (itemWithCursor && !itemWithCursor.insertion.isTransformation()) { return }
80+
if (insertionAtCursor && !insertionAtCursor.isTransformation()) { return }
5581

5682
this.destroy()
5783
}
@@ -80,30 +106,35 @@ module.exports = class SnippetExpansion {
80106

81107
applyAllTransformations () {
82108
this.editor.transact(() => {
83-
this.tabStopMarkers.forEach((item, index) =>
84-
this.applyTransformations(index, true))
109+
this.insertionsByIndex.forEach((insertion, index) =>
110+
this.applyTransformations(index))
85111
})
86112
}
87113

88-
applyTransformations (tabStop, initial = false) {
89-
const items = [...this.tabStopMarkers[tabStop]]
90-
if (items.length === 0) { return }
114+
applyTransformations (tabStopIndex) {
115+
const insertions = [...this.insertionsByIndex[tabStopIndex]]
116+
if (insertions.length === 0) { return }
91117

92-
const primary = items.shift()
93-
const primaryRange = primary.marker.getBufferRange()
118+
const primaryInsertion = insertions.shift()
119+
const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange()
94120
const inputText = this.editor.getTextInBufferRange(primaryRange)
95121

96122
this.ignoringBufferChanges(() => {
97-
for (const item of items) {
98-
const {marker, insertion} = item
99-
var range = marker.getBufferRange()
100-
123+
for (const [index, insertion] of insertions.entries()) {
101124
// Don't transform mirrored tab stops. They have their own cursors, so
102125
// mirroring happens automatically.
103126
if (!insertion.isTransformation()) { continue }
104127

128+
var marker = this.markersForInsertions.get(insertion)
129+
var range = marker.getBufferRange()
130+
105131
var outputText = insertion.transform(inputText)
106132
this.editor.transact(() => this.editor.setTextInBufferRange(range, outputText))
133+
134+
// Manually adjust the marker's range rather than rely on its internal
135+
// heuristics. (We don't have to worry about whether it's been
136+
// invalidated because setting its buffer range implicitly marks it as
137+
// valid again.)
107138
const newRange = new Range(
108139
range.start,
109140
range.start.traverse(new Point(0, outputText.length))
@@ -114,36 +145,115 @@ module.exports = class SnippetExpansion {
114145
}
115146

116147
placeTabStopMarkers (startPosition, tabStops) {
117-
for (const tabStop of tabStops) {
148+
// Tab stops within a snippet refer to one another by their external index
149+
// (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but
150+
// we renumber them starting at 0 and using consecutive numbers.
151+
//
152+
// Luckily, we don't need to convert between the two numbering systems very
153+
// often. But we do have to build a map from external index to our internal
154+
// index. We do this in a separate loop so that the table is complete
155+
// before we need to consult it in the following loop.
156+
const indexTable = {}
157+
for (let [index, tabStop] of tabStops.entries()) {
158+
indexTable[tabStop.index] = index
159+
}
160+
161+
for (let [index, tabStop] of tabStops.entries()) {
118162
const {insertions} = tabStop
119-
const markers = []
120163

121164
if (!tabStop.isValid()) { continue }
122165

123166
for (const insertion of insertions) {
124167
const {range} = insertion
125168
const {start, end} = range
169+
let references = null
170+
if (insertion.references) {
171+
references = insertion.references.map(external => indexTable[external])
172+
}
173+
// Since this method is called only once at the beginning of a snippet expansion, we know that 0 is about to be the active tab stop.
174+
const shouldBeInclusive = (index === 0) || (references && references.includes(0))
126175
const marker = this.getMarkerLayer(this.editor).markBufferRange([
127176
startPosition.traverse(start),
128177
startPosition.traverse(end)
129-
])
130-
markers.push({
131-
index: markers.length,
132-
marker,
133-
insertion
134-
})
178+
], { exclusive: !shouldBeInclusive })
179+
// Now that we've created these markers, we need to store them in a
180+
// data structure because they'll need to be deleted and re-created
181+
// when their exclusivity changes.
182+
this.markersForInsertions.set(insertion, marker)
183+
184+
if (references) {
185+
const relatedInsertions = this.relatedInsertionsByIndex.get(index) || []
186+
relatedInsertions.push(insertion)
187+
this.relatedInsertionsByIndex.set(index, relatedInsertions)
188+
}
135189
}
136-
137-
this.tabStopMarkers.push(markers)
190+
this.insertionsByIndex[index] = insertions
138191
}
139192

140193
this.setTabStopIndex(0)
141194
this.applyAllTransformations()
142195
}
143196

197+
// When two insertion markers are directly adjacent to one another, and the
198+
// cursor is placed right at the border between them, the marker that should
199+
// "claim" the newly typed content will vary based on context.
200+
//
201+
// All else being equal, that content should get added to the marker (if any)
202+
// whose tab stop is active, or else the marker whose tab stop's placeholder
203+
// references an active tab stop. The `exclusive` setting on a marker
204+
// controls whether that marker grows to include content added at its edge.
205+
//
206+
// So we need to revisit the markers whenever the active tab stop changes,
207+
// figure out which ones need to be touched, and replace them with markers
208+
// that have the settings we need.
209+
adjustTabStopMarkers (oldIndex, newIndex) {
210+
// Take all the insertions whose markers were made inclusive when they
211+
// became active and restore their original marker settings.
212+
const insertionsForOldIndex = [
213+
...this.insertionsByIndex[oldIndex],
214+
...(this.relatedInsertionsByIndex.get(oldIndex) || [])
215+
]
216+
217+
for (let insertion of insertionsForOldIndex) {
218+
this.replaceMarkerForInsertion(insertion, {exclusive: true})
219+
}
220+
221+
// Take all the insertions belonging to the newly active tab stop (and all
222+
// insertions whose placeholders reference the newly active tab stop) and
223+
// change their markers to be inclusive.
224+
const insertionsForNewIndex = [
225+
...this.insertionsByIndex[newIndex],
226+
...(this.relatedInsertionsByIndex.get(newIndex) || [])
227+
]
228+
229+
for (let insertion of insertionsForNewIndex) {
230+
this.replaceMarkerForInsertion(insertion, {exclusive: false})
231+
}
232+
}
233+
234+
replaceMarkerForInsertion (insertion, settings) {
235+
const marker = this.markersForInsertions.get(insertion)
236+
237+
// If the marker is invalid or destroyed, return it as-is. Other methods
238+
// need to know if a marker has been invalidated or destroyed, and we have
239+
// no need to change the settings on such markers anyway.
240+
if (!marker.isValid() || marker.isDestroyed()) {
241+
return marker
242+
}
243+
244+
// Otherwise, create a new marker with an identical range and the specified
245+
// settings.
246+
const range = marker.getBufferRange()
247+
const replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings)
248+
249+
marker.destroy()
250+
this.markersForInsertions.set(insertion, replacement)
251+
return replacement
252+
}
253+
144254
goToNextTabStop () {
145255
const nextIndex = this.tabStopIndex + 1
146-
if (nextIndex < this.tabStopMarkers.length) {
256+
if (nextIndex < this.insertionsByIndex.length) {
147257
if (this.setTabStopIndex(nextIndex)) {
148258
return true
149259
} else {
@@ -167,28 +277,39 @@ module.exports = class SnippetExpansion {
167277
if (this.tabStopIndex > 0) { this.setTabStopIndex(this.tabStopIndex - 1) }
168278
}
169279

170-
setTabStopIndex (tabStopIndex) {
171-
this.tabStopIndex = tabStopIndex
280+
setTabStopIndex (newIndex) {
281+
const oldIndex = this.tabStopIndex
282+
this.tabStopIndex = newIndex
283+
// Set a flag before moving any selections so that our change handlers know
284+
// that the movements were initiated by us.
172285
this.settingTabStop = true
286+
// Keep track of whether we placed any selections or cursors.
173287
let markerSelected = false
174288

175-
const items = this.tabStopMarkers[this.tabStopIndex]
176-
if (items.length === 0) { return false }
289+
const insertions = this.insertionsByIndex[this.tabStopIndex]
290+
if (insertions.length === 0) { return false }
177291

178292
const ranges = []
179293
this.hasTransforms = false
180-
for (const item of items) {
181-
const {marker, insertion} = item
294+
295+
// Go through the active tab stop's markers to figure out where to place
296+
// cursors and/or selections.
297+
for (const insertion of insertions) {
298+
const marker = this.markersForInsertions.get(insertion)
182299
if (marker.isDestroyed()) { continue }
183300
if (!marker.isValid()) { continue }
184301
if (insertion.isTransformation()) {
302+
// Set a flag for later, but skip transformation insertions because
303+
// they don't get their own cursors.
185304
this.hasTransforms = true
186305
continue
187306
}
188307
ranges.push(marker.getBufferRange())
189308
}
190309

191310
if (ranges.length > 0) {
311+
// We have new selections to apply. Reuse existing selections if
312+
// possible, destroying the unused ones if we already have too many.
192313
for (const selection of this.selections.slice(ranges.length)) { selection.destroy() }
193314
this.selections = this.selections.slice(0, ranges.length)
194315
for (let i = 0; i < ranges.length; i++) {
@@ -202,34 +323,48 @@ module.exports = class SnippetExpansion {
202323
this.selections.push(newSelection)
203324
}
204325
}
326+
// We placed at least one selection, so this tab stop was successfully
327+
// set.
205328
markerSelected = true
206329
}
207330

208331
this.settingTabStop = false
209332
// If this snippet has at least one transform, we need to observe changes
210333
// made to the editor so that we can update the transformed tab stops.
211-
if (this.hasTransforms) { this.snippets.observeEditor(this.editor) }
334+
if (this.hasTransforms) {
335+
this.snippets.observeEditor(this.editor)
336+
} else {
337+
this.snippets.stopObservingEditor(this.editor)
338+
}
339+
340+
if (oldIndex !== null) {
341+
this.adjustTabStopMarkers(oldIndex, newIndex)
342+
}
212343

213344
return markerSelected
214345
}
215346

216347
goToEndOfLastTabStop () {
217-
if (this.tabStopMarkers.length === 0) { return }
218-
const items = this.tabStopMarkers[this.tabStopMarkers.length - 1]
219-
if (items.length === 0) { return }
220-
const {marker: lastMarker} = items[items.length - 1]
348+
const size = this.insertionsByIndex.length
349+
if (size === 0) { return }
350+
const insertions = this.insertionsByIndex[size - 1]
351+
if (insertions.length === 0) { return }
352+
const lastMarker = this.markersForInsertions.get(insertions[insertions.length - 1])
353+
221354
if (lastMarker.isDestroyed()) {
222355
return false
223356
} else {
224-
this.editor.setCursorBufferPosition(lastMarker.getEndBufferPosition())
357+
this.seditor.setCursorBufferPosition(lastMarker.getEndBufferPosition())
225358
return true
226359
}
227360
}
228361

229362
destroy () {
230363
this.subscriptions.dispose()
231364
this.getMarkerLayer(this.editor).clear()
232-
this.tabStopMarkers = []
365+
this.insertionsByIndex = []
366+
this.relatedInsertionsByIndex = new Map()
367+
this.markersForInsertions = new Map();
233368
this.snippets.stopObservingEditor(this.editor)
234369
this.snippets.clearExpansions(this.editor)
235370
}

lib/snippet.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
const {Range} = require('atom')
22
const TabStopList = require('./tab-stop-list')
33

4+
function tabStopsReferencedWithinTabStopContent (segment) {
5+
const results = []
6+
for (const item of segment) {
7+
if (item.index) {
8+
results.push(item.index, ...tabStopsReferencedWithinTabStopContent(item.content))
9+
}
10+
}
11+
return new Set(results)
12+
}
13+
414
module.exports = class Snippet {
515
constructor({name, prefix, bodyText, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyTree}) {
616
this.name = name
@@ -28,12 +38,17 @@ module.exports = class Snippet {
2838
if (index === 0) { index = Infinity; }
2939
const start = [row, column]
3040
extractTabStops(content)
41+
const referencedTabStops = tabStopsReferencedWithinTabStopContent(content)
3142
const range = new Range(start, [row, column])
3243
const tabStop = this.tabStopList.findOrCreate({
3344
index,
3445
snippet: this
3546
})
36-
tabStop.addInsertion({ range, substitution })
47+
tabStop.addInsertion({
48+
range,
49+
substitution,
50+
references: Array.from(referencedTabStops)
51+
})
3752
} else if (typeof segment === 'string') {
3853
bodyText.push(segment)
3954
var segmentLines = segment.split('\n')

lib/tab-stop.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ class TabStop {
2020
return !all
2121
}
2222

23-
addInsertion ({ range, substitution }) {
24-
let insertion = new Insertion({ range, substitution })
23+
addInsertion ({ range, substitution, references }) {
24+
let insertion = new Insertion({ range, substitution, references })
2525
let insertions = this.insertions
2626
insertions.push(insertion)
2727
insertions = insertions.sort((i1, i2) => {

0 commit comments

Comments
 (0)