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

Actions accessible from multiple places #1335

Closed
Akuli opened this issue Jul 5, 2023 · 20 comments
Closed

Actions accessible from multiple places #1335

Akuli opened this issue Jul 5, 2023 · 20 comments

Comments

@Akuli
Copy link
Owner

Akuli commented Jul 5, 2023

From #1334:

Ideally we come up with a pattern for "registering" "commands" that can be selectively shared between the toolbar, command pallette (#1319), right-click menu (#1026), and probably the main menu as well?

This actually existed before, but I got rid of it because the commands ended up only being added into the menubar (it was a plugin), and so the registering thingy only acted as an unnecessary layer that didn't provide anything useful. But back then there was only one place to put commands into, the menubar. Now we have 4, soon 5 or 6:

This IMO should be part of the core Porcupine, not a plugin, since it doesn't really make sense to run Porcupine without being able to at least register some commands, but I would still like to keep the right-click menus and toolbar as plugins so that they can be disabled individually. For example, maybe you don't use the toolbar and you want to disable it, or maybe you keep right-clicking something accidentally and you want to disable the right-click menus.

With that, I suggest:

  1. Make a new core Porcupine module (not plugin), say porcupine/commands.py, where you can register commands and it stores them globally. This module wouldn't actually show the commands anywhere, but it should have some way to query the existing commands for right-click menus and toolbars/menubars. (As I said, this used to exist before, it was called porcupine/actions.py.)
  2. Turn the menubar into a plugin that uses the new registering system. Change all menubar stuff to go through the new system.
  3. Turn both existing right-click menus into a plugin, say right_click_menus.py, that uses the new command system.
  4. Implement a new command pallette plugin using the new command system.
  5. Implement a new toolbar plugin using the new command system.

I think it is really important to do step 1 first to avoid rewriting code that was just written, but the order of other steps doesn't really matter.

Thoughts?

@benjamin-kirkbride
Copy link
Contributor

benjamin-kirkbride commented Jul 5, 2023

Right off the bat, I would like to propose that the nomenclature for this is "action" (or something else) and not "command". The reason for this is because "command" will refer to specifically text that is used to initiate #1319.

Now we have 3, soon 4 or 5

Should we consider keybindings, or perhaps virtual events (or both?) as the 6th? Better way to put it, should the same system handle "registering" keybindings as well?

I would still like to keep the right-click menus and toolbar as plugins

A problem I potentially see (but maybe for some reason this is wrong?) is that we will end up in a situation where a ton of plugins have load_after = ["toolbar", "rightclick_menu", "filemanager"], or is the idea that the suggested commands.pymodule (henceforth known asactions.py`) would solve those sorts of concerns?

Could it make sense to have them be part of the core, but still able to be disabled? Or is this not a problem in the way I'm thinking ? To be clear I'm not saying "They shouldn't be able to be disabled.", I am saying "Are there other costs/tradeoffs associated with them being plugins that we could avoid, while still meeting the goal of having them be optional?"

Turn both existing right-click menus into a plugin, say right_click_menus.py

rightclick_menu.py is already a plugin I believe

I think it is really important to do step 1 first to avoid rewriting code that was just written

Completely agree, as I said in #1334 I was hoping to use the toolbar demo to spur the conversation about how to handle this holistically.


As far as the actions.py architecture, what do you consider to be it's scope.

Lets use black as an example scenario:

  • The plugin that implements black functionality is python_tools.py.
  • I only want black related actions to be available when the filetype is appropriate (Python in this instance)
  • I only want black related actions to be available when black is installed (humor me; eventually I am going to propose a Pandoc plugin, a draw.io plugin, an imagemagick plugin, etc)
  • I want to register top menu options for black under some specific path (as it is today more or less)
  • I want to register a toolbar button to run black on the current file (I again ask that you not get caught up on whether or not having black on the toolbar makes sense 😉). The button needs to have an icon associated with it, and hover text/a hint.
  • I want to register "command palatte" commands to format the selection, entire document, or entire project
  • I want to register a right click on the tab to format the document or selection
  • I want to register a right click on file to format the file
  • I want to register a right click on a folder to format all files in the folder, recursively or not
  • I want to register a keyboard shortcut, or a virtual event to enable a keyboard shortcut to be set?

Option 1

actions.py has built in integration for each of the "action consumers"; It has functions/methods to register:

  • toolbar buttons via hooks into the toolbar plugin
  • commands via hooks into the command palette plugin
  • right click menus on the tab and on the filemenu via their respective plugins
  • keyboard shortcuts and/or virtual events that are used by the kb shortcut system (still not sure about this one)

It also has ways to gracefully handle those plugins being disabled, but it does not handle those plugins being missing, and it also doesn't have a way for a new plugin to be added, say if someone wanted to make a "cli argument" plugin to trigger actions, or an "http rpc api" plugin for registering actions.....

Option 2

A more dynamic system that I haven't had the time to flesh out mentally that allows plugins to register themselves as "action consumers" in a plugable way. Maybe using virtual events? Don't really know the system well enough to say


Okay I ran out of steam on thinking this through. Curious to hear others thoughts at this point.

@Akuli
Copy link
Owner Author

Akuli commented Jul 5, 2023

the nomenclature for this is "action"

Let's go with action :)

Should we consider keybindings, or perhaps virtual events (or both?) as the 6th?

Yes. Currently key bindings trigger menu items through virtual events named <<Menubar:Foo>>, so they would be yet another way to invoke an action.

load_after = ["toolbar", "rightclick_menu", "filemanager"]

This won't be necessary, because we can use the <<PluginsLoaded>> virtual event, generated in porcupine/pluginloader.py. Plugins that loop over all actions to e.g. show some in a toolbar can bind to that to update their state after all the plugin loading. If needed, we can add a separate virtual event that fires whenever an action is added/removed, and use that.

With that, I don't see why specific ways to trigger actions shouldn't be plugins.

rightclick_menu.py is already a plugin I believe

Yes, so that part of my plan is already half-done :)

As far as the actions.py architecture, what do you consider to be it's scope.

This is an important question. I think we can break this question down to two subquestions:

  1. How do we decide the consumers of an action? For example, whether there should be a toolbar button or not.
  2. How do we specify whether the action can be invoked / what are its inputs. Does it take a file path? Folder path (recursive blacking)? FileTab? FileTab that must have some text selected (e.g. pastebin plugin, maybe black)? A combination of these?

For 1, I would say we make it the responsibility of the consumer. For example, a consumer can:

  • have a config file that lists the relevant actions (default_keybindings.tcl + keybindings.tcl)
  • just use all actions (command pallette)
  • all actions that take a path to a file/folder as input (right-click menu when right-clicking a folder)

Menubar can maybe use a YAML file that lists all actions shown in it and the submenu structure, but then third-party plugins can't add stuff to the menubar which seems off. Or actions.py could include some way to specify whether you want the thing in menubar or not, similarly to how menubar.add_filetab_command() and such work now.

For 2, I think actions.py can expose a function for adding an action that takes a path, and another function for adding an action that takes a FileTab. If your action can work with either one (e.g. blacking), maybe just call both functions with the same action name, possibly passing different callback functions (one blacks files on disk, the other blacks the content currently in the editor).

@benjamin-kirkbride
Copy link
Contributor

For 1, I would say we make it the responsibility of the consumer

To be clear, what you are saying is for instance is that the plugin that implements black would register some "actions", lets say one for formatting a given Path black_format_file, and one for formatting a given "selection" (string) black_format_string. Then, the toolbar plugin could "subscribe" to black_format_path with a button, the right click menu for a tab would subscribe to both (but maybe only show black_format_string if there is a selection?), the menu would subscribe to both (but black_format_string would be disabled if no selection?), the command palette would subscribe to both, etc. When I say "subscribe" I am talking about some literal code that exists (could be a conf file, but I'm a bit skeptical of that) for each "consumer" plugin.

I actually don't love this, for two reasons. Reason one is it makes adding new plugins that register new actions only half the story, because now you also have to potentially modify the consumers. I like the way that adding things to the menu works now as far as each plugin explicitly setting it up, though I do think the implementation could be more general (AKA what we are trying to work out).

What if each plugin also had a actions container (maybe a dict) that it used to register it's actions?

but then third-party plugins can't add stuff to the menubar which seems off
IMO this makes that untennable, I think new plugins being able to use this system without having to do more than drop a .py in the plugins dir is a requirement

Or actions.py could include some way to specify whether you want the thing in menubar or not, similarly to how menubar.add_filetab_command() and such work now

I think this makes the most sense

I think actions.py can expose a function for adding an action that takes a path, and another function for adding an action that takes a FileTab

Here are the inputs I can think of for actions that we might consider supporting. Many of these might seem "redundant" because you technically can get them via the FileTab or the Path, but I think we should consider adding for convenience:

  • FileTab
  • Path
  • Selection (as string)
  • filetype_name

Maybe the last two are unnecessary though.


Something I don't see addressed here is conditional availability of actions. If I'm working on a Markdown file, I don't think the Command Palette should show the "Black - Format Entire File" command. There will be many many examples of this, and I don't think that filetype_name will be the only condition that comes up, though I can't think of any other conditions ATM.


After writing the above, I'm honestly wondering other than some utility functions what does actions.py even do? I feel pretty confident that the right design is keeping both the registering of "actions" as well as the wiring of "actions" to "consumers" should be local to the plugin module that adds the actions, as a best practice (keybindings need more thought here).

Lets get a specific example. A module called black.py has this at the bottom:

class BlackFile(Action):
    tooltip = "Format entire file with Black"
    active_if_filetype = ["Python"]

    def __call__(self, file: Path) -> None:
        ...

class BlackSelection(Action):
    tooltip = "Format selection with Black"
    active_if_filetype = ["Python"]
    active_if_selection = True

    def __call__(tab: FileTab) -> None:
        ...

def setup() -> None:
    menubar.add_filetab_command("Tools/Python/Black", black_file)

    buttons = []
    buttons.append(
        toolbar.Button(text="Black" command=black_file)
    )
    # toolbar automatically adds `FileTab` to callables
    toolbar.add_button_group(filetype_name="Python", name="Python Tools", buttons=buttons)

    # automatically adds `FileTab`
    rightclick_menu.add("Format file with Black", BlackFile)
    rightclick_menu.add("Format selection with Black", BlackSelection)

    # automatically adds `FileTab`
    command_pallete.add("Format file with Black", BlackFile)
    command_pallete.add("Format selection with Black", BlackSelection)

Now that I write all that out, I'm actually thinking it may make more sense to instead register to each consumer within the theoretical Action class ? The question is, how do you make a new consumer. Say Action looked something like this:

class Action:
    # short name for the action
    name: str
    # Tooltip text for the action
    tooltip: str | None = None
    # List of filetypes for which the action is active, if None then active for all
    active_filetypes: list[str] | None = None
    # Determines if the action is active when there is a selection
    active_if_selection: bool = False

    actions: dict[str, Type[Action]] = {}

    def __call__(self, *args, **kwargs) -> None:
        raise NotImplementedError

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.actions[cls.name] = cls

and then the consumer plugins add methods to Action class, which are used by the plugins that create Actions.

Then, if someone wants to make a new consumer, they just need to add some more methods to Action within the their module and they are good to go.

Am I crazy?

@benjamin-kirkbride
Copy link
Contributor

and then the consumer plugins add methods to Action class, which are used by the plugins that create Actions.

Okay so this part doesn't make sense after more thought, but I think the rest tracks.

I think ActionTemplate is a better name though.

@benjamin-kirkbride
Copy link
Contributor

benjamin-kirkbride commented Jul 6, 2023

To summarize/finalize what I think:

  • Action: a type of class that inherits from actions.ActionTemplate that can take a FileTab or a Path as the only input argument.
    • The ActionTemplate has a number of class attributes that are used to help determine if the action should be active (this should perhaps be a method and not a set of attributes), common metadata, etc
    • Actions are typically created by plugins.
    • Actions are added to a registry of actions, such that it is possible for new, custom plugins to hook into them (we need nomenclature for "official" plugins vs "custom user added plugins" - the action registry is intended for the latter)
  • "Action Consumer Plugin": a plugin which has objects that "consumes" Actions, typically for UI purposes. Things like the ToolBar, Main Menu, Right Click Menus, etc.
    • While Action Consumer Plugins are what ultimately use the actions, they are not where the actions get associated with the consumers; that happens in the plugins where the actions are created (typically). For instance, the "black file" action is added to the menu in the black plugin module, not in the menu plugin.

I'm happy to take a stab at a first pass of this, if you think my design is reasonable

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

I am talking about some literal code that exists (could be a conf file, but I'm a bit skeptical of that) for each "consumer" plugin.
I actually don't love this
...
While Action Consumer Plugins are what ultimately use the actions, they are not where the actions get associated with the consumers; that happens in the plugins where the actions are created (typically).

I think the "typically" is important. Your preferred way is sometimes good, and sometimes it isn't. For example, it works very well for the menubar: as you said, you don't want to modify the consumers, i.e. add something to a global "which actions should the menubar show" thing.

But sometimes this isn't what we want. Consider key bindings. There are only so many keys on the keyboard, so registering a keybinding with a third-party plugin doesn't make much sense: there's no good way to avoid conflicts. It is much better to have a global place that configures all keybindings (default_keybindings.tcl), and if a user wants a key binding for a third-party plugin, they can add it (keybindings.tcl).

conditional availability of actions

I suggest:

  • For most cases, predefined flags (e.g. "available for any path that points to a folder" or "available for any Python file")
  • For custom cases, a callback function that takes the inputs of the action and returns a boolean.

This is the design that menubar uses now. It works.

class BlackFile(Action):

I want it to be simpler and have less boilerplate. Something like this maybe?

from porcupine.actions import add_action

def black_files_on_disk(path: Path) -> None:
    ...

def black_opened_file(filetab: FileTab) -> None:
    ...

def setup() -> None:
    add_action(
        name="Black",
        description="Format Python code with black",  # for tooltips
        menu="Tools/Python",  # for the menubar
        filetype_names=["Python", "Python stub file"],
        path_callback=black_files_on_disk,
        path_callback_takes_folders=True,
        filetab_callback=black_opened_file,
    )

This action would be available:

  • for FileTabs where the filetype is "Python" or "Python stub file"
  • for files on disk with .py and .pyi suffix, or that contain a shebang that says python (we already have a way to detect the filetype of a file, though it's currently in a plugin)
  • for any folder.

The action registry could be something like list[Action], where Action is a simple data container class, with no fancy inheritance or the like. The add_action() could be basically an .append().

how do you make a new consumer

Something like from porcupine.actions import get_actions, which returns the internal list (or maybe a copy or an iterator since you're not supposed to modify it directly).

@Akuli Akuli changed the title Commands accessible from multiple places Actions accessible from multiple places Jul 6, 2023
@benjamin-kirkbride
Copy link
Contributor

registering a keybinding with a third-party plugin doesn't make much sense

I think it could make sense to register a default, but have a system to override any keybinding, basically replace default_keybindings.tcl with an "keybind registry" that actions can be registered to, and overridden from of course. This allows plugins to be created and distributed in a way that doesn't require users to manually update a config file just to get keybindings.

This perhaps should be it's own issue - "How to Handle Keybindings in the Action Economy"

  • For most cases, predefined flags (e.g. "available for any path that points to a folder" or "available for any Python file")
  • For custom cases, a callback function that takes the inputs of the action and returns a boolean.

I agree with this, more thought/discussion about how to implement the API is needed, but broadly "have some helpers for the common cases, and allow a callback for complicated ones" should cover everything :)

I want it to be simpler and have less boilerplate. Something like this maybe?

A few thoughts:

First of all, I think that you need to define actions individually for the actions that require a Path and ones that require a FileTab because I think we will want the ability to have different descriptions, names, etc for each of them. It also seems like a lot for one function to support, as the scope of this expands. Going off your add_action api, I would suggest something more like add_filetab_action, add_path_action (with takes_folders = False as a default kwarg), etc. I think it's worth pointing out my class examples have almost the exact same LoC that your functional one does if you take this into account 😅

Second, I think we want the ability to use a class to define an action, because I think we will want the ability for the actions to have state. Take for instance if we had a git rebase -i action, that opens a modal or perhaps another tab. A better example might be keeping track of what commands have been used recently in the "Command Palette" plugin, or which tests failed in a "pytest" plugin.

This obviously doesn't mean someone would be required to use classes to define actions, I think having helper functions for the majority of actions makes sense, but I think we would want them to be classes under the hood, not just a dict or something.

    menu="Tools/Python",  # for the menubar

I think we should avoid this, and instead register things to the menubar separately. That said, I think some categorization/tag metadata being added to the actions could make sense, and it could look exactly like the way the menubar is formatted now.

black = create_action(
    name="Black",
    description="Format Python code with black",  # for tooltips
    category="Tools/Python",  # action consumer agnostic
    callback=black_files_on_disk
)

def setup() -> None:
    menubar.add(black, path=None) # if path == None: use action.category as path

The action registry could be something like list[Action], where Action is a simple data container class, with no fancy inheritance or the like. The add_action() could be basically an .append().

If it's a list, how do you query it?

A reason I think that defining actions as top level, importable objects in the plugins where they are implemented is it lets you import actions from other plugins directly, rather than from the central actions module. AKA no need for from porcupine.actions import get_actions because you would instead do something like from porcupine.plugins.black import BlackFile or similar. This IMO is more obvious to do as a plugin dev, and also solves the problem of multiple plugins registering actions with the same (or similar) names. I'm not necessarily saying that there should not be a central action registry, just that it shouldn't be used generally by action consumers. Basically the only use it would have would potentially be some kind of internal help tool, and creating a custom action consumer plugin (as you noted). I don't feel super strongly about this point though.


Bringing it all together:

filetype_names=["Python", "Python stub file"]

# creates an object with a type that tells action consumers to inject a `Path` as the first arg to the callback
black_format_path = create_path_action(
    name="Black Format Path",
    description="Format File or Directory with black",  # for tooltips
    category="Tools/Python",  # action consumer agnostic
    supports_directories = True,
    filetype_names = filetype_names,
)

# creates an object with a type that tells action consumers to inject a `FileTab` as the first arg to the callback
black_format_tab = create_filetab_action(
    name="Black Format Tab",
    description="Format current tab with black",  # for tooltips
    category="Tools/Python",  # action consumer agnostic
    filetype_names = filetype_names,
)

def setup() -> None:
    menubar.add(command=black_format_path, menu_path=None) # if menu_path == None: use action.category as path
    menubar.add(command=black_format_tab, menu_path=None) # if menu_path == None: use action.category as path

    toolbar_buttons = []
    # uses `name` for button text and `description` for tooltip by default if nothing else provided
    toolbar_buttons.append(toolbar.Button(command=black_format_path, icon=None)
    toolbar_buttons.append(toolbar.Button(command=black_format_tab, icon=None)
    toolbar.add_button_group(button_group = toolbar_buttons, priority = 1)


    command_palette.add(command=black_format_tab)
    # no `black_format_path` because it wouldn't make sense

    keybindings.add_default(command=black_format_tab, keybinding="<<CTRL-SHIFT-S>>")# sorry I don't actually know how to do this but you get the point
    # no `black_format_path` because it wouldn't make sense

   tab_right_click_menu.add ...

    directory_tree.right_click_menu.add ...

Thoughts?

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

This allows plugins to be created and distributed in a way that doesn't require users to manually update a config file just to get keybindings.

This is not a problem we currently have, so we'll solve it later :)

First of all, I think that you need to define actions individually for the actions that require a Path and ones that require a FileTab because I think we will want the ability to have different descriptions, names, etc for each of them.

I initially wanted to do it with separate actions, then combine them together. Now I'm not sure. Let's try it first with separate actions since you prefer that :)

exact same LoC

Code complexity is not just about LoC. To be honest I like to avoid OOP trickery whenever it isn't helpful.

I think we will want the ability for the actions to have state.

Please no. There's already many ways to have state, and I don't want more. For example, a git rebase -i action could open a new custom tab, which is an instance of a custom class that has state. This is already possible. An action is only a launcher, not a stateful thing.

A better example might be keeping track of what commands have been used recently in the "Command Palette" plugin, or which tests failed in a "pytest" plugin.

Existing plugins already handle this in various ways. For example, the run plugin has a JSON history file, and the find plugin creates a stateful finder widget for each tab when invoking for the first time for that tab.

menu="Tools/Python", # for the menubar

I think we should avoid this, and instead register things to the menubar separately. That said, I think some categorization/tag metadata being added to the actions could make sense, and it could look exactly like the way the menubar is formatted now.

I think this is a detail that is easy to change later without redesigning everything. Maybe for now we could name the thing menu and take a slash-separated path?

from porcupine.plugins.black import BlackFile

Why would one plugin want to import the action of another plugin? It makes sense to import some stuff though, e.g. the directory tree plugin exposes a get_directory_tree() function, but why would you want to get a specific action object?

def setup() -> None:
....
command_palette.add(command=black_format_tab)
....

IMO the black plugin shouldn't know about the command palette. The command palette should be able to invoke anything, so adding everything to the command palette explicitly would be just unnecessary copy/paste.

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

After thinking about the menubar a bit more, I think your way is good:

  • We don't want all actions in the menubar, and the plugin making the action should decide whether it goes there or not. A function call directly to the menubar module does exactly that in a very obvious way.
  • Going through porcupine/actions.py would restrict what you can put into the menubar. Maybe you want to add something fancy that isn't just an action, e.g. the filetype chooser.

Some details that I don't really care about at this point, easy to decide/change later:

  • Whether the menubar module takes an action object or a name to query from the porcupine.actions module.
  • Is it porcupine/menubar.py or porcupine/plugins/menubar.py.

To make action objects accessible for the menubar, and for stuff in general, the action-adding functions in porcupine/actions.py could return the action they just added to the internal registry. This means that there's no documented/recommended way to create an action that is not in the registry, which is perfect for the command palette (it should be able to run any action --> just search the registry).

@benjamin-kirkbride
Copy link
Contributor

This is not a problem we currently have, so we'll solve it later :)

Sooner than later I think ;)

There's already many ways to have state, and I don't want more

Fair enough, I may be ignorant of the options available here so I'll defer to you on this.

I think this is a detail that is easy to change later without redesigning everything. Maybe for now we could name the thing menu and take a slash-separated path?

I'm not exactly sure what you mean by this. You are saying still have a menu arg in the various action creation/registration functions which the "mainmenu" plugin uses to insert things? What if we don't want an action to be in the main menu? leave it None?

Why would one plugin want to import the action of another plugin?

Creating new a new, custom "action consumer" plugin would be the thing I conceive of.

IMO the black plugin shouldn't know about the command palette. The command palette should be able to invoke anything, so adding everything to the command palette explicitly would be just unnecessary copy/paste.

Are you saying specifically the command palette shouldn't have to explicitly have commands registered, or are you saying that all "action consumers" should be able to automatically infer what actions are appropriate to include?


I think we are close to having a design we are both happy with :)

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

I agree that we're close. I like the discussion :)

I'm not exactly sure what you mean by this.

I added a new comment that is all about the menubar.

Why would one plugin want to import the action of another plugin?

Creating new a new, custom "action consumer" plugin would be the thing I conceive of.

...

Are you saying specifically the command palette shouldn't have to explicitly have commands registered, or are you saying that all "action consumers" should be able to automatically infer what actions are appropriate to include?

This should depend on the consumer. You add explicitly to the menubar, but get everything implicitly into the command palette.

It will be hard to add a consumer with explicit adding, since you need to update plugins to tell about their actions to that consumer. I think this is a good tradeoff for making the menubar work without adding all actions there.

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

To clarify that last point, I think a consumer shouldn't hard-code anything about specific actions. A toolbar isn't very useful if you don't let the user add a custom "Run" or "Paste" button to it, or any other action for that matter. So it doesn't make sense to import a specific action in a consumer, because that's not configurable.

There are better options:

  • Config file (key bindings, toolbar)
  • Explicit adding (menubar)
  • Implicit adding aka loop through all actions (command palette)

@benjamin-kirkbride
Copy link
Contributor

I think a consumer shouldn't hard-code anything about specific actions

I agree in general, but I want it to be possible to do this specifically so that 3rd party plugins are capable of implementing consumers.

A toolbar isn't very useful if you don't let the user add a custom "Run" or "Paste" button to it

In my mind, this is the perfect example of when someone should be directed to make a custom plugin for themselves. In the vein of #1325, IMO (feel free to disagree! it's not my project ofc) the audience of Porcupine is the kind of person who would be happy to make a 10 line plugin to add an "undo" or a "run" or a "paste remove indentation" button to their toolbar if that's something they wanted, as opposed to us having to work out a config file format to cover every conceivable way someone might want to customize their toolbar (same reason we don't want a config for the menubar IMO). Using a config file for "defaults" IMO is not a great design especially in the context of supporting 3rd party plugins.

There are better options:

I think it should be like this:

  • default values via explicit adding, configurable with config file: key bindings (can come later)
  • explicit adding: toolbar, menubar, right click menus
  • implicit adding: command palette

But I'm open to debate :)

@benjamin-kirkbride
Copy link
Contributor

I agree in general, but I want it to be possible to do this specifically so that 3rd party plugins are capable of implementing consumers.

To be clear, having a registry of actions satisfies this need, to my understanding, so I think we are covered here.

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

Indeed, you can search the registry for whatever you want. This would also make it easier to cover the "plugin not installed" "action not found" case: import wrapped in try/except doesn't quite work as intended, because it's possible to import from a disabled plugin.

Using a config file for "defaults" IMO is not a great design especially in the context of supporting 3rd party plugins.

There are two big reasons why Porcupine uses defaults config files:

  • Default config file serves as an example of how to write a custom config file. As you noted, a config file is useless if users don't know what to put there.
  • Default config file ensures that bugs don't go unnoticed. Most config file bugs apply to both config files, and if the default config file doesn't work, it will get noticed when testing, even if you have no custom config.

And yes, the downside is that the user has to add third-party things to their config file explicitly. That's why the menubar doesn't use a config file.

I agree that if it is more difficult to learn how a config file works than how to make a custom plugin, then the config file is dumb. But it doesn't have to be that way. Default config files help as examples to copy/paste from, but also, I make sure to choose the best possible file formats. That's why Porcupine uses so many different formats: .tcl, .yaml, .toml and .json. (At a first glance .tcl seems terrible, but it turned out to be concise and guessable as it is mostly event add "<<Menubar:Run/Kill process>>" <Shift-F4> and similar lines.)

In my mind, this is the perfect example of when someone should be directed to make a custom plugin for themselves.

So if I understand this correctly, the user would be encouraged to make a add_paste_to_toolbar.py plugin. It would grab an action from one existing plugin and shove it into another existing plugin. This doesn't exactly feel like good design to me.

I'm not sure whether the toolbar should use a config file. The disadvantage is that the syntax can become too complicated if you want to e.g. add all actions from a specific plugin or hide them when the filetype doesn't match. I think we should decide toolbar details later, in a separate issue.

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

explicit adding: ... right click menus

I was actually hoping the right-click menus would become implicit. They currently use explicit adding, and they are quite empty. I think it would be nice to automagically fill them with useful actions.

But again, we can work on this later. The actions.py we're designing will make it possible to go in either direction :)

@benjamin-kirkbride
Copy link
Contributor

Default config file serves as an example of how to write a custom config file. As you noted, a config file is useless if users don't know what to put there.

You can achieve this with a example user config with commented out examples, which is a much more common pattern.

Default config file ensures that bugs don't go unnoticed. Most config file bugs apply to both config files, and if the default config file doesn't work, it will get noticed when testing, even if you have no custom config.

Not sure I understand this point. If we only had one config file, for overrides, we would just write some tests for it and wouldn't need custom logic for this?

I think we should decide toolbar details later, in a separate issue.

Sure. I think the remaining points of contention perhaps should all be relegated to other issues; to my understanding we seem to have agreed on how to implement actions, and the remaining contention is around how the consumers should be integrated into that system.

Perhaps it's time for a prototype of actions.py?

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

Let's go for it :)

IMO the initial PR should add:

  • actions.py: the two action classes, the registry and the related functions
  • A few producers (i.e. add an action in a few plugins)
  • One consumer

Ideas for what the first consumer could be:

  • Menubar: explicit adding, so a function menubar.add_action_to_menu(action=black_action, menu="Tools/Python") or similar
  • Command palette: some sort of super simple/dumb implementation that is not anywhere near user-friendly enough, but is to be improved later
  • Key bindings: keep existing config file structure, but change a few binds to action names instead of pointing to menubar

Probably not toolbar, because we have many undecided toolbar details.

@Akuli
Copy link
Owner Author

Akuli commented Jul 6, 2023

a example user config with commented out examples

This is a common pattern, but causes problems when upgrading. If you add a new config option, your existing users likely won't use it, because their file full of comments does not have a comment for the new option.

I have had this problem with ssh configs on debian-based systems that I maintain remotely, for example. But it would be much more painful with Porcupine, because I tend to change the configs quite aggressively when I change them: lots of added and deleted and renamed options. That's why Porcupine's user config files only contain a comment that links to the default config on the main branch. It basically never gets old.

This is the main reason. The other reason is not really as important, but I'll try to explain it too.

just write some tests for it and wouldn't need custom logic for this?

The tl;dr is that "custom logic" is usually super simple whereas "just write some tests" is hard/complicated. Let's take keybindings.tcl / default_keybindings.tcl as an example.

def setup() -> None:
    default_path = Path(__file__).absolute().parent.parent / "default_keybindings.tcl"
    user_path = dirs.user_config_path / "keybindings.tcl"
    menubar.add_config_file_button(user_path)

    try:
        with user_path.open("x") as file:
            file.write(
                """\
# This Tcl file is executed when Porcupine starts. It's meant to be used for
# custom key bindings. See Porcupine's default key binding file for examples:
#
#    https://github.com/Akuli/porcupine/blob/main/porcupine/default_keybindings.tcl
"""
            )
    except FileExistsError:
        pass

    try:
        get_main_window().tk.call("source", default_path)
        get_main_window().tk.call("source", user_path)
    except tkinter.TclError:
        # more verbose error message than default, including file names and line numbers
        raise tkinter.TclError(get_main_window().getvar("errorInfo")) from None

Things to note:

  • The default and user configs are loaded in very much the same way: if one is broken, then they are both broken.
  • There are some tests that fail if default_keybindings.tcl is not loaded. For example, test_no_previous_command_error() hard-codes "Shift+F5" which comes from default_keybindings.tcl.
  • I use almost all key bindings in default_keybindings.tcl weekly. This means that I know they work as intended.

So, there's no need to unit-test keybindings.tcl specifically after I have confirmed manually that it works. On the other hand, with a file full of comments I would need to unit test setting every key binding to a custom thing separately, because I never customize most of them.

I'm not saying that unit tests are useless or fully unnecessary though. There are tests for some tricky cases that I don't run into often, at least test_backspace_in_beginning_of_file() which is really a weird case that took me years to run into.

@benjamin-kirkbride
Copy link
Contributor

Ideas for what the first consumer could be:

I think menubar, because it 1) exists today and 2) we are in complete alignment (I believe) about how it should be implemented

I'll try to explain it too
I think I understand your perspective, but I'm not sure I'm convinced. That's okay though, I'm sure we will hash it out in time as this gets more fleshed out :)


I'm going to create a high level issue to track the progress of this whole "epic", and sub-issues to it for the specific tasks. I think we should close this issue as it has served it's purpose to kick this off IMO

This was referenced Jul 6, 2023
@Akuli Akuli closed this as completed Jul 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants