[gnome-builder] libide-foundry: add shortcut engine



commit b7aac9445f28f03ef0dd11ab09ad4beaba96405a
Author: Christian Hergert <chergert redhat com>
Date:   Mon Jul 11 21:41:18 2022 -0700

    libide-foundry: add shortcut engine
    
    This adds a new shortcut engine that can be extended by plugins using
    keybindings.json or by providing an IdeShortcutProvider for more control
    over how shortcuts are activated and when. See examples in this commit
    for the format of keybindings.json and how it differs from other editors.

 src/libide/gui/gtk/keybindings.gsl            |  94 +++++
 src/libide/gui/gtk/keybindings.json           |  25 ++
 src/libide/gui/ide-shortcut-bundle-private.h  |  39 ++
 src/libide/gui/ide-shortcut-bundle.c          | 501 ++++++++++++++++++++++++++
 src/libide/gui/ide-shortcut-manager-private.h |  37 ++
 src/libide/gui/ide-shortcut-manager.c         | 397 ++++++++++++++++++++
 src/libide/gui/ide-shortcut-provider.c        |  68 ++++
 src/libide/gui/ide-shortcut-provider.h        |  46 +++
 src/libide/gui/libide-gui.gresource.xml       |   2 +
 src/libide/gui/meson.build                    |   6 +
 10 files changed, 1215 insertions(+)
