Behind Pulsar

Less than 1 minute

Behind Pulsar

Under Construction

This document is under construction, please check back soon for updates. Please see our socials and feel free to ask for assistance or inquire as to the status of this document.

Now that we've written a number of packages and themes, let's take minute to take a closer look at some of the ways that Pulsar works in greater depth. Here we'll go into more of a deep dive on individual internal APIs and systems of Pulsar, even looking at some Atom source to see how things are really getting done.

Configuration API

Reading Config Settings

If you are writing a package that you want to make configurable, you'll need to read config settings via the atom.config global. You can read the current value of a namespaced config key with atom.config.get:

// read a value with `config.get`
if (atom.config.get("editor.showInvisibles")) {
	this.showInvisibles();
}

Or you can subscribe via atom.config.observe to track changes from any view object.

const {View} = require('space-pen')

class MyView extends View {
  function attached() {
    this.fontSizeObserveSubscription =
      atom.config.observe('editor.fontSize', (newValue, {previous}) => {
        this.adjustFontSize(newValue)
      })
  }

  function detached() {
    this.fontSizeObserveSubscription.dispose()
  }
}

The atom.config.observe method will call the given callback immediately with the current value for the specified key path, and it will also call it in the future whenever the value of that key path changes. If you only want to invoke the callback the next time the value changes, use atom.config.onDidChange instead.

Subscription methods return Disposableopen in new window objects that can be used to unsubscribe. Note in the example above how we save the subscription to the @fontSizeObserveSubscription instance variable and dispose of it when the view is detached. To group multiple subscriptions together, you can add them all to a CompositeDisposableopen in new window that you dispose when the view is detached.

Writing Config Settings

The atom.config database is populated on startup from LNX/MAC: ~/.pulsar/config.cson - WIN: %USERPROFILE%\.pulsar\config.cson but you can programmatically write to it with atom.config.set:

// basic key update
atom.config.set("core.showInvisibles", true);

If you're exposing package configuration via specific key paths, you'll want to associate them with a schema in your package's main module. Read more about schemas in the Config API documentationopen in new window.

Keymaps In-Depth

Structure of a Keymap File

Keymap files are encoded as JSON or CSON files containing nested hashes. They work much like style sheets, but instead of applying style properties to elements matching the selector, they specify the meaning of keystrokes on elements matching the selector. Here is an example of some bindings that apply when keystrokes pass through atom-text-editor elements:

Beneath the first selector are several keybindings, mapping specific key combinations to commands. When an element with the atom-text-editor class is focused and LNX/WIN: Ctrl+Backspace - MAC: Alt+Backspace is pressed, a custom DOM event called editor:delete-to-beginning-of-word is emitted on the atom-text-editor element.

The second selector group also targets editors, but only if they don't have the mini attribute. In this example, the commands for code folding don't really make sense on mini-editors, so the selector restricts them to regular editors.

Key Combinations

Key combinations express one or more keys combined with optional modifier keys. For example: ctrl-w v, or cmd-shift-up. A key combination is composed of the following symbols, separated by a -. A key sequence can be expressed as key combinations separated by spaces.

TypeExamples
Character literalsa 4 $
Modifier keyscmd ctrl alt shift
Special keysenter escape backspace delete tab home end pageup pagedown left right up down space

Commands

Commands are custom DOM events that are triggered when a key combination or sequence matches a binding. This allows user interface code to listen for named commands without specifying the specific keybinding that triggers it. For example, the following code creates a command to insert the current date in an editor:

atom.commands.add("atom-text-editor", {
	"user:insert-date": function (event) {
		const editor = this.getModel();
		return editor.insertText(new Date().toLocaleString());
	},
});

atom.commands refers to the global CommandRegistry instance where all commands are set and consequently picked up by the command palette.

When you are looking to bind new keys, it is often useful to use the Command Palette (LNX/WIN: Ctrl+Shift+P - MAC: Cmd+Shift+P) to discover what commands are being listened for in a given focus context. Commands are "humanized" following a simple algorithm, so a command like editor:fold-current-row would appear as "Editor: Fold Current Row".

"Composed" Commands

A common question is, "How do I make a single keybinding execute two or more commands?" There isn't any direct support for this in Pulsar, but it can be achieved by creating a custom command that performs the multiple actions you desire and then creating a keybinding for that command. For example, let's say I want to create a "composed" command that performs a Select Line followed by Cut. You could add the following to your init.js:

atom.commands.add("atom-text-editor", "custom:cut-line", function () {
	const editor = this.getModel();
	editor.selectLinesContainingCursors();
	editor.cutSelectedText();
});

Then let's say we want to map this custom command to alt-ctrl-z, you could add the following to your keymap:

'atom-text-editor':
  'alt-ctrl-z': 'custom:cut-line'

Specificity and Cascade Order

As is the case with CSS applying styles, when multiple bindings match for a single element, the conflict is resolved by choosing the most specific selector. If two matching selectors have the same specificity, the binding for the selector appearing later in the cascade takes precedence.

Currently, there's no way to specify selector ordering within a single keymap, because JSON objects do not preserve order. We handle cases where selector ordering is critical by breaking the keymap into separate files, such as snippets-1.cson and snippets-2.cson.

Selectors and Custom Packages

If a keybinding should only apply to a specific grammar, you can limit bindings to that grammar using the data-grammar attribute on the atom-text-editor element:

"atom-text-editor[data-grammar='source example']":
  'ctrl-.': 'custom:custom-command'

While selectors can be applied to the entire editor by what grammar is associated with it, they cannot be applied to scopes defined within the grammar or to sub-elements of atom-text-editor.

Removing Bindings

When the keymap system encounters a binding with the unset! directive as its command, it will treat the current element as if it had no key bindings matching the current keystroke sequence and continue searching from its parent. For example, the following code removes the keybinding for a in the Tree View, which is normally used to trigger the tree-view:add-file command:

'.tree-view':
  'a': 'unset!'

Keybinding Resolver

But if some element above the Tree View had a keybinding for a, that keybinding would still execute even when the focus is inside the Tree View.

When the keymap system encounters a binding with the abort! directive as its command, it will stop searching for a keybinding. For example, the following code removes the keybinding for LNX/WIN: Ctrl+O - MAC: Cmd+O when the selection is inside an editor pane:

But if you click inside the Tree View and press LNX/WIN: Ctrl+O - MAC: Cmd+O , it will work.

Forcing Chromium's Native Keystroke Handling

If you want to force the native browser behavior for a given keystroke, use the native! directive as the command of a binding. This can be useful to enable the correct behavior in native input elements. If you apply the .native-key-bindings class to an element, all the keystrokes typically handled by the browser will be assigned the native! directive.

Tips

Tip: Components and input elements may not correctly handle backspace and arrow keys without forcing this behavior. If your backspace isn't working correctly inside of a component, add either the directive or the .native-key-bindings class.

Overloading Key Bindings

Occasionally, it makes sense to layer multiple actions on top of the same key binding. An example of this is the snippets package. Snippets are inserted by typing a snippet prefix such as for and then pressing Tab. Every time Tab is pressed, we want to execute code attempting to expand a snippet if one exists for the text preceding the cursor. If a snippet doesn't exist, we want Tab to actually insert whitespace.

To achieve this, the snippets package makes use of the .abortKeyBinding() method on the event object representing the snippets:expand command.

// pseudo-code
editor.command("snippets:expand", (e) => {
	if (this.cursorFollowsValidPrefix()) {
		this.expandSnippet();
	} else {
		e.abortKeyBinding();
	}
});

When the event handler observes that the cursor does not follow a valid prefix, it calls e.abortKeyBinding(), telling the keymap system to continue searching for another matching binding.

Step-by-Step: How Keydown Events are Mapped to Commands

  • A keydown event occurs on a focused element.
  • Starting at the focused element, the keymap walks upward towards the root of the document, searching for the most specific CSS selector that matches the current DOM element and also contains a keystroke pattern matching the keydown event.
  • When a matching keystroke pattern is found, the search is terminated and the pattern's corresponding command is triggered on the current element.
  • If .abortKeyBinding() is called on the triggered event object, the search is resumed, triggering a binding on the next-most-specific CSS selector for the same element or continuing upward to parent elements.
  • If no bindings are found, the event is handled by Chromium normally.

