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

Feat: Actions #1349

Merged
merged 46 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
0dfdece
we out here
benjamin-kirkbride Jul 7, 2023
77214b1
I spent literally 3 hours trying to figure this out
benjamin-kirkbride Jul 7, 2023
d9b209d
Trying to make CI happy: delete kw_only=True (new in python 3.10)
Akuli Jul 7, 2023
2d80aba
slots=True too
Akuli Jul 7, 2023
e22cbef
Fix typing syntax problems
Akuli Jul 7, 2023
63d7a98
fix callback type
benjamin-kirkbride Jul 7, 2023
5557eb7
Merge branch 'main' into actions_init
benjamin-kirkbride Jul 7, 2023
9c330d3
incorporating feedback
benjamin-kirkbride Jul 7, 2023
499e20c
finishing touches
benjamin-kirkbride Jul 7, 2023
1173674
Run pycln, black and isort
benjamin-kirkbride Jul 7, 2023
543cdb5
Update porcupine/actions.py
benjamin-kirkbride Jul 7, 2023
0ffe9cf
extremely useful test
benjamin-kirkbride Jul 7, 2023
824ec0c
moderate improvement to useful test
benjamin-kirkbride Jul 7, 2023
253fdca
remove availability_callback types
benjamin-kirkbride Jul 7, 2023
199ab5f
Run pycln, black and isort
Akuli Jul 7, 2023
489d382
Merge remote-tracking branch 'origin/main' into HEAD
Akuli Jul 7, 2023
6c55c80
Delete duplicated ruff config
Akuli Jul 7, 2023
db47f32
convert python_tools to actions
benjamin-kirkbride Jul 7, 2023
6cd954a
moving the ruff around
Akuli Jul 7, 2023
0e77816
optionally get labels from action.name
benjamin-kirkbride Jul 7, 2023
4f867b1
make update_enabledness always provide a true tab
benjamin-kirkbride Jul 7, 2023
f213ed3
last nit
benjamin-kirkbride Jul 8, 2023
23f3ec9
disable on non-filetab tab
benjamin-kirkbride Jul 8, 2023
123b0dc
rework non-filetab checking
benjamin-kirkbride Jul 8, 2023
6c7ca14
Uncurse porcupine/menubar.py
Akuli Jul 8, 2023
9319b6b
Run pycln, black and isort
Akuli Jul 8, 2023
63afbdd
s/evaluates to/returns
benjamin-kirkbride Jul 10, 2023
72ee7dd
Update porcupine/plugins/python_tools.py
benjamin-kirkbride Jul 10, 2023
1edd389
Update porcupine/plugins/python_tools.py
benjamin-kirkbride Jul 10, 2023
0946690
move action definitions to setup
benjamin-kirkbride Jul 10, 2023
82532ab
fix annotation
benjamin-kirkbride Jul 10, 2023
157a1f3
add `filetype_is` availability helper
benjamin-kirkbride Jul 11, 2023
0b5f0a9
fix #1363
benjamin-kirkbride Jul 11, 2023
2bd83f4
Merge branch 'main' into actions_init
benjamin-kirkbride Jul 12, 2023
8128316
menubar has no idea about filetypes
benjamin-kirkbride Jul 12, 2023
35bd251
Merge branch 'main' into actions_init
benjamin-kirkbride Jul 15, 2023
2efc3ee
Update porcupine/menubar.py
benjamin-kirkbride Jul 17, 2023
809b900
Update porcupine/actions.py
benjamin-kirkbride Jul 17, 2023
bdbbc10
Merge branch 'main' into actions_init
benjamin-kirkbride Jul 17, 2023
3f528fc
Update porcupine/actions.py
benjamin-kirkbride Jul 17, 2023
034a972
Update actions.py
rdbende Jul 17, 2023
815b56d
Merge branch 'main' into actions_init
rdbende Jul 18, 2023
959787f
Merge branch 'main' into actions_init
benjamin-kirkbride Aug 1, 2023
6867446
remove filetab_only switch
benjamin-kirkbride Aug 1, 2023
d861f09
Rename function
rdbende Aug 1, 2023
3813ccd
There are test too. Heck yeah XD
rdbende Aug 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions porcupine/actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import Callable, Union

from porcupine.tabs import FileTab


@dataclass(frozen=True)
class BareAction:
"""Action that requires no context in the callback"""

name: str
description: str
callback: Callable[[], None]
availability_callback: Callable[[], bool]


@dataclass(frozen=True)
class FileTabAction:
"""Action that requires a FileTab to be provided to the callback"""

name: str
description: str
callback: Callable[[FileTab], None]
availability_callback: Callable[[FileTab], bool]


@dataclass(frozen=True)
class PathAction:
"""Action that requires a Path to be provided to the callback"""

name: str
description: str
callback: Callable[[Path], None]
availability_callback: Callable[[Path], bool]


Action = Union[BareAction, FileTabAction, PathAction]

_actions: dict[str, Action] = {}


def register_bare_action(
*,
name: str,
description: str,
callback: Callable[..., None],
availability_callback: Callable[[], bool] = lambda: True,
) -> BareAction:
if name in _actions:
raise ValueError(f"Action with the name {name!r} already exists")
action = BareAction(
name=name,
description=description,
callback=callback,
availability_callback=availability_callback,
)
_actions[name] = action
return action


def register_filetab_action(
*,
name: str,
description: str,
callback: Callable[[FileTab], None],
availability_callback: Callable[[FileTab], bool] = lambda tab: True,
) -> FileTabAction:
if name in _actions:
raise ValueError(f"Action with the name {name!r} already exists")
action = FileTabAction(
name=name,
description=description,
callback=callback,
availability_callback=availability_callback,
)
_actions[name] = action
return action


def register_path_action(
benjamin-kirkbride marked this conversation as resolved.
Show resolved Hide resolved
*,
name: str,
description: str,
callback: Callable[[Path], None],
availability_callback: Callable[[Path], bool] = lambda path: True,
) -> PathAction:
if name in _actions:
raise ValueError(f"Action with the name {name!r} already exists")
action = PathAction(
name=name,
description=description,
callback=callback,
availability_callback=availability_callback,
)
_actions[name] = action
return action


def get_action(name: str) -> Action | None:
return _actions.get(name)


def get_all_actions() -> dict[str, Action]:
return _actions.copy()


# Availability Helpers


def filetype_is(filetypes: str | list[str]) -> Callable[[FileTab], bool]:
def _filetype_is(filetypes: list[str], tab: FileTab) -> bool:
try:
filetype = tab.settings.get("filetype_name", object)
except KeyError:
# don't ask me why a `get` method can raise a KeyError :p
return False

return filetype in filetypes

if isinstance(filetypes, str):
filetypes = [filetypes]

return partial(_filetype_is, filetypes)
64 changes: 55 additions & 9 deletions porcupine/menubar.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from tkinter import filedialog
from typing import Any, Callable, Iterator, List, Literal

from porcupine import pluginmanager, settings, tabs, utils
from porcupine import actions, pluginmanager, settings, tabs, utils
from porcupine._state import get_main_window, get_tab_manager, quit
from porcupine.settings import global_settings

Expand Down Expand Up @@ -241,9 +241,21 @@ def update_keyboard_shortcuts() -> None:
_update_shortcuts_for_opening_submenus()


def set_enabled_based_on_tab(
path: str, callback: Callable[[tabs.Tab | None], bool]
) -> Callable[..., None]:
_menu_item_enabledness_callbacks: list[Callable[..., None]] = []
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not super happy with this design, but I just want this merged.



def _refresh_menu_item_enabledness(*junk: object) -> None:
for callback in _menu_item_enabledness_callbacks:
callback(*junk)


# TODO: create type for events
def register_enabledness_check_event(event: str) -> None:
"""Register an event which will cause all menu items to check if they are available"""
get_tab_manager().bind(event, _refresh_menu_item_enabledness, add=True)


def set_enabled_based_on_tab(path: str, callback: Callable[[tabs.Tab], bool]) -> None:
"""Use this for disabling menu items depending on the currently selected tab.

When the selected :class:`~porcupine.tabs.Tab` changes, ``callback`` will
Expand Down Expand Up @@ -275,18 +287,22 @@ def setup():
easier.
"""

def update_enabledness(*junk: object) -> None:
def update_enabledness(*junk: object, path: str) -> None:
tab = get_tab_manager().select()

parent, child = _split_parent(path)
menu = get_menu(parent)
index = _find_item(menu, child)
if index is None:
raise LookupError(f"menu item {path!r} not found")
menu.entryconfig(index, state=("normal" if callback(tab) else "disabled"))
if tab is not None and callback(tab):
menu.entryconfig(index, state="normal")
else:
menu.entryconfig(index, state="disabled")

update_enabledness(path=path)

update_enabledness()
get_tab_manager().bind("<<NotebookTabChanged>>", update_enabledness, add=True)
return update_enabledness
_menu_item_enabledness_callbacks.append(partial(update_enabledness, path=path))


def get_filetab() -> tabs.FileTab:
Expand Down Expand Up @@ -323,8 +339,38 @@ def setup() -> None:
set_enabled_based_on_tab(path, (lambda tab: isinstance(tab, tabs.FileTab)))


def add_filetab_action(path: str, action: actions.FileTabAction, **kwargs: Any) -> None:
"""
This is a convenience function that does several things:

* Create a menu item at the given path with action.name as label
* Ensure the menu item is enabled only when the selected tab is a
:class:`~porcupine.tabs.FileTab` AND when
:class:`~porcupine.actions.FileTabAction.availability_callback`
returns True.
* Run :class:`~porcupine.actions.FileTabAction.callback` when the
menu item is clicked.

The ``callback`` is called with the selected tab as the only
argument when the menu item is clicked.

You usually don't need to provide any keyword arguments in ``**kwargs``,
but if you do, they are passed to :meth:`tkinter.Menu.add_command`.
"""

get_menu(path).add_command(
label=action.name, command=lambda: action.callback(get_filetab()), **kwargs
)
set_enabled_based_on_tab(
path,
benjamin-kirkbride marked this conversation as resolved.
Show resolved Hide resolved
benjamin-kirkbride marked this conversation as resolved.
Show resolved Hide resolved
callback=lambda tab: isinstance(tab, tabs.FileTab) and action.availability_callback(tab),
)


# TODO: pluginify?
def _fill_menus_with_default_stuff() -> None:
register_enabledness_check_event("<<NotebookTabChanged>>")

# Make sure to get the order of menus right:
# File, Edit, <everything else>, Help
get_menu("Help") # handled specially in get_menu
Expand Down
4 changes: 4 additions & 0 deletions porcupine/plugins/filetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ def apply_filetype_to_tab(filetype: FileType, tab: tabs.FileTab) -> None:
if name not in {"filename_patterns", "shebang_regex"}:
tab.settings.set(name, value, from_config=True, tag="from_filetype")

get_tab_manager().event_generate("<<TabFiletypeApplied>>")


def on_path_changed(tab: tabs.FileTab, junk: object = None) -> None:
log.info(f"file path changed: {tab.path}")
Expand Down Expand Up @@ -250,6 +252,8 @@ def _add_filetype_menuitem(name: str, tk_var: tkinter.StringVar) -> None:


def setup() -> None:
menubar.register_enabledness_check_event("<<TabFiletypeApplied>>")

global_settings.add_option("default_filetype", "Python")

