diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97b3f381a..4b2d276c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,8 @@ I have tried to make contributing easy: Instead of working on an issue, you can also create something that you would like to have in an editor. - You don't need to read anything before you can get started. - I recommend having a look at [the Porcupine plugin API docs](https://akuli.github.io/porcupine/), + I recommend having a look at the `dev-doc` folder, + especially [dev-doc/architecture-and-design.md](dev-doc/architecture-and-design.md), but that's not required. - Don't worry too much about whether your code is good or not. I will review the pull requests and try to help you out. diff --git a/dev-doc/architecture-and-design.md b/dev-doc/architecture-and-design.md new file mode 100644 index 000000000..3328cd61b --- /dev/null +++ b/dev-doc/architecture-and-design.md @@ -0,0 +1,114 @@ +# Porcupine's Architecture and Design + +**TL;DR:** +- core of Porcupine is small, and most of the functionality is in plugins +- plugins are loaded dynamically +- each plugin does a specific thing, and can be enabled/disabled individually by the user +- plugins can depend on each other when needed, and they pass messages to each other through virtual events. + + +## Small core, powerful plugins + +Porcupine's architecture is super simple. There is: +- A **very minimal core** that provides only very basic functionality and can barely edit a text file. +- Lots of **plugins** that use the core to implement various things. + +The core means everything except the plugins. + +Plugins are just files in the `porcupine/plugins/` directory. +When Porcupine starts, it looks inside that directory and dynamically imports each file. +It then calls a function named `setup()` in each plugin. +(It is also possible to place plugins into one other directory, but most people should never need this feature.) + +Basically, if something can reasonably be a plugin, it most likely is a plugin in Porcupine. +To get an idea of just how strongly Porcupine relies on plugins, +you can run it with all plugins disabled: + +``` +(env)$ python -m porcupine --no-plugins +``` + +It will look something like this: + +![Screenshot of porcupine without plugins 1](images/no-plugins-1.png) + +![Screenshot of porcupine without plugins 2](images/no-plugins-2.png) + + +## Pros and cons + +The plugin-heavy design has many advantages: +- You don't usually need to read a lot of code to understand how something works. + Instead, you study just one plugin, and at the time of writing this documentation, + the average length of a plugin is only **167 lines**! +- Finding the buggy code is easy in Porcupine. + If feature X doesn't work, it is most likely caused by `porcupine/plugins/X.py`. +- Most plugins are somewhat self-contained, so it's easy to understand how they work. +- Users can use the plugin manager to disable features they don't like, + even if Porcupine developers didn't add something specifically to disable a feature. + +And some disadvantages: +- The loading order (that is, order of calling `setup()` functions) matters. +- Some plugins need to communicate with each other, and it can get complicated. + +Basically, because so much functionality is implemented as plugins, +the plugins depend on each other in various ways, and it can complicate things. +It also just doesn't feel right to many people, and I understand that. + +That said, the plugin-heavy design works very well in practice. +This plugin system has made Porcupine much more successful than it would have been otherwise. +Even though there are lots of features, Porcupine is still very maintainable, +because it's easy to ignore a lot of code you don't care about. + + +## Loading Order + +By default, Porcupine invokes the `setup()` functions +in alphabetical order when running normally, and in random order when running tests. + +To control the order, plugins can define global variables named `setup_before` and `setup_after`. +Let's say you have `porcupine/plugins/foo.py` and `porcupine/plugins/bar.py`. +By default, the order will be alphabetical: + +1. `bar.setup()` +2. `foo.setup()` + +But if `foo.py` sets `setup_before = ["bar"]`, or if `bar.py` sets `setup_after = ["foo"]`, the order will be: + +1. `foo.setup()` +2. `bar.setup()` + +For example, the `filemanager` plugin adds things like "New file here" and "New directory here" +to the right-click menu you get from the directory tree on the left. +To do this, it accesses the directory tree in its `setup()`, so the `filemanager` plugin contains this line: + +``` +setup_after = ["directory_tree"] +``` + + +## Communicating Between Plugins + +Ideally, all plugins would be self-contained. +Each plugin would do its own thing, and the plugins wouldn't know anything about each other. +In practice, this isn't quite true. +For example, the `langserver` plugin needs to interact with many parts of Porcupine, +including several other plugins. + +There are basically two ways how plugins can communicate with each other: +1. Importing a `get_foo()` function +2. Virtual events + +A good example of importing a `get_foo()` function is the directory tree on the left. +The `directory_tree` plugin defines a function `get_directory_tree()` that returns the directory tree widget. +(It is an instance of a subclass of `tkinter.ttk.Treeview`.) +Other plugins (such as `filemanager` and `git_status`) then add more functionality to the directory tree. +To access it, they import the `get_directory_tree()` function from the `directory_tree` plugin and call it from `setup()`. + +Virtual events are Porcupine's (and tkinter's) way to do callbacks. +If plugin A generates a virtual event on a widget, +and plugin B binds to that virtual event (in its `setup()`, for example), +then a callback function specified in plugin B will run whenever plugin A triggers the event. +This is useful, because plugin A can run code in plugin B without knowing anything about plugin B. +For more info about virtual events, see [virtual-events.md](virtual-events.md). + diff --git a/dev-doc/images/no-plugins-1.png b/dev-doc/images/no-plugins-1.png new file mode 100644 index 000000000..36ec7951c Binary files /dev/null and b/dev-doc/images/no-plugins-1.png differ diff --git a/dev-doc/images/no-plugins-2.png b/dev-doc/images/no-plugins-2.png new file mode 100644 index 000000000..a04bf0a40 Binary files /dev/null and b/dev-doc/images/no-plugins-2.png differ diff --git a/dev-doc/virtual-events.md b/dev-doc/virtual-events.md new file mode 100644 index 000000000..e4c07bb4c --- /dev/null +++ b/dev-doc/virtual-events.md @@ -0,0 +1,144 @@ +# Virtual Events + +If you are tempted to make a list of callback functions, expose it to another part of Porcupine, +and then call each function in the list, you should probably use a virtual event instead. +Here's how they work: + +```python +def print_hi(event): + print("hi") + +some_widget.bind("<>", print_hi, add=True) +some_widget.event_generate("<>") # this prints hi +``` + +You can name virtual events anything you want as long as you use the double `<<` and `>>`. +There are some predefined virtual events, but you likely won't clash with them by accident. + +Virtual events are specific to a widget. +Nothing will happen if you `.bind()` and `.event_generate()` on different widgets. +This is useful for things that only affect one tab, for example. + +Make sure to spell virtual events the same way in all places. +**You will get no errors if you misspell the name of a virtual event.** +Your callbacks just won't run. + + +## Multiple Callbacks + +(Actually, this section applies to all tkinter events, not just virtual events.) + +With `add=True`, you can bind multiple things to the same virtual event. +This is almost always what you should do in Porcupine. +For whatever reason, the default is `add=False`, which deletes all existing bindings. + +```python +def print_hi(event): + print("hi") + +def print_hello(event): + print("hello") + +some_widget.bind("<>", print_hi, add=True) +some_widget.bind("<>", print_hello, add=True) +some_widget.event_generate("<>") # prints hi and hello +``` + +You can return the string `"break"` from a callback function to prevent running other +callbacks that have been added afterwards. + +```python +def print_hi(event): + print("hi") + return "break" # <--- This stops processing the event + +def print_hello(event): + print("hello") + +some_widget.bind("<>", print_hi, add=True) +some_widget.bind("<>", print_hello, add=True) +some_widget.event_generate("<>") # only prints hi +``` + +This is typically used together with [setup_before and setup_after](archotecture-and-design.md#loading-order) +to decide which plugin gets to handle a virtual event. + + +## Passing Data + +It is possible to pass data when generating a virtual event +and receive (a copy of) the data when the `.bind()` callback runs. +See the docstring of the `porcupine.utils.bind_with_data()` function for details. +You need to use a function from `porcupine.utils` because +Tkinter doesn't expose this functionality very nicely, +but it is needed often because Porcupine relies quite heavily on virtual events. + + +## Simple Example: `<>` + +**Source Code:** +- [porcupine/tabs.py](../porcupine/tabs.py) (search for `<>`) +- [porcupine/plugins/mergeconflict.py](../porcupine/plugins/mergeconflict.py) + +A couple things need to happen when a file's content is read from disk and placed into a text widget. +For example, if the file contains [Git merge conflicts](https://akuli.github.io/git-guide/branches.html#merges-and-merge-conflicts), +the `mergeconflict` plugin will notice them and show a nice UI for resolving them. + +To implement this, we need some way to run code in a plugin when the core is done reloading a file's content. +We obviously cannot import from `porcupine.plugins.mergeconflict` in the core, +so we need some sort of callbacks, +and because this is Porcupine, we use virtual events for the callbacks. + +Specifically, the `mergeconflict` plugin binds to `<>` on each new tab. +Here's how it works: +1. Porcupine's core reads a file's content from disk and places into a text widget. +2. Porcupine's core generates a `<>` event. +3. The `mergeconflict` plugin has done a `.bind("<>", ...)`, + so a function in the `mergeconflict` plugin runs. +4. The `mergeconflict` plugin finds all merge conflicts in the file and does its thing. + + +## Complex Example: jump to definition + +**Source Code:** +- [porcupine/plugins/jump_to_definition.py](../porcupine/plugins/jump_to_definition.py) +- [porcupine/plugins/langserver.py](../porcupine/plugins/langserver.py) (search for `jump_to_def`) +- [porcupine/plugins/urls.py](../porcupine/plugins/urls.py) + +Let's say you have a line of Python code that looks like `self.do_something()`. +If you bring the cursor to the middle of `do_something()` and press Ctrl+Enter, +or if you control-click `do_something`, +Porcupine will take you to a line of code that looks something like `def do_something(self):`. + +This is implemented in two different plugins: +- The `langserver` plugin is responsible for finding the `def do_something(self):` line from your project. +- The `jump_to_definition` plugin does everything else. + +The `jump_to_definition` plugin doesn't know anything about the langserver plugin. +All it knows is that the user requests a jump-to-definition, +and eventually it gets a response telling it where to go. +This is great, because langserver isn't necessarily the only way to find out where something is defined. +For example, if you have `https://example.com/` in your code and you Ctrl+Enter on it, +that is *also* a jump-to-definition as far as Porcupine is concerned, +although the "definition" is a website in this case and it opens in a browser window. + +More specifically, here's what happens when you press Ctrl+Enter on `self.do_something()`: + +1. The `jump_to_definition` sees the Ctrl+Enter. It generates a `<>` virtual event. +2. The `urls` plugin has done a `.bind("<>", ...)`, but it ignores the event. +2. The `langserver` plugin has done a `.bind("<>", ...)`, + so a function in the `langserver` plugin runs. +3. The `langserver` plugin figures out where the `def do_something(self):` line is. + This will take a while. Porcupine doesn't freeze when this happens. +4. The `langserver` plugin generates a `<>` virtual event. +5. The `jump_to_definition` plugin has done a `.bind("<>")`, + so it receives the response from the `langserver` plugin. +6. The `jump_to_definition` plugin takes you to the `def do_something(self):` line. + +And here's what happens when you press Ctrl+Enter on a URL: + +1. The `jump_to_definition` sees the Ctrl+Enter. It generates a `<>` virtual event. +2. The `urls` plugin has done a `.bind("<>", ...)`, + so a function in the `urls` plugin runs. +3. The `urls` plugin opens the URL in the default web browser. +4. The `urls` plugin returns `"break"` to prevent the `langserver` plugin from handling the event.