Sublime Forum

Custom symbol lists for given scope selector

#1

Hi forum,

Let’s say I want to mimic the functionality of the local symbol list (Ctrl+R by default), but target a specific scope selector.

(The reason I want to do this is, I would like to be able to have multiple different “symbol lists” for one file. One for “headings”, and one for “functions”. These are both useful types of symbols, which can (and in my case do) legitimately occur in the same file, but I don’t want to have them in the same list, as they don’t belong together logically. So if I can target a different scope on the fly, then I can maintain two separate lists for each.)

I can use the following code to add a test_show_overlay command to Sublime Text (similar to the built-in show_overlay command), which will bring up a list of functions defined in a python file. (We could of course pass it whatever scope selector we please instead.)

import sublime
import sublime_plugin

class TestShowOverlayCommand(sublime_plugin.WindowCommand):
    def run(self, scope="entity.name.function.python"):
        self.original_selection = [
            (region.a, region.b) for region in self.window.active_view().sel()
        ]
        self.matches = sorted(
            self.window.active_view().find_by_selector(scope),
            key=lambda region: region.begin(),
        )
        self.window.show_quick_panel(
            [
                self.window.active_view().substr(region)
                for region in self.matches
            ],
            self.on_done,
            on_highlight=self.on_highlight,
        )

    def on_done(self, index):
        regions = [sublime.Region(*tup) for tup in self.original_selection]
        middle_region = regions[len(regions) // 2]
        self.window.active_view().sel().clear()
        self.window.active_view().sel().add_all(regions)
        self.window.active_view().show_at_center(middle_region)

    def on_highlight(self, index):
        if 0 <= index < len(self.matches):
            region = self.matches[index]
            self.window.active_view().sel().clear()
            self.window.active_view().sel().add(
                sublime.Region(region.a, region.b)
            )
            self.window.active_view().show_at_center(region)

To test this command, we can (for example) add it to the command palette with a .sublime-commands file.

{
  "caption": "run test overlay",
  "command": "test_show_overlay"
}

And finally, for a file to test this command on, consider the following Python file.

def foo(): pass
def bar(): pass

Compare what happens when we bring up the local symbol list, vs. what happens when we run our “run test overlay” command on this file. In both cases we get the list:

  • foo
  • bar

But when you switch between selecting different items in this list, the results are different. In the built-in version, the active selection in the file changes, to highlight whichever thing in the list you are currently selecting. Whilst in this test version, the selection stays the same, even though in the code we should be changing the selection.

(Note: modify our example file to

def foo(): pass

def bar(): pass

and insert as many blank lines as necessary between our definition of foo and bar, until one is at the top of your screen and the other is at the bottom. Then try test_show_overlay again, and observe that now the active selection does change. Why does it work in this situation?)

So that’s my question: why doesn’t the selection (appear to?) change on highlighting different choices in the test_show_overlay list? How can I get the same behaviour as the local symbol list, in that the active selection changes appropriately?

3 Likes

#2

hey, nice descriptive post - your problem is very clear :slight_smile:
unfortunately you are running into this ST bug:

there is a fairly simple workaround listed in that thread (def refreshSelectionBugWorkAround), which is to use the self.view.add_regions API to force ST to redraw the view correctly.

2 Likes

#3

@kingkeith thanks very much for the speedy and informative reply! :slight_smile: I wasn’t sure whether it was a bug or a misunderstanding on my part about the API, its nice to be reassured I wasn’t missing something obvious!

0 Likes

#4

it has since come to light that this isn’t actually a bug, but more the fact that plugins shouldn’t change the selection without an edit object - otherwise the selection won’t be stored in the undo stack as you’d expect. Which makes perfect sense now that I know about it; it was a real lightbulb moment for me! :wink:

so the correct solution is to use a TextCommand to update the selection from a quick panel callback. Unfortunately no such command exists by default (the closest I could find is the GotoLineCommand in Packages/Default/goto_line.py), meaning each plugin will have to use their own, which is a shame.

2 Likes

#5

Hmm, okay. After playing around with TextCommands for a while, here’s a “correct” approach.

import sublime
import sublime_plugin

class TestShowSelectionCommand(sublime_plugin.TextCommand):
    def run(self, edit, regions):
        if regions:
            middle_region = regions[len(regions) // 2]
            self.view.sel().clear()
            self.view.sel().add_all([sublime.Region(*tup) for tup in regions])
            self.view.show_at_center(sublime.Region(*middle_region))

class TestShowOverlayCommand(sublime_plugin.WindowCommand):
    def run(self, scope="entity.name.function.python"):
        self.original_selection = [
            (region.a, region.b) for region in self.window.active_view().sel()
        ]
        self.matches = sorted(
            [
                (region.begin(), region.end())
                for region in self.window.active_view().find_by_selector(scope)
            ],
            key=lambda tup: tup[0]
        )
        self.window.show_quick_panel(
            [
                self.window.active_view().substr(sublime.Region(*tup))
                for tup in self.matches
            ],
            self.on_done,
            on_highlight=self.on_highlight,
        )

    def on_done(self, index):
        if 0 <= index < len(self.matches):
            region = self.matches[index]
            self.window.active_view().run_command(
                "test_show_selection", {"regions": [region]}
            )
        else:
            self.window.active_view().run_command(
                "test_show_selection", {"regions": self.original_selection}
            )

    def on_highlight(self, index):
        if 0 <= index < len(self.matches):
            region = self.matches[index]
            self.window.active_view().run_command(
                "test_show_selection", {"regions": [region]}
            )

Note: passing the Region objects directly to the test_show_selection command seems to break things. So instead I construct tuples to represent the regions, and then re-construct the regions inside the text command. Also, make sure to call view.run_command(...) and not window.run_command(...).

0 Likes