Behind Pulsar
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 Disposable
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 CompositeDisposable
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 documentation.
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.
Type | Examples |
---|---|
Character literals | a 4 $ |
Modifier keys | cmd ctrl alt shift |
Special keys | enter 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!'
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 events. 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 console
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.
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::set
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 Object that wraps an Array
of String
s. 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::get
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:
Editor::getRootScopeDescriptor
to get the language's descriptor. For example:[".source.js"]
Editor::scopeDescriptorForBufferPosition
to get the descriptor at a specific position in the buffer.Cursor::getScopeDescriptor
to get a cursor's descriptor based on position. eg. if the cursor were in the name of the method in our example it would return["source.js", "meta.function.js", "entity.name.function.js"]
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 ranges, 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.dev will only list packages that are hosted on GitHub, 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:
- Update the version number in your package's
package.json
. The version number must match the regular expression:^\d+\.\d+\.\d+
- Commit the version number change
- Create a Git tag referencing the above commit. The tag must match the regular expression
^v\d+\.\d+\.\d+
and the part after thev
must match the full text of the version number in thepackage.json
- Execute
git push --follow-tags
- Execute
pulsar -p publish --tag tagname
wheretagname
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 collaborator 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 organization. Anyone who is a member of an organization's team 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 repository 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.dev. 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.dev 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.dev 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.