aboutsummaryrefslogtreecommitdiff
path: root/data/core/scrollbar.lua
blob: f657c5ed7be1650997d38bea04cda09a673f772d (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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
local core = require "core"
local common = require "core.common"
local config = require "core.config"
local style = require "core.style"
local Object = require "core.object"

---Scrollbar
---Use Scrollbar:set_size to set the bounding box of the view the scrollbar belongs to.
---Use Scrollbar:update to update the scrollbar animations.
---Use Scrollbar:draw to draw the scrollbar.
---Use Scrollbar:on_mouse_pressed, Scrollbar:on_mouse_released,
---Scrollbar:on_mouse_moved and Scrollbar:on_mouse_left to react to mouse movements;
---the scrollbar won't update automatically.
---Use Scrollbar:set_percent to set the scrollbar location externally.
---
---To manage all the orientations, the scrollbar changes the coordinates system
---accordingly. The "normal" coordinate system adapts the scrollbar coordinates
---as if it's always a vertical scrollbar, positioned at the end of the bounding box.
---@class core.scrollbar : core.object
local Scrollbar = Object:extend()

function Scrollbar:__tostring() return "Scrollbar" end

---@class ScrollbarOptions
---@field direction "v" | "h" @Vertical or Horizontal
---@field alignment "s" | "e" @Start or End (left to right, top to bottom)
---@field force_status "expanded" | "contracted" | false @Force the scrollbar status
---@field expanded_size number? @Override the default value specified by `style.expanded_scrollbar_size`
---@field contracted_size number? @Override the default value specified by `style.scrollbar_size`
---@field minimum_thumb_size number? @Override the default value specified by `style.minimum_thumb_size`
---@field contracted_margin number? @Override the default value specified by `style.contracted_scrollbar_margin`
---@field expanded_margin number? @Override the default value specified by `style.expanded_scrollbar_margin`

---@param options ScrollbarOptions
function Scrollbar:new(options)
  ---Position information of the owner
  self.rect = {
    x = 0, y = 0, w = 0, h = 0,
    ---Scrollable size
    scrollable = 0
  }
  self.normal_rect = {
    across = 0,
    along = 0,
    across_size = 0,
    along_size = 0,
    scrollable = 0
  }
  ---@type integer @Position in percent [0-1]
  self.percent = 0
  ---@type boolean @Scrollbar dragging status
  self.dragging = false
  ---@type integer @Private. Used to offset the start of the drag from the top of the thumb
  self.drag_start_offset = 0
  ---What is currently being hovered. `thumb` implies` track`
  self.hovering = { track = false, thumb = false }
  ---@type "v" | "h"@Vertical or Horizontal
  self.direction = options.direction or "v"
  ---@type "s" | "e" @Start or End (left to right, top to bottom)
  self.alignment = options.alignment or "e"
  ---@type number @Private. Used to keep track of animations
  self.expand_percent = 0
  ---@type "expanded" | "contracted" | false @Force the scrollbar status
  self.force_status = options.force_status
  self:set_forced_status(options.force_status)
  ---@type number? @Override the default value specified by `style.scrollbar_size`
  self.contracted_size = options.contracted_size
  ---@type number? @Override the default value specified by `style.expanded_scrollbar_size`
  self.expanded_size = options.expanded_size
  ---@type number? @Override the default value specified by `style.minimum_thumb_size`
  self.minimum_thumb_size = options.minimum_thumb_size
  ---@type number? @Override the default value specified by `style.contracted_scrollbar_margin`
  self.contracted_margin = options.contracted_margin
  ---@type number? @Override the default value specified by `style.expanded_scrollbar_margin`
  self.expanded_margin = options.expanded_margin
end


---Set the status the scrollbar is forced to keep
---@param status "expanded" | "contracted" | false @The status to force
function Scrollbar:set_forced_status(status)
  self.force_status = status
  if self.force_status == "expanded" then
    self.expand_percent = 1
  end
end


function Scrollbar:real_to_normal(x, y, w, h)
  x, y, w, h = x or 0, y or 0, w or 0, h or 0
  if self.direction == "v" then
    if self.alignment == "s" then
      x = (self.rect.x + self.rect.w) - x - w
    end
    return x, y, w, h
  else
    if self.alignment == "s" then
      y = (self.rect.y + self.rect.h) - y - h
    end
    return y, x, h, w
  end
end


function Scrollbar:normal_to_real(x, y, w, h)
  x, y, w, h = x or 0, y or 0, w or 0, h or 0
  if self.direction == "v" then
    if self.alignment == "s" then
      x = (self.rect.x + self.rect.w) - x - w
    end
    return x, y, w, h
  else
    if self.alignment == "s" then
      x = (self.rect.y + self.rect.h) - x - w
    end
    return y, x, h, w
  end
end


function Scrollbar:_get_thumb_rect_normal()
  local nr = self.normal_rect
  local sz = nr.scrollable
  if sz == math.huge or sz <= nr.along_size
  then
    return 0, 0, 0, 0
  end
  local scrollbar_size = self.contracted_size or style.scrollbar_size
  local expanded_scrollbar_size = self.expanded_size or style.expanded_scrollbar_size
  local along_size = math.max(self.minimum_thumb_size or style.minimum_thumb_size, nr.along_size * nr.along_size / sz)
  local across_size = scrollbar_size
  across_size = across_size + (expanded_scrollbar_size - scrollbar_size) * self.expand_percent
  return
    nr.across + nr.across_size - across_size,
    nr.along + self.percent * (nr.along_size - along_size),
    across_size,
    along_size
end

---Get the thumb rect (the part of the scrollbar that can be dragged)
---@return integer,integer,integer,integer @x, y, w, h
function Scrollbar:get_thumb_rect()
  return self:normal_to_real(self:_get_thumb_rect_normal())
end


function Scrollbar:_get_track_rect_normal()
  local nr = self.normal_rect
  local sz = nr.scrollable
  if sz <= nr.along_size or sz == math.huge then
    return 0, 0, 0, 0
  end
  local scrollbar_size = self.contracted_size or style.scrollbar_size
  local expanded_scrollbar_size = self.expanded_size or style.expanded_scrollbar_size
  local across_size = scrollbar_size
  across_size = across_size + (expanded_scrollbar_size - scrollbar_size) * self.expand_percent
  return
    nr.across + nr.across_size - across_size,
    nr.along,
    across_size,
    nr.along_size
end

---Get the track rect (the "background" of the scrollbar)
---@return number,number,number,number @x, y, w, h
function Scrollbar:get_track_rect()
  return self:normal_to_real(self:_get_track_rect_normal())
end


function Scrollbar:_overlaps_normal(x, y)
  local sx, sy, sw, sh = self:_get_thumb_rect_normal()
  local scrollbar_margin =      self.expand_percent  * (self.expanded_margin or style.expanded_scrollbar_margin) +
                           (1 - self.expand_percent) * (self.contracted_margin or style.contracted_scrollbar_margin)
  local result
  if x >= sx - scrollbar_margin and x <= sx + sw and y >= sy and y <= sy + sh then
    result = "thumb"
  else
    sx, sy, sw, sh = self:_get_track_rect_normal()
    if x >= sx - scrollbar_margin and x <= sx + sw and y >= sy and y <= sy + sh then
      result = "track"
    end
  end
  return result
end

---Get what part of the scrollbar the coordinates overlap
---@return "thumb"|"track"|nil
function Scrollbar:overlaps(x, y)
  x, y = self:real_to_normal(x, y)
  return self:_overlaps_normal(x, y)
end


function Scrollbar:_on_mouse_pressed_normal(button, x, y, clicks)
  local overlaps = self:_overlaps_normal(x, y)
  if overlaps then
    local _, along, _, along_size = self:_get_thumb_rect_normal()
    self.dragging = true
    if overlaps == "thumb" then
      self.drag_start_offset = along - y
      return true
    elseif overlaps == "track" then
      local nr = self.normal_rect
      self.drag_start_offset = - along_size / 2
      return common.clamp((y - nr.along - along_size / 2) / (nr.along_size - along_size), 0, 1)
    end
  end
end

---Updates the scrollbar with mouse pressed info.
---Won't update the scrollbar position automatically.
---Use Scrollbar:set_percent to update it.
---
---This sets the dragging status if needed.
---
---Returns a falsy value if the event happened outside the scrollbar.
---Returns `true` if the thumb was pressed.
---If the track was pressed this returns a value between 0 and 1
---representing the percent of the position.
---@return boolean|number
function Scrollbar:on_mouse_pressed(button, x, y, clicks)
  if button ~= "left" then return end
  x, y = self:real_to_normal(x, y)
  return self:_on_mouse_pressed_normal(button, x, y, clicks)
end

---Updates the scrollbar hover status.
---This gets called by other functions and shouldn't be called manually
function Scrollbar:_update_hover_status_normal(x, y)
  local overlaps = self:_overlaps_normal(x, y)
  self.hovering.thumb = overlaps == "thumb"
  self.hovering.track = self.hovering.thumb or overlaps == "track"
  return self.hovering.track or self.hovering.thumb
end

function Scrollbar:_on_mouse_released_normal(button, x, y)
  self.dragging = false
  return self:_update_hover_status_normal(x, y)
end

---Updates the scrollbar dragging status
function Scrollbar:on_mouse_released(button, x, y)
  if button ~= "left" then return end
  x, y = self:real_to_normal(x, y)
  return self:_on_mouse_released_normal(button, x, y)
end


function Scrollbar:_on_mouse_moved_normal(x, y, dx, dy)
  if self.dragging then
    local nr = self.normal_rect
    local _, _, _, along_size = self:_get_thumb_rect_normal()
    return common.clamp((y - nr.along + self.drag_start_offset) / (nr.along_size - along_size), 0, 1)
  end
  return self:_update_hover_status_normal(x, y)
end

---Updates the scrollbar with mouse moved info.
---Won't update the scrollbar position automatically.
---Use Scrollbar:set_percent to update it.
---
---This updates the hovering status.
---
---Returns a falsy value if the event happened outside the scrollbar.
---Returns `true` if the scrollbar is hovered.
---If the scrollbar was being dragged, this returns a value between 0 and 1
---representing the percent of the position.
---@return boolean|number
function Scrollbar:on_mouse_moved(x, y, dx, dy)
  x, y = self:real_to_normal(x, y)
  dx, dy = self:real_to_normal(dx, dy) -- TODO: do we need this? (is this even correct?)
  return self:_on_mouse_moved_normal(x, y, dx, dy)
end

---Updates the scrollbar hovering status
function Scrollbar:on_mouse_left()
  self.hovering.track, self.hovering.thumb = false, false
end

---Updates the bounding box of the view the scrollbar belongs to.
---@param x number
---@param y number
---@param w number
---@param h number
---@param scrollable number @size of the scrollable area
function Scrollbar:set_size(x, y, w, h, scrollable)
  self.rect.x, self.rect.y, self.rect.w, self.rect.h = x, y, w, h
  self.rect.scrollable = scrollable

  local nr = self.normal_rect
  nr.across, nr.along, nr.across_size, nr.along_size = self:real_to_normal(x, y, w, h)
  nr.scrollable = scrollable
end

---Updates the scrollbar location
---@param percent number @number between 0 and 1 where 0 means thumb at the top and 1 at the bottom
function Scrollbar:set_percent(percent)
  self.percent = percent
end

---Updates the scrollbar animations
function Scrollbar:update()
  -- TODO: move the animation code to its own class
  if not self.force_status then
    local dest = (self.hovering.track or self.dragging) and 1 or 0
    local diff = math.abs(self.expand_percent - dest)
    if not config.transitions or diff < 0.05 or config.disabled_transitions["scroll"] then
      self.expand_percent = dest
    else
      local rate = 0.3
      if config.fps ~= 60 or config.animation_rate ~= 1 then
        local dt = 60 / config.fps
        rate = 1 - common.clamp(1 - rate, 1e-8, 1 - 1e-8)^(config.animation_rate * dt)
      end
      self.expand_percent = common.lerp(self.expand_percent, dest, rate)
    end
    if diff > 1e-8 then
      core.redraw = true
    end
  elseif self.force_status == "expanded" then
    self.expand_percent = 1
  elseif self.force_status == "contracted" then
    self.expand_percent = 0
  end
end


---Draw the scrollbar track
function Scrollbar:draw_track()
  if not (self.hovering.track or self.dragging)
     and self.expand_percent == 0 then
    return
  end
  local color = { table.unpack(style.scrollbar_track) }
  color[4] = color[4] * self.expand_percent
  local x, y, w, h = self:get_track_rect()
  renderer.draw_rect(x, y, w, h, color)
end

---Draw the scrollbar thumb
function Scrollbar:draw_thumb()
  local highlight = self.hovering.thumb or self.dragging
  local color = highlight and style.scrollbar2 or style.scrollbar
  local x, y, w, h = self:get_thumb_rect()
  renderer.draw_rect(x, y, w, h, color)
end

---Draw both the scrollbar track and thumb
function Scrollbar:draw()
  self:draw_track()
  self:draw_thumb()
end

return Scrollbar