# load_filetypes() got already called in setup_argument_parser()
Expand Down
20 changes: 17 additions & 3 deletions porcupine/plugins/python_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pathlib import Path
from tkinter import messagebox

from porcupine import menubar, tabs, textutils, utils
from porcupine import actions, menubar, tabs, textutils, utils
from porcupine.plugins import python_venv

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -63,5 +63,19 @@ def format_code_in_textwidget(tool: str, tab: tabs.FileTab) -> None:


def setup() -> None:
menubar.add_filetab_command("Tools/Python/Black", partial(format_code_in_textwidget, "black"))
menubar.add_filetab_command("Tools/Python/Isort", partial(format_code_in_textwidget, "isort"))
black_format_tab_action = actions.register_filetab_action(
name="Black Format Tab",
description="Autoformat open tab using Black",
callback=partial(format_code_in_textwidget, "black"),
availability_callback=actions.filetype_is("Python"),
)

isort_format_tab_action = actions.register_filetab_action(
name="isort Format Tab",
description="Sort Imports of open tab with isort",
callback=partial(format_code_in_textwidget, "isort"),
availability_callback=actions.filetype_is("Python"),
)

menubar.add_filetab_action("Tools/Python", black_format_tab_action)
menubar.add_filetab_action("Tools/Python", isort_format_tab_action)
29 changes: 29 additions & 0 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from porcupine import actions


def test_action_registry():
bare_action = actions.register_bare_action(
name="bare action", description="", callback=lambda: None
)
filetab_action = actions.register_filetab_action(
name="filetab action", description="", callback=lambda tab: None
)
path_action = actions.register_path_action(
name="path action", description="", callback=lambda path: None
)

assert isinstance(bare_action, actions.BareAction)
assert isinstance(filetab_action, actions.FileTabAction)
assert isinstance(path_action, actions.PathAction)

all_actions = actions.get_all_actions()
for action in [bare_action, filetab_action, path_action]:
assert actions.get_action(action.name) is action
assert action in all_actions.values()

assert actions.get_action("nonexistent action") is None

all_actions["garbage"] = "mean lean fighting machine" # type: ignore
assert (
actions.get_action("garbage") is None
), "`all_actions` should be a copy, changes to it should not effect `_actions`"
47 changes: 46 additions & 1 deletion tests/test_menubar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from porcupine import get_main_window, menubar, tabs
from porcupine import actions, get_main_window, menubar, tabs


def test_virtual_events_calling_menu_callbacks():
Expand Down Expand Up @@ -89,3 +89,48 @@ def test_alt_f4_bug_without_filetab(mocker):
mock_quit = mocker.patch("porcupine.menubar.quit")
get_main_window().event_generate("<Alt-F4>")
mock_quit.assert_called_once_with()


def test_add_filetab_action(filetab, tmp_path):
def _callback(tab):
filetab.save_as(tmp_path / "asdf.md")
tab.update()

# TODO: https://github.com/Akuli/porcupine/issues/1364
assert filetab.settings.get("filetype_name", object) == "Python"

# create action
action = actions.register_filetab_action(
name="python",
description="test python action",
callback=_callback,
availability_callback=actions.filetype_is("Python"),
)

path = "testy_test/python"

# check that no item exists at path
menu_item = menubar._find_item(
menubar.get_menu(menubar._split_parent(path)[0]), menubar._split_parent(path)[1]
)
assert menu_item is None

# register action to path
menubar.add_filetab_action(path=path, action=action)

# check path item exists
menu = menubar.get_menu(menubar._split_parent(path)[0])
menu_item = menubar._find_item(menu, menubar._split_parent(path)[1])
assert menu_item is not None

# check path item available
assert menu.entrycget(index=menu_item, option="state") == "normal"

# activate item
action.callback(filetab)

# verify something happened
assert filetab.settings.get("filetype_name", object) == "Markdown"

# check unavailable (because Markdown != Python)
assert menu.entrycget(index=menu_item, option="state") == "disabled"
Loading