Overriding Pulsar's Keyboard Layout Recognition

Sometimes the problem isn't mapping the command to a key combination, the problem is that Pulsar doesn't recognize properly what keys you're pressing. This is due to some limitations in how Chromium reports keyboard eventsopen in new window. But even this can be customized now.

You can add the following to your init.js to send Ctrl+@ when you press Ctrl+Alt+G:

atom.keymaps.addKeystrokeResolver(({ event }) => {
	if (
		event.code === "KeyG" &&
		event.altKey &&
		event.ctrlKey &&
		event.type !== "keyup"
	) {
		return "ctrl-@";
	}
});

Or if you are still using the init.coffee file:

atom.keymaps.addKeystrokeResolver ({event}) ->
  if event.code is 'KeyG' and event.altKey and event.ctrlKey and event.type isnt 'keyup'
    return 'ctrl-@'

If you want to know the event for the keystroke you pressed you can paste the following script to your developer tools consoleopen in new window

document.addEventListener("keydown", (e) => console.log(e), true);

This will print every keypress event in Pulsar to the console so you can inspect KeyboardEvent.key and KeyboardEvent.code.

Scoped Settings, Scopes and Scope Descriptors

Pulsar supports language-specific settings. You can soft wrap only Markdown files, or set the tab length to 4 in Python files.

Language-specific settings are a subset of something more general we call "scoped settings". Scoped settings allow targeting down to a specific syntax token type. For example, you could conceivably set a setting to target only Ruby comments, only code inside Markdown files, or even only JavaScript function names.

Scope Names in Syntax Tokens

Each token in the editor has a collection of scope names. For example, the aforementioned JavaScript function name might have the scope names function and name. An open paren might have the scope names punctuation, parameters, begin.

Scope names work just like CSS classes. In fact, in the editor, scope names are attached to a token's DOM node as CSS classes.

Take this piece of JavaScript:

function functionName() {
	console.log("Log it out");
}

In the dev tools, the first line's markup looks like this.

Markup

All the class names on the spans are scope names. Any scope name can be used to target a setting's value.

Scope Selectors

Scope selectors allow you to target specific tokens just like a CSS selector targets specific nodes in the DOM. Some examples:

'.source.js' # selects all javascript tokens
'.source.js .function.name' # selects all javascript function names
'.function.name' # selects all function names in any language

Config::setopen in new window accepts a scopeSelector. If you'd like to set a setting for JavaScript function names, you can give it the JavaScript function name scopeSelector:

atom.config.set("my-package.my-setting", "special value", {
	scopeSelector: ".source.js .function.name",
});

Scope Descriptors

A scope descriptor is an Objectopen in new window that wraps an Array of Strings. The Array describes a path from the root of the syntax tree to a token including all scope names for the entire path.

In our JavaScript example above, a scope descriptor for the function name token would be:

["source.js", "meta.function.js", "entity.name.function.js"];

Config::getopen in new window accepts a scopeDescriptor. You can get the value for your setting scoped to JavaScript function names via:

const scopeDescriptor = [
	"source.js",
	"meta.function.js",
	"entity.name.function.js",
];
const value = atom.config.get("my-package.my-setting", {
	scope: scopeDescriptor,
});

But, you do not need to generate scope descriptors by hand. There are a couple methods available to get the scope descriptor from the editor:

Let's revisit our example using these methods:

const editor = atom.workspace.getActiveTextEditor();
const cursor = editor.getLastCursor();
const valueAtCursor = atom.config.get("my-package.my-setting", {
	scope: cursor.getScopeDescriptor(),
});
const valueForLanguage = atom.config.get("my-package.my-setting", {
	scope: editor.getRootScopeDescriptor(),
});

Serialization in Pulsar

When a window is refreshed or restored from a previous session, the view and its associated objects are deserialized from a JSON representation that was stored during the window's previous shutdown. For your own views and objects to be compatible with refreshing, you'll need to make them play nicely with the serializing and deserializing.

Package Serialization Hook

Your package's main module can optionally include a serialize method, which will be called before your package is deactivated. You should return a JSON-serializable object, which will be handed back to you as an object argument to activate next time it is called. In the following example, the package keeps an instance of MyObject in the same state across refreshes.

