diff --git a/.github/workflows/test_validate_plugins.py b/.github/workflows/test_validate_plugins.py index 1af4a78..cb27248 100644 --- a/.github/workflows/test_validate_plugins.py +++ b/.github/workflows/test_validate_plugins.py @@ -307,5 +307,52 @@ Configure the update interval in plugin settings. self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings))) +class WidgetActionsTests(unittest.TestCase): + def validate_actions(self, entry: dict, plugin_api: object = 14) -> list[str]: + validator = validate_plugins.Validator(Path("/repo")) + validator.validate_widget_fields( + Path("/repo/example/plugin.toml"), + "widget[0]", + entry, + plugin_api, + ) + return validator.errors + + def test_accepts_every_gesture(self) -> None: + actions = {gesture: "volume-mute" for gesture in validate_plugins.WIDGET_GESTURES} + self.assertEqual(self.validate_actions({"actions": actions}), []) + + def test_accepts_exec_and_none(self) -> None: + self.assertEqual( + self.validate_actions({"actions": {"middle": "exec playerctl pause", "right": "none"}}), + [], + ) + + def test_entry_without_actions_is_fine(self) -> None: + self.assertEqual(self.validate_actions({"id": "bar"}), []) + + def test_rejects_unknown_gesture(self) -> None: + self.assertNotEqual(self.validate_actions({"actions": {"ctrl+left": "volume-mute"}}), []) + + def test_rejects_non_table(self) -> None: + self.assertNotEqual(self.validate_actions({"actions": "volume-mute"}), []) + + def test_rejects_non_string_action(self) -> None: + self.assertNotEqual(self.validate_actions({"actions": {"middle": 42}}), []) + + def test_rejects_empty_action(self) -> None: + self.assertNotEqual(self.validate_actions({"actions": {"middle": ""}}), []) + + def test_rejects_bare_exec(self) -> None: + self.assertNotEqual(self.validate_actions({"actions": {"middle": "exec"}}), []) + + def test_requires_plugin_api_14(self) -> None: + errors = self.validate_actions({"actions": {"middle": "volume-mute"}}, plugin_api=13) + self.assertTrue(any("plugin_api >= 14" in error for error in errors)) + + def test_widget_entry_accepts_actions_field(self) -> None: + self.assertIn("actions", validate_plugins.ENTRY_FIELDS["widget"]) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py index f1f46f4..a8bef75 100644 --- a/.github/workflows/validate-plugins.py +++ b/.github/workflows/validate-plugins.py @@ -112,6 +112,17 @@ SETTING_OWNER_TYPES = ("widget", "panel", "desktop_widget", "launcher_provider") SETTING_TYPES = {"string", "string_list", "bool", "glyph", "select", "folder", "file", "int", "color"} PANEL_PLACEMENTS = {"attached", "floating"} PANEL_KEYBOARD_FOCUS = {"on_demand", "exclusive", "none"} +WIDGET_GESTURES = { + "left", + "right", + "middle", + "back", + "forward", + "scroll_up", + "scroll_down", + "scroll_left", + "scroll_right", +} PANEL_POSITIONS = { "auto", "center", @@ -132,7 +143,7 @@ ROOT_FIELDS = set(ROOT_STRING_FIELDS) | set(ROOT_ARRAY_FIELDS) | set(ENTRY_TYPES } BASE_ENTRY_FIELDS = {"id", "entry"} ENTRY_FIELDS = { - "widget": BASE_ENTRY_FIELDS | {"setting"}, + "widget": BASE_ENTRY_FIELDS | {"setting", "actions"}, "panel": BASE_ENTRY_FIELDS | { "setting", @@ -778,6 +789,46 @@ class Validator: manifest_path, context, "capture_keys must be an array of key chord strings" ) + def validate_widget_fields( + self, + manifest_path: Path, + context: str, + entry: dict[str, Any], + plugin_api: Any, + ) -> None: + if "actions" not in entry: + return + + if not is_int(plugin_api) or plugin_api < 14: + self.add_context_error(manifest_path, context, "actions requires plugin_api >= 14") + return + + actions = entry["actions"] + if not isinstance(actions, dict): + self.add_context_error( + manifest_path, context, "actions must be a table of gesture bindings" + ) + return + + for gesture, action in actions.items(): + if gesture not in WIDGET_GESTURES: + valid = ", ".join(sorted(WIDGET_GESTURES)) + self.add_context_error( + manifest_path, context, f"actions.{gesture} is not a gesture (expected one of: {valid})" + ) + continue + if not is_non_empty_string(action): + self.add_context_error( + manifest_path, context, f"actions.{gesture} must be a non-empty string" + ) + continue + # The shell resolves the verb against its live IPC registry, which is not available + # here, so only the grammar is checked: " [args]", "exec ", or "none". + if action.split()[0] == "exec" and len(action.split(maxsplit=1)) < 2: + self.add_context_error( + manifest_path, context, f"actions.{gesture}: exec needs a command line" + ) + def validate_entries( self, manifest_path: Path, @@ -825,6 +876,9 @@ class Validator: if entry_type == "panel": self.validate_panel_fields(manifest_path, context, entry, manifest.get("plugin_api")) + if entry_type == "widget": + self.validate_widget_fields(manifest_path, context, entry, manifest.get("plugin_api")) + if entry_type in SETTING_OWNER_TYPES and "setting" in entry: self.validate_settings( manifest_path,