Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Behavior #64

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cc49e23
Add behavior.ts, experimental replacement for modules
marijnh Dec 11, 2018
718883d
Port state module to use behaviors
marijnh Dec 11, 2018
7293d98
Revise the way behavior priorities are handled
marijnh Dec 13, 2018
7315add
Port plugins to use behaviors
marijnh Dec 13, 2018
0caf231
Wire up basic indentation commands using Behavior.indentation
marijnh Dec 13, 2018
43e4f96
Remove .some accessor from SetBehavior
marijnh Dec 13, 2018
7ce7d40
Stricter typing for BehaviorSpec
adrianheine Dec 14, 2018
cff18c2
Rename BehaviorSpec to BehaviorUse
adrianheine Dec 15, 2018
817468c
Make Behavior.get a partial function, drop BehaviorSet class
marijnh Dec 16, 2018
cba9d18
Make combineConfig a top-level function
marijnh Dec 16, 2018
b775ecd
Move viewPlugin to the view module
marijnh Dec 16, 2018
563aedd
Implement separation between extensions and behaviors
marijnh Dec 17, 2018
9195df6
Port the rest of state/ to adjust extension interface
marijnh Dec 17, 2018
4ca5f28
Make all the code use Extension
marijnh Dec 17, 2018
943201b
Comment extension.ts a bit better
marijnh Dec 17, 2018
737e4ee
Make behaviors and extensions functions
marijnh Jan 9, 2019
c901cf6
Move behavior subsystem out of state/, so that view/ can also use it
marijnh Jan 9, 2019
5594398
Port extensions and behaviors to yet another approach
marijnh Jan 14, 2019
52dd467
Move behavior tests to the extension package
marijnh Jan 14, 2019
37786d6
Use behavior-style extensions for the view
marijnh Jan 15, 2019
6fb612c
Clean up use of local behavior in history
marijnh Jan 15, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions commands/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {EditorSelection, SelectionRange, MetaSlot} from "../../state/src"
import {EditorState, EditorSelection, SelectionRange, MetaSlot, StateExtension} from "../../state/src"
import {EditorView} from "../../view/src"

