/*
 * Copyright © 2019 Benjamin Otte
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library. If not, see <http://www.gnu.org/licenses/>.
 *
 * Authors: Benjamin Otte <otte@gnome.org>
 */

#include "config.h"

#include "gtktreeexpander.h"

#include "gtkboxlayout.h"
#include "gtkbuiltiniconprivate.h"
#include "gtkdropcontrollermotion.h"
#include "gtkgestureclick.h"
#include "gtkintl.h"
#include "gtktreelistmodel.h"

/**
 * SECTION:gtktreeexpander
 * @title: GtkTreeExpander
 * @short_description: An indenting expander button for use in a tree list
 * @see_also: #GtkTreeListModel
 *
 * GtkTreeExpander is a widget that provides an expander for a list.
 *
 * It is typically placed as a bottommost child into a #GtkListView to allow
 * users to expand and collapse children in a list with a #GtkTreeListModel.
 * It will provide the common UI elements, gestures and keybindings for this
 * purpose.
 *
 * On top of this, the "listitem.expand", "listitem.collapse" and
 * "listitem.toggle-expand" actions are provided to allow adding custom UI
 * for managing expanded state.
 *
 * The #GtkTreeListModel must be set to not be passthrough. Then it will provide
 * #GtkTreeListRow items which can be set via gtk_tree_expander_set_list_row()
 * on the expander. The expander will then watch that row item automatically.  
 * gtk_tree_expander_set_child() sets the widget that displays the actual row
 * contents.
 *
 * # CSS nodes
 *
 * |[<!-- language="plain" -->
 * treeexpander
 * ├── [indent]*
 * ├── [expander]
 * ╰── <child>
 * ]|
 *
 * GtkTreeExpander has zero or one CSS nodes with the name "expander" that should
 * display the expander icon. The node will be `:checked` when it is expanded.
 * If the node is not expandable, an "indent" node will be displayed instead.
 *
 * For every level of depth, another "indent" node is prepended.
 */

struct _GtkTreeExpander
{
  GtkWidget parent_instance;

  GtkTreeListRow *list_row;
  GtkWidget *child;

  GtkWidget *expander;
  guint notify_handler;

  guint expand_timer;
};

enum
{
  PROP_0,
  PROP_CHILD,
  PROP_ITEM,
  PROP_LIST_ROW,

  N_PROPS
};

G_DEFINE_TYPE (GtkTreeExpander, gtk_tree_expander, GTK_TYPE_WIDGET)

static GParamSpec *properties[N_PROPS] = { NULL, };

static void
gtk_tree_expander_click_gesture_pressed (GtkGestureClick *gesture,
                                         int              n_press,
                                         double           x,
                                         double           y,
                                         gpointer         unused)
{
  GtkWidget *widget = gtk_event_controller_get_widget (GTK_EVENT_CONTROLLER (gesture));

  gtk_widget_activate_action (widget, "listitem.toggle-expand", NULL);

  gtk_widget_set_state_flags (widget,
                              GTK_STATE_FLAG_ACTIVE,
                              FALSE);
}

static void
gtk_tree_expander_click_gesture_released (GtkGestureClick *gesture,
                                          int              n_press,
                                          double           x,
                                          double           y,
                                          gpointer         unused)
{
  gtk_widget_unset_state_flags (gtk_event_controller_get_widget (GTK_EVENT_CONTROLLER (gesture)),
                                GTK_STATE_FLAG_ACTIVE);
}

static void
gtk_tree_expander_click_gesture_canceled (GtkGestureClick  *gesture,
                                          GdkEventSequence *sequence,
                                          gpointer          unused)
{
  gtk_widget_unset_state_flags (gtk_event_controller_get_widget (GTK_EVENT_CONTROLLER (gesture)),
                                GTK_STATE_FLAG_ACTIVE);
}

static void
gtk_tree_expander_update_for_list_row (GtkTreeExpander *self)
{
  if (self->list_row == NULL)
    {
      GtkWidget *child;

      for (child = gtk_widget_get_first_child (GTK_WIDGET (self));
           child != self->child;
           child = gtk_widget_get_first_child (GTK_WIDGET (self)))
        {
          gtk_widget_unparent (child);
        }
      self->expander = NULL;
    }
  else
    {
      GtkWidget *child;
      guint i, depth;

      depth = gtk_tree_list_row_get_depth (self->list_row);
      if (gtk_tree_list_row_is_expandable (self->list_row))
        {
          if (self->expander == NULL)
            {
              GtkGesture *gesture;

              self->expander = gtk_builtin_icon_new ("expander");

              gesture = gtk_gesture_click_new ();
              gtk_event_controller_set_propagation_phase (GTK_EVENT_CONTROLLER (gesture),
                                                          GTK_PHASE_BUBBLE);
              gtk_gesture_single_set_touch_only (GTK_GESTURE_SINGLE (gesture),
                                                 FALSE);
              gtk_gesture_single_set_button (GTK_GESTURE_SINGLE (gesture),
                                             GDK_BUTTON_PRIMARY);
              g_signal_connect (gesture, "pressed",
                                G_CALLBACK (gtk_tree_expander_click_gesture_pressed), NULL);
              g_signal_connect (gesture, "released",
                                G_CALLBACK (gtk_tree_expander_click_gesture_released), NULL);
              g_signal_connect (gesture, "cancel",
                                G_CALLBACK (gtk_tree_expander_click_gesture_canceled), NULL);
              gtk_widget_add_controller (self->expander, GTK_EVENT_CONTROLLER (gesture));

              gtk_widget_insert_before (self->expander,
                                        GTK_WIDGET (self),
                                        self->child);
            }
          if (gtk_tree_list_row_get_expanded (self->list_row))
            gtk_widget_set_state_flags (self->expander, GTK_STATE_FLAG_CHECKED, FALSE);
          else
            gtk_widget_unset_state_flags (self->expander, GTK_STATE_FLAG_CHECKED);
          child = gtk_widget_get_prev_sibling (self->expander);
        }
      else
        {
          g_clear_pointer (&self->expander, gtk_widget_unparent);
          depth++;
          if (self->child)
            child = gtk_widget_get_prev_sibling (self->child);
          else
            child = gtk_widget_get_last_child (GTK_WIDGET (self));
        }

      for (i = 0; i < depth; i++)
        {
          if (child)
            child = gtk_widget_get_prev_sibling (child);
          else
            gtk_widget_insert_after (gtk_builtin_icon_new ("indent"), GTK_WIDGET (self), NULL);
        }

      while (child)
        {
          GtkWidget *prev = gtk_widget_get_prev_sibling (child);
          gtk_widget_unparent (child);
          child = prev;
        }
    }
}

static void
gtk_tree_expander_list_row_notify_cb (GtkTreeListRow  *list_row,
                                      GParamSpec      *pspec,
                                      GtkTreeExpander *self)
{
  if (pspec->name == g_intern_static_string ("expanded"))
    {
      if (self->expander)
        {
          if (gtk_tree_list_row_get_expanded (list_row))
            gtk_widget_set_state_flags (self->expander, GTK_STATE_FLAG_CHECKED, FALSE);
          else
            gtk_widget_unset_state_flags (self->expander, GTK_STATE_FLAG_CHECKED);
        }
    }
  else if (pspec->name == g_intern_static_string ("item"))
    {
      g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ITEM]);
    }
  else
    {
      /* can this happen other than when destroying the row? */
      gtk_tree_expander_update_for_list_row (self);
    }
}

static gboolean
gtk_tree_expander_focus (GtkWidget        *widget,
                         GtkDirectionType  direction)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  /* The idea of this function is the following:
   * 1. If any child can take focus, do not ever attempt
   *    to take focus.
   * 2. Otherwise, if this item is selectable or activatable,
   *    allow focusing this widget.
   *
   * This makes sure every item in a list is focusable for
   * activation and selection handling, but no useless widgets
   * get focused and moving focus is as fast as possible.
   */
  if (self->child)
    {
      if (gtk_widget_get_focus_child (widget))
        return FALSE;
      if (gtk_widget_child_focus (self->child, direction))
        return TRUE;
    }

  if (gtk_widget_is_focus (widget))
    return FALSE;

  if (!gtk_widget_get_can_focus (widget))
    return FALSE;

  gtk_widget_grab_focus (widget);

  return TRUE;
}

static gboolean
gtk_tree_expander_grab_focus (GtkWidget *widget)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  if (self->child && gtk_widget_grab_focus (self->child))
    return TRUE;

  return GTK_WIDGET_CLASS (gtk_tree_expander_parent_class)->grab_focus (widget);
}

