summaryrefslogtreecommitdiff
path: root/lib/gitlab/relative_positioning/mover.rb
blob: 9d891bfbe3b733310bef3e826d62b1568827b84c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# frozen_string_literal: true

module Gitlab
  module RelativePositioning
    class Mover
      attr_reader :range, :start_position

      def initialize(start, range)
        @range = range
        @start_position = start
      end

      def move_to_end(object)
        focus = context(object, ignoring: object)
        max_pos = focus.max_relative_position

        move_to_range_end(focus, max_pos)
      end

      def move_to_start(object)
        focus = context(object, ignoring: object)
        min_pos = focus.min_relative_position

        move_to_range_start(focus, min_pos)
      end

      def move(object, first, last)
        raise ArgumentError, 'object is required' unless object

        lhs = context(first, ignoring: object)
        rhs = context(last, ignoring: object)
        focus = context(object)
        range = RelativePositioning.range(lhs, rhs)

        if range.cover?(focus)
          # Moving a object already within a range is a no-op
        elsif range.open_on_left?
          move_to_range_start(focus, range.rhs.relative_position)
        elsif range.open_on_right?
          move_to_range_end(focus, range.lhs.relative_position)
        else
          pos_left, pos_right = create_space_between(range)
          desired_position = position_between(pos_left, pos_right)
          focus.place_at_position(desired_position, range.lhs)
        end
      end

      def context(object, ignoring: nil)
        return unless object

        ItemContext.new(object, range, ignoring: ignoring)
      end

      private

      def gap_too_small?(pos_a, pos_b)
        return false unless pos_a && pos_b

        (pos_a - pos_b).abs < MIN_GAP
      end

      def move_to_range_end(context, max_pos)
        range_end = range.last + 1

        new_pos = if max_pos.nil?
                    start_position
                  elsif gap_too_small?(max_pos, range_end)
                    max = context.max_sibling
                    max.ignoring = context.object
                    max.shift_left
                    position_between(max.relative_position, range_end)
                  else
                    position_between(max_pos, range_end)
                  end

        context.object.relative_position = new_pos
      end

      def move_to_range_start(context, min_pos)
        range_end = range.first - 1

        new_pos = if min_pos.nil?
                    start_position
                  elsif gap_too_small?(min_pos, range_end)
                    sib = context.min_sibling
                    sib.ignoring = context.object
                    sib.shift_right
                    position_between(sib.relative_position, range_end)
                  else
                    position_between(min_pos, range_end)
                  end

        context.object.relative_position = new_pos
      end

      def create_space_between(range)
        pos_left = range.lhs&.relative_position
        pos_right = range.rhs&.relative_position

        return [pos_left, pos_right] unless gap_too_small?(pos_left, pos_right)

        gap = range.rhs.create_space_left
        [pos_left - gap.delta, pos_right]
      rescue NoSpaceLeft
        gap = range.lhs.create_space_right
        [pos_left, pos_right + gap.delta]
      end

      # This method takes two integer values (positions) and
      # calculates the position between them. The range is huge as
      # the maximum integer value is 2147483647.
      #
      # We avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION].
      #
      # Then we handle one of three cases:
      #  - If the gap is too small, we raise NoSpaceLeft
      #  - If the gap is larger than MAX_GAP, we place the new position at most
      #    IDEAL_DISTANCE from the edge of the gap.
      #  - otherwise we place the new position at the midpoint.
      #
      # The new position will always satisfy: pos_before <= midpoint <= pos_after
      #
      # As a precondition, the gap between pos_before and pos_after MUST be >= 2.
      # If the gap is too small, NoSpaceLeft is raised.
      #
      # @raises NoSpaceLeft
      def position_between(pos_before, pos_after)
        pos_before ||= range.first
        pos_after ||= range.last

        pos_before, pos_after = [pos_before, pos_after].sort

        gap_width = pos_after - pos_before

        if gap_too_small?(pos_before, pos_after)
          raise NoSpaceLeft
        elsif gap_width > MAX_GAP
          if pos_before <= range.first
            pos_after - IDEAL_DISTANCE
          elsif pos_after >= range.last
            pos_before + IDEAL_DISTANCE
          else
            midpoint(pos_before, pos_after)
          end
        else
          midpoint(pos_before, pos_after)
        end
      end

      def midpoint(lower_bound, upper_bound)
        ((lower_bound + upper_bound) / 2.0).ceil.clamp(lower_bound, upper_bound - 1)
      end
    end
  end
end