// SPDX-License-Identifier: Unlicense OR MIT

package widget

import (
	"image"

	"gioui.org/gesture"
	"gioui.org/io/key"
	"gioui.org/io/pointer"
	"gioui.org/layout"
	"gioui.org/op"
)

// Scrollbar holds the persistent state for an area that can
// display a scrollbar. In particular, it tracks the position of a
// viewport along a one-dimensional region of content. The viewport's
// position can be adjusted by drag operations along the display area,
// or by clicks within the display area.
//
// Scrollbar additionally detects when a scroll indicator region is
// hovered.
type Scrollbar struct {
	track, indicator gesture.Click
	drag             gesture.Drag
	delta            float32

	dragging   bool
	oldDragPos float32
}

// Update updates the internal state of the scrollbar based on events
// since the previous call to Update. The provided axis will be used to
// normalize input event coordinates and constraints into an axis-
// independent format. viewportStart is the position of the beginning
// of the scrollable viewport relative to the underlying content expressed
// as a value in the range [0,1]. viewportEnd is the position of the end
// of the viewport relative to the underlying content, also expressed
// as a value in the range [0,1]. For example, if viewportStart is 0.25
// and viewportEnd is .5, the viewport described by the scrollbar is
// currently showing the second quarter of the underlying content.
func (s *Scrollbar) Update(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) {
	// Calculate the length of the major axis of the scrollbar. This is
	// the length of the track within which pointer events occur, and is
	// used to scale those interactions.
	trackHeight := float32(axis.Convert(gtx.Constraints.Max).X)
	s.delta = 0

	centerOnClick := func(normalizedPos float32) {
		// When the user clicks on the scrollbar we center on that point, respecting the limits of the beginning and end
		// of the scrollbar.
		//
		// Centering gives a consistent experience whether the user clicks above or below the indicator.
		target := normalizedPos - (viewportEnd-viewportStart)/2
		s.delta += target - viewportStart
		if s.delta < -viewportStart {
			s.delta = -viewportStart
		} else if s.delta > 1-viewportEnd {
			s.delta = 1 - viewportEnd
		}
	}

	// Jump to a click in the track.
	for {
		event, ok := s.track.Update(gtx.Source)
		if !ok {
			break
		}
		if event.Kind != gesture.KindClick ||
			event.Modifiers != key.Modifiers(0) ||
			event.NumClicks > 1 {
			continue
		}
		pos := axis.Convert(image.Point{
			X: int(event.Position.X),
			Y: int(event.Position.Y),
		})
		normalizedPos := float32(pos.X) / trackHeight
		// Clicking on the indicator should not jump to that position on the track. The user might've just intended to
		// drag and changed their mind.
		if !(normalizedPos >= viewportStart && normalizedPos <= viewportEnd) {
			centerOnClick(normalizedPos)
		}
	}

	// Offset to account for any drags.
	for {
		event, ok := s.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(axis))
		if !ok {
			break
		}
		switch event.Kind {
		case pointer.Drag:
		case pointer.Release, pointer.Cancel:
			s.dragging = false
			continue
		default:
			continue
		}
		dragOffset := axis.FConvert(event.Position).X
		// The user can drag outside of the constraints, or even the window. Limit dragging to within the scrollbar.
		if dragOffset < 0 {
			dragOffset = 0
		} else if dragOffset > trackHeight {
			dragOffset = trackHeight
		}
		normalizedDragOffset := dragOffset / trackHeight

		if !s.dragging {
			s.dragging = true
			s.oldDragPos = normalizedDragOffset

			if normalizedDragOffset < viewportStart || normalizedDragOffset > viewportEnd {
				// The user started dragging somewhere on the track that isn't covered by the indicator. Consider this a
				// click in addition to a drag and jump to the clicked point.
				//
				// TODO(dh): this isn't perfect. We only get the pointer.Drag event once the user has actually dragged,
				// which means that if the user presses the mouse button and neither releases it nor drags it, nothing
				// will happen.
				pos := axis.Convert(image.Point{
					X: int(event.Position.X),
					Y: int(event.Position.Y),
				})
				normalizedPos := float32(pos.X) / trackHeight
				centerOnClick(normalizedPos)
			}
		} else {
			s.delta += normalizedDragOffset - s.oldDragPos

			if viewportStart+s.delta < 0 {
				// Adjust normalizedDragOffset - and thus the future s.oldDragPos - so that futile dragging up has to be
				// countered with dragging down again. Otherwise, dragging up would have no effect, but dragging down would
				// immediately start scrolling. We want the user to undo their ineffective drag first.
				normalizedDragOffset -= viewportStart + s.delta
				// Limit s.delta to the maximum amount scrollable
				s.delta = -viewportStart
			} else if viewportEnd+s.delta > 1 {
				normalizedDragOffset += (1 - viewportEnd) - s.delta
				s.delta = 1 - viewportEnd
			}
			s.oldDragPos = normalizedDragOffset
		}
	}

	// Process events from the indicator so that hover is
	// detected properly.
	for {
		if _, ok := s.indicator.Update(gtx.Source); !ok {
			break
		}
	}
}

// AddTrack configures the track click listener for the scrollbar to use
// the current clip area.
func (s *Scrollbar) AddTrack(ops *op.Ops) {
	s.track.Add(ops)
}

// AddIndicator configures the indicator click listener for the scrollbar to use
// the current clip area.
func (s *Scrollbar) AddIndicator(ops *op.Ops) {
	s.indicator.Add(ops)
}

// AddDrag configures the drag listener for the scrollbar to use
// the current clip area.
func (s *Scrollbar) AddDrag(ops *op.Ops) {
	s.drag.Add(ops)
}

// IndicatorHovered reports whether the scroll indicator is currently being
// hovered by the pointer.
func (s *Scrollbar) IndicatorHovered() bool {
	return s.indicator.Hovered()
}

// TrackHovered reports whether the scroll track is being hovered by the
// pointer.
func (s *Scrollbar) TrackHovered() bool {
	return s.track.Hovered()
}

// ScrollDistance returns the normalized distance that the scrollbar
// moved during the last call to Layout as a value in the range [-1,1].
func (s *Scrollbar) ScrollDistance() float32 {
	return s.delta
}

// Dragging reports whether the user is currently performing a drag gesture
// on the indicator. Note that this can return false while ScrollDistance is nonzero
// if the user scrolls using a different control than the scrollbar (like a mouse
// wheel).
func (s *Scrollbar) Dragging() bool {
	return s.dragging
}

// List holds the persistent state for a layout.List that has a
// scrollbar attached.
type List struct {
	Scrollbar
	layout.List
}