static void
gtk_tree_expander_clear_list_row (GtkTreeExpander *self)
{
  if (self->list_row == NULL)
    return;

  g_signal_handler_disconnect (self->list_row, self->notify_handler);
  self->notify_handler = 0;
  g_clear_object (&self->list_row);
}

static void
gtk_tree_expander_dispose (GObject *object)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (object);

  if (self->expand_timer)
    {
      g_source_remove (self->expand_timer);
      self->expand_timer = 0;
    }

  gtk_tree_expander_clear_list_row (self);
  gtk_tree_expander_update_for_list_row (self);

  g_clear_pointer (&self->child, gtk_widget_unparent);

  g_assert (self->expander == NULL);

  G_OBJECT_CLASS (gtk_tree_expander_parent_class)->dispose (object);
}

static void
gtk_tree_expander_get_property (GObject    *object,
                                guint       property_id,
                                GValue     *value,
                                GParamSpec *pspec)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (object);

  switch (property_id)
    {
    case PROP_CHILD:
      g_value_set_object (value, self->child);
      break;

    case PROP_ITEM:
      g_value_set_object (value, gtk_tree_expander_get_item (self));
      break;

    case PROP_LIST_ROW:
      g_value_set_object (value, self->list_row);
      break;

    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
      break;
    }
}

static void
gtk_tree_expander_set_property (GObject      *object,
                                guint         property_id,
                                const GValue *value,
                                GParamSpec   *pspec)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (object);

  switch (property_id)
    {
    case PROP_CHILD:
      gtk_tree_expander_set_child (self, g_value_get_object (value));
      break;

    case PROP_LIST_ROW:
      gtk_tree_expander_set_list_row (self, g_value_get_object (value));
      break;

    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
      break;
    }
}

static void
gtk_tree_expander_expand (GtkWidget  *widget,
                          const char *action_name,
                          GVariant   *parameter)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  if (self->list_row == NULL)
    return;

  gtk_tree_list_row_set_expanded (self->list_row, TRUE);
}

static void
gtk_tree_expander_collapse (GtkWidget  *widget,
                            const char *action_name,
                            GVariant   *parameter)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  if (self->list_row == NULL)
    return;

  gtk_tree_list_row_set_expanded (self->list_row, FALSE);
}

static void
gtk_tree_expander_toggle_expand (GtkWidget  *widget,
                                 const char *action_name,
                                 GVariant   *parameter)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  if (self->list_row == NULL)
    return;

  gtk_tree_list_row_set_expanded (self->list_row, !gtk_tree_list_row_get_expanded (self->list_row));
}

static gboolean
expand_collapse_right (GtkWidget *widget,
                       GVariant  *args,
                       gpointer   unused)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  if (self->list_row == NULL)
    return FALSE;

  gtk_tree_list_row_set_expanded (self->list_row, gtk_widget_get_direction (widget) != GTK_TEXT_DIR_RTL);

  return TRUE;
}

static gboolean
expand_collapse_left (GtkWidget *widget,
                      GVariant  *args,
                      gpointer   unused)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (widget);

  if (self->list_row == NULL)
    return FALSE;

  gtk_tree_list_row_set_expanded (self->list_row, gtk_widget_get_direction (widget) == GTK_TEXT_DIR_RTL);

  return TRUE;
}

