How to implement a new snippet class

Lepiter can be extended with new types of snippets. This tutorial introduces a running example of a minimal “Block Quote” snippet that displays literal text (just like a Markdown block quote) to illustrate exactly the steps needed to create the new snippet type.

There is frequently a need to insert plain quoted text for code, but this is not well-supported by existing snippets. In Lepiter, the Markdown “block quote” notation introduces an executable code snippet, like this:

        
3+4
        
      

If we don't want executable code, we can set the “language” of the content to text, like this:

        
3+4
        
      

This removes the evaluation buttons, but is not very nicely rendered. Instead we would like a new kind of editable “Block quote” snippet that looks like this:

3+4

How do we implement such a snippet?

A snippet implementation consists of three collaborating classes, a model , a view and a view model , following the MVVM pattern — Model–view–viewmodel. The model represents the domain model, holding the state of the snippet and all behavior related to the state. The view model holds and manages the state that is needed for the visual representation of the snippet in a page. Finally, the view is the element that provides the interactive display of the snippet. It is updated when the view model updates, and conversely, UI events that update the view are propagated to the view model.

Snippet model classes are all direct or indirect subclasses of LeSnippet LeContent subclass: #LeSnippet instanceVariableNames: 'parent uid' classVariableNames: '' package: 'Lepiter-Core-Model' . In this case, a LeBlockQuoteSnippet LePlainTextSnippet subclass: #LeBlockQuoteSnippet instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' is a particular kind of textual snippet, so we define it as a subclass of LePlainTextSnippet LeTextualSnippet subclass: #LePlainTextSnippet uses: TLeSpotterPagesSearch - {#children. #hasChildren} instanceVariableNames: 'text cachedTextualLinks ast' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' , an abstract superclass of pure textual (not code) snippets. As a model class, LeBlockQuoteSnippet LePlainTextSnippet subclass: #LeBlockQuoteSnippet instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' holds the actual content of the literal text block, as illustrated in this diagram at the left.

Snippet view model classes are all subclasses of LeSnippetViewModel LeAbstractSnippetViewModel subclass: #LeSnippetViewModel instanceVariableNames: '' classVariableNames: '' package: 'Lepiter-UI-Snippet-! View Models' . In this case, LeBlockQuoteSnippetViewModel LePlainTextCoderSnippetViewModel subclass: #LeBlockQuoteSnippetViewModel instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' subclasses LePlainTextCoderSnippetViewModel LeTextualSnippetViewModel subclass: #LePlainTextCoderSnippetViewModel instanceVariableNames: 'textCoder textCoderViewModel' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' and tracks the state of the text within a separate textCoderViewModel.

Snippet views all subclass LeSnippetElement BlElement subclass: #LeSnippetElement uses: TLeWithSnippetViewModel + TBrLayoutResizable instanceVariableNames: '' classVariableNames: 'KeyboardShortcuts' package: 'Lepiter-UI-Snippet-! Views' . In this case, LeBlockQuoteSnippetElement LePlainTextCoderSnippetElement subclass: #LeBlockQuoteSnippetElement instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' subclasses LePlainTextCoderSnippetElement LeTextualSnippetElement subclass: #LePlainTextCoderSnippetElement instanceVariableNames: 'editorElement' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' , which provides a basic editorElement for editing text, an instance of GtTextualCoderEditorElement BrEditor subclass: #GtTextualCoderEditorElement uses: TBlAssertUIProcess + TGtWithTextualCoderViewModel instanceVariableNames: 'completion evaluationHighlighter evaluationPrinter shortcuts cursorsUpdater textUpdater addOnsElementFuture' classVariableNames: '' package: 'GToolkit-Coder-UI-Coder - Textual' .

Note that for a single given model, there may be multiple active views and view models. For example, the same page may be open in multiple windows. The snippets of that page each have a unique model, but there will be multiple views of those snippets, each with its own view model.

Note also that only snippet views that are actually visible on the screen are allocated, otherwise they may be recycled. The view models, on the other hand, will persist while the parent page is in view.

Now let's look at the details of each of these classes. We will describe the general steps, and the specific cases for our running example.

(1) Define a subclass of LeSnippet LeContent subclass: #LeSnippet instanceVariableNames: 'parent uid' classVariableNames: '' package: 'Lepiter-Core-Model' .

Since literal block quotes are textual snippets, we define LeBlockQuoteSnippet LePlainTextSnippet subclass: #LeBlockQuoteSnippet instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' as a subclass of LePlainTextSnippet LeTextualSnippet subclass: #LePlainTextSnippet uses: TLeSpotterPagesSearch - {#children. #hasChildren} instanceVariableNames: 'text cachedTextualLinks ast' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' , the parent of text-based snippets, such as LeTextSnippet LePlainTextSnippet subclass: #LeTextSnippet instanceVariableNames: 'paragraphStyle' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' . Code snippets, on the other hand, subclass LeCodeSnippet LeTextualSnippet subclass: #LeCodeSnippet instanceVariableNames: 'coder' classVariableNames: '' package: 'Lepiter-Core-Model' .

There are several methods to implement. We'll get to these step by step.

(2) Define and initialize any needed state. Define any accessors needed to recreate an instance with its state from plain strings.

We inherit all the state we need from LePlainTextSnippet LeTextualSnippet subclass: #LePlainTextSnippet uses: TLeSpotterPagesSearch - {#children. #hasChildren} instanceVariableNames: 'text cachedTextualLinks ast' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' , namely a text slot, which is initialized to an instance of BlRunRopedText BlText subclass: #BlRunRopedText instanceVariableNames: 'attributeRuns rope' classVariableNames: '' package: 'Bloc-Text-Rope-Text' .

(LeBlockQuoteSnippet new string: 'hello') text
  

(3) Implement #storeOn: to generate Smalltalk code which, when evaluated, creates a copy of this snippet.

We also inherit LeBlockQuoteSnippet>>#storeOn: storeOn: aStream aStream nextPut: $(; nextPutAll: self className; nextPutAll: ' new string: '. self string storeOn: aStream. self childrenDo: [ :snippet | aStream nextPutAll: '; addSnippet: '. snippet storeOn: aStream ]. aStream nextPutAll: '; yourself)' , which works out of the box for our new snippet:

(LeBlockQuoteSnippet new
	string: 'Literal text';
	yourself) storeString
  

(4) Override the class methods leJsonV4AttributeMapping and leJsonV4Name to specify what state should be mapped to the JSON representation.

Snippets belong to Lepiter pages, which are (normally) stored in a Lepiter database as JSON files. (There are also transient databases that exist only in memory, but the more usual case if for databases to be persisted to the file system.) To support this, we must specify how to map the snippet to its JSON representation (and back).

We inherit LePlainTextSnippet>>#leJsonV4AttributeMapping leJsonV4AttributeMapping ^ super leJsonV4AttributeMapping add: (#string -> #string); yourself , and since we add no new state, this works out of the box.

On the other hand, we have to implement LeBlockQuoteSnippet>>#leJsonV4Name leJsonV4Name "The name for this type of snippet in the JSON representation." ^ #blockQuoteSnippet to return a name — #blockQuoteSnippet — for this kind of snippet in the string representation.

NB: To register the new mapping, we must update the singleton that stores all the mappings:

LeJsonV4 cleanUniqueInstance.
  

We can verify that a mapping now exists:

LeJsonV4 cleanUniqueInstance uniqueInstance 
	newReader mappings
		includesKey: LeBlockQuoteSnippet
  

and we can serialize a block snippet like this:

LeJsonV4 uniqueInstance
	serializePretty: (LeBlockQuoteSnippet new
			string: 'hello';
			yourself)
  

(5) Implement the boilerplate class methods contextMenuItemSpecification and description to include this snippet type in the contextual menu for Lepiter pages.

We implement LeBlockQuoteSnippet>>#contextMenuItemSpecification contextMenuItemSpecification "This method is required for every snippet class that should appear in the context menu of a page for adding new snippets." <leSnippetSpecification> ^ LeContextMenuItemSpecification new snippetClass: self; title: self description and LeBlockQuoteSnippet>>#description description "Text for the context menu" ^ 'Block quote' .

(6) Possibly implement canMoveToAnotherDatabase to return true if there are no dependencies to files in the database.

If a snippet has dependencies on files or attachments that are associated with the database of its page, then that page cannot be trivially moved to another database without breaking. If the necessary logic is not implement, this method should return false.

The following classes currently return false.

(LeSnippet allSubclasses reject: #isAbstract)
	select: [ :c | c new canMoveToAnotherDatabase not ]
  

We inherit this method from LeTextualSnippet>>#canMoveToAnotherDatabase canMoveToAnotherDatabase (self incomingLinks anySatisfy: [ :aLink | aLink isAttachedLink ]) ifTrue: [ ^ false ]. (self outgoingExplicitLinks anySatisfy: [ :aLink | aLink isAttachedLink ]) ifTrue: [ ^ false ]. ^ true , and do not need to change it.

We do, however, need to add a test case of an example page containing the snippet to the example class MovingPagesExamples Object subclass: #MovingPagesExamples instanceVariableNames: '' classVariableNames: '' package: 'Lepiter-Core-Examples-Moving' for testing. We define MovingPagesExamples>>#blockQuoteSnippetPage blockQuoteSnippetPage <moveablePage> <gtExample> ^ (LePage named: 'A BlockQuote snippet page') addSnippet: (LeBlockQuoteSnippet new string: 'Literal text'; yourself); yourself , making sure to set its method category to *GToolkit-Demo-Snippets, so it will be an extension method from the new snippet's package, so as not to introduce a dependency from the Lepiter core package to our package. When we are done we can check that the tests pass, especially MovingPagesExamples>>#moveAllPages moveAllPages <gtExample> | source target sourceSize targetSize | source := self moveablePageDatabase. target := self emptyDatabase. sourceSize := source pages size. targetSize := target pages size. self assert: sourceSize > 0. self assert: targetSize equals: 0. source pages do: [ :p | | newSourceSize newTargetSize | self assert: p canMoveToAnotherDatabase. p moveToDatabase: target. newSourceSize := source pages size. newTargetSize := target pages size. self assert: newSourceSize equals: sourceSize - 1. self assert: newTargetSize equals: targetSize + 1. sourceSize := newSourceSize. targetSize := newTargetSize ]. ^ target .

(7) Implement required factory method empty to return a basic instance.

Again, we inherit LeBlockQuoteSnippet>>#empty empty "Return a block with empty text" ^ self new text: '' asRopedText , and do not need to change anything:

LeBlockQuoteSnippet empty
  

(8) Define the View Model class, and use it to implement the required method asSnippetViewModel to return an instance of the view model holding all the state needed to create the view.

We define LeBlockQuoteSnippetViewModel LePlainTextCoderSnippetViewModel subclass: #LeBlockQuoteSnippetViewModel instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' (see next section), and implement LeBlockQuoteSnippet>>#asSnippetViewModel asSnippetViewModel "The view model for this class, holding all model state for the UI view." <return: #LeSnippetViewModel> ^ LeBlockQuoteSnippetViewModel new snippetModel: self to return an instance of the view model initialized with this object (self) as its snippet model.

(9) Implement acceptVisitor:. This is needed for any utilities that visit the pages of a Lepiter database, such as the HTML export facility.

We implement LeBlockQuoteSnippet>>#acceptVisitor: acceptVisitor: aVisitor "NB: In case visitors need to do something special here, we need to introduce a new method visitBlockQuoteSnippet: and add it to TLeModelVisitor, the trait for all Lepiter model visitors." ^ aVisitor visitTextSnippet: self to simply apply visitTextSnippet:. If we want to take special action, we will have to add a new visit method, and possibly adapt the existing visitors. See the trait TLeModelVisitor Trait named: #TLeModelVisitor instanceVariableNames: '' package: 'Lepiter-Core-Visitor' .

(10) Implement any remaining abstract methods.

The specific list will depend on where in the class hierarchy the snippet class is defined. We check for any remaining abstract methods as follows:

LeBlockQuoteSnippet allMethods select: #isAbstract
  

There are no remaining abstract methods left to implement, so we are done here.

(1) Define a subclass of LeSnippetViewModel LeAbstractSnippetViewModel subclass: #LeSnippetViewModel instanceVariableNames: '' classVariableNames: '' package: 'Lepiter-UI-Snippet-! View Models' (or one of its subclasses).

We define LeBlockQuoteSnippetViewModel LePlainTextCoderSnippetViewModel subclass: #LeBlockQuoteSnippetViewModel instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' as a subclass of LePlainTextCoderSnippetViewModel LeTextualSnippetViewModel subclass: #LePlainTextCoderSnippetViewModel instanceVariableNames: 'textCoder textCoderViewModel' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' . This class provides the scaffolding needed to subscribe to the snippet model, and to update the view model when there are changes.

(2) Define accessors for any state needed to maintain the view. Typically these will simply forward to the snippetModel.

We inherit textCoder and textCoderViewModel, but we still have to initialize them.

We define LeBlockQuoteSnippetViewModel>>#initializeTextCoderViewModel initializeTextCoderViewModel "Initialize the state needed to keep the view and the model in sync. My parent handles the subscriptions and the updates." textCoder := GtTextCoder new. textCoderViewModel := textCoder asCoderViewModel. to define the text coder model and view model that we will need for the view.

(3) Implement the abstract method snippetElementClass to return the class of the GUI element class for this snippet.

We implement LeBlockQuoteSnippetViewModel>>#snippetElementClass snippetElementClass ^ LeBlockQuoteSnippetElement to return the view class (next section).

(4) Implement any remaining abstract methods.

As before, we can check if any abstract methods remain to be implemented:

LeBlockQuoteSnippetViewModel allMethods select: #isAbstract
  

There is nothing left to implement, so we are done here.

(1) Define a subclass of LeSnippetElement BlElement subclass: #LeSnippetElement uses: TLeWithSnippetViewModel + TBrLayoutResizable instanceVariableNames: '' classVariableNames: 'KeyboardShortcuts' package: 'Lepiter-UI-Snippet-! Views' (or one of its subclasses).

We define LeBlockQuoteSnippetElement LePlainTextCoderSnippetElement subclass: #LeBlockQuoteSnippetElement instanceVariableNames: '' classVariableNames: '' package: 'GToolkit-Demo-Snippets-BlockQuote' as a subclass of LePlainTextCoderSnippetElement LeTextualSnippetElement subclass: #LePlainTextCoderSnippetElement instanceVariableNames: 'editorElement' classVariableNames: '' package: 'Lepiter-Snippet-Text-Snippet' , which serves as a base element for text-based snippets, and includes an instance of GtTextualCoderEditorElement BrEditor subclass: #GtTextualCoderEditorElement uses: TBlAssertUIProcess + TGtWithTextualCoderViewModel instanceVariableNames: 'completion evaluationHighlighter evaluationPrinter shortcuts cursorsUpdater textUpdater addOnsElementFuture' classVariableNames: '' package: 'GToolkit-Coder-UI-Coder - Textual' as its editorElement.

(2) Define and initialize the snippet element.

We inherit and override LeBlockQuoteSnippetElement>>#initializeEditorElement initializeEditorElement "Specialize the editor element to use a code font and a light grey background." super initializeEditorElement. editorElement background: self backgroundColor; aptitude: BrGlamorousCodeEditorAptitude to set the editor elements background to a very light gray, and to use a code font.

(4) Implement any remaining abstract methods.

There is nothing left to do.

LeBlockQuoteSnippetElement allMethods select: #isAbstract
  

Now we are basically done. Don't forget, however, if we need to do anything special in visitors, we will have to change LeBlockQuoteSnippet>>#acceptVisitor: acceptVisitor: aVisitor "NB: In case visitors need to do something special here, we need to introduce a new method visitBlockQuoteSnippet: and add it to TLeModelVisitor, the trait for all Lepiter model visitors." ^ aVisitor visitTextSnippet: self , introduce a new visitBlockQuoteSnippet: method for all visitors in the trait TLeModelVisitor Trait named: #TLeModelVisitor instanceVariableNames: '' package: 'Lepiter-Core-Visitor' , and eventually update selected visitors to override the default implementation.