---------------------------------------------------------------------------
-- A layout that allows its children to take more space than what's available
-- in the surrounding container. If the content does exceed the available
-- size, a scrollbar is added and scrolling behavior enabled.
--
--@DOC_wibox_layout_defaults_overflow_EXAMPLE@
-- @author Lucas Schwiderski
-- @copyright 2021 Lucas Schwiderski
-- @layoutmod wibox.layout.overflow
-- @supermodule wibox.layout.fixed
---------------------------------------------------------------------------

local base = require('wibox.widget.base')
local fixed = require('wibox.layout.fixed')
local separator = require('wibox.widget.separator')
local gtable = require('gears.table')
local gshape = require('gears.shape')
local gobject = require('gears.object')
local mousegrabber = mousegrabber

local rubato = require("src.lib.rubato")

local overflow = { mt = {} }

-- Determine the required space to draw the layout's children and, if necessary,
-- the scrollbar.
function overflow:fit(context, orig_width, orig_height)
  local widgets = self._private.widgets
  local num_widgets = #widgets
  if num_widgets < 1 then
    return 0, 0
  end

  local width, height = orig_width, orig_height
  local scrollbar_width = self._private.scrollbar_width
  local scrollbar_enabled = self._private.scrollbar_enabled
  local used_in_dir, used_max = 0, 0
  local is_y = self._private.dir == "y"
  local avail_in_dir = is_y and orig_height or orig_width

  -- Set the direction covered by scrolling to the maximum value
  -- to allow widgets to take as much space as they want.
  if is_y then
    height = math.huge
  else
    width = math.huge
  end

  -- First, determine widget sizes.
  -- Only when the content doesn't fit and needs scrolling should
  -- we reduce content size to make space for a scrollbar.
  for _, widget in ipairs(widgets) do
    local w, h = base.fit_widget(self, context, widget, width, height)

    if is_y then
      used_max = math.max(used_max, w)
      used_in_dir = used_in_dir + h
    else
      used_max = math.max(used_max, h)
      used_in_dir = used_in_dir + w
    end
  end

  local spacing = self._private.spacing * (num_widgets - 1)
  used_in_dir = used_in_dir + spacing

  local need_scrollbar = scrollbar_enabled and used_in_dir > avail_in_dir

  -- Even if `used_max == orig_(width|height)` already, `base.fit_widget`
  -- will clamp return values, so we can "overextend" here.
  if need_scrollbar then
    used_max = used_max + scrollbar_width
  end

  if is_y then
    return used_max, used_in_dir
  else
    return used_in_dir, used_max
  end
end

-- Layout children, scrollbar and spacing widgets.
-- Only those widgets that are currently visible will be placed.
function overflow:layout(context, orig_width, orig_height)
  local result = {}
  local is_y = self._private.dir == "y"
  local widgets = self._private.widgets
  local avail_in_dir = is_y and orig_height or orig_width
  local scrollbar_width = self._private.scrollbar_width
  local scrollbar_enabled = self._private.scrollbar_enabled
  local scrollbar_position = self._private.scrollbar_position
  local width, height = orig_width, orig_height
  local widget_x, widget_y = 0, 0
  local used_in_dir, used_max = 0, 0

  -- Set the direction covered by scrolling to the maximum value
  -- to allow widgets to take as much space as they want.
  if is_y then
    height = math.huge
  else
    width = math.huge
  end

  -- First, determine widget sizes.
  -- Only when the content doesn't fit and needs scrolling should
  -- we reduce content size to make space for a scrollbar.
  for _, widget in pairs(widgets) do
    local w, h = base.fit_widget(self, context, widget, width, height)

    if is_y then
      used_max = math.max(used_max, w)
      used_in_dir = used_in_dir + h
    else
      used_max = math.max(used_max, h)
      used_in_dir = used_in_dir + w
    end
  end

  used_in_dir = used_in_dir + self._private.spacing * (#widgets - 1)

  -- Save size for scrolling behavior
  self._private.avail_in_dir = avail_in_dir
  self._private.used_in_dir = used_in_dir

  local need_scrollbar = used_in_dir > avail_in_dir and scrollbar_enabled

  local scroll_position = self._private.scroll_factor

  if need_scrollbar then
    local scrollbar_widget = self._private.scrollbar_widget
    local bar_x, bar_y = 0, 0
    local bar_w, bar_h
    -- The percentage of how much of the content can be visible within
    -- the available space
    local visible_percent = avail_in_dir / used_in_dir
    -- Make scrollbar length reflect `visible_percent`
    -- TODO: Apply a default minimum length
    local bar_length = math.floor(visible_percent * avail_in_dir)
    local bar_pos = (avail_in_dir - bar_length) * self._private.scroll_factor

    if is_y then
      bar_w, bar_h = base.fit_widget(self, context, scrollbar_widget, scrollbar_width, bar_length)
      bar_y = bar_pos

      if scrollbar_position == "left" then
        widget_x = widget_x + bar_w
      elseif scrollbar_position == "right" then
        bar_x = orig_width - bar_w
      end

      self._private.bar_length = bar_h

      width = width - bar_w
    else
      bar_w, bar_h = base.fit_widget(self, context, scrollbar_widget, bar_length, scrollbar_width)
      bar_x = bar_pos

      if scrollbar_position == "top" then
        widget_y = widget_y + bar_h
      elseif scrollbar_position == "bottom" then
        bar_y = orig_height - bar_h
      end

      self._private.bar_length = bar_w

      height = height - bar_h
    end

    table.insert(result, base.place_widget_at(
      scrollbar_widget,
      math.floor(bar_x),
      math.floor(bar_y),
      math.floor(bar_w),
      math.floor(bar_h)
    ))
  end

  local pos, spacing = 0, self._private.spacing
  local interval = used_in_dir - avail_in_dir

  local spacing_widget = self._private.spacing_widget
  if spacing_widget then
    if is_y then
      local _
      _, spacing = base.fit_widget(self, context, spacing_widget, width, spacing)
    else
      spacing = base.fit_widget(self, context, spacing_widget, spacing, height)
    end
  end

  for i, w in ipairs(widgets) do
    local content_x, content_y
    local content_w, content_h = base.fit_widget(self, context, w, width, height)

    -- When scrolling down, the content itself moves up -> substract
    local scrolled_pos = pos - (scroll_position * interval)

    -- Stop processing completely once we're passed the visible portion
    if scrolled_pos > avail_in_dir then
      break
    end

    if is_y then
      content_x, content_y = widget_x, scrolled_pos
      pos = pos + content_h + spacing

      if self._private.fill_space then
        content_w = width
      end
    else
      content_x, content_y = scrolled_pos, widget_y
      pos = pos + content_w + spacing

      if self._private.fill_space then
        content_h = height
      end
    end

    local is_in_view = is_y
        and (scrolled_pos + content_h > 0)
        or (scrolled_pos + content_w > 0)

    if is_in_view then
      -- Add the spacing widget, but not before the first widget
      if i > 1 and spacing_widget then
        table.insert(result, base.place_widget_at(
          spacing_widget,
          -- The way how spacing is added for regular widgets
          -- and the `spacing_widget` is disconnected:
          -- The offset for regular widgets is added to `pos` one
          -- iteration _before_ the one where the widget is actually
          -- placed.
          -- Because of that, the placement for the spacing widget
          -- needs to substract that offset to be placed right after
          -- the previous regular widget.
          math.floor(is_y and content_x or (content_x - spacing)),
          math.floor(is_y and (content_y - spacing) or content_y),
          math.floor(is_y and content_w or spacing),
          math.floor(is_y and spacing or content_h)
        ))
      end

      table.insert(result, base.place_widget_at(
        w,
        math.floor(content_x),
        math.floor(content_y),
        math.floor(content_w),
        math.floor(content_h)
      ))
    end
  end

  return result
end

function overflow:before_draw_children(_, cr, width, height)
  -- Clip drawing for children to the space we're allowed to draw in
  cr:rectangle(0, 0, width, height)
  cr:clip()
end

--- The amount of units to advance per scroll event.
--
-- This affects calls to `scroll` and the default mouse wheel handler.
--
-- The default is `10`.
--
-- @property step
-- @tparam number step The step size.
function overflow:set_step(step)
  self._private.step = step
  -- We don't need to emit enything here, since changing step only really
  -- takes effect the next time the user scrolls
end

--- Scroll the layout's content by `amount * step`.
--
-- A positive amount scroll down/right, a negative amount scrolls up/left.
--
-- The amount of units scrolled is affected by `step`.
--
-- @method overflow:scroll
-- @tparam number amount The amount to scroll by.
-- @emits property::overflow::scroll_factor
-- @emitstparam property::overflow::scroll_factor number scroll_factor The new
--   scroll factor.
-- @emits widget::layout_changed
-- @emits widget::redraw_needed
function overflow:scroll(amount)
  if amount == 0 then
    return
  end
  local interval = self._private.used_in_dir
  local delta = self._private.step / interval

  local factor = self._private.scroll_factor + (delta * amount)
  self:set_scroll_factor(factor)
end

--- The scroll factor.
--
-- The scroll factor represents how far the layout's content is currently
-- scrolled. It is represented as a fraction from `0` to `1`, where `0` is the
-- start of the content and `1` is the end.
--
-- @property scroll_factor
-- @tparam number scroll_factor The scroll factor.
-- @propemits true false

overflow.rubato_timed = rubato.timed { duration = 0.2 }

local first_call = true
function overflow:set_scroll_factor(factor)
  local current = self._private.scroll_factor
  local interval = self._private.used_in_dir - self._private.avail_in_dir
  if current == factor
      -- the content takes less space than what is available, i.e. everything
      -- is already visible
      or interval <= 0
      -- the scroll factor is out of range
      or (current <= 0 and factor < 0)
      or (current >= 1 and factor > 1) then
    return
  end


  local function update_scroll()
    self._private.scroll_factor = self.rubato_timed.pos
    self:emit_signal("widget::layout_changed")
    self:emit_signal("property::scroll_factor", factor)
  end

  -- Make sure it only subscribes once
  if first_call then
    self.rubato_timed:subscribe(update_scroll)
    --first_call = false
  end

  -- Set the target to the new target + remaining target from last scroll. This makes it scroll faster and correctly.
  self.rubato_timed.target = math.min(1,
    math.max(factor + (self.rubato_timed.target - self.rubato_timed.pos), 0))
end

function overflow:get_scroll_factor()
  return self._private.scroll_factor
end

--- The scrollbar width.
--
-- For horizontal scrollbars, this is the scrollbar height
--
-- The default is `5`.
--
--@DOC_wibox_layout_overflow_scrollbar_width_EXAMPLE@
--
-- @property scrollbar_width
-- @tparam number scrollbar_width The scrollbar width.
-- @propemits true false

function overflow:set_scrollbar_width(width)
  if self._private.scrollbar_width == width then
    return
  end

  self._private.scrollbar_width = width

  self:emit_signal("widget::layout_changed")
  self:emit_signal("property::scrollbar_width", width)
end

--- The scrollbar position.
--
-- For horizontal scrollbars, this can be `"top"` or `"bottom"`,
-- for vertical scrollbars this can be `"left"` or `"right"`.
-- The default is `"right"`/`"bottom"`.
--
--@DOC_wibox_layout_overflow_scrollbar_position_EXAMPLE@
--
-- @property scrollbar_position
-- @tparam string scrollbar_position The scrollbar position.
-- @propemits true false

function overflow:set_scrollbar_position(position)
  if self._private.scrollbar_position == position then
    return
  end

  self._private.scrollbar_position = position

  self:emit_signal("widget::layout_changed")
  self:emit_signal("property::scrollbar_position", position)
end

function overflow:get_scrollbar_position()
  return self._private.scrollbar_position
end

--- The scrollbar visibility.
--
-- If this is set to `false`, no scrollbar will be rendered, even if the layout's
-- content overflows. Mouse wheel scrolling will work regardless.
--
-- The default is `true`.
--
-- @property scrollbar_enabled
-- @tparam boolean scrollbar_enabled The scrollbar visibility.
-- @propemits true false

function overflow:set_scrollbar_enabled(enabled)
  if self._private.scrollbar_enabled == enabled then
    return
  end

  self._private.scrollbar_enabled = enabled

  self:emit_signal("widget::layout_changed")
  self:emit_signal("property::scrollbar_enabled", enabled)
end

function overflow:get_scrollbar_enabled()
  return self._private.scrollbar_enabled
end

-- Wraps a callback function for `mousegrabber` that is capable of
-- updating the scroll factor.
local function build_grabber(container, initial_x, initial_y, geo)
  local is_y = container._private.dir == "y"
  local bar_interval = container._private.avail_in_dir - container._private.bar_length
  local start_pos = container._private.scroll_factor * bar_interval
  local start = is_y and initial_y or initial_x

  -- Calculate a matrix transforming from screen coordinates into widget
  -- coordinates.
  -- This is required for mouse movement to work when the widget has been
  -- transformed by something like `wibox.container.rotate`.
  local matrix_from_device = geo.hierarchy:get_matrix_from_device()
  local wgeo = geo.drawable.drawable:geometry()
  local matrix = matrix_from_device:translate(-wgeo.x, -wgeo.y)

  return function(mouse)
    if not mouse.buttons[1] then
      return false
    end

    local x, y = matrix:transform_point(mouse.x, mouse.y)
    local pos = is_y and y or x
    container:set_scroll_factor((start_pos + (pos - start)) / bar_interval)

    return true
  end
end

-- Applies a mouse button signal using `build_grabber` to a scrollbar widget.
local function apply_scrollbar_mouse_signal(container, w)
  w:connect_signal('button::press', function(_, x, y, button_id, _, geo)
    if button_id ~= 1 then
      return
    end
    mousegrabber.run(build_grabber(container, x, y, geo), "fleur")
  end)
end

--- The scrollbar widget.
-- This widget is rendered as the scrollbar element.
--
-- The default is `wibox.widget.separator{ shape = gears.shape.rectangle }`.
--
--@DOC_wibox_layout_overflow_scrollbar_widget_EXAMPLE@
--
-- @property scrollbar_widget
-- @tparam widget scrollbar_widget The scrollbar widget.
-- @propemits true false

function overflow:set_scrollbar_widget(widget)
  local w = base.make_widget_from_value(widget)

  apply_scrollbar_mouse_signal(self, w)

  self._private.scrollbar_widget = w

  self:emit_signal("widget::layout_changed")
  self:emit_signal("property::scrollbar_widget", widget)
end

function overflow:get_scrollbar_widget()
  return self._private.scrollbar_widget
end

local function new(dir, ...)
  local ret = fixed[dir](...)

  gtable.crush(ret, overflow, true)
  ret.widget_name = gobject.modulename(2)
  -- Tell the widget system to prevent clicks outside the layout's extends
  -- to register with child widgets, even if they actually extend that far.
  -- This prevents triggering button presses on hidden/clipped widgets.
  ret.clip_child_extends = true

  -- Manually set the scroll factor here. We don't know the bounding size yet.
  ret._private.scroll_factor = 0

  -- Apply defaults. Bypass setters to avoid signals.
  ret._private.step = 10
  ret._private.fill_space = true
  ret._private.scrollbar_width = 5
  ret._private.scrollbar_enabled = true
  ret._private.scrollbar_position = dir == "vertical" and "right" or "bottom"

  local scrollbar_widget = separator({ shape = gshape.rectangle })
  apply_scrollbar_mouse_signal(ret, scrollbar_widget)
  ret._private.scrollbar_widget = scrollbar_widget

  ret:connect_signal('button::press', function(self, _, _, button)
    if button == 4 then
      self:scroll(-1)
    elseif button == 5 then
      self:scroll(1)
    end
  end)

  return ret
end

--- Returns a new horizontal overflow layout.
-- Child widgets are placed similar to `wibox.layout.fixed`, except that
-- they may take as much width as they want. If the total width of all child
-- widgets exceeds the width available whithin the layout's outer container
-- a scrollbar will be added and scrolling behavior enabled.
-- @tparam widget ... Widgets that should be added to the layout.
-- @constructorfct wibox.layout.overflow.horizontal
function overflow.horizontal(...)
  return new("horizontal", ...)
end

--- Returns a new vertical overflow layout.
-- Child widgets are placed similar to `wibox.layout.fixed`, except that
-- they may take as much height as they want. If the total height of all child
-- widgets exceeds the height available whithin the layout's outer container
-- a scrollbar will be added and scrolling behavior enabled.
-- @tparam widget ... Widgets that should be added to the layout.
-- @constructorfct wibox.layout.overflow.horizontal
function overflow.vertical(...)
  return new("vertical", ...)
end

return setmetatable(overflow, overflow.mt)

-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80