export type Command = (view: EditorView) => boolean
Expand Down Expand Up @@ -86,6 +86,58 @@ function deleteText(view: EditorView, dir: "forward" | "backward") {
export const deleteCharBackward: Command = view => deleteText(view, "backward")
export const deleteCharForward: Command = view => deleteText(view, "forward")

// FIXME support indenting by tab, configurable indent units

function space(n: number) {
let result = ""
for (let i = 0; i < n; i++) result += " "
return result
}

function getIndentation(state: EditorState, pos: number): number {
for (let f of state.behavior.get(StateExtension.indentation)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think indentation would be nicer as a standalone import.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Does this go for all of the static values on StateExtension? (Would it make sense to continue this discussion in #67 ?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it goes for all of them. Maybe because they are not extensions, but behaviors? I think indentation should be indentationGetter or getIndentation or indentationAt and they could all have a Behavior suffix for clarity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point, behaviors can be considered to be a type of extension.

These are not getters—if you call them, you create an extension that holds the value you pass.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, they are not getters, but the behavior provided by indentation is »getting the indentation at a specific position«. state.behavior.get(StateExtension.indentation) returns an array of functions that return the indentation at a specific position.

let result = f(state, pos)
if (result > -1) return result
}
return -1
}

export function insertNewlineAndIndent({state, dispatch}: EditorView): boolean {
let indentation = state.selection.ranges.map(r => getIndentation(state, r.from)), i = 0
dispatch(state.transaction.reduceRanges((tr, range) => {
let indent = indentation[i++]
return {transaction: tr.replace(range.from, range.to, ["", space(indent)]),
range: new SelectionRange(range.from + indent + 1)}
}).scrollIntoView())
return true
}

export function indentSelection({state, dispatch}: EditorView): boolean {
let lastLine = -1, positions = []
for (let range of state.selection.ranges) {
for (let {start, end} = state.doc.lineAt(range.from);;) {
if (start != lastLine) {
lastLine = start
let indent = getIndentation(state, start), current
if (indent > -1 &&
indent != (current = /^\s*/.exec(state.doc.slice(start, Math.min(end, start + 100)))![0].length))
positions.push({pos: start, current, indent})
}
if (end + 1 > range.to) break
;({start, end} = state.doc.lineAt(end + 1))
}
}
if (positions.length > 0) {
let tr = state.transaction
for (let {pos, current, indent} of positions) {
let start = tr.changes.mapPos(pos)
tr = tr.replace(start, start + current, space(indent))
}
dispatch(tr)
}
return true
}

export const pcBaseKeymap: {[key: string]: Command} = {
"ArrowLeft": moveCharLeft,
"ArrowRight": moveCharRight,
Expand All @@ -107,7 +159,8 @@ export const pcBaseKeymap: {[key: string]: Command} = {
"Mod-End": selectDocEnd,
"Mod-a": selectAll,
"Backspace": deleteCharBackward,
"Delete": deleteCharForward
"Delete": deleteCharForward,
"Enter": insertNewlineAndIndent
}

export const macBaseKeymap: {[key: string]: Command} = {
Expand Down
35 changes: 6 additions & 29 deletions demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,35 @@ import {EditorView} from "../view/src/"
import {keymap} from "../keymap/src/keymap"
import {history, redo, redoSelection, undo, undoSelection} from "../history/src/history"
import {gutter} from "../gutter/src/index"
import {baseKeymap} from "../commands/src/commands"
import {baseKeymap, indentSelection} from "../commands/src/commands"
import {legacyMode} from "../legacy-modes/src/index"
import {matchBrackets} from "../matchbrackets/src/matchbrackets"
import javascript from "../legacy-modes/src/javascript"
import {specialChars} from "../special-chars/src/special-chars"
import {multipleSelections} from "../multiple-selections/src/multiple-selections"

let mode = legacyMode(javascript({indentUnit: 2}, {}))

// FIXME these should move to commands and access the indentation
// feature through some kind of generic mechanism that allows plugins
// to advertise that they can do indentation
function crudeInsertNewlineAndIndent({state, dispatch}: EditorView): boolean {
let indentation = (mode as any).indentation(state, state.selection.primary.from)
if (indentation > -1)
dispatch(state.transaction.replaceSelection("\n" + " ".repeat(indentation)).scrollIntoView())
return true
}
function crudeIndentLine({state, dispatch}: EditorView): boolean {
let cursor = state.selection.primary.head // FIXME doesn't indent multiple lines
let line = state.doc.lineAt(cursor), text = line.slice(0, 100)
let space = /^ */.exec(text)[0].length // FIXME doesn't handle tabs
let indentation = (mode as any).indentation(state, line.start)
if (indentation == -1) indentation = space
let tr = state.transaction.replace(line.start, line.start + space, " ".repeat(indentation)).scrollIntoView()
if (cursor <= line.start + space)
tr = tr.setSelection(EditorSelection.single(line.start + indentation))
dispatch(tr)
return true
}
let mode = legacyMode({mode: javascript({indentUnit: 2}, {}) as any})

let isMac = /Mac/.test(navigator.platform)
let state = EditorState.create({doc: `"use strict";
const {readFile} = require("fs");

readFile("package.json", "utf8", (err, data) => {
console.log(data);
});`, plugins: [
});`, extensions: [
gutter(),
history(),
specialChars({}),
specialChars(),
multipleSelections(),
mode,
matchBrackets({decorationsPlugin: mode}),
matchBrackets(),
keymap({
"Mod-z": undo,
"Mod-Shift-z": redo,
"Mod-u": view => undoSelection(view) || true,
[isMac ? "Mod-Shift-u" : "Alt-u"]: redoSelection,
"Ctrl-y": isMac ? undefined : redo,
"Enter": crudeInsertNewlineAndIndent,
"Shift-Tab": crudeIndentLine
"Shift-Tab": indentSelection
}),
keymap(baseKeymap),
]})
Expand Down
182 changes: 182 additions & 0 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
const none = [] as any

const enum Kind { BEHAVIOR, MULTI, UNIQUE }

export class Extension {
// @internal
constructor(/* @internal */ public kind: Kind,
/* @internal */ public id: any,
/* @internal */ public value: any,
/* @internal */ public priority: number = -2) {}

private setPrio(priority: number): this {
// Crude casting because TypeScript doesn't understand new this.constructor
return new (this.constructor as any)(this.kind, this.id, this.value, priority) as this
}
fallback() { return this.setPrio(-1) }
extend() { return this.setPrio(1) }
override() { return this.setPrio(2) }

// @internal
flatten(priority: number, target: Extension[] = []) {
if (this.kind == Kind.MULTI) for (let ext of this.value as Extension[]) ext.flatten(this.priority > -2 ? this.priority : priority, target)
else target.push(this.priority > -2 ? this : this.setPrio(priority))
return target
}

// Insert this extension in an array of extensions so that it
// appears after any already-present extensions with the same or
// lower priority, but before any extensions with higher priority.
// @internal
collect(array: Extension[]) {
let i = 0
while (i < array.length && array[i].priority >= this.priority) i++
array.splice(i, 0, this)
}

// Define a type of behavior, which is the thing that extensions
// eventually resolve to. Each behavior can have an ordered sequence
// of values associated with it. An `Extension` can be seen as a
// tree of sub-extensions with behaviors as leaves.
static defineBehavior<Value>() {
let behavior = (value: Value) => new this(Kind.BEHAVIOR, behavior, value)
return behavior
}

static unique<Spec>(instantiate: (specs: Spec[]) => Extension, defaultSpec?: Spec): (spec?: Spec) => Extension {
const type = new UniqueExtensionType(instantiate)
return (spec: Spec | undefined = defaultSpec) => {
if (spec === undefined) throw new RangeError("This extension has no default spec")
return new this(Kind.UNIQUE, type, spec)
}
}

static all(...extensions: Extension[]) {
return new this(Kind.MULTI, null, extensions)
}

// Resolve an array of extenders by expanding all extensions until
// only behaviors are left, and then collecting the behaviors into
// arrays of values, preserving priority ordering throughout.
static resolve(extensions: ReadonlyArray<Extension>): BehaviorStore {
let pending: Extension[] = new this(Kind.MULTI, null, extensions).flatten(0)
// This does a crude topological ordering to resolve behaviors
// top-to-bottom in the dependency ordering. If there are no
// cyclic dependencies, we can always find a behavior in the top
// `pending` array that isn't a dependency of any unresolved
// behavior, and thus find and order all its specs in order to
// resolve them.
for (let resolved: UniqueExtensionType[] = [];;) {
let top = findTopUnique(pending, this)
if (!top) break // Only behaviors left
// Prematurely evaluated a behavior type because of missing
// sub-behavior information -- start over, in the assumption
// that newly gathered information will make the next attempt
// more successful.
if (resolved.indexOf(top) > -1) return this.resolve(extensions)
top.resolve(pending)
resolved.push(top)
}
// Collect the behavior values.
let store = new BehaviorStore
for (let ext of pending) {
if (!(ext instanceof this)) {
// Collect extensions of the wrong type into store.foreign
store.foreign.push(ext)
continue
}
if (store.behaviors.indexOf(ext.id) > -1) continue // Already collected
let values: Extension[] = []
for (let e of pending) if (e.id == ext.id) e.collect(values)
store.behaviors.push(ext.id)
store.values.push(values.map(v => v.value))
}
return store
}
}

class UniqueExtensionType {
knownSubs: UniqueExtensionType[] = []

constructor(public instantiate: (...specs: any[]) => Extension) {}

hasSub(type: UniqueExtensionType): boolean {
for (let known of this.knownSubs)
if (known == type || known.hasSub(type)) return true
return false
}

resolve(extensions: Extension[]) {
// Replace all instances of this type in extneions with the
// sub-extensions that instantiating produces.
let ours: Extension[] = []
for (let ext of extensions) if (ext.id == this) ext.collect(ours)
let first = true
for (let i = 0; i < extensions.length; i++) {
let ext = extensions[i]
if (ext.id != this) continue
let sub = first ? this.subs(ours.map(s => s.value), ext.priority) : none
extensions.splice(i, 1, ...sub)
first = false
i += sub.length - 1
}
}

subs(specs: any[], priority: number) {
let subs = this.instantiate(specs).flatten(priority)
for (let sub of subs)
if (sub.kind == Kind.UNIQUE && this.knownSubs.indexOf(sub.id) == -1) this.knownSubs.push(sub.id)
return subs
}
}

// An instance of this is part of EditorState and stores the behaviors
// provided for the state.
export class BehaviorStore {
// @internal
behaviors: any[] = []
// @internal
values: any[][] = []
// Any extensions that weren't an instance of the given type when
// resolving.
foreign: Extension[] = []

get<Value>(behavior: (v: Value) => Extension): Value[] {
let found = this.behaviors.indexOf(behavior)
return found < 0 ? none : this.values[found]
}
}

// Find the extension type that must be resolved next, meaning it is
// not a (transitive) sub-extension of any other extensions that are
// still in extenders.
function findTopUnique(extensions: Extension[], type: typeof Extension): UniqueExtensionType | null {
let foundUnique = false
for (let ext of extensions) if (ext.kind == Kind.UNIQUE && ext instanceof type) {
foundUnique = true
if (!extensions.some(e => e.kind == Kind.UNIQUE && (e.id as UniqueExtensionType).hasSub(ext.id)))
return ext.id
}
if (foundUnique) throw new RangeError("Sub-extension cycle in unique extensions")
return null
}

// Utility function for combining behaviors to fill in a config
// object from an array of provided configs. Will, by default, error
// when a field gets two values that aren't ===-equal, but you can
// provide combine functions per field to do something else.
export function combineConfig<Config>(configs: ReadonlyArray<Config>,
combine: {[key: string]: (first: any, second: any) => any} = {},
defaults?: Config): Config {
let result: any = {}
for (let config of configs) for (let key of Object.keys(config)) {
let value = (config as any)[key], current = result[key]
if (current === undefined) result[key] = value
else if (current === value || value === undefined) {} // No conflict
else if (Object.hasOwnProperty.call(combine, key)) result[key] = combine[key](current, value)
else throw new Error("Config merge conflict for field " + key)
}
if (defaults) for (let key in defaults)
if (result[key] === undefined) result[key] = (defaults as any)[key]
return result
}
56 changes: 56 additions & 0 deletions extension/test/test-extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const ist = require("ist")
import {Extension} from "../src/extension"

function mk(...extensions: Extension[]) { return Extension.resolve(extensions) }

let num = Extension.defineBehavior<number>()

describe("EditorState behavior", () => {
it("allows querying of behaviors", () => {
let str = Extension.defineBehavior<string>()
let store = mk(num(10), num(20), str("x"), str("y"))
ist(store.get(num).join(), "10,20")
ist(store.get(str).join(), "x,y")
})

it("includes sub-extenders", () => {
let e = (s: string) => Extension.all(num(s.length), num(+s))
let store = mk(num(5), e("20"), num(40), e("100"))
ist(store.get(num).join(), "5,2,20,40,3,100")
})

it("only includes sub-behaviors of unique extensions once", () => {
let e = Extension.unique<number>(ns => num(ns.reduce((a, b) => a + b, 0)))
let store = mk(num(1), e(2), num(4), e(8))
ist(store.get(num).join(), "1,10,4")
})

it("returns an empty array for absent behavior", () => {
ist(JSON.stringify(mk().get(num)), "[]")
})

it("sorts extensions by priority", () => {
let str = Extension.defineBehavior<string>()
let store = mk(str("a"), str("b"), str("c").extend(),
str("d").override(), str("e").fallback(),
str("f").extend(), str("g"))
ist(store.get(str).join(), "d,c,f,a,b,g,e")
})

it("lets sub-extensions inherit their parent's priority", () => {
let e = (n: number) => num(n)
let store = mk(num(1), e(2).override(), e(4))
ist(store.get(num).join(), "2,1,4")
})

it("uses default specs", () => {
let e = Extension.unique((specs: number[]) => num(specs.reduce((a, b) => a + b)), 10)
let store = mk(e(), e(5))
ist(store.get(num).join(), "15")
})

it("only allows omitting use argument when there's a default", () => {
let e = Extension.unique((specs: number[]) => num(0))
ist.throws(() => e())
})
})
Loading