1# frozen_string_literal: true 2 3module Gitlab 4 module RelativePositioning 5 class Mover 6 attr_reader :range, :start_position 7 8 def initialize(start, range) 9 @range = range 10 @start_position = start 11 end 12 13 def move_to_end(object) 14 focus = context(object, ignoring: object) 15 max_pos = focus.max_relative_position 16 17 move_to_range_end(focus, max_pos) 18 end 19 20 def move_to_start(object) 21 focus = context(object, ignoring: object) 22 min_pos = focus.min_relative_position 23 24 move_to_range_start(focus, min_pos) 25 end 26 27 def move(object, first, last) 28 raise ArgumentError, 'object is required' unless object 29 30 lhs = context(first, ignoring: object) 31 rhs = context(last, ignoring: object) 32 focus = context(object) 33 range = RelativePositioning.range(lhs, rhs) 34 35 if range.cover?(focus) 36 # Moving a object already within a range is a no-op 37 elsif range.open_on_left? 38 move_to_range_start(focus, range.rhs.relative_position) 39 elsif range.open_on_right? 40 move_to_range_end(focus, range.lhs.relative_position) 41 else 42 pos_left, pos_right = create_space_between(range) 43 desired_position = position_between(pos_left, pos_right) 44 focus.place_at_position(desired_position, range.lhs) 45 end 46 end 47 48 def context(object, ignoring: nil) 49 return unless object 50 51 ItemContext.new(object, range, ignoring: ignoring) 52 end 53 54 private 55 56 def gap_too_small?(pos_a, pos_b) 57 return false unless pos_a && pos_b 58 59 (pos_a - pos_b).abs < MIN_GAP 60 end 61 62 def move_to_range_end(context, max_pos) 63 range_end = range.last + 1 64 65 new_pos = if max_pos.nil? 66 start_position 67 elsif gap_too_small?(max_pos, range_end) 68 max = context.max_sibling 69 max.ignoring = context.object 70 max.shift_left 71 position_between(max.relative_position, range_end) 72 else 73 position_between(max_pos, range_end) 74 end 75 76 context.object.relative_position = new_pos 77 end 78 79 def move_to_range_start(context, min_pos) 80 range_end = range.first - 1 81 82 new_pos = if min_pos.nil? 83 start_position 84 elsif gap_too_small?(min_pos, range_end) 85 sib = context.min_sibling 86 sib.ignoring = context.object 87 sib.shift_right 88 position_between(sib.relative_position, range_end) 89 else 90 position_between(min_pos, range_end) 91 end 92 93 context.object.relative_position = new_pos 94 end 95 96 def create_space_between(range) 97 pos_left = range.lhs&.relative_position 98 pos_right = range.rhs&.relative_position 99 100 return [pos_left, pos_right] unless gap_too_small?(pos_left, pos_right) 101 102 gap = range.rhs.create_space_left 103 [pos_left - gap.delta, pos_right] 104 rescue NoSpaceLeft 105 gap = range.lhs.create_space_right 106 [pos_left, pos_right + gap.delta] 107 end 108 109 # This method takes two integer values (positions) and 110 # calculates the position between them. The range is huge as 111 # the maximum integer value is 2147483647. 112 # 113 # We avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION]. 114 # 115 # Then we handle one of three cases: 116 # - If the gap is too small, we raise NoSpaceLeft 117 # - If the gap is larger than MAX_GAP, we place the new position at most 118 # IDEAL_DISTANCE from the edge of the gap. 119 # - otherwise we place the new position at the midpoint. 120 # 121 # The new position will always satisfy: pos_before <= midpoint <= pos_after 122 # 123 # As a precondition, the gap between pos_before and pos_after MUST be >= 2. 124 # If the gap is too small, NoSpaceLeft is raised. 125 # 126 # @raises NoSpaceLeft 127 def position_between(pos_before, pos_after) 128 pos_before ||= range.first 129 pos_after ||= range.last 130 131 pos_before, pos_after = [pos_before, pos_after].sort 132 133 gap_width = pos_after - pos_before 134 135 if gap_too_small?(pos_before, pos_after) 136 raise NoSpaceLeft 137 elsif gap_width > MAX_GAP 138 if pos_before <= range.first 139 pos_after - IDEAL_DISTANCE 140 elsif pos_after >= range.last 141 pos_before + IDEAL_DISTANCE 142 else 143 midpoint(pos_before, pos_after) 144 end 145 else 146 midpoint(pos_before, pos_after) 147 end 148 end 149 150 def midpoint(lower_bound, upper_bound) 151 ((lower_bound + upper_bound) / 2.0).ceil.clamp(lower_bound, upper_bound - 1) 152 end 153 end 154 end 155end 156