Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disable an input handler dynamically (in case there is only one item to select from) #3347

Closed
FichteFoll opened this issue May 16, 2020 · 4 comments

Comments

@FichteFoll
Copy link
Collaborator

FichteFoll commented May 16, 2020

Problem description

The base situation is that I want to show an input handler based on a dynamic condition, that being a list of dynamic length. The following three conditions are possible:

  1. The list is empty. The command can't be completed and it doesn't make sense to show an input handler.
  2. The list contains exactly one item. One could ask the user to confirm this choice, but this case will be the most common one and it introduces an additional input step, especially when invoked through a key binding, so I'd like to skip the input handler.
  3. The list contains 2 or more items. A list input handler should be shown and ask the user for a selection.

Implementing the third case is trivial, so I'll skip that here.

The first case can be achieved by implementing the required logic in an is_enabled method that would prevent from the command being callable at all. To prevent redundant computations, the results of the computation can be stored in an instance attribute. However, this function is executed as soon as the command is to be made available in a menu or the command palette, which means it's not possible to give the user an explanation for why the command would not be able to run.

The second case has no direct solution, currently. Input handlers are only called if calling the command's run method fails with a TypeError indicating that a parameter was missing, so the argument cannot have a default value. However, it is not possible within input() to modfiy the arguments or specify a value to be used directly without causing the input handler to be displayed.

Workaround

A working workaround is to raise a crafted TypeError from your command's run method after you made the computation and determined dynamically that you need input from the user. An example is provided below.

Indicentally, this workaround also allows the comman to just return early in case a precondition is not met while showing a message to the user, unlike in is_enabled. Also, is_enabled has an open issue with not being called with the given args in all situations (#3249).

Preferred solution

Allow adding arguments to the command call within input() by returning a dict of keys to be added (and modified) instead of only InputHandler instances.

Example code

The workaround is volatile. Not cleaning up the cached handler attribute means that run is not run when the command is invoked from the command line, because that checks input before running run, where we would reset the attribute otherwise.

Cleaning the attribute before returning from input() causes an infinite recursion and crashes the plugin host, because sublime_api.can_accept_input(self.name(), args) makes a call and checks input() before opening the command palette for it, which calls input() again.

A command palette entry must exist. (I used { "caption": "Test Command", "command": "test" },.)

import sublime_plugin

num_items = 0


class TestCommand(sublime_plugin.WindowCommand):

    handler = None

    def input(self, args):
        print("input()")
        if self.handler:
            print("returing handler")
            return self.handler

    def run(self, value=None):
        print("run()")
        global num_items
        self.handler = None

        if value is None:
            print("No value provided")
            items = list(range(num_items))
            num_items += 1

            print("items={!r}".format(items))
            if not items:
                print("nothing to be done")
                return
            elif len(items) == 1:
                value = items[0]
            else:
                # Prepare input handler items and cause ST to call input()
                self.handler = SimpleListInputHandler('value', items, self)
                raise TypeError("required positional argument")

        print("Performing task on", value)


class SimpleListInputHandler(sublime_plugin.ListInputHandler):
    def __init__(self, param_name, items, instance):
        self.param_name = param_name
        self.items = items
        self.instance = instance

    def name(self):
        return self.param_name

    def placeholder(self):
        return self.param_name

    def list_items(self):
        self.instance.handler = None  # Reset handler here after we know the user was offered a list
        return list(map(str, self.items))

As an aside, when the list returned in list_items() consists of integers and not string, the error in the console says

ValueError: Sequence must contain 2 values

which is not at all what was happening, as the sequence definitely had 2 values. In fact, the code works fine if there is only 1 value. It's just that with 0 values that weird things happen (and I get the previously mentioned infinite recursion.)
Edit: looks like the 0 case is described in #3105.

@FichteFoll
Copy link
Collaborator Author

An additional related functionality I recently thought of would be showing an accepted input handler for a pre-selected value. For example, I have a command that accepts 3 arguments, two operands and the operator's string representation. (Input handlers skipped for brevity.)

import sublime_plugin
import operator

OPERATORS = {
    '+': operator.add,
    '-': operator.sub,
    '*': operator.mul,
    '/': operator.truediv,
}


class BinaryArithmeticCommand(sublime_plugin.TextCommand):
    def run(self, edit, operand1, operator, operand2):
        result = OPERATORS[operator](float(operand1), float(operand2))
        for region in self.view.sel():
            self.view.replace(edit, region, str(result))

If I called, view.run_command("binary_arithmetic", {"operator": "+"}), I would like ST to prompt for the first operand with an input handler, then skip the input handler for the operand but still show the selected operator as a breadcrumb in the palette. I don't believe this to be possible currently. The best workaround would be to define initial_text, which would still require the user to hit "enter" once.

@BenjaminSchaaf
Copy link
Member

This behaviour can be achieved without any workarounds:

class TestCommand(sublime_plugin.WindowCommand):
    def input(self, args):
        global num_items
        if num_items > 1:
            return SimpleListInputHandler('value', list(range(num_items)))

    def run(self, **args):
        global num_items

        if num_items == 1:
            value = 0
        elif 'value' not in args:
            return
        else:
            value = args['value']

        print("Performing task on", value)

@FichteFoll
Copy link
Collaborator Author

I finally found some time again for this problem. Turns out, the fundamental problem was that I was trying to execute the command directly as opposed to calling it through show_overlay, because then input is always called before run. This is really easy to forget, it seems.

I also realized I don't mention this in the guide page I made for docs.sublimetext.io, so I'll add this there as well.

@FichteFoll
Copy link
Collaborator Author

FichteFoll commented Apr 12, 2021

Full plugin code for future references:

import sublime_plugin

num_items = 1


class TestCommand(sublime_plugin.WindowCommand):
    def input(self, args):
        global num_items
        if 'value' not in args and num_items > 1:
            return SimpleListInputHandler('value', list(range(num_items)))

    def run(self, **args):
        global num_items

        value = args.get('value')
        if value is None:
            if num_items == 1:
                value = 0
            else:
                print("no arg provided")
                return

        print("Performing task on", value)
        num_items += 1


class SimpleListInputHandler(sublime_plugin.ListInputHandler):
    def __init__(self, param_name, items):
        self.param_name = param_name
        self.items = items

    def name(self):
        return self.param_name

    def placeholder(self):
        return self.param_name

    def list_items(self):
        return list(map(str, self.items))

How to call it directly:

window.run_command("show_overlay", {"overlay": "command_palette", "command": "test", "args": {"value": 1}})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants