Skip to content

Commit

Permalink
Add dev-doc folder and document some stuff there
Browse files Browse the repository at this point in the history
  • Loading branch information
Akuli committed Mar 20, 2024
1 parent a328899 commit 7423ef6
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 1 deletion.
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
114 changes: 114 additions & 0 deletions dev-doc/architecture-and-design.md
Original file line number Diff line number Diff line change
@@ -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](no-plugins-1.png)

![Screenshot of porcupine without plugins 2](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).

Binary file added dev-doc/no-plugins-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dev-doc/no-plugins-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
144 changes: 144 additions & 0 deletions dev-doc/virtual-events.md
Original file line number Diff line number Diff line change
@@ -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("<<PrintHi>>", print_hi, add=True)
some_widget.event_generate("<<PrintHi>>") # 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("<<PrintHi>>", print_hi, add=True)
some_widget.bind("<<PrintHi>>", print_hello, add=True)
some_widget.event_generate("<<PrintHi>>") # 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("<<PrintHi>>", print_hi, add=True)
some_widget.bind("<<PrintHi>>", print_hello, add=True)
some_widget.event_generate("<<PrintHi>>") # 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: `<<Reloaded>>`

**Source Code:**
- [porcupine/tabs.py](../porcupine/tabs.py) (search for `<<Reloaded>>`)
- [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 `<<Reloaded>>` 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 `<<Reloaded>>` event.
3. The `mergeconflict` plugin has done a `.bind("<<Reloaded>>", ...)`,
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 `<<JumpToDefinitionRequest>>` virtual event.
2. The `urls` plugin has done a `.bind("<<JumpToDefinitionRequest>>", ...)`, but it ignores the event.
2. The `langserver` plugin has done a `.bind("<<JumpToDefinitionRequest>>", ...)`,
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 `<<JumpToDefinitionResponse>>` virtual event.
5. The `jump_to_definition` plugin has done a `.bind("<<JumpToDefinitionResponse>>")`,
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 `<<JumpToDefinitionRequest>>` virtual event.
2. The `urls` plugin has done a `.bind("<<JumpToDefinitionRequest>>", ...)`,
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.

0 comments on commit 7423ef6

Please sign in to comment.