From 9ff4be00597ec2be3cc7a53f1a383d1f36df46e5 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Tue, 1 Aug 2023 09:32:01 -0400 Subject: [PATCH] Feat: Actions (#1349) Co-authored-by: Akuli Co-authored-by: rdbende --- porcupine/actions.py | 127 ++++++++++++++++++++++++++++++ porcupine/menubar.py | 64 ++++++++++++--- porcupine/plugins/filetypes.py | 4 + porcupine/plugins/python_tools.py | 20 ++++- tests/test_actions.py | 29 +++++++ tests/test_menubar.py | 47 ++++++++++- 6 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 porcupine/actions.py create mode 100644 tests/test_actions.py diff --git a/porcupine/actions.py b/porcupine/actions.py new file mode 100644 index 000000000..a6bdddc77 --- /dev/null +++ b/porcupine/actions.py @@ -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) diff --git a/porcupine/menubar.py b/porcupine/menubar.py index dad810ab2..7f0cee30b 100644 --- a/porcupine/menubar.py +++ b/porcupine/menubar.py @@ -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 @@ -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 @@ -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("<>", update_enabledness, add=True) - return update_enabledness + _menu_item_enabledness_callbacks.append(partial(update_enabledness, path=path)) def get_filetab() -> tabs.FileTab: @@ -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("<>") + # Make sure to get the order of menus right: # File, Edit, , Help get_menu("Help") # handled specially in get_menu diff --git a/porcupine/plugins/filetypes.py b/porcupine/plugins/filetypes.py index d86edba2c..55c5a6320 100644 --- a/porcupine/plugins/filetypes.py +++ b/porcupine/plugins/filetypes.py @@ -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("<>") + def on_path_changed(tab: tabs.FileTab, junk: object = None) -> None: log.info(f"file path changed: {tab.path}") @@ -250,6 +252,8 @@ def _add_filetype_menuitem(name: str, tk_var: tkinter.StringVar) -> None: def setup() -> None: + menubar.register_enabledness_check_event("<>") + global_settings.add_option("default_filetype", "Python") # load_filetypes() got already called in setup_argument_parser() diff --git a/porcupine/plugins/python_tools.py b/porcupine/plugins/python_tools.py index f07589e28..ecc9fa28e 100644 --- a/porcupine/plugins/python_tools.py +++ b/porcupine/plugins/python_tools.py @@ -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__) @@ -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) diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 000000000..9ffe04d7c --- /dev/null +++ b/tests/test_actions.py @@ -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`" diff --git a/tests/test_menubar.py b/tests/test_menubar.py index 0886c3a1f..9b548572a 100644 --- a/tests/test_menubar.py +++ b/tests/test_menubar.py @@ -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(): @@ -89,3 +89,48 @@ def test_alt_f4_bug_without_filetab(mocker): mock_quit = mocker.patch("porcupine.menubar.quit") get_main_window().event_generate("") 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"