Skip to content

Commit

Permalink
Feat: Actions (#1349)
Browse files Browse the repository at this point in the history
Co-authored-by: Akuli <[email protected]>
Co-authored-by: rdbende <[email protected]>
  • Loading branch information
3 people authored Aug 1, 2023
1 parent 9b32234 commit 9ff4be0
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 13 deletions.
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(
*,
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]] = []


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,
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"

0 comments on commit 9ff4be0

Please sign in to comment.