module.exports = {
	activate(state) {
		this.myObject = state
			? atom.deserializers.deserialize(state)
			: new MyObject("Hello");
	},

	serialize() {
		return this.myObject.serialize();
	},
};

Serialization Methods

class MyObject {
	constructor(data) {
		this.data = data;
	}

	serialize() {
		return {
			deserializer: "MyObject",
			data: this.data,
		};
	}
}

serialize()

Objects that you want to serialize should implement .serialize(). This method should return a serializable object, and it must contain a key named deserializer whose value is the name of a registered deserializer that can convert the rest of the data to an object. It's usually just the name of the class itself.

Registering Deserializers

The other side of the coin is deserializers, whose job is to convert a state object returned from a previous call to serialize back into a genuine object.

deserializers in package.json

The preferred way to register deserializers is via your package's package.json file:

{
  "name": "wordcount",
  ...
  "deserializers": {
    "MyObject": "deserializeMyObject"
  }
}

Here, the key ("MyObject") is the name of the deserializer—the same string used by the deserializer field in the object returned by your serialize() method. The value ("deserializeMyObject") is the name of a function in your main module that'll be passed the serialized data and will return a genuine object. For example, your main module might look like this:

module.exports = {
	deserializeMyObject({ data }) {
		return new MyObject(data);
	},
};

Now you can call the global deserialize method with state returned from serialize, and your class's deserialize method will be selected automatically.

atom.deserializers.add(klass)

An alternative is to use the atom.deserializers.add method with your class in order to make it available to the deserialization system. Usually this is used in conjunction with a class-level deserialize method:

class MyObject {
	static initClass() {
		atom.deserializers.add(this);
	}

	static deserialize({ data }) {
		return new MyObject(data);
	}

	constructor(data) {
		this.data = data;
	}

	serialize() {
		return {
			deserializer: "MyObject",
			data: this.data,
		};
	}
}

MyObject.initClass();

While this used to be the standard method of registering a deserializer, the package.json method is now preferred since it allows Pulsar to defer loading and executing your code until it's actually needed.

Versioning

class MyObject {
	static initClass() {
		atom.deserializers.add(this);

		this.version = 2;
	}

	static deserialize(state) {
		// ...
	}

	serialize() {
		return {
			version: this.constructor.version,
			// ...
		};
	}
}

MyObject.initClass();

Your serializable class can optionally have a class-level @version property and include a version key in its serialized state. When deserializing, Pulsar will only attempt to call deserialize if the two versions match, and otherwise return undefined.

Developing Node Modules

Pulsar contains a number of packages that are Node modules instead of Pulsar packages. If you want to make changes to the Node modules, for instance atom-keymap, you have to link them into the development environment differently than you would a normal Pulsar package.

Linking a Node Module Into Your Pulsar Dev Environment

Here are the steps to run a local version of a Node module within Pulsar. We're using atom-keymap as an example:

After you get the Node module linked and working, every time you make a change to the Node module's code, you will have to exit Pulsar and do the following:

$ cd <WHERE YOU CLONED THE NODE MODULE>
$ npm install
$ cd <WHERE YOU CLONED PULSAR>
$ pulsar -p rebuild
$ pulsar --dev .

Interacting With Other Packages Via Services

Pulsar packages can interact with each other through versioned APIs called services. To provide a service, in your package.json, specify one or more version numbers, each paired with the name of a method on your package's main module:

{
	"providedServices": {
		"my-service": {
			"description": "Does a useful thing",
			"versions": {
				"1.2.3": "provideMyServiceV1",
				"2.3.4": "provideMyServiceV2"
			}
		}
	}
}

In your package's main module, implement the methods named above. These methods will be called any time a package is activated that consumes their corresponding service. They should return a value that implements the service's API.

module.exports = {
	activate() {
		// ...
	},

	provideMyServiceV1() {
		return adaptToLegacyAPI(myService);
	},

	provideMyServiceV2() {
		return myService;
	},
};

Similarly, to consume a service, specify one or more version rangesopen in new window, each paired with the name of a method on the package's main module:

{
	"consumedServices": {
		"another-service": {
			"versions": {
				"^1.2.3": "consumeAnotherServiceV1",
				">=2.3.4 <2.5": "consumeAnotherServiceV2"
			}
		}
	}
}

