//////////////////////////////////////////////////////////////////////////////////////////
//                               ___            _     ___                               //
//                               |   |   \/    | ) |  |                                 //
//                           O-  |-  |   |  -  |   |  |-  -O                            //
//                               |   |_  |     |   |  |_                                //
//                                                                                      //
//////////////////////////////////////////////////////////////////////////////////////////

// SPDX-FileCopyrightText: Simon Schneegans <code@simonschneegans.de>
// SPDX-License-Identifier: MIT

'use strict';

import GLib from 'gi://GLib';
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import Cairo from 'gi://cairo';
import St from 'gi://St';
import Cogl from 'gi://Cogl';

import * as utils from '../common/utils.js';

//////////////////////////////////////////////////////////////////////////////////////////
// This class is instantiated once by the Menu and is responsible for drawing the       //
// wedges behind the currently active menu. The wedge beneath the mouse pointer is      //
// highlighted. Signals such as "child-hovered-event" or "parent-selected-event" are    //
// fired once the user clicks inside one of the wedges.                                 //
// This class also tracks the mouse movement and registers stroke direction changes for //
// the marking mode.                                                                    //
//                                                                                      //
// Originally I tried to implement the wedge-drawing with a huge Clutter.Canvas, but    //
// this turned out to be much to slow. Then I tried to override vfunc_paint() of the    //
// actor, but I failed to create a shader program in GJS.                               //
//  * There are some deprecated Cogl.create_*() methods but they return undefined.      //
//  * The new CoglSnippet API of Cogl 2.0+ is not yet available as it seems.            //
// I still believe that this approach would be better than the Clutter.ShaderEffect I   //
// am using now. As a Clutter.OffscreenEffect, the Clutter.ShaderEffect allocates an    //
// offscreen framebuffer for rendering which is not required in our case. So if there   //
// are performance issues, this is definitely something to look at!                     //
//                                                                                      //
// In the current implementation one large square-shaped actor with a                   //
// Clutter.ShaderEffect is responsible for drawing the wedge-fill gradients, including  //
// the currently highlighted one. Then there are multiple smaller elongated actors,     //
// one for each separator line. They all share one Clutter.Canvas which contains a      //
// simple gradient line drawn with Cairo.                                               //
//////////////////////////////////////////////////////////////////////////////////////////

// clang-format off
export var SelectionWedges = GObject.registerClass({
  Properties: {},
  Signals: {
    // This is fired when the mouse pointer enters one of the wedges. The index of the
    // corresponding child is passed in as parameter. If no wedge is hovered anymore
    // (e.g. the center element is hovered) -1 will be passed in.
    "child-hovered-event":    { param_types: [GObject.TYPE_INT] },

    // This is fired when the primary mouse button is pressed inside a wedge. It will also
    // be fired if a gesture was detected (either a corner or a timeout). In the latter
    // case, the passed boolean will be true. The first integer is the child's index, the
    // last two integers are the pixel coordinates of the selection event.
    "child-selected-event":   { param_types: [GObject.TYPE_INT, GObject.TYPE_BOOLEAN,
                                              GObject.TYPE_INT, GObject.TYPE_INT] },

    // Same as "child-hovered-event", but for the parent wedge.
    "parent-hovered-event":   {},

    // Same as "child-selected-event", but for the parent wedge.
    "parent-selected-event":  { param_types: [GObject.TYPE_BOOLEAN,
                                              GObject.TYPE_INT, GObject.TYPE_INT] },

    // This is fired if secondary mouse button is pressed. In future there might be ofter
    // reasons for this to get fired.
    "cancel-selection-event": {},
  }
},
class SelectionWedges extends Clutter.Actor {
      // clang-format on

      // ------------------------------------------------------------ constructor /
      // destructor

      _init(params = {}) {
        super._init(params);

        // This will contain the angles at which separator lines should be drawn. In
        // degree, clockwise, O° at the top.
        this._separatorAngles = [];

        // This is the index of the currently hovered wedge.
        this._hoveredWedge = -1;

        // This is the index of the parent wedge.
        this._parentIndex = -1;

        // This stores information required for gesture detection.
        this._stroke = {
          start: null,         // The point where the mouse stroke began.
          end: null,           // The current end point of the stroke.
          pauseTimeout: null,  // A timeout ID which is used for stroke pause detection.
        };

        // This is attached as a child to *this* and is responsible for drawing the
        // wedge radial gradient. This is done with a Clutter.ShaderEffect as this is much
        // faster than a Clutter.Canvas.
        this._wedgeActor  = new Clutter.Actor();
        this._wedgeShader = new Clutter.ShaderEffect({
          shader_type: Cogl.ShaderType ? Cogl.ShaderType.FRAGMENT :
                                         Clutter.ShaderType.FRAGMENT_SHADER,
        });

        // This shader could be simplified by using vec4's for the colors, but I did not
        // manage to call Clutter.ShaderEffect.set_uniform_value() accordingly from GJS.
        // If anybody knows how this is accomplished, feel free to send a pull request :-)
        this._wedgeShader.set_shader_source(`
      uniform float r;
      uniform float g;
      uniform float b;
      uniform float a;

      uniform float rHover;
      uniform float gHover;
      uniform float bHover;
      uniform float aHover;

      uniform float innerRadius;
      uniform float startAngle;
      uniform float endAngle;

      void main(void) {

        // We first assume the normal color for all fragments.
        cogl_color_out = vec4(r, g, b, a);
        
        // Then we use the hover color for fragments outside the innerRadius and
        // between startAngle and endAngle.
        vec2  pos      = 2.0 * (cogl_tex_coord_in[0].xy - vec2(0.5));
        float distance = sqrt(pos.x * pos.x + pos.y * pos.y);
        float angle    = 0.0;
        if (distance > innerRadius) {
          angle = acos(pos.x / distance) * 180.0 / 3.141592653589793;
          if (pos.y < 0.0) {
            angle = 360.0 - angle;
          }
          
          angle = mod((angle + 90.0), 360.0);
          if (angle > startAngle && angle <= endAngle ||
              angle + 360.0 > startAngle && angle + 360.0 <= endAngle) {

            cogl_color_out = vec4(rHover, gHover, bHover, aHover);
          }
        }

        // Fade-out the color towards the outer radius.
        float distFac     = 1.0 - clamp((length(pos) - innerRadius) / (1.0-innerRadius), 0.0, 1.0);
        cogl_color_out.a *= distFac;

        // Inherit the opacity of parent actors.
        cogl_color_out.a *= cogl_color_in.a;
        
        // We use premultiplied alpha.
        cogl_color_out.rgb *= cogl_color_out.a;
      }
    `);

        this._wedgeActor.add_effect(this._wedgeShader);
        this.add_child(this._wedgeActor);

        // This actor acts as a group for all separators so that we can easily iterate
        // over them and destroy them once we're done.
        this._separators = new Clutter.Actor();
        this.add_child(this._separators);
      }

      // -------------------------------------------------------------------- public
      // interface

      // This is called once in the beginning and then whenever the user changes something
      // in the settings.
      onSettingsChange(settings) {

        // Parse all settings required for wedge rendering.
        const globalScale = settings.get_double('global-scale') * utils.getHDPIScale();

        // clang-format off
        this._settings = {
          wedgeWidth:              settings.get_double('wedge-width')          * globalScale,
          wedgeInnerRadius:        settings.get_double('wedge-inner-radius')   * globalScale,
          wedgeColor:              utils.parseColor(settings.get_string('wedge-color'))[1],
          wedgeColorHover:         utils.parseColor(settings.get_string('wedge-color-hover'))[1],
          wedgeSeparatorColor:     utils.parseColor(settings.get_string('wedge-separator-color'))[1],
          wedgeSeparatorWidth:     settings.get_double('wedge-separator-width') * globalScale,
          gestureSelectionTimeout: settings.get_double('gesture-selection-timeout'),
          gestureJitterThreshold:  settings.get_double('gesture-jitter-threshold')  * globalScale,
          gestureMinStrokeLength:  settings.get_double('gesture-min-stroke-length') * globalScale,
          gestureMinStrokeAngle:   settings.get_double('gesture-min-stroke-angle'),
          hoverMode:               settings.get_boolean('hover-mode'),
        };
        // clang-format on

        // Update the size and position of the wedge actor.
        const outerRadius = this._settings.wedgeInnerRadius + this._settings.wedgeWidth;
        this._wedgeActor.set_size(outerRadius * 2, outerRadius * 2);
        this._wedgeActor.set_translation(-outerRadius, -outerRadius, 0);

        // Update the size and position of all separator actors.
        this._separators.get_children().forEach(child => {
          child.set_size(
              this._settings.wedgeInnerRadius + this._settings.wedgeWidth,
              this._settings.wedgeSeparatorWidth + 5);
          child.translation_y = -child.height / 2;
          child.queue_repaint();
        });

        // This could be shortened if we could set vec4's as uniforms...
        this._setFloatUniform('r', this._settings.wedgeColor.red / 255);
        this._setFloatUniform('g', this._settings.wedgeColor.green / 255);
        this._setFloatUniform('b', this._settings.wedgeColor.blue / 255);
        this._setFloatUniform('a', this._settings.wedgeColor.alpha / 255);

        this._setFloatUniform('rHover', this._settings.wedgeColorHover.red / 255);
        this._setFloatUniform('gHover', this._settings.wedgeColorHover.green / 255);
        this._setFloatUniform('bHover', this._settings.wedgeColorHover.blue / 255);
        this._setFloatUniform('aHover', this._settings.wedgeColorHover.alpha / 255);

        this._setFloatUniform(
            'innerRadius', this._settings.wedgeInnerRadius / outerRadius);
      }

      // Returns the currently hovered child's index. Returns -1 if either no wedge or the
      // parent wedge is currently hovered.
      getHoveredChild() {
        if (this._hoveredWedge < 0 || this._parentIndex < 0) {
          return this._hoveredWedge;
        }

        if (this._parentIndex == this._hoveredWedge) {
          return -1;
        }

        // There is a parent wedge - we have to decrease all indices after the parent
        // wedge by one.
        return (this._hoveredWedge > this._parentIndex) ? this._hoveredWedge - 1 :
                                                          this._hoveredWedge;
      }

      // Given the angular positions of all child items, this calculates the separator
      // angles. It deletes all previous separator actors and creates new ones
      // accordingly. The itemAngles array should be sorted, but the smallest value has
      // not to be at the start. That means, the smallest value can be somewhere in the
      // list, all following angles should be monotonically increasing continuing at the
      // start of the list (in a ring-like fashion).
      setItemAngles(itemAngles, parentAngle) {

        this._itemAngles   = itemAngles;
        this._hoveredWedge = -1;
        this._parentIndex  = -1;

        this._setFloatUniform('startAngle', 0);
        this._setFloatUniform('endAngle', 0);

        // If a parentAngle is given, we have to insert it into the list. Due to the
        // ring-like sorting of the itemAngles, this is surprisingly involved.
        if (parentAngle != undefined) {
          for (let i = 0; i <= itemAngles.length; i++) {
            let doInsertion = false;

            // Insert it definitely if we reached the end of the list.
            if (i == itemAngles.length) {
              doInsertion = true;

            } else if (i > 0) {
              if (itemAngles[i - 1] < parentAngle && parentAngle < itemAngles[i]) {
                doInsertion = true;
              }

              if (itemAngles[i - 1] > itemAngles[i]) {
                if (parentAngle < itemAngles[i]) doInsertion = true;
                if (parentAngle > itemAngles[i - 1]) doInsertion = true;
              }
            }

            // We found the index to insert it.
            if (doInsertion) {
              this._parentIndex = i;
              itemAngles.splice(i, 0, parentAngle);
              break;
            }
          }
        }

        // Destroy obsolete actors. We could re-use existing ones, but they seem to be
        // quite cheap to construct.
        this._separatorAngles = [];
        this._separators.destroy_all_children();

        // There are no separators if we have only one or no child items.
        if (itemAngles.length >= 2) {
          for (let i = 0; i < itemAngles.length; i++) {
            const startAngle =
                itemAngles[(i + itemAngles.length - 1) % itemAngles.length];
            let endAngle = itemAngles[i];

            // Make sure we wrap around.
            if (endAngle < startAngle) {
              endAngle += 360;
            }

            // The separator is exactly in the middle of two items.
            const separatorAngle = (startAngle + endAngle) / 2;
            this._separatorAngles.push(separatorAngle);

            // Now create the separator actor and rotate it according to the angle. It
            // just contains a simple line with a gradient.
            const separator = new St.DrawingArea({
              width: this._settings.wedgeInnerRadius + this._settings.wedgeWidth,
              height: this._settings.wedgeSeparatorWidth + 5
            });

            separator.connect('repaint', (canvas) => {
              const ctx             = canvas.get_context();
              const [width, height] = canvas.get_surface_size();

              ctx.setOperator(Cairo.Operator.CLEAR);
              ctx.paint();
              ctx.setOperator(Cairo.Operator.OVER);

              const gradient =
                  new Cairo.LinearGradient(this._settings.wedgeInnerRadius, 0, width, 0);
              const color = this._settings.wedgeSeparatorColor;
              gradient.addColorStopRGBA(
                  0, color.red / 255, color.green / 255, color.blue / 255,
                  color.alpha / 255);
              gradient.addColorStopRGBA(
                  1, color.red / 255, color.green / 255, color.blue / 255, 0);

              ctx.setLineWidth(this._settings.wedgeSeparatorWidth);
              ctx.setLineCap(Cairo.LineCap.ROUND);
              ctx.moveTo(this._settings.wedgeInnerRadius, height / 2);
              ctx.lineTo(width, height / 2);
              ctx.setSource(gradient);
              ctx.stroke();

              // Explicitly tell Cairo to free the context memory. Is this really
              // necessary?
              ctx.$dispose();
            });

            // Turn by 90° as 0° is up in our case.
            separator.rotation_angle_z = separatorAngle - 90;
            separator.translation_y    = -separator.height / 2;
            separator.set_pivot_point(0, 0.5);
            this._separators.add_child(separator);
          }
        }
      }

      // This emits 'parent-selected-event' or 'child-selected-event' depending on the
      // currently hovered wedge. It also resets the current gesture detection.
      emitSelection(coords, fromGesture = false) {
        this.resetStroke();

        if (this._hoveredWedge >= 0) {
          if (this._hoveredWedge == this._parentIndex) {
            this.emit('parent-selected-event', fromGesture, coords[0], coords[1]);
          } else {
            this.emit(
                'child-selected-event', this.getHoveredChild(), fromGesture, coords[0],
                coords[1]);
          }
        } else if (!fromGesture) {
          if (this._parentIndex >= 0) {
            this.emit('parent-selected-event', fromGesture, coords[0], coords[1]);
          } else {
            this.emit('cancel-selection-event');
          }
        }
      }

      // Given the relative pointer position, this calculates the currently active child
      // wedge. For a wedge to become active, the pointer position must be farther away
      // from the center than defined by the wedge-inner-radius settings key. It emits the
      // 'parent-hovered-event' and 'child-hovered-event' signals when the hovered wedge
      // changes. It also tracks the motion of the mouse while the left mouse button is
      // pressed and emits selection events when a stroke corner or a pause in the motion
      // is detected.
      onMotionEvent(coords, state) {
        const [ok, x, y] = this.transform_stage_point(coords[0], coords[1]);

        const distance             = Math.sqrt(x * x + y * y);
        let hoveredWedge           = -1;
        let hoveredWedgeStartAngle = 0;
        let hoveredWedgeEndAngle   = 0;

        // There is only something hovered if the pointer is outside the inner circle.
        if (distance > this._settings.wedgeInnerRadius) {
          let angle = Math.acos(x / distance) * 180 / Math.PI;
          if (y < 0) {
            angle = 360 - angle;
          }

          // Turn 0° up.
          angle = (angle + 90) % 360;

          // If there is only one full 360°-wedge, it is hovered.
          if (this._itemAngles.length == 1) {
            hoveredWedge           = 0;
            hoveredWedgeStartAngle = 0;
            hoveredWedgeEndAngle   = 360;
          }

          // Now search the wedge the pointer resides in currently.
          for (let i = 0; i < this._separatorAngles.length; i++) {
            const startAngle = this._separatorAngles[i];
            let endAngle = this._separatorAngles[(i + 1) % this._separatorAngles.length];

            // Make sure we wrap around.
            if (endAngle < startAngle) {
              endAngle += 360;
            }

            if ((angle > startAngle && angle <= endAngle) ||
                (angle + 360 > startAngle && angle + 360 <= endAngle)) {
              hoveredWedge           = i;
              hoveredWedgeStartAngle = startAngle;
              hoveredWedgeEndAngle   = endAngle;
              break
            }
          }
        }

        // Update uniforms only if hovered wedge changed.
        if (hoveredWedge != this._hoveredWedge) {
          this._setFloatUniform('startAngle', hoveredWedgeStartAngle);
          this._setFloatUniform('endAngle', hoveredWedgeEndAngle);
          this._hoveredWedge = hoveredWedge;

          if (hoveredWedge >= 0 && hoveredWedge == this._parentIndex) {
            this.emit('parent-hovered-event');
          } else {
            this.emit('child-hovered-event', this.getHoveredChild());
          }
        }

        // Now we try to detect gestures. This is done only if either the left mouse
        // button is pressed or a modifier key is held down. Consider the diagram below:
        //
        //                                  M
        //                                .
        //                              .
        //     S -------------------- E
        //
        // The mouse button was pressed at S (_stroke.start) and the moved to E
        // (_stroke.end). When the next motion event comes in (M) and the left button is
        // still pressed, we compare the directions of S->E with E->M. If they differ
        // significantly, this is considered a corner. There are some minimum lengths for
        // both vectors - if they are not long enough, nothing is done. If E->M is long
        // enough, but there is no corner, E is set to M and we wait for the next motion
        // event.
        if (this.isGestureModifier(state)) {
          if (this._stroke.start == null) {

            // It's the first event of this gesture, so we store the current mouse
            // position as start and end. There is nothing more to be done.
            this._stroke.start = coords;
            this._stroke.end   = coords;

          } else {

            // Calculate the vector S->E in the diagram above.
            const strokeDir = [
              this._stroke.end[0] - this._stroke.start[0],
              this._stroke.end[1] - this._stroke.start[1]
            ];

            const strokeLength =
                Math.sqrt(strokeDir[0] * strokeDir[0] + strokeDir[1] * strokeDir[1]);

            if (strokeLength > this._settings.gestureMinStrokeLength) {

              // Calculate the vector E->M in the diagram above.
              const tipDir =
                  [coords[0] - this._stroke.end[0], coords[1] - this._stroke.end[1]];

              const tipLength = Math.sqrt(tipDir[0] * tipDir[0] + tipDir[1] * tipDir[1]);

              if (tipLength > this._settings.gestureJitterThreshold) {

                // If the tip vector is long enough, the pointer was not stationary.
                // Remove the timer again.
                if (this._stroke.pauseTimeout != null) {
                  GLib.source_remove(this._stroke.pauseTimeout);
                  this._stroke.pauseTimeout = null;
                }

                // Now compute the angle between S->E and E->M.
                const angle = Math.acos(
                    tipDir[0] / tipLength * strokeDir[0] / strokeLength +
                    tipDir[1] / tipLength * strokeDir[1] / strokeLength);

                //  Emit the selection events if it exceeds the configured threshold. We
                //  pass the coordinates of E for the selection event.
                if (angle * 180 / Math.PI > this._settings.gestureMinStrokeAngle) {
                  this.emitSelection(this._stroke.end, true);
                }

                // Update the point M in the diagram above to be the new E for the next
                // motion event.
                this._stroke.end = coords;
              }

              // The stroke is long enough to become a gesture. We register a timer to
              // detect pause-events where the pointer was stationary for some time. These
              // events also lead to selections. If the selection timeout is set to zero,
              // we emit the selection instantaneously.
              if (this._settings.gestureSelectionTimeout == 0) {

                // If the selection timeout is set to zero, we want to select an item as
                // soon as the pointer exceeds the minimum stroke length. As the pointer
                // may have moved really quickly, we reconstruct where it left this
                // radius.
                const scale = this._settings.gestureMinStrokeLength / strokeLength;
                const selectionCoords = [
                  this._stroke.start[0] + strokeDir[0] * scale,
                  this._stroke.start[1] + strokeDir[1] * scale
                ];

                this.emitSelection(selectionCoords, true);

              } else if (this._stroke.pauseTimeout == null) {
                this._stroke.pauseTimeout = GLib.timeout_add(
                    GLib.PRIORITY_DEFAULT, this._settings.gestureSelectionTimeout, () => {
                      this.emitSelection(coords, true);
                      return false;
                    });
              }

            } else {

              // The vector S->E is not long enough to be a gesture, so we only update the
              // end point.
              this._stroke.end = coords;
            }
          }
        } else {
          // The mouse button is not pressed anymore, so we can abort gesture detection.
          this.resetStroke();
        }
      }


      // Returns true if the left or right button is pressed, or a modifier is held down
      // (for the "Turbo-Mode"). Thanks to the Super+RMB mode, we can actually select
      // items with the right mouse button...
      isGestureModifier(mods) {
        const hoverMode = this._settings.hoverMode;
        const buttonPressed =
            (mods &
             (Clutter.ModifierType.BUTTON1_MASK | Clutter.ModifierType.BUTTON2_MASK |
              Clutter.ModifierType.BUTTON3_MASK)) > 0;

        const shortcutPressed =
            (mods &
             (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK |
              Clutter.ModifierType.MOD1_MASK | Clutter.ModifierType.SUPER_MASK |
              Clutter.ModifierType.HYPER_MASK | Clutter.ModifierType.META_MASK |
              Clutter.ModifierType.MOD4_MASK)) > 0;

        return hoverMode || buttonPressed || shortcutPressed;
      }

      // Resets the current gesture detection. This is done when an item was selected or
      // the mouse pointer released.
      resetStroke() {
        if (this._stroke.pauseTimeout != null) {
          GLib.source_remove(this._stroke.pauseTimeout);
          this._stroke.pauseTimeout = null;
        }

        this._stroke.start = null;
        this._stroke.end   = null;
      }

      // ----------------------------------------------------------------------- private
      // stuff

      // Clutter.ShaderEffect.set_uniform_value() works well if floating point Numbers are
      // passed to the method. However, when you pass an integer Number, an OpenGL error
      // (1282, Invalid operation) is thrown. It seems that the implementation assumes in
      // this case that the uniform is actually of type int. Here is a crude workaround -
      // I just make sure that no integers are passed to the method!
      _setFloatUniform(name, value) {
        if (Number.isInteger(value)) {
          value += 0.0000001;
        }
        this._wedgeShader.set_uniform_value(name, value);
      }
    });