---
diff --git a/src/libide/gui/gtk/keybindings.gsl b/src/libide/gui/gtk/keybindings.gsl
new file mode 100644
index 000000000..32e6fbe30
--- /dev/null
+++ b/src/libide/gui/gtk/keybindings.gsl
@@ -0,0 +1,94 @@
+require Ide
+
+require Adw       version "1"
+require GLib      version "2.0"
+require Gdk       version "4.0"
+require Gio       version "2.0"
+require Gsk       version "4.0"
+require Gtk       version "4.0"
+require GtkSource version "5"
+require Json      version "1.0"
+require Jsonrpc   version "1.0"
+require Panel     version "1"
+require Template  version "1.0"
+require Vte       version "3.91"
+
+def inEditor()
+  (focus != null) && typeof(focus).is_a(typeof(Ide.SourceView))
+end
+
+def inTerminal()
+  (focus != null) && typeof(focus).is_a(typeof(Ide.Terminal))
+end
+
+def inGrid()
+  (focus != null) && (focus.get_ancestor(typeof(Ide.Grid)) != null)
+end
+
+def inPage()
+  (focus != null) && (focus.get_ancestor(typeof(Ide.Page)) != null)
+end
+
+def hasProject()
+  workbench.has_project()
+end
+
+def canBuild()
+  hasProject() && Ide.BuildManager.from_context(workbench.context).get_can_build()
+end
+
+def canEdit()
+  (workspace != null) && (typeof(workspace) != typeof(Ide.GreeterWorkspace))
+end
+
+def canSearch()
+  (workspace != null) && (typeof(workspace) != typeof(Ide.GreeterWorkspace))
+end
+
+def inPopover()
+  (focus != null) && (focus.get_ancestor(typeof(Gtk.Popover)) != null)
+end
+
+def inPopoverSearch()
+  (focus != null) && (focus.get_ancestor(typeof(Ide.SearchPopover)) != null)
+end
+
+def inPageSearch()
+  ((page != null) && \
+   (focus != null) && \
+   focus.is_ancestor(page) && \
+   (focus.get_ancestor(typeof(Gtk.Revealer)) != null) && \
+   !page.is_ancestor(focus.get_ancestor(typeof(Gtk.Revealer))))
+end
+
+def isBuilding()
+  workbench.has_project() && Ide.BuildManager.from_context(workbench.context).get_busy()
+end
+
+def isRunning()
+  workbench.has_project() && Ide.RunManager.from_context(workbench.context).get_busy()
+end
+
+def isDebugging()
+  workbench.has_project() && Ide.DebugManager.from_context(workbench.context).get_active()
+end
+
+def inEditorWithLanguage(language_id)
+  inEditor() && (page.get_buffer().get_language_id() == language_id)
+end
+
+def hasSelection()
+  inEditor() && page.get_buffer().get_has_selection()
+end
+
+def inPageWithTypeName(name)
+  (page != null) && (typeof(page).name() == name) && (focus != null) && focus.is_ancestor(page)
+end
+
+def hasErrors()
+  workbench.has_project() && (Ide.BuildManager.from_context(workbench.context).get_error_count() > 0)
+end
+
+def hasWarnings()
+  workbench.has_project() && (Ide.BuildManager.from_context(workbench.context).get_warning_count() > 0)
+end
diff --git a/src/libide/gui/gtk/keybindings.json b/src/libide/gui/gtk/keybindings.json
new file mode 100644
index 000000000..7b87d715a
--- /dev/null
+++ b/src/libide/gui/gtk/keybindings.json
@@ -0,0 +1,25 @@
+/* Global Search */
+{ "trigger" : "<Control>Return", "action" : "workbench.global-search", "when" : "canSearch()", "phase" : 
"capture" },
+{ "trigger" : "Escape", "action" : "search.hide", "when" : "inPopoverSearch() || inPageSearch()", "phase" : 
"capture" },
+
+/* New Files */
+{ "trigger" : "<Control>n", "action" : "editorui.new-file", "when" : "canEdit()", "phase" : "bubble" },
+
+/* Open Files */
+{ "trigger" : "<Control>o", "action" : "workbench.open", "when" : "canEdit()", "phase" : "bubble" },
+
+/* Switching pages in grid */
+{ "trigger" : "<Alt>1", "action" : "frame.page", "args" : "1", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>2", "action" : "frame.page", "args" : "2", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>3", "action" : "frame.page", "args" : "3", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>4", "action" : "frame.page", "args" : "4", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>5", "action" : "frame.page", "args" : "5", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>6", "action" : "frame.page", "args" : "6", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>7", "action" : "frame.page", "args" : "7", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>8", "action" : "frame.page", "args" : "8", "when" : "inGrid()", "phase" : "capture" },
+{ "trigger" : "<Alt>9", "action" : "frame.page", "args" : "9", "when" : "inGrid()", "phase" : "capture" },
+
+/* Workspace Actions */
+{ "trigger" : "<Control>comma", "action" : "app.preferences", "phase" : "capture" },
+{ "trigger" : "<Alt>comma", "action" : "workbench.configure", "phase" : "capture" },
+{ "trigger" : "<Control>w", "action" : "frame.close-page-or-frame", "when" : "inPage()", "phase" : "bubble" 
},
diff --git a/src/libide/gui/ide-shortcut-bundle-private.h b/src/libide/gui/ide-shortcut-bundle-private.h
new file mode 100644
index 000000000..1f8ec5183
--- /dev/null
+++ b/src/libide/gui/ide-shortcut-bundle-private.h
@@ -0,0 +1,39 @@
+/* ide-shortcut-bundle.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_SHORTCUT_BUNDLE (ide_shortcut_bundle_get_type())
+
+G_DECLARE_FINAL_TYPE (IdeShortcutBundle, ide_shortcut_bundle, IDE, SHORTCUT_BUNDLE, GObject)
+
+IdeShortcutBundle *ide_shortcut_bundle_new   (void);
+gboolean           ide_shortcut_bundle_parse (IdeShortcutBundle  *self,
+                                              GFile              *file,
+                                              GError            **error);
+
+#define ide_shortcut_is_phase(obj,pha) \
+  (g_object_get_data(G_OBJECT(obj), "PHASE") == pha)
+
+G_END_DECLS
diff --git a/src/libide/gui/ide-shortcut-bundle.c b/src/libide/gui/ide-shortcut-bundle.c
new file mode 100644
index 000000000..88c4b8b97
--- /dev/null
+++ b/src/libide/gui/ide-shortcut-bundle.c
@@ -0,0 +1,501 @@
+/* ide-shortcut-bundle.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-shortcut-bundle"
+
+#include "config.h"
+
+#include <gtk/gtk.h>
+#include <json-glib/json-glib.h>
+#include <tmpl-glib.h>
+
+#include "ide-gui-global.h"
+#include "ide-gui-resources.h"
+#include "ide-shortcut-bundle-private.h"
+#include "ide-workbench.h"
+#include "ide-workspace.h"
+
+struct _IdeShortcutBundle
+{
+  GObject    parent_instance;
+  GPtrArray *items;
+};
+
+typedef struct
+{
+  TmplExpr            *when;
+  GVariant            *args;
+  GtkShortcutAction   *action;
+  GtkPropagationPhase  phase;
+} IdeShortcut;
+
+static TmplScope *imports_scope;
+
+static IdeShortcut *
+ide_shortcut_new (const char          *action,
+                  GVariant            *args,
+                  TmplExpr            *when,
+                  GtkPropagationPhase  phase)
+{
+  IdeShortcut *ret;
+
+  g_assert (action != NULL);
+  g_assert (phase == GTK_PHASE_CAPTURE || phase == GTK_PHASE_BUBBLE);
+
+  ret = g_slice_new0 (IdeShortcut);
+  ret->action = gtk_named_action_new (action);
+  ret->args = args ? g_variant_ref (args) : NULL;
+  ret->when = when ? tmpl_expr_ref (when) : NULL;
+  ret->phase = phase;
+
+  return ret;
+}
+
+static void
+ide_shortcut_free (IdeShortcut *shortcut)
+{
+  g_clear_pointer (&shortcut->when, tmpl_expr_unref);
+  g_clear_pointer (&shortcut->args, g_variant_unref);
+  g_clear_object (&shortcut->action);
+  shortcut->phase = 0;
+  g_slice_free (IdeShortcut, shortcut);
+}
+
+static void
+set_object (TmplScope  *scope,
+            const char *name,
+            GType       type,
+            gpointer    object)
+{
+  if (object != NULL)
+    tmpl_scope_set_object (scope, name, object);
+  else
+    tmpl_scope_set_null (scope, name);
+}
+
+static gboolean
+ide_shortcut_activate (GtkWidget *widget,
+                       GVariant  *args,
+                       gpointer   user_data)
+{
+  IdeShortcut *shortcut = user_data;
+  GtkWidget *focus = NULL;
+
+  g_assert (GTK_IS_WIDGET (widget));
+  g_assert (shortcut != NULL);
+
+  if (shortcut->when != NULL)
+    {
+      g_autoptr(TmplScope) scope = tmpl_scope_new_with_parent (imports_scope);
+      g_autoptr(GError) error = NULL;
+      g_auto(GValue) enabled = G_VALUE_INIT;
+
+      IdeWorkspace *workspace = ide_widget_get_workspace (widget);
+      IdeWorkbench *workbench = ide_widget_get_workbench (widget);
+      IdePage *page = workspace ? ide_workspace_get_most_recent_page (workspace) : NULL;
+
+      if (GTK_IS_ROOT (widget))
+        focus = gtk_root_get_focus (GTK_ROOT (widget));
+
+      if (focus == NULL)
+        focus = widget;
+
+      set_object (scope, "focus", GTK_TYPE_WIDGET, focus);
+      set_object (scope, "workbench", IDE_TYPE_WORKBENCH, workbench);
+      set_object (scope, "workspace", IDE_TYPE_WORKSPACE, workspace);
+      set_object (scope, "page", IDE_TYPE_PAGE, page);
+
+      if (!tmpl_expr_eval (shortcut->when, scope, &enabled, &error))
+        {
+          g_warning ("Failure to eval \"when\": %s", error->message);
+          return FALSE;
+        }
+
+      if (!G_VALUE_HOLDS_BOOLEAN (&enabled))
+        {
+          GValue as_bool = G_VALUE_INIT;
+
+          g_value_init (&as_bool, G_TYPE_BOOLEAN);
+          if (!g_value_transform (&enabled, &as_bool))
+            return FALSE;
+
+          g_value_unset (&enabled);
+          enabled = as_bool;
+        }
+
+      g_assert (G_VALUE_HOLDS_BOOLEAN (&enabled));
+
+      if (!g_value_get_boolean (&enabled))
+        return FALSE;
+    }
+
+  return gtk_shortcut_action_activate (shortcut->action,
+                                       GTK_SHORTCUT_ACTION_EXCLUSIVE,
+                                       focus ? focus : widget,
+                                       shortcut->args);
+}
+
+static guint
+ide_shortcut_bundle_get_n_items (GListModel *model)
+{
+  IdeShortcutBundle *self = IDE_SHORTCUT_BUNDLE (model);
+
+  return self->items ? self->items->len : 0;
+}
+
+static gpointer
+ide_shortcut_bundle_get_item (GListModel *model,
+                              guint       position)
+{
+  IdeShortcutBundle *self = IDE_SHORTCUT_BUNDLE (model);
+
+  if (self->items == NULL || position >= self->items->len)
+    return NULL;
+
+  return g_object_ref (g_ptr_array_index (self->items, position));
+}
+
+static GType
+ide_shortcut_bundle_get_item_type (GListModel *model)
+{
+  return GTK_TYPE_SHORTCUT;
+}
+
+static void
+list_model_iface_init (GListModelInterface *iface)
+{
+  iface->get_n_items = ide_shortcut_bundle_get_n_items;
+  iface->get_item = ide_shortcut_bundle_get_item;
+  iface->get_item_type = ide_shortcut_bundle_get_item_type;
+}
+
+G_DEFINE_FINAL_TYPE_WITH_CODE (IdeShortcutBundle, ide_shortcut_bundle, G_TYPE_OBJECT,
+                               G_IMPLEMENT_INTERFACE (G_TYPE_LIST_MODEL, list_model_iface_init))
+
+static void
+ide_shortcut_bundle_dispose (GObject *object)
+{
+  IdeShortcutBundle *self = (IdeShortcutBundle *)object;
+
+  g_clear_pointer (&self->items, g_ptr_array_unref);
+
+  G_OBJECT_CLASS (ide_shortcut_bundle_parent_class)->dispose (object);
+}
+
+static void
+ide_shortcut_bundle_class_init (IdeShortcutBundleClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_shortcut_bundle_dispose;
+
+  g_resources_register (ide_gui_get_resource ());
+}
+
+static void
+ide_shortcut_bundle_init (IdeShortcutBundle *self)
+{
+  if (g_once_init_enter (&imports_scope))
+    {
+      g_autoptr(GBytes) bytes = g_resources_lookup_data ("/org/gnome/libide-gui/gtk/keybindings.gsl", 0, 
NULL);
+      const char *str = (const char *)g_bytes_get_data (bytes, NULL);
+      g_autoptr(TmplExpr) expr = NULL;
+      g_autoptr(GError) error = NULL;
+      g_auto(GValue) return_value = G_VALUE_INIT;
+      TmplScope *scope = tmpl_scope_new ();
+
+      if (!(expr = tmpl_expr_from_string (str, &error)))
+        g_critical ("Failed to parse keybindings.gsl: %s", error->message);
+      else if (!tmpl_expr_eval (expr, scope, &return_value, &error))
+        g_critical ("Failed to eval keybindings.gsl: %s", error->message);
+
+      g_once_init_leave (&imports_scope, scope);
+    }
+
+  self->items = g_ptr_array_new_with_free_func (g_object_unref);
+}
+
+IdeShortcutBundle *
+ide_shortcut_bundle_new (void)
+{
+  return g_object_new (IDE_TYPE_SHORTCUT_BUNDLE, NULL);
+}
+
+static gboolean
+get_string_member (JsonObject  *obj,
+                   const char  *name,
+                   const char **value,
+                   GError     **error)
+{
+  JsonNode *node;
+  const char *str;
+
+  *value = NULL;
+
+  if (!json_object_has_member (obj, name))
+    return TRUE;
+
+  node = json_object_get_member (obj, name);
+
+  if (!JSON_NODE_HOLDS_VALUE (node))
+    {
+      g_set_error (error,
+                   G_IO_ERROR,
+                   G_IO_ERROR_INVALID_DATA,
+                   "Key \"%s\" contains something other than a string",
+                   name);
+      return FALSE;
+    }
+
+  str = json_node_get_string (node);
+
+  if (str != NULL && strlen (str) > 1024)
+    {
+      g_set_error (error,
+                   G_IO_ERROR,
+                   G_IO_ERROR_INVALID_DATA,
+                   "Implausible string found, bailing. Length %"G_GSIZE_FORMAT,
+                   strlen (str));
+      return FALSE;
+    }
+
+  *value = g_intern_string (str);
+
+  return TRUE;
+}
+
+static gboolean
+parse_phase (const char          *str,
+             GtkPropagationPhase *phase)
+{
+  if (str == NULL || str[0] == 0 || strcasecmp ("capture", str) == 0)
+    {
+      *phase = GTK_PHASE_CAPTURE;
+      return TRUE;
+    }
+  else if (strcasecmp ("bubble", str) == 0)
+    {
+      *phase = GTK_PHASE_BUBBLE;
+      return TRUE;
+    }
+
+  return FALSE;
+}
+
+static gboolean
+populate_from_object (IdeShortcutBundle  *self,
+                      JsonNode           *node,
+                      GError            **error)
+{
+  g_autoptr(GtkShortcutTrigger) trigger = NULL;
+  g_autoptr(GtkShortcutAction) callback = NULL;
+  g_autoptr(GtkShortcut) shortcut = NULL;
+  g_autoptr(TmplExpr) when = NULL;
+  g_autoptr(GVariant) args = NULL;
+  const char *trigger_str = NULL;
+  const char *when_str = NULL;
+  const char *args_str = NULL;
+  const char *phase_str = NULL;
+  const char *command = NULL;
+  const char *action = NULL;
+  GtkPropagationPhase phase = 0;
+  JsonObject *obj;
+
+  g_assert (IDE_IS_SHORTCUT_BUNDLE (self));
+  g_assert (node != NULL);
+  g_assert (JSON_NODE_HOLDS_OBJECT (node));
+
+  obj = json_node_get_object (node);
+
+  /* TODO: We might want to add title/description so that our internal
+   *       keybindings can be displayed to the user from global search
+   *       with more than just a command name and/or arguments.
+   */
+
+  if (!get_string_member (obj, "trigger", &trigger_str, error) ||
+      !get_string_member (obj, "when", &when_str, error) ||
+      !get_string_member (obj, "args", &args_str, error) ||
+      !get_string_member (obj, "command", &command, error) ||
+      !get_string_member (obj, "action", &action, error) ||
+      !get_string_member (obj, "phase", &phase_str, error))
+    return FALSE;
+
+  if (!(trigger = gtk_shortcut_trigger_parse_string (trigger_str)))
+    {
+      g_set_error (error,
+                   G_IO_ERROR,
+                   G_IO_ERROR_INVALID_DATA,
+                   "Failed to parse shortcut trigger: \"%s\"",
+                   trigger_str);
+      return FALSE;
+    }
+
+  if (!ide_str_empty0 (command) && !ide_str_empty0 (action))
+    {
+      g_set_error (error,
+                   G_IO_ERROR,
+                   G_IO_ERROR_INVALID_DATA,
+                   "Cannot specify both \"command\" and \"action\" (\"%s\" and \"%s\")",
+                   command, action);
+      return FALSE;
+    }
+
+  if (!ide_str_empty0 (args_str))
+    {
+      /* Parse args from string into GVariant if any */
+      if (!(args = g_variant_parse (NULL, args_str, NULL, NULL, error)))
+        return FALSE;
+    }
+
+  if (!ide_str_empty0 (command))
+    {
+      GVariantBuilder builder;
+
+      g_variant_builder_init (&builder, G_VARIANT_TYPE ("(smv)"));
+      g_variant_builder_add (&builder, "s", command);
+      g_variant_builder_open (&builder, G_VARIANT_TYPE ("mv"));
+      if (args != NULL)
+        g_variant_builder_add_value (&builder, args);
+      g_variant_builder_close (&builder);
+
+      g_clear_pointer (&args, g_variant_unref);
+      args = g_variant_builder_end (&builder);
+      action = "workbench.command";
+    }
+
+  if (!ide_str_empty0 (when_str))
+    {
+      if (!(when = tmpl_expr_from_string (when_str, error)))
+        return FALSE;
+    }
+
+  if (!parse_phase (phase_str, &phase))
+    {
+      g_set_error (error,
+                   G_IO_ERROR,
+                   G_IO_ERROR_INVALID_DATA,
+                   "Unknown phase \"%s\"",
+                   phase_str);
+      return FALSE;
+    }
+
+  callback = gtk_callback_action_new (ide_shortcut_activate,
+                                      ide_shortcut_new (action, args, when, phase),
+                                      (GDestroyNotify) ide_shortcut_free);
+  shortcut = gtk_shortcut_new (g_steal_pointer (&trigger),
+                               g_steal_pointer (&callback));
+  g_object_set_data (G_OBJECT (shortcut), "PHASE", GINT_TO_POINTER (phase));
+  g_ptr_array_add (self->items, g_steal_pointer (&shortcut));
+
+  return TRUE;
+}
+
+static gboolean
+populate_from_array (IdeShortcutBundle  *self,
+                     JsonNode           *node,
+                     GError            **error)
+{
+  JsonArray *ar;
+  guint n_items;
+
+  g_assert (IDE_IS_SHORTCUT_BUNDLE (self));
+  g_assert (node != NULL);
+  g_assert (JSON_NODE_HOLDS_ARRAY (node));
+
+  ar = json_node_get_array (node);
+  n_items = json_array_get_length (ar);
+
+  for (guint i = 0; i < n_items; i++)
+    {
+      JsonNode *element = json_array_get_element (ar, i);
+
+      if (JSON_NODE_HOLDS_ARRAY (element))
+        {
+          if (!populate_from_array (self, element, error))
+            return FALSE;
+        }
+      else if (JSON_NODE_HOLDS_OBJECT (element))
+        {
+          if (!populate_from_object (self, element, error))
+            return FALSE;
+        }
+      else
+        {
+          g_set_error (error,
+                       G_IO_ERROR,
+                       G_IO_ERROR_INVALID_DATA,
+                       "Somthing other than an object found within array");
+          return FALSE;
+        }
+    }
+
+  return TRUE;
+}
+
+gboolean
+ide_shortcut_bundle_parse (IdeShortcutBundle  *self,
+                           GFile              *file,
+                           GError            **error)
+{
+  g_autoptr(JsonParser) parser = NULL;
+  g_autofree char *data = NULL;
+  g_autofree char *expanded = NULL;
+  JsonNode *root;
+  gsize len = 0;
+
+  g_return_val_if_fail (IDE_IS_SHORTCUT_BUNDLE (self), FALSE);
+  g_return_val_if_fail (G_IS_FILE (file), FALSE);
+
+  /* @data is always \0 terminated by g_file_load_contents() */
+  if (!g_file_load_contents (file, NULL, &data, &len, NULL, error))
+    return FALSE;
+
+  /* We sort of want to look like keybindings.json style, which could
+   * mean some munging for trailing , and missing [].
+   */
+  g_strstrip (data);
+  len = strlen (data);
+  if (len > 0 && data[len-1] == ',')
+    data[len-1] = 0;
+  expanded = g_strdup_printf ("[%s]", data);
+
+  parser = json_parser_new ();
+  if (!json_parser_load_from_data (parser, expanded, -1, error))
+    return FALSE;
+
+  /* Nothing to do if the contents are empty */
+  if (!(root = json_parser_get_root (parser)))
+    return TRUE;
+
+  /* In case we get arrays containing arrays, try to handle them gracefully
+   * and unscrew this terribly defined file format by VSCode.
+   */
+  if (JSON_NODE_HOLDS_ARRAY (root))
+    return populate_from_array (self, root, error);
+  else if (JSON_NODE_HOLDS_OBJECT (root))
+    return populate_from_object (self, root, error);
+
+  g_set_error (error,
+               G_IO_ERROR,
+               G_IO_ERROR_INVALID_DATA,
+               "Got something other than an array or object");
+
+  return FALSE;
+}
diff --git a/src/libide/gui/ide-shortcut-manager-private.h b/src/libide/gui/ide-shortcut-manager-private.h
new file mode 100644
index 000000000..d85ac5852
--- /dev/null
+++ b/src/libide/gui/ide-shortcut-manager-private.h
@@ -0,0 +1,37 @@
+/* ide-shortcut-manager-private.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include <libpeas/peas.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_SHORTCUT_MANAGER (ide_shortcut_manager_get_type())
+
+G_DECLARE_FINAL_TYPE (IdeShortcutManager, ide_shortcut_manager, IDE, SHORTCUT_MANAGER, IdeObject)
+
+IdeShortcutManager *ide_shortcut_manager_from_context     (IdeContext *context);
+void                ide_shortcut_manager_add_resources    (const char *resource_path);
+void                ide_shortcut_manager_remove_resources (const char *resource_path);
+
+G_END_DECLS
diff --git a/src/libide/gui/ide-shortcut-manager.c b/src/libide/gui/ide-shortcut-manager.c
new file mode 100644
index 000000000..7a98f462f
--- /dev/null
+++ b/src/libide/gui/ide-shortcut-manager.c
@@ -0,0 +1,397 @@
+/* ide-shortcut-manager.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-shortcut-manager"
+
+#include "config.h"
+
+#include <gtk/gtk.h>
+
+#include <libide-plugins.h>
+
+#include "ide-shortcut-bundle-private.h"
+#include "ide-shortcut-manager-private.h"
+#include "ide-shortcut-provider.h"
+
+struct _IdeShortcutManager
+{
+  IdeObject   parent_instance;
+
+  /* Holds [plugin_models,internal_models] so that plugin models take
+   * priority over the others.
+   */
+  GListStore *toplevel;
+
+  /* Holds bundles loaded from plugins, more recently loaded plugins
+   * towards the head of the list.
+   *
+   * Plugins loaded dynamically could change ordering here, which might
+   * be something we want to address someday. In practice, it doesn't
+   * happen very often and people restart applications often.
+   */
+  GListStore *plugin_models;
+
+  /* A flattened list model we proxy through our interface */
+  GtkFlattenListModel *flatten;
+
+  /* Extension set of IdeShortcutProvider */
+  IdeExtensionSetAdapter *providers;
+  GListStore *providers_models;
+};
+
+static GType
+ide_shortcut_manager_get_item_type (GListModel *model)
+{
+  return GTK_TYPE_SHORTCUT;
+}
+
+static guint
+ide_shortcut_manager_get_n_items (GListModel *model)
+{
+  IdeShortcutManager *self = IDE_SHORTCUT_MANAGER (model);
+
+  if (self->flatten)
+    return g_list_model_get_n_items (G_LIST_MODEL (self->flatten));
+
+  return 0;
+}
+
+static gpointer
+ide_shortcut_manager_get_item (GListModel *model,
+                               guint       position)
+{
+  IdeShortcutManager *self = IDE_SHORTCUT_MANAGER (model);
+  GtkShortcut *ret = NULL;
+
+  if (self->flatten)
+    ret = g_list_model_get_item (G_LIST_MODEL (self->flatten), position);
+
+  return ret;
+}
+
+static void
+list_model_iface_init (GListModelInterface *iface)
+{
+  iface->get_item_type = ide_shortcut_manager_get_item_type;
+  iface->get_n_items = ide_shortcut_manager_get_n_items;
+  iface->get_item = ide_shortcut_manager_get_item;
+}
+
+G_DEFINE_FINAL_TYPE_WITH_CODE (IdeShortcutManager, ide_shortcut_manager, IDE_TYPE_OBJECT,
+                               G_IMPLEMENT_INTERFACE (G_TYPE_LIST_MODEL, list_model_iface_init))
+
+static GListStore *plugin_models;
+
+static GListModel *
+get_internal_shortcuts (void)
+{
+  static GtkFlattenListModel *flatten;
+
+  if (flatten == NULL)
+    {
+      static const char *names[] = { "libide-gui", };
+      GListStore *internal_models;
+
+      internal_models = g_list_store_new (G_TYPE_LIST_MODEL);
+
+      for (guint i = 0; i < G_N_ELEMENTS (names); i++)
+        {
+          g_autoptr(IdeShortcutBundle) bundle = ide_shortcut_bundle_new ();
+          g_autofree char *uri = g_strdup_printf ("resource:///org/gnome/%s/gtk/keybindings.json", names[i]);
+          g_autoptr(GFile) file = g_file_new_for_uri (uri);
+          g_autoptr(GError) error = NULL;
+
+          if (!g_file_query_exists (file, NULL))
+            continue;
+
+          if (!ide_shortcut_bundle_parse (bundle, file, &error))
+            g_critical ("Failed to parse %s: %s", uri, error->message);
+          else
+            g_list_store_append (internal_models, bundle);
+        }
+
+      flatten = gtk_flatten_list_model_new (G_LIST_MODEL (internal_models));
+    }
+
+  return G_LIST_MODEL (flatten);
+}
+
+static void
+ide_shortcut_manager_items_changed_cb (IdeShortcutManager *self,
+                                       guint               position,
+                                       guint               removed,
+                                       guint               added,
+                                       GListModel         *model)
+{
+  g_assert (IDE_IS_SHORTCUT_MANAGER (self));
+  g_assert (G_IS_LIST_MODEL (model));
+
+  g_list_model_items_changed (G_LIST_MODEL (self), position, removed, added);
+}
+
+static void
+on_provider_added_cb (IdeExtensionSetAdapter *set,
+                      PeasPluginInfo         *plugin_info,
+                      PeasExtension          *exten,
+                      gpointer                user_data)
+{
+  IdeShortcutProvider *provider = (IdeShortcutProvider *)exten;
+  IdeShortcutManager *self = user_data;
+  g_autoptr(GListModel) model = NULL;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_EXTENSION_SET_ADAPTER (set));
+  g_assert (plugin_info != NULL);
+  g_assert (IDE_IS_SHORTCUT_PROVIDER (provider));
+
+  if ((model = ide_shortcut_provider_list_shortcuts (provider)))
+    {
+      IDE_TRACE_MSG ("Adding shortcut model for %s with %d items",
+                     peas_plugin_info_get_module_name (plugin_info),
+                     g_list_model_get_n_items (model));
+      g_object_set_data_full (G_OBJECT (provider),
+                              "SHORTCUTS_MODEL",
+                              g_object_ref (model),
+                              g_object_unref);
+      g_list_store_append (self->providers_models, model);
+    }
+
+  IDE_EXIT;
+}
+
+static void
+on_provider_removed_cb (IdeExtensionSetAdapter *set,
+                        PeasPluginInfo         *plugin_info,
+                        PeasExtension          *exten,
+                        gpointer                user_data)
+{
+  IdeShortcutProvider *provider = (IdeShortcutProvider *)exten;
+  IdeShortcutManager *self = user_data;
+  GListModel *model;
+
+  IDE_ENTRY;
+
+  g_assert (IDE_IS_EXTENSION_SET_ADAPTER (set));
+  g_assert (plugin_info != NULL);
+  g_assert (IDE_IS_SHORTCUT_PROVIDER (provider));
+
+  if (self->providers_models == NULL)
+    IDE_EXIT;
+
+  if ((model = g_object_get_data (G_OBJECT (provider), "SHORTCUTS_MODEL")))
+    {
+      guint n_items;
+
+      g_assert (G_IS_LIST_MODEL (model));
+
+      n_items = g_list_model_get_n_items (G_LIST_MODEL (self->providers_models));
+
+      for (guint i = 0; i < n_items; i++)
+        {
+          g_autoptr(GListModel) item = g_list_model_get_item (G_LIST_MODEL (self->providers_models), i);
+
+          if (item == model)
+            {
+              g_list_store_remove (self->providers_models, i);
+              IDE_EXIT;
+            }
+        }
+    }
+
+  IDE_EXIT;
+}
+
+static void
+ide_shortcut_manager_parent_set (IdeObject *object,
+                                 IdeObject *parent)
+{
+  IdeShortcutManager *self = (IdeShortcutManager *)object;
+
+  g_assert (IDE_IS_SHORTCUT_MANAGER (self));
+  g_assert (!parent || IDE_IS_OBJECT (parent));
+
+  if (parent == NULL)
+    return;
+
+  if (self->providers == NULL)
+    {
+      self->providers = ide_extension_set_adapter_new (IDE_OBJECT (self),
+                                                       peas_engine_get_default (),
+                                                       IDE_TYPE_SHORTCUT_PROVIDER,
+                                                       NULL, NULL);
+      g_signal_connect (self->providers,
+                        "extension-added",
+                        G_CALLBACK (on_provider_added_cb),
+                        self);
+      g_signal_connect (self->providers,
+                        "extension-removed",
+                        G_CALLBACK (on_provider_removed_cb),
+                        self);
+      ide_extension_set_adapter_foreach_by_priority (self->providers,
+                                                     on_provider_added_cb,
+                                                     self);
+    }
+}
+
+static void
+ide_shortcut_manager_dispose (GObject *object)
+{
+  IdeShortcutManager *self = (IdeShortcutManager *)object;
+
+  g_clear_object (&self->providers);
+  g_clear_object (&self->providers_models);
+  g_clear_object (&self->toplevel);
+  g_clear_object (&self->plugin_models);
+  g_clear_object (&self->flatten);
+
+  G_OBJECT_CLASS (ide_shortcut_manager_parent_class)->dispose (object);
+}
+
+static void
+ide_shortcut_manager_class_init (IdeShortcutManagerClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  IdeObjectClass *i_object_class = IDE_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_shortcut_manager_dispose;
+
+  i_object_class->parent_set = ide_shortcut_manager_parent_set;
+
+  g_type_ensure (IDE_TYPE_SHORTCUT_PROVIDER);
+}
+
+static void
+ide_shortcut_manager_init (IdeShortcutManager *self)
+{
+  GtkFlattenListModel *flatten;
+
+  if (plugin_models == NULL)
+    plugin_models = g_list_store_new (G_TYPE_LIST_MODEL);
+
+  self->toplevel = g_list_store_new (G_TYPE_LIST_MODEL);
+  self->plugin_models = g_object_ref (plugin_models);
+  self->providers_models = g_list_store_new (G_TYPE_LIST_MODEL);
+
+  flatten = gtk_flatten_list_model_new (g_object_ref (G_LIST_MODEL (self->providers_models)));
+  g_list_store_append (self->toplevel, flatten);
+  g_object_unref (flatten);
+
+  flatten = gtk_flatten_list_model_new (g_object_ref (G_LIST_MODEL (self->plugin_models)));
+  g_list_store_append (self->toplevel, flatten);
+  g_object_unref (flatten);
+
+  g_list_store_append (self->toplevel, get_internal_shortcuts ());
+
+  self->flatten = gtk_flatten_list_model_new (g_object_ref (G_LIST_MODEL (self->toplevel)));
+  g_signal_connect_object (self->flatten,
+                           "items-changed",
+                           G_CALLBACK (ide_shortcut_manager_items_changed_cb),
+                           self,
+                           G_CONNECT_SWAPPED);
+}
+
+/**
+ * ide_shortcut_manager_from_context:
+ * @context: an #IdeContext
+ *
+ * Gets the shortcut manager for the contenxt
+ *
+ * Returns: (transfer none): an #IdeShortcutManager
+ */
+IdeShortcutManager *
+ide_shortcut_manager_from_context (IdeContext *context)
+{
+  IdeShortcutManager *ret;
+
+  g_return_val_if_fail (IDE_IS_MAIN_THREAD (), NULL);
+  g_return_val_if_fail (IDE_IS_CONTEXT (context), NULL);
+
+  if (!(ret = ide_context_peek_child_typed (context, IDE_TYPE_SHORTCUT_MANAGER)))
+    {
+      g_autoptr(IdeObject) child = NULL;
+
+      child = ide_object_ensure_child_typed (IDE_OBJECT (context), IDE_TYPE_SHORTCUT_MANAGER);
+      ret = ide_context_peek_child_typed (context, IDE_TYPE_SHORTCUT_MANAGER);
+    }
+
+  return ret;
+}
+
+void
+ide_shortcut_manager_add_resources (const char *resource_path)
+{
+  g_autoptr(GFile) keybindings_json = NULL;
+  g_autoptr(GError) error = NULL;
+  g_autofree char *keybindings_json_path = NULL;
+  g_autoptr(IdeShortcutBundle) bundle = NULL;
+
+  g_return_if_fail (resource_path != NULL);
+
+  keybindings_json_path = g_build_filename (resource_path, "gtk", "keybindings.json", NULL);
+
+  if (g_str_has_prefix (resource_path, "resource://"))
+    keybindings_json = g_file_new_for_uri (keybindings_json_path);
+  else
+    keybindings_json = g_file_new_for_path (keybindings_json_path);
+
+  if (!g_file_query_exists (keybindings_json, NULL))
+    return;
+
+  bundle = ide_shortcut_bundle_new ();
+
+  if (!ide_shortcut_bundle_parse (bundle, keybindings_json, &error))
+    {
+      g_warning ("Failed to parse %s: %s", resource_path, error->message);
+      return;
+    }
+
+  g_object_set_data_full (G_OBJECT (bundle),
+                          "RESOURCE_PATH",
+                          g_strdup (resource_path),
+                          g_free);
+
+  if (plugin_models == NULL)
+    plugin_models = g_list_store_new (G_TYPE_LIST_MODEL);
+
+  g_list_store_append (plugin_models, bundle);
+}
+
+void
+ide_shortcut_manager_remove_resources (const char *resource_path)
+{
+  guint n_items;
+
+  g_return_if_fail (resource_path != NULL);
+  g_return_if_fail (plugin_models != NULL);
+
+  n_items = g_list_model_get_n_items (G_LIST_MODEL (plugin_models));
+
+  for (guint i = 0; i < n_items; i++)
+    {
+      g_autoptr(IdeShortcutBundle) bundle = g_list_model_get_item (G_LIST_MODEL (plugin_models), i);
+
+      if (g_strcmp0 (resource_path, g_object_get_data (G_OBJECT (bundle), "RESOURCE_PATH")) == 0)
+        {
+          g_list_store_remove (plugin_models, i);
+          return;
+        }
+    }
+}
diff --git a/src/libide/gui/ide-shortcut-provider.c b/src/libide/gui/ide-shortcut-provider.c
new file mode 100644
index 000000000..05b6a87c7
--- /dev/null
+++ b/src/libide/gui/ide-shortcut-provider.c
@@ -0,0 +1,68 @@
+/* ide-shortcut-provider.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-shortcut-provider"
+
+#include "config.h"
+
+#include <gtk/gtk.h>
+
+#include "ide-shortcut-provider.h"
+
+G_DEFINE_INTERFACE (IdeShortcutProvider, ide_shortcut_provider, IDE_TYPE_OBJECT)
+
+static GListModel *
+ide_shortcut_provider_real_list_shortcuts (IdeShortcutProvider *self)
+{
+  return G_LIST_MODEL (g_list_store_new (GTK_TYPE_SHORTCUT));
+}
+
+static void
+ide_shortcut_provider_default_init (IdeShortcutProviderInterface *iface)
+{
+  iface->list_shortcuts = ide_shortcut_provider_real_list_shortcuts;
+}
+
+/**
+ * ide_shortcut_provider_list_shortcuts:
+ * @self: a #IdeShortcutProvider
+ *
+ * Gets a #GListModel of #GtkShortcut.
+ *
+ * This function should return a #GListModel of #GtkShortcut that are updated
+ * as necessary by the plugin. This list model is used to activate shortcuts
+ * based on user input and allows more control by plugins over when and how
+ * shortcuts may activate.
+ *
+ * Returns: (transfer full): A #GListModel of #GtkShortcut
+ */
+GListModel *
+ide_shortcut_provider_list_shortcuts (IdeShortcutProvider *self)
+{
+  GListModel *ret;
+
+  IDE_ENTRY;
+
+  g_return_val_if_fail (IDE_IS_SHORTCUT_PROVIDER (self), NULL);
+
+  ret = IDE_SHORTCUT_PROVIDER_GET_IFACE (self)->list_shortcuts (self);
+
+  IDE_RETURN (ret);
+}
diff --git a/src/libide/gui/ide-shortcut-provider.h b/src/libide/gui/ide-shortcut-provider.h
new file mode 100644
index 000000000..8c4c75e70
--- /dev/null
+++ b/src/libide/gui/ide-shortcut-provider.h
@@ -0,0 +1,46 @@
+/* ide-shortcut-provider.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GUI_INSIDE) && !defined (IDE_GUI_COMPILATION)
+# error "Only <libide-gui.h> can be included directly."
+#endif
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_SHORTCUT_PROVIDER (ide_shortcut_provider_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_INTERFACE (IdeShortcutProvider, ide_shortcut_provider, IDE, SHORTCUT_PROVIDER, IdeObject)
+
+struct _IdeShortcutProviderInterface
+{
+  GTypeInterface parent_iface;
+
+  GListModel *(*list_shortcuts) (IdeShortcutProvider *self);
+};
+
+IDE_AVAILABLE_IN_ALL
+GListModel *ide_shortcut_provider_list_shortcuts (IdeShortcutProvider *self);
+
+G_END_DECLS
diff --git a/src/libide/gui/libide-gui.gresource.xml b/src/libide/gui/libide-gui.gresource.xml
index acf0f00a7..753d9830c 100644
--- a/src/libide/gui/libide-gui.gresource.xml
+++ b/src/libide/gui/libide-gui.gresource.xml
@@ -2,6 +2,8 @@
 <gresources>
   <gresource prefix="/org/gnome/libide-gui">
     <file preprocess="xml-stripblanks">gtk/menus.ui</file>
+    <file>gtk/keybindings.gsl</file>
+    <file>gtk/keybindings.json</file>
     <file>images/style-preview-dark.png</file>
     <file>images/style-preview-default.png</file>
     <file>images/style-preview-light.png</file>
diff --git a/src/libide/gui/meson.build b/src/libide/gui/meson.build
index f79caf0fc..d58055cf7 100644
--- a/src/libide/gui/meson.build
+++ b/src/libide/gui/meson.build
@@ -27,6 +27,7 @@ libide_gui_public_headers = [
   'ide-preferences-window.h',
   'ide-primary-workspace.h',
   'ide-session-addin.h',
+  'ide-shortcut-provider.h',
   'ide-workbench.h',
   'ide-workbench-addin.h',
   'ide-workspace.h',
@@ -49,6 +50,8 @@ libide_gui_private_headers = [
   'ide-preferences-builtin-private.h',
   'ide-recoloring-private.h',
   'ide-session-private.h',
+  'ide-shortcut-bundle-private.h',
+  'ide-shortcut-manager-private.h',
 ]
 
 libide_gui_private_sources = [
@@ -63,6 +66,8 @@ libide_gui_private_sources = [
   'ide-primary-workspace-actions.c',
   'ide-recoloring.c',
   'ide-session.c',
+  'ide-shortcut-bundle.c',
+  'ide-shortcut-manager.c',
   'ide-workspace-actions.c',
 ]
 
@@ -88,6 +93,7 @@ libide_gui_public_sources = [
   'ide-preferences-addin.c',
   'ide-preferences-window.c',
   'ide-session-addin.c',
+  'ide-shortcut-provider.c',
   'ide-workbench.c',
   'ide-workbench-addin.c',
   'ide-workspace.c',


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]