lib/menu/menu_item.ex

defmodule ExSni.Menu.Item do
  alias ExSni.Icon.Info, as: IconInfo
  alias ExSni.Menu

  defstruct id: 0,
            uid: "",
            type: :standard,
            enabled: true,
            visible: true,
            label: "",
            icon: nil,
            checked: false,
            children: [],
            callbacks: []

  @type id() :: non_neg_integer()
  @type item_type() :: :separator | :root | :standard | :checkbox | :radio | :menu
  @type toggle_type() :: nil | :checkmark | :radio
  @type toggle_state() :: nil | :on | :off
  @type t() :: %__MODULE__{
          id: id(),
          uid: String.t(),
          type: item_type(),
          enabled: boolean(),
          visible: boolean(),
          label: String.t(),
          icon: nil | String.t() | IconInfo.t(),
          checked: boolean(),
          children: list(t()),
          callbacks: list(Menu.callback())
        }

  @type layout() :: {:dbus_variant, {:struct, list()}, dbus_menu_item()}
  @type dbus_variant() :: {:dbus_variant, any(), any()}
  @type dbus_menu_properties() :: list({String.t(), dbus_variant()})
  @type dbus_menu_item() :: {integer(), dbus_menu_properties(), list(dbus_menu_item())}

  @dbus_menu_item_type {:struct, [:int32, {:dict, :string, :variant}, {:array, :variant}]}

  @spec separator() :: t()
  def separator() do
    %__MODULE__{id: 1, type: :separator}
  end

  @spec root(children :: list(t())) :: t()
  def root(children \\ []) do
    %__MODULE__{id: 0, type: :root, children: children}
  end

  @spec menu(children :: list(t())) :: t()
  def menu(children \\ []) do
    %__MODULE__{type: :menu, children: children}
  end

  @spec checkbox() :: t()
  def checkbox(label \\ "") do
    %__MODULE__{type: :checkbox}
    |> set_label(label)
  end

  @spec radio() :: t()
  def radio(label \\ "") do
    %__MODULE__{type: :radio}
    |> set_label(label)
  end

  @spec standard(label :: String.t()) :: t()
  def standard(label \\ "") do
    %__MODULE__{type: :standard}
    |> set_label(label)
  end

  @doc """
  WARNING: Always use unique IDs across the entire tree.
  If you set the same ID for a node and any of its descendants,
  most menu hosts (i.e. libdbusmenu) will recurse indefinitely when attempting
  to build the list of IDs to request the layout for; which will most likely
  result in a system-wide crash/hang.

  To store custom ID (e.g. "id" attribute), use `uid` property and `set_uid/2`
  """
  @spec set_id(t(), id :: id()) :: t()
  def set_id(%__MODULE__{type: type} = item, id) when type not in [:root] do
    %{item | id: id}
  end

  def set_id(%__MODULE__{} = item, _) do
    item
  end

  @spec set_uid(t(), uid :: String.t()) :: t()
  def set_uid(%__MODULE__{} = item, uid) do
    %{item | uid: uid}
  end

  @spec set_label(t(), label :: String.t()) :: t()
  def set_label(%__MODULE__{type: type} = item, label)
      when type not in [:separator] and is_binary(label) do
    %{item | label: label}
  end

  def set_label(item, _) do
    item
  end

  @spec set_callbacks(t(), callbacks :: list(Menu.callback())) :: t()
  def set_callbacks(%__MODULE__{} = item, callbacks) do
    %{item | callbacks: callbacks}
  end

  @spec enable(t()) :: t()
  def enable(%__MODULE__{} = item) do
    set_enabled(item, true)
  end

  @spec disable(t()) :: t()
  def disable(%__MODULE__{} = item) do
    set_enabled(item, false)
  end

  @spec toggle_enabled(t()) :: t()
  def toggle_enabled(%__MODULE__{enabled: true} = item) do
    set_enabled(item, false)
  end

  def toggle_enabled(%__MODULE__{enabled: false} = item) do
    set_enabled(item, true)
  end

  @spec set_enabled(t(), value :: boolean()) :: t()
  def set_enabled(%__MODULE__{} = item, value) when is_boolean(value) do
    %{item | enabled: value}
  end

  @spec visible(t()) :: t()
  def visible(%__MODULE__{} = item) do
    set_visible(item, true)
  end

  @spec hidden(t()) :: t()
  def hidden(%__MODULE__{} = item) do
    set_visible(item, false)
  end

  @spec toggle_visible(t()) :: t()
  def toggle_visible(%__MODULE__{visible: true} = item) do
    set_visible(item, false)
  end

  def toggle_visible(%__MODULE__{visible: false} = item) do
    set_visible(item, true)
  end

  @spec set_visible(t(), value :: boolean()) :: t()
  def set_visible(%__MODULE__{} = item, value) when is_boolean(value) do
    %{item | visible: value}
  end

  @spec set_checked(t(), new_state :: boolean()) :: t()
  def set_checked(item, value \\ true)

  def set_checked(%__MODULE__{type: type} = item, value)
      when type in [:checkbox, :radio] and is_boolean(value) do
    %{item | checked: value}
  end

  def set_checked(item, _) do
    item
  end

  @spec toggle_checked(t()) :: t()
  def toggle_checked(%__MODULE__{checked: true} = item) do
    set_checked(item, false)
  end

  def toggle_checked(%__MODULE__{checked: false} = item) do
    set_checked(item, true)
  end

  @spec get_layout(t(), integer(), list(String.t())) :: layout()
  def get_layout(%__MODULE__{id: id, children: children} = menu_item, depth, properties) do
    prop_values =
      properties
      |> Enum.map(fn property ->
        case ExSni.DbusProtocol.get_property(menu_item, property, :ignore_default) do
          {:ok, value} -> {property, value}
          _ -> nil
        end
      end)
      |> Enum.reject(&(&1 == nil))

    children =
      case depth do
        0 -> []
        -1 -> Enum.map(children, &get_layout(&1, -1, properties))
        depth -> Enum.map(children, &get_layout(&1, depth - 1, properties))
      end

    {:dbus_variant, @dbus_menu_item_type,
     {
       id,
       prop_values,
       children
     }}
  end

  @spec find_item(t(), id()) :: nil | t()
  def find_item(%__MODULE__{children: []}, _id) do
    nil
  end

  def find_item(%__MODULE__{children: [%__MODULE__{id: id} = item | _]}, id) do
    item
  end

  def find_item(%__MODULE__{children: [item | items]}, id) do
    case find_item(item, id) do
      nil -> find_item(%{item | children: items}, id)
      item -> item
    end
  end

  # This does not check for :id property
  @spec get_changed_properties(current :: t(), other :: t(), properties :: list(atom())) ::
          list(atom())
  def get_changed_properties(%__MODULE__{} = current, %__MODULE__{} = other) do
    get_changed_properties(current, other, [:type, :label, :enabled, :visible, :icon, :checked])
  end

  def get_changed_properties(%__MODULE__{}, %__MODULE__{}, []) do
    []
  end

  def get_changed_properties(%__MODULE__{} = current, %__MODULE__{} = other, [
        property | properties
      ]) do
    current_value = Map.get(current, property)
    other_value = Map.get(other, property)

    if current_value == other_value do
      get_changed_properties(current, other, properties)
    else
      [property | get_changed_properties(current, other, properties)]
    end
  end

  def get_dbus_changed_properties(current, other, opts \\ [])

  def get_dbus_changed_properties(%__MODULE__{} = current, %__MODULE__{} = other, opts) do
    [
      "label",
      "type",
      "enabled",
      "visible",
      "icon-name",
      "icon-data",
      "toggle-type",
      "toggle-state",
      "children-display"
    ]
    |> Enum.reduce(
      [],
      fn property, acc ->
        case get_dbus_changed_property(current, other, property, opts) do
          {:ok, {property, dbus_value}} -> [{property, dbus_value} | acc]
          _ -> acc
        end
      end
    )
  end

  defp get_dbus_changed_property(%__MODULE__{} = current, %__MODULE__{} = other, property, opts) do
    case ExSni.DbusProtocol.get_property(current, property, opts) do
      {:ok, dbus_value} ->
        case ExSni.DbusProtocol.get_property(other, property, opts) do
          {:ok, ^dbus_value} -> :equal
          _ -> {:ok, {property, dbus_value}}
        end

      _ ->
        :error
    end
  end

  defimpl ExSni.DbusProtocol do
    def get_property(item, property) do
      get_property(item, property, [])
    end

    def get_property(%{type: :separator}, "type", _) do
      ok_dbus_variant(:string, "separator")
    end

    def get_property(%{type: _}, "type", :ignore_default) do
      ok_dbus_variant(:string, "standard")
    end

    def get_property(%{type: _}, "type", _) do
      default("standard")
    end

    def get_property(%{enabled: true}, "enabled", :ignore_default) do
      ok_dbus_variant(:boolean, true)
    end

    def get_property(%{enabled: true}, "enabled", _) do
      default(true)
    end

    def get_property(%{enabled: false}, "enabled", _) do
      ok_dbus_variant(:boolean, false)
    end

    def get_property(%{visible: true}, "visible", :ignore_default) do
      ok_dbus_variant(:boolean, true)
    end

    def get_property(%{visible: true}, "visible", _) do
      default(true)
    end

    def get_property(%{visible: false}, "visible", _) do
      ok_dbus_variant(:boolean, false)
    end

    def get_property(%{label: ""}, "label", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(%{label: ""}, "label", _) do
      default("")
    end

    def get_property(%{label: label}, "label", _) do
      ok_dbus_variant(:string, label)
    end

    def get_property(%{icon: ""}, "icon-name", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(%{icon: ""}, "icon-name", _) do
      default("")
    end

    def get_property(%{type: :separator}, "icon-name", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(%{type: :separator}, "icon-name", _) do
      default("")
    end

    def get_property(%{icon: icon_name}, "icon-name", _) when is_binary(icon_name) do
      ok_dbus_variant(:string, icon_name)
    end

    def get_property(%{icon: %IconInfo{name: ""}}, "icon-name", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(%{icon: %IconInfo{name: ""}}, "icon-name", _) do
      default("")
    end

    def get_property(%{icon: %IconInfo{name: name}}, "icon-name", _) do
      ok_dbus_variant(:string, name)
    end

    def get_property(%{type: :separator}, "icon-data", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(%{type: :separator}, "icon-data", _) do
      default("")
    end

    def get_property(%{icon: %IconInfo{data: ""}}, "icon-data", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(%{icon: %IconInfo{data: ""}}, "icon-data", _) do
      default(nil)
    end

    def get_property(%{icon: %IconInfo{data: data}}, "icon-data", _) when is_binary(data) do
      {:ok, data}
    end

    def get_property(_, "icon-data", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(_, "icon-data", _) do
      default("")
    end

    def get_property(%{type: :checkbox}, "toggle-type", _) do
      ok_dbus_variant(:string, "checkmark")
    end

    def get_property(%{type: :radio}, "toggle-type", _) do
      ok_dbus_variant(:string, "radio")
    end

    def get_property(_, "toogle-type", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(_, "toggle-type", _) do
      default("")
    end

    def get_property(%{type: type, checked: true}, "toggle-state", _)
        when type in [:checkbox, :radio] do
      ok_dbus_variant(:int32, 1)
    end

    def get_property(%{type: type, checked: false}, "toggle-state", _)
        when type in [:checkbox, :radio] do
      ok_dbus_variant(:int32, 0)
    end

    def get_property(_, "toggle-state", :ignore_default) do
      ok_dbus_variant(:int32, -1)
    end

    def get_property(_, "toggle-state", _) do
      default(-1)
    end

    def get_property(%{type: type, children: [_ | _]}, "children-display", _)
        when type in [:root, :menu] do
      ok_dbus_variant(:string, "submenu")
    end

    def get_property(_, "children-display", :ignore_default) do
      ok_dbus_variant(:string, "")
    end

    def get_property(_, "children-display", _) do
      default("")
    end

    def get_property(_, _, _) do
      {:error, "org.freedesktop.DBus.Error.UnknownProperty", "Invalid property"}
    end

    def get_properties(item, []) do
      get_properties(item, [
        "type",
        "enabled",
        "visible",
        "label",
        "icon-name",
        "icon-data",
        "toggle-type",
        "toggle-state",
        "children-display"
      ])
    end

    def get_properties(item, properties) do
      get_properties(item, properties, [])
    end

    def get_properties(item, properties, opts) do
      properties
      |> Enum.reduce([], fn property, acc ->
        case get_property(item, property, opts) do
          {:ok, value} -> [{property, value} | acc]
          _ -> acc
        end
      end)
    end

    defp ok_dbus_variant(type, value) do
      {:ok, {:dbus_variant, type, value}}
    end

    defp default(value) do
      {:default, value}
    end
  end
end