static void
gtk_tree_expander_class_init (GtkTreeExpanderClass *klass)
{
  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
  GObjectClass *gobject_class = G_OBJECT_CLASS (klass);

  widget_class->focus = gtk_tree_expander_focus;
  widget_class->grab_focus = gtk_tree_expander_grab_focus;

  gobject_class->dispose = gtk_tree_expander_dispose;
  gobject_class->get_property = gtk_tree_expander_get_property;
  gobject_class->set_property = gtk_tree_expander_set_property;

  /**
   * GtkTreeExpander:child:
   *
   * The child widget with the actual contents
   */
  properties[PROP_CHILD] =
    g_param_spec_object ("child",
                         P_("Child"),
                         P_("The child widget with the actual contents"),
                         GTK_TYPE_WIDGET,
                         G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);

  /**
   * GtkTreeExpander:item:
   *
   * The item held by this expander's row
   */
  properties[PROP_ITEM] =
      g_param_spec_object ("item",
                           P_("Item"),
                           P_("The item held by this expander's row"),
                           G_TYPE_OBJECT,
                           G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);

  /**
   * GtkTreeExpander:list-row:
   *
   * The list row to track for expander state
   */
  properties[PROP_LIST_ROW] =
    g_param_spec_object ("list-row",
                         P_("List row"),
                         P_("The list row to track for expander state"),
                         GTK_TYPE_TREE_LIST_ROW,
                         G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);

  g_object_class_install_properties (gobject_class, N_PROPS, properties);

  /**
   * GtkTreeExpander|listitem.expand:
   *
   * Expands the expander if it can be expanded.
   */
  gtk_widget_class_install_action (widget_class,
                                   "listitem.expand",
                                   NULL,
                                   gtk_tree_expander_expand);

  /**
   * GtkTreeExpander|listitem.collapse:
   *
   * Collapses the expander.
   */
  gtk_widget_class_install_action (widget_class,
                                   "listitem.collapse",
                                   NULL,
                                   gtk_tree_expander_collapse);

  /**
   * GtkTreeExpander|listitem.toggle-expand:
   *
   * Tries to expand the expander if it was collapsed or collapses it if
   * it was expanded.
   */
  gtk_widget_class_install_action (widget_class,
                                   "listitem.toggle-expand",
                                   NULL,
                                   gtk_tree_expander_toggle_expand);

  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_plus, 0,
                                       "listitem.expand", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Add, 0,
                                       "listitem.expand", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_asterisk, 0,
                                       "listitem.expand", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Multiply, 0,
                                       "listitem.expand", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_minus, 0,
                                       "listitem.collapse", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Subtract, 0,
                                       "listitem.collapse", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_slash, 0,
                                       "listitem.collapse", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Divide, 0,
                                       "listitem.collapse", NULL);

  gtk_widget_class_add_binding (widget_class, GDK_KEY_Right, GDK_SHIFT_MASK,
                                expand_collapse_right, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_KP_Right, GDK_SHIFT_MASK,
                                expand_collapse_right, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_Right, GDK_CONTROL_MASK | GDK_SHIFT_MASK,
                                expand_collapse_right, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_KP_Right, GDK_CONTROL_MASK | GDK_SHIFT_MASK,
                                expand_collapse_right, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_Left, GDK_SHIFT_MASK,
                                expand_collapse_left, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_KP_Left, GDK_SHIFT_MASK,
                                expand_collapse_left, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_Left, GDK_CONTROL_MASK | GDK_SHIFT_MASK,
                                expand_collapse_left, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_KP_Left, GDK_CONTROL_MASK | GDK_SHIFT_MASK,
                                expand_collapse_left, NULL);

  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_space, GDK_CONTROL_MASK,
                                       "listitem.toggle-expand", NULL);
  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Space, GDK_CONTROL_MASK,
                                       "listitem.toggle-expand", NULL);

#if 0
  /* These can't be implements yet. */
  gtk_widget_class_add_binding (widget_class, GDK_KEY_BackSpace, 0, go_to_parent_row, NULL, NULL);
  gtk_widget_class_add_binding (widget_class, GDK_KEY_BackSpace, GDK_CONTROL_MASK, go_to_parent_row, NULL, NULL);
#endif

  gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BOX_LAYOUT);
  gtk_widget_class_set_css_name (widget_class, I_("treeexpander"));
}

static gboolean
gtk_tree_expander_expand_timeout (gpointer data)
{
  GtkTreeExpander *self = GTK_TREE_EXPANDER (data);

  if (self->list_row != NULL)
    gtk_tree_list_row_set_expanded (self->list_row, TRUE);

  self->expand_timer = 0;

  return G_SOURCE_REMOVE;
}

#define TIMEOUT_EXPAND 500

static void
gtk_tree_expander_drag_enter (GtkDropControllerMotion *motion,
                              double                   x,
                              double                   y,
                              GtkTreeExpander         *self)
{
  if (self->list_row == NULL)
    return;

  if (!gtk_tree_list_row_get_expanded (self->list_row) &&
      !self->expand_timer)
    {
      self->expand_timer = g_timeout_add (TIMEOUT_EXPAND, (GSourceFunc) gtk_tree_expander_expand_timeout, self);
      g_source_set_name_by_id (self->expand_timer, "[gtk] gtk_tree_expander_expand_timeout");
    }
}

static void
gtk_tree_expander_drag_leave (GtkDropControllerMotion *motion,
                              GtkTreeExpander         *self)
{
  if (self->expand_timer)
    {
      g_source_remove (self->expand_timer);
      self->expand_timer = 0;
    }
}

static void
gtk_tree_expander_init (GtkTreeExpander *self)
{
  GtkEventController *controller;

  gtk_widget_set_focusable (GTK_WIDGET (self), TRUE);

  controller = gtk_drop_controller_motion_new ();
  g_signal_connect (controller, "enter", G_CALLBACK (gtk_tree_expander_drag_enter), self);
  g_signal_connect (controller, "leave", G_CALLBACK (gtk_tree_expander_drag_leave), self);
  gtk_widget_add_controller (GTK_WIDGET (self), controller);
}

/**
 * gtk_tree_expander_new:
 *
 * Creates a new #GtkTreeExpander
 *
 * Returns: a new #GtkTreeExpander
 **/
GtkWidget *
gtk_tree_expander_new (void)
{
  return g_object_new (GTK_TYPE_TREE_EXPANDER,
                       NULL);
}

/**
 * gtk_tree_expander_get_child
 * @self: a #GtkTreeExpander
 *
 * Gets the child widget displayed by @self.
 *
 * Returns: (nullable) (transfer none): The child displayed by @self
 **/
GtkWidget *
gtk_tree_expander_get_child (GtkTreeExpander *self)
{
  g_return_val_if_fail (GTK_IS_TREE_EXPANDER (self), NULL);

  return self->child;
}

/**
 * gtk_tree_expander_set_child:
 * @self: a #GtkTreeExpander widget
 * @child: (nullable): a #GtkWidget, or %NULL
 *
 * Sets the content widget to display.
 */
void
gtk_tree_expander_set_child (GtkTreeExpander *self,
                             GtkWidget       *child)
{
  g_return_if_fail (GTK_IS_TREE_EXPANDER (self));
  g_return_if_fail (child == NULL || GTK_IS_WIDGET (child));

  if (self->child == child)
    return;

  g_clear_pointer (&self->child, gtk_widget_unparent);

  if (child)
    {
      self->child = child;
      gtk_widget_set_parent (child, GTK_WIDGET (self));
    }

  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_CHILD]);
}

/**
 * gtk_tree_expander_get_item
 * @self: a #GtkTreeExpander
 *
 * Forwards the item set on the #GtkTreeListRow that @self is managing.
 *
 * This call is essentially equivalent to calling
 * `gtk_tree_list_row_get_item (gtk_tree_expander_get_list_row (@self))`.
 *
 * Returns: (nullable) (transfer none): The item of the row
 **/
gpointer
gtk_tree_expander_get_item (GtkTreeExpander *self)
{
  g_return_val_if_fail (GTK_IS_TREE_EXPANDER (self), NULL);

  if (self->list_row == NULL)
    return NULL;

  return gtk_tree_list_row_get_item (self->list_row);
}

/**
 * gtk_tree_expander_get_list_row
 * @self: a #GtkTreeExpander
 *
 * Gets the list row managed by @self.
 *
 * Returns: (nullable) (transfer none): The list row displayed by @self
 **/
GtkTreeListRow *
gtk_tree_expander_get_list_row (GtkTreeExpander *self)
{
  g_return_val_if_fail (GTK_IS_TREE_EXPANDER (self), NULL);

  return self->list_row;
}

/**
 * gtk_tree_expander_set_list_row:
 * @self: a #GtkTreeExpander widget
 * @list_row: (nullable): a #GtkTreeListRow, or %NULL
 *
 * Sets the tree list row that this expander should manage.
 */
void
gtk_tree_expander_set_list_row (GtkTreeExpander *self,
                                GtkTreeListRow  *list_row)
{
  g_return_if_fail (GTK_IS_TREE_EXPANDER (self));
  g_return_if_fail (list_row == NULL || GTK_IS_TREE_LIST_ROW (list_row));

  if (self->list_row == list_row)
    return;

  g_object_freeze_notify (G_OBJECT (self));

  gtk_tree_expander_clear_list_row (self);

  if (list_row)
    {
      self->list_row = g_object_ref (list_row);
      self->notify_handler = g_signal_connect (list_row,
                                               "notify",
                                               G_CALLBACK (gtk_tree_expander_list_row_notify_cb),
                                               self);
    }

  gtk_tree_expander_update_for_list_row (self);

  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_LIST_ROW]);
  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ITEM]);

  g_object_thaw_notify (G_OBJECT (self));
}