These methods will be called any time a package is activated that provides their corresponding service. They will receive the service object as an argument. You will usually need to perform some kind of cleanup in the event that the package providing the service is deactivated. To do this, return a Disposable from your service-consuming method:

const { Disposable } = require("atom");

module.exports = {
	activate() {
		// ...
	},

	consumeAnotherServiceV1(service) {
		useService(adaptServiceFromLegacyAPI(service));
		return new Disposable(() => stopUsingService(service));
	},

	consumeAnotherServiceV2(service) {
		useService(service);
		return new Disposable(() => stopUsingService(service));
	},
};

Maintaining Your Packages

Pre-release information

This section is about a feature in pre-release. The information below documents the intended functionality but there is still ongoing work to support these features with stability.

While publishing is, by far, the most common action you will perform when working with the packages you provide, there are other things you may need to do.

Publishing a Package Manually

STOP

Publishing a package manually is not a recommended practice and is only for the advanced user who has published packages before. If you perform the steps wrong, you may be unable to publish the new version of your package and may have to completely unpublish your package in order to correct the faulty state. You have been warned.

Some people prefer to control every aspect of the package publishing process. Normally, the ppm tool manages certain details during publishing to keep things consistent and make everything work smoothly. If you're one of those people that prefers to do things manually, there are certain steps you'll have to take in order to make things work just as smoothly as if ppm has taken care of things for you.

Note

Note: The ppm tool will only publish and https://pulsar-edit.devopen in new window will only list packages that are hosted on GitHubopen in new window, regardless of what process is used to publish them.

When you have completed the changes that you want to publish and are ready to start the publishing process, you must perform the following steps on the master branch:

  1. Update the version number in your package's package.json. The version number must match the regular expression: ^\d+\.\d+\.\d+
  2. Commit the version number change
  3. Create a Git tag referencing the above commit. The tag must match the regular expression ^v\d+\.\d+\.\d+ and the part after the v must match the full text of the version number in the package.json
  4. Execute git push --follow-tags
  5. Execute pulsar -p publish --tag tagname where tagname must match the name of the tag created in the above step

Adding a Collaborator

Some packages get too big for one person. Sometimes priorities change and someone else wants to help out. You can let others help or create co-owners by adding them as a collaboratoropen in new window on the GitHub repository for your package. Note: Anyone that has push access to your repository will have the ability to publish new versions of the package that belongs to that repository.

You can also have packages that are owned by a GitHub organizationopen in new window. Anyone who is a member of an organization's teamopen in new window which has push access to the package's repository will be able to publish new versions of the package.

Transferring Ownership

STOP

🚨 This is a permanent change. There is no going back! 🚨

If you want to hand off support of your package to someone else, you can do that by transferring the package's repositoryopen in new window to the new owner. Once you do that, they can publish a new version with the updated repository information in the package.json.

Unpublish Your Package

If you no longer want to support your package and cannot find anyone to take it over, you can unpublish your package from https://pulsar-edit.devopen in new window. For example, if your package is named package-name then the command you would execute is:

$ pulsar -p unpublish <package-name>

This will remove your package from the https://pulsar-edit.devopen in new window package registry. Anyone who has already downloaded a copy of your package will still have it and be able to use it, but it will no longer be available for installation by others.

Unpublish a Specific Version

If you mistakenly published a version of your package or perhaps you find a glaring bug or security hole, you may want to unpublish just that version of your package. For example, if your package is named package-name and the bad version of your package is v1.2.3 then the command you would execute is:

$ pulsar -p unpublish <package-name@1.2.3>

This will remove just this particular version from the https://pulsar-edit.devopen in new window package registry.

Rename Your Package

If you need to rename your package for any reason, you can do so with one simple command – pulsar -p publish --rename changes the name field in your package's package.json, pushes a new commit and tag, and publishes your renamed package. Requests made to the previous name will be forwarded to the new name.

$ pulsar -p publish --rename <new-package-name>

Tips

Tip: Once a package name has been used, it cannot be re-used by another package even if the original package is unpublished.

Summary

You should now have a better understanding of some of the core Pulsar APIs and systems.