6

I use UltiSnips for many of my TeX snippets, and I'm trying to create one to automatically insert a new \item inside itemize or enumerate environments.

The way it should work is the following: every time I'm done writing an \item, if I press the trigger key (<tab> for me) a function should be called that does the following:

  1. If the tabstop corrisponding to that \item is empty, delete that line and jump out of the environment;
  2. If it's not, create a new line with a new \item and a new tabstop after it.

I'm new to UltiSnips, but I think I can do this with a post-jump snippet action.

A skeleton of the possible code is the following

global !p
def new_tabstop_or_exit(item):
    # returns True if item has some non-whitespace chars
    if not item.isspace:
        # insert '\item' on the next line followed by a new tabstop
    else:
        # delete the current line and go to $0
endglobal

post_jump "if snip.tabstop == 0: new_tabstop_or_exit(snip.tabstops[snip.tabstop].current_text)"
snippet item "Itemize environment" b
\begin{itemize}
  \item $1
\end{itemize}
$0
endsnippet

snip.tabstops is the list with all tabstop objects, snip.tabstop is the number of the tabstop we jumped onto (see the docs, section 4.10.3).

So it should work something like this (_indicates the cursor position):

a. expand the snippet and place the cursor on the first tabstop

item<tab>
-->
\begin{itemize}
  \item _
\end{itemize}

b. tabstop $1 is not empty, so create a new line with \item and a new tabstop following it (this can go on indefinitely)

\begin{itemize}
  \item this is a test<tab>
\end{itemize}
--> 
\begin{itemize}
  \item this is a test
  \item _
\end{itemize}

c. tabstop is left empty, delete the line and jump out of itemize environment

\begin{itemize}
  \item this is a test
  \item _<tab>
\end{itemize}
--> 
\begin{itemize}
  \item this is a test
\end{itemize}
_

I hope I've been sufficiently clear. Any help is greatly appreciated.

PS: if someone knows a way to do this via some other method/plugin please let me know, UltiSnips is just what came to my mind.

2 Answers 2

8
+50

Main Ideas

There are 3 cases to consider (reworded slightly from the original question):
a) If not in an itemize environment, create one, and place the cursor after the first \item.
b) If in an itemize environment and if the list item is not empty, create a new \item.
c) If in an itemize environment and if the list item is empty, delete the line and jump out of itemize environment.

Instead of a single snippet, we define three snippets that address each of the cases separately:

# case a
context "not in_itemize(snip)"
snippet \item "start list of items" b
\begin{itemize}
    \item $1
\end{itemize}
endsnippet

# case b
context "in_itemize(snip)"
snippet "(^\s*)\\item\s*\S+.*$" "create next item" r
`!p snip.rv=match.group(0)`
`!p snip.rv=match.group(1)`\item $0
endsnippet

# case c
context "in_itemize(snip)"
post_jump "delline_exititemize(snip)"
snippet "^\s*\\item\s*" "exit itemize" r
endsnippet
  • We use a context in_itemize() to check if we are in an itemize environment.

  • For case c, we have used the post_jump action delline_exititemize() to manipulate the text buffer and cursor.

  • We use different regex snippets to distinguish cases b and c.


Snippet Details

Case a is the 'standard' snippet. It creates the itemize environment and places the cursor after \item.

Now we would like to distinguish between cases b and c. Instead of checking the tabstop's value, another way is to check if there are non-whitespace characters after \item (case b) or not (case c). This is done by a regular expression trigger, as seen by the 'r' at the end of the snippet header line. The regex \S+ in case b checks if there are non-whitespace characters.

For case b, the line

`!p snip.rv=match.group(0)`

repeats the original text, and

`!p snip.rv=match.group(1)`\item $0

adds a new \item to the list.

match.group(1) captures the indentation of the previous line. This ensures that indentation is preserved.

For case c, the snippet doesn't produce any actual text. Rather, the post_jump action delline_exititemize manipulates the buffer and cursor positions, by removing the current line, and moving the cursor outside the itemize environment.


Helpers for 'context' and 'post_jump'

We will have to write the two helper functions in_itemize() and delline_exititemize() in python (more precisely, using vim's python interface).

The code for the helper function in_itemize() is as follows:

def in_itemize(snip):
    # find using searchpairpos('\begin{itemize}','','\end{itemize}','bnWz')
    beginpos = vim.eval("searchpairpos('\\\\begin{itemize}','','\\\\end{itemize}','bnWz')")
    endpos   = vim.eval("searchpairpos('\\\\begin{itemize}','','\\\\end{itemize}', 'nWz')")
    curpos   = snip.cursor.to_vim_cursor()
    if beginpos == ['0','0'] or endpos == ['0','0']: return False
    if posCompare(beginpos, curpos) < 0 and posCompare(curpos, endpos) < 0:
        return True
    else:
        return False

This uses vim's searchpairpos() function to find the locations of \begin{itemize} and \end{itemize}. It is wrapped with vim.eval() since we are calling the vim function in python. These start and end positions are then compared with the cursor's position. In short, this function checks if the cursor is encompassed by \begin{itemize} and \end{itemize}.

(The function posCompare is rather straightforward to implement. It is included in the full code below)


The code for deleting the current line and exiting the itemize environment is as follows:

def delline_exititemize(snip):
    cursorline = snip.cursor[0] 
    del snip.buffer[cursorline]

    # add blank line after \end{itemize}
    snip.buffer.append("\n", cursorline + 1 + 1)
    # cursorline+1 is the linenr (1-based)

    # place cursor on blank line after \end{itemize}
    snip.cursor.set(cursorline+1, 0)

This deletes the current line, adds a new line after \end{itemize}, and moves the cursor to the desired location. We have used the properties of snip to achieve this. See :h UltiSnips-custom-context-snippets paragraph 6, for details. Note that we have to convert between 0-based and 1-based indexing.


Putting all pieces together

We have now filled in all parts. The last step is to place all python code in a global !p ... endglobal block, or in a separate file in .vim/pythonx. For the former approach, here is the full code:

# helpers

global !p
def delline_exititemize(snip):
    cursorline = snip.cursor[0] 
    del snip.buffer[cursorline]

    # add blank line after \end{itemize}
    snip.buffer.append("\n", cursorline + 1 + 1)
    # cursorline+1 is the linenr (1-based)

    # place cursor on blank line after \end{itemize}
    snip.cursor.set(cursorline+1, 0)


def in_itemize(snip):
    # find using searchpairpos('\begin{itemize}','','\end{itemize}','bnWz')
    beginpos = vim.eval("searchpairpos('\\\\begin{itemize}','','\\\\end{itemize}','bnWz')")
    endpos   = vim.eval("searchpairpos('\\\\begin{itemize}','','\\\\end{itemize}', 'nWz')")
    curpos   = snip.cursor.to_vim_cursor()
    if beginpos == ['0','0'] or endpos == ['0','0']: return False
    if posCompare(beginpos, curpos) < 0 and posCompare(curpos, endpos) < 0:
        return True
    else:
        return False

def posCompare(cur1, cur2):
    """ returns -1 if cur1 is before cur2, +1 if after, and 0 if cur1==cur2
        cur1, cur2 are required to be lists of the form [row,col]
    """
    cur1r = int(cur1[0])
    cur1c = int(cur1[1])
    cur2r = int(cur2[0])
    cur2c = int(cur2[1])
    if   cur1r < cur2r: return -1
    elif cur1r > cur2r: return  1
    else:
        if   cur1c < cur2c: return -1
        elif cur1c > cur2c: return  1
        else: return 0
endglobal


# snippets

context "not in_itemize(snip)"
snippet \item "start list of items" b
\begin{itemize}
    \item $1
\end{itemize}
endsnippet

context "in_itemize(snip)"
snippet "(^\s*)\\item\s*\S+.*$" "create next item" r
`!p snip.rv=match.group(0)`
`!p snip.rv=match.group(1)`\item $0
endsnippet

context "in_itemize(snip)"
post_jump "delline_exititemize(snip)"
snippet "^\s*\\item\s*" "exit itemize" r
endsnippet
1

What about writing your own mapping for TAB that analyses its context?

  • if it detect ^\s*\\item\s*$ on the current line, it clears the line then jumps over the end of the current itemize/enumerate/... environment
  • otherwise it runs the default mapping.

In lh-brackets, I have a function that may help to override existing mappings: lh#bracket#enrich_imap()

" ~/.vim/ftplugin/tex/youritem.vim
call lh#brackets#enrich_imap('<tab>',
    \ { 'condition': 'getline(".") =~ "^\\s*\\\\item\\s*$"',
    \   'action': 'YourFunctionThatDeleteAndJumpAndYouLlHaveToWrite()' },
    \ 1} " -> buffer local => restricted to (La)TeX

Sorry I won't break down my function here as it's not trivial and made of several other functions. For information, the starting point is maparg() to extract the existing mapping. Then, it's about testing conditions to return the associated action, evaluated. If no condition match, the default mapping is evaluated.

PS: if you wish to re-implement my function, it's possible you may not have it define a :map-<expr> depending on how you proceed. Indeed you want to jump, and map-<expr> is quite limited in this area.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.