#!/usr/bin/env python

# Welcome to this demo of my popup scrollbar concept!
# To have something to scroll, this file loads itself read-only.
# Please try the following:
#
# - Move the mouse to the scrollbar and left-drag immediately
# - Move the mouse up and down above the scrollbar
#
# - Click on the popup arrow buttons
# - Click and hold the arrow buttons
# - Drag away from the arrow buttons
#
# - Middle mouse button click
# - Middle mouse button drag
# - Right click
#
# - Resize the window, make it very low
#
#
# Thanks to the people in #pygtk (irc.gimp.org) and the authors of following resources:
# http://www.oluyede.org/blog/writing-a-widget-using-cairo-and-pygtk-28/
# http://www.tortall.net/mu/wiki/CairoTutorial
# http://www.tortall.net/mu/wiki/PyGTKCairoTutorial
# http://python.developpez.com/cours/pygtktutorial//php/pygtken/sec-EventHandling.php
# http://www.learningpython.com/2006/07/25/writing-a-custom-widget-using-pygtk/
# http://python.developpez.com/cours/pygtktutorial/php/pygtken/sec-UsingAdjustmentsTheEasyWay.php
#
#
# No rights reserved, feel free to do whatever you want with this code,
# but it would be nice if you give me credit.
# Thorsten Wilms 2008-04-30
# http://thorwil.wordpress.com/


from __future__ import division
import pygtk
pygtk.require('2.0')
import gtk
from gtk import gdk
import gobject
import cairo

# Create new widget, inherit from gtk.DrawingArea
class PopupScroller(gtk.DrawingArea):
	def __init__(self, adjustment):
		super(PopupScroller, self).__init__()
		
		# gtk.Widget signals
		self.connect("expose_event", self.expose)
		self.connect("motion_notify_event", self.motion_notify)
		self.connect("enter_notify_event", self.enter_notify)
		self.connect("leave_notify_event", self.leave_notify)
		self.connect("button_press_event", self.button_press)
		self.connect("button_release_event", self.button_release)
		self.connect("scroll_event", self.on_button_scroll_event)
		
		# unmask events
		self.add_events(gtk.gdk.POINTER_MOTION_HINT_MASK |
							 gtk.gdk.POINTER_MOTION_MASK |
							 gtk.gdk.ENTER_NOTIFY_MASK |
							 gtk.gdk.LEAVE_NOTIFY_MASK |
							 gtk.gdk.BUTTON_PRESS_MASK |
							 gtk.gdk.BUTTON_RELEASE_MASK)

		self._height = 0 # set in expose
		self._width = 0 # set in expose
		self._show_arrows = False
		self._popup_y = 0 # position of the popup arrow buttons
		self._popup_height = 56 # From to of up-button to bottom of down-button
		self._button_size = 0 # height and width of popup buttons, set in expose
		self._arrow_padding = 4 # padding around arrows (button height - arrow height = padding)
						 
		# set position and size of the view indicator
		self._indicator_start = 0
		self._indicator_fraction = 0.1 # visible / whole content
		self._indicator_size = 0 # set in apply_adjustment
		
		# Mouse cursor hovering over button?
		self._hover_up = False
		self._hover_down = False
		
		# left mouse button state
		self._button_left = False
		self._button_middle = False
		self._click_offset = 0
		self._is_drag = False
		
		# The popup needs to be repositined on initial expose,
		# but not after a click (it would jump).
		self._initialize = True
		
		self._click_up = False
		self._click_down = False
		self._step = 10
		
		self._adjustment = adjustment
		self.apply_adjustment(adjustment)
		
	# called if view changes adjustment
	def apply_adjustment(self, adj):
		# adjustment: value, lower, upper, step_incr, page_incr, page_size
		self._indicator_fraction = adj.page_size / adj.upper
		self._indicator_start = adj.value * self._indicator_fraction
		self._step = adj.step_increment  * self._indicator_fraction
		self.queue_draw()

	# redraw on expose events     
	def expose(self, widget, event):
		# Create the cairo context
		cr = self.window.cairo_create()
		# Restrict Cairo to the exposed area; avoid extra work
		cr.rectangle(event.area.x, event.area.y,
		event.area.width, event.area.height)
		cr.clip()
		
		self._height = event.area.height
		self._width = event.area.width
		self._button_size = self._width - 2
		self._indicator_size = self._height * self._indicator_fraction
		
		self.draw(cr, *self.window.get_size())
		
	def enter_notify(self, widget, event):
		self.init_popup(widget, event)
		self.adapt_cursor()
			
	
	def leave_notify(self, widget, event):
		# With GTK# 2.12.5 and Metacity (Ubuntu Gutsy), clicking on the 
		# DrawingArea triggers leave and enter before press and release.
		# So I have to check if this is really a leave.
		if event.x < 0 or event.x >= self._width or event.y < 0 or event.y >= self._height:
			self._initialize = True
			self._show_arrows = False	
		
		self.queue_draw()
		
	def init_popup (self, widget, event):
		self._show_arrows = True
		# Set initial position of the popup
		if self._initialize == True:
			maximum = self._height - self._popup_height
			self._popup_y = event.y - self._popup_height / 2
			if self._popup_y < 0:
				self._popup_y = 0
			if self._popup_y > maximum:
				self._popup_y = maximum
			self._initialize = False
		
	def motion_notify(self, widget, event):
		# if this is a hint, then let's get all the necessary information, if not it's all we need.
		if event.is_hint:
			x, y, state = event.window.get_pointer()
		else:
			x = event.x
			y = event.y
			state = event.state
				
		# Set position of the popup if no arow button is pressed
		if self._click_up == False and self._click_down == False:
			# upper bound
			if event.y <= self._popup_y:
				self._popup_y = event.y
			# lower bound
			if event.y >= self._popup_y + self._popup_height:
				self._popup_y = event.y - self._popup_height
			
		# Check if over up button, only if view indicator isn't all the way up
		if event.y <= self._popup_y + self._button_size and self._indicator_start > 0:
			self._hover_up = True
		else:
			self._hover_up = False
			
		# Check if over down button. only if view indicator isn't all the way down
		maximum = self._height - self._indicator_size
		if event.y >= self._popup_y + self._popup_height - self._button_size and self._indicator_start != maximum:
			self._hover_down = True
		else:
			self._hover_down = False
			
		if self._button_left == True:
			self._hover_up = False
			self._hover_down = False
			
		# Change cursor if hovering over buttons, but not if left button is held down
		# This avoids a cursor change while repeating on up/down button and moving the pointer just a bit
		if self._button_left != True:
			self.adapt_cursor()
		
		# If user drags away from up/down button, stop stepping and slide instead
		if self._button_left == True and (self._click_up == True or self._click_down == True):
			threshold = 20
			if abs(self._y_on_click - event.y) >= threshold:
				self._is_drag = True
				self._click_up = False
				self._click_down = False
				# Reset click offset, as if button was clicked again
				self._click_offset = 0 - self._indicator_start + event.y
				self.adapt_cursor()
		
		# Set value if left button is down and no arrow button is pressed
		if self._button_left == True and self._click_up == False and self._click_down == False:
			maximum = self._adjustment.upper - self._adjustment.page_size
			new_value = (event.y - self._click_offset) / self._indicator_fraction
			if new_value < maximum:
				self._adjustment.set_value(new_value)
			else:
				self._adjustment.set_value(maximum)
				
		# Set value if middle button is down, centered
		if self._button_middle == True and self._button_left == False:
			maximum = self._adjustment.upper - self._adjustment.page_size
			new_value = event.y / self._indicator_fraction - self._adjustment.page_size / 2
			if new_value < maximum:
				self._adjustment.set_value(new_value)
			else:
				self._adjustment.set_value(maximum)
			
			# Center popup
			self._popup_y = event.y - self._popup_height / 2

		# trigger redraw / expose
		self.queue_draw()
									 
	def button_press(self, widget, event):
		if event.button == 1:
			self._y_on_click = event.y
			duration = 100
			self._button_left = True
			self._click_offset = 0 - self._indicator_start + event.y
			
			# Check for up button
			if self._hover_up == True:
				self._click_up = True
				step_up(self._adjustment, False)
				gobject.timeout_add(duration, self.repeat)
			
			# Check for down button
			if self._hover_down == True:
				self._click_down = True
				step_down(self._adjustment, False)
				gobject.timeout_add(duration, self.repeat)
		
		if event.button == 2:
			self._button_middle = True
			# Make indicator jump to pointer on middle click, but only if left button isn't down
			if self._button_left == False:
				self._show_arrows = False
				# Set value such that mouse pointer is in the middle of the page
				maximum = self._adjustment.upper - self._adjustment.page_size
				new_value = event.y / self._indicator_fraction - self._adjustment.page_size / 2
				if new_value < maximum:
					self._adjustment.set_value(new_value)
				else:
					self._adjustment.set_value(maximum)
					
		if event.button == 3:
			self._show_arrows = False
			# Context menu
			menu = gtk.Menu()
			one = gtk.MenuItem("Start")
			two = gtk.MenuItem("End")
			menu.append(one)
			menu.append(two)	
			one.connect("activate", self.start)
			two.connect("activate", self.end)
			
			# disable items if the can't have an effect
			if self._adjustment.value == 0:
				one.set_sensitive(False)
				
			if self._adjustment.value == self._adjustment.upper - self._adjustment.page_size:
				two.set_sensitive(False)
				
			self._initialize = True
				
			menu.show_all()
			menu.popup(None, None, None, event.button, event.get_time())
			
		# trigger redraw / expose		
		self.queue_draw()
			
	def button_release(self, widget, event):
		if event.button == 1:
			# cause popup to be repositioned if click was not on up or down button.
			if self._click_up == False and self._click_down == False:
				self._initialize = True
				self.init_popup(widget, event)
				
			self._button_left = False
			self._click_up = False
			self._click_down = False
			self._is_drag = False
			
		if event.button == 2:
			self._button_middle = False
		
		self._show_arrows = True
		
		# trigger redraw / expose
		self.queue_draw()
			
	# Repeat click action if up/down button is click-held
	def repeat (self):
		if self._click_up == True and self._is_drag == False:
			step_up(self._adjustment, False)
			self.queue_draw() # trigger redraw / expose
			return True # run again
		elif self._click_down == True and self._is_drag == False:
			step_down(self._adjustment, False)
			self.queue_draw() # trigger redraw / expose
			return True # run again
		else:
			return False # stop
			
	def on_button_scroll_event(self, widget, event):
		on_button_scroll_event(self._adjustment, event)
		
	# Move indicator to the top, called from context menu
	def start(self, widget):
		self._adjustment.set_value(0)
		# trigger redraw / expose
		self.queue_draw()
		
	# Move indicator to the bottom, called from context menu
	def end(self, widget):
		self._adjustment.set_value(self._adjustment.upper - self._adjustment.page_size)
		# trigger redraw / expose
		self.queue_draw()
	
	# draw stuff and finally adjust adjustment.value 
	def draw(self, cr, width, height):	
		# Diff between gradient start and stop
		gradient_diff = 0.1
		
		# Background gradient
		alpha = 1.0
		# context, gtk-element, gtk-state, gradient-diff, x, y, x, y
		self.set_source_gradient(cr, "bg", "ACTIVE", gradient_diff, 0.0, 0.0, width, 0.0, alpha)
		cr.rectangle(0, 0, width, height)
		cr.fill()
		
		# Outline
		self.set_source_color(cr, "dark", "NORMAL", alpha)
		cr.set_line_width(1)
		cr.rectangle(0, 0, width, height)
		cr.stroke()
		
		# Indicator
		# Lower alpha if cursor is over buttons or outside
		if self._hover_up == False and self._hover_down == False:
			alpha = 1.0
		else:
			alpha = 0.75
		
		if self._initialize == True:
			alpha = 0.75
			
		# High alpha while button is down (be it dragging or holding)
		if self._button_left == True:
			alpha = 1.0
			
		self.set_source_gradient(cr, "bg", "SELECTED", gradient_diff, 0.0, 0.0, width, 0.0, alpha)
		
		# Indicator triangle
		# Draw a little triangle if the indicator becomes so thin that it's hard to make out
		tri_height = width / 2
		if self._indicator_size < 4:
			tri_v_center = self._indicator_start + self._indicator_size / 2
			tri_v_top = tri_v_center - tri_height / 2
			
			cr.move_to(2, tri_v_top)
			cr.rel_line_to(tri_height, tri_height / 2)
			cr.rel_line_to(- tri_height, tri_height / 2)
			cr.rel_line_to(0, - tri_height)
			cr.fill()
		
		# Indicator rectangle
		cr.rectangle(1, self._indicator_start, width -2 , self._indicator_size)
		cr.fill()
		
		# Draw stripes (relief)
		offset = self._indicator_start % 6
		for y in range(0, height, 6):
			# Dark
			cr.set_source_rgba(0, 0, 0, 0.2)
			cr.move_to(2, y + offset)
			cr.line_to(width -2, y + offset)
			cr.stroke()
			# Bright
			cr.set_source_rgba(1.0, 1.0, 1.0, 0.4)
			cr.move_to(2, y +1 + offset)
			cr.line_to(width -2, y + 1 + offset)
			cr.stroke()
		
		# Draw shadow, always black
		cr.set_source_rgba(0, 0, 0, 0.3)
		cr.set_line_width(1)
		cr.set_line_cap(0)
		cr.set_line_join(0)
		cr.move_to(1, 1)
		cr.line_to(1, height -2)
		cr.move_to(1, 1)
		cr.line_to(width -1, 1)
		cr.stroke()
		
		# Lower alpha if cursor is not over up button
		if self._hover_up == True:
			alpha_up = 1.0
		else:
			alpha_up = 0.5
			
		# Lower alpha if cursor is not over down button	
		if self._hover_down == True:
			alpha_down = 1.0
		else:
			alpha_down = 0.5
			
		# Prepare line drawing
		cr.set_line_width(3)
		cr.set_line_cap(1)
		cr.set_line_join(1)
		
		# Draw popup buttons
		if self._show_arrows == True and self._button_left == False:
			
			# Up button, if view indicator is not all the way up
			if self._indicator_start > 0:
				# Up, base
				self.set_source_color(cr, "bg", "NORMAL", alpha_up)
				cr.rectangle(1, self._popup_y, self._button_size, width)
				cr.fill()
				
				# Up, arrow lines starting from bottom left
				self.set_source_color(cr, "text", "NORMAL", alpha_up)
				self.draw_up_arrow(cr, width)
			
			# Down button, if view indicator is not all the way down
			maximum = self._height - self._indicator_size
			if self._indicator_start != maximum:
				# Down, base
				self.set_source_color(cr, "bg", "NORMAL", alpha_down)
				cr.rectangle(1, self._popup_y + self._popup_height - self._button_size, self._button_size, width)
				cr.fill()
				
				# Down, arrow lines starting from bottom left
				self.set_source_color(cr, "text", "NORMAL", alpha_down)
				self.draw_down_arrow(cr, width)
		
		# Draw up button clicked
		if self._click_up == True:
			# Base
			self.set_source_color(cr, "text", "NORMAL", alpha_up)
			cr.rectangle(1, self._popup_y, self._button_size, width)
			cr.fill()
			
			# Up: Draw arrow lines starting from bottom left
			self.set_source_color(cr, "bg", "NORMAL", 1.0)
			self.draw_up_arrow(cr, width)
			
		# Draw down button clicked
		if self._click_down == True:
			# Base 
			self.set_source_color(cr, "text", "NORMAL", alpha_down)
			cr.rectangle(1, self._popup_y + self._popup_height - self._button_size, self._button_size, width)
			cr.fill()
			
			# Down: Draw arrow lines starting from bottom left
			self.set_source_color(cr, "bg", "NORMAL", alpha_down)
			self.draw_down_arrow(cr, width)
		
		# update adjustment value
		self._adjustment.set_value(self._indicator_start / self._indicator_fraction)
			
	# Up: Draw arrow lines starting from bottom left
	def draw_up_arrow (self, context, width):
		context.move_to(self._arrow_padding + 1, self._popup_y + self._button_size - self._arrow_padding)
		context.line_to(width / 2, self._popup_y + self._arrow_padding)
		context.rel_line_to(self._button_size / 2 - self._arrow_padding, self._button_size - 2 * self._arrow_padding)
		context.stroke()
		
	# Up: Draw arrow lines starting from top left
	def draw_down_arrow (self, context, width):
		context.move_to(self._arrow_padding + 1, self._popup_y + self._popup_height - self._button_size + self._arrow_padding)
		context.line_to(width / 2, self._popup_y + self._popup_height - self._arrow_padding)
		context.rel_line_to(self._button_size / 2 - self._arrow_padding, - (self._button_size - 2 * self._arrow_padding))
		context.stroke()
	
	# Get GTK theme color
	def get_color (self, element, state):
		style = self.get_style()
		rgb_gtk = getattr(style, element)[getattr(gtk, "STATE_" + state)]
		r = rgb_gtk.red / 65535
		g = rgb_gtk.green / 65535
		b = rgb_gtk.blue / 65535
		rgb_cairo = [r, g, b]
		return rgb_cairo
		
	def set_source_color (self, context, element, state, alpha):
		rgb = self.get_color(element, state)
		context.set_source_rgba(rgb[0], rgb[1], rgb[2], alpha)
		
	def set_source_gradient (self, context, element, state, gradient_diff, x1, y1, x2, y2, alpha):
		rgb = self.get_color(element, state)
		r1 = rgb[0] - gradient_diff / 2
		g1 = rgb[1] - gradient_diff / 2
		b1 = rgb[2] - gradient_diff / 2
		r2 = rgb[0] + gradient_diff / 2
		g2 = rgb[1] + gradient_diff / 2
		b2 = rgb[2] + gradient_diff / 2
		
		pat = cairo.LinearGradient (x1, y1, x2, y2)
		pat.add_color_stop_rgba (0, r1, g1, b1, alpha) # Left stop, position, rgba
		pat.add_color_stop_rgba (1, r2, g2, b2, alpha) # Right stop
		context.set_source(pat)
		
	def adapt_cursor (self):
		if self._hover_up == True or self._hover_down == True:
			self.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.HAND2))
		else:
			self.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.DOUBLE_ARROW))

# My own class just to add scroll-wheel handling to ScrolledWindow with scrollbars hidden
class WheeledWindow(gtk.ScrolledWindow):
	def __init__(self, adj_v, adj_h):
		super(WheeledWindow, self).__init__()
		
		# gtk.Widget signals
		self.connect("scroll_event", self.on_button_scroll_event)

	def on_button_scroll_event(self, widget, event):
		on_button_scroll_event(self.get_vadjustment(), event)
		
def main():
	window = gtk.Window()
	hbox = gtk.HBox(homogeneous=False, spacing=0)
	
	textview = gtk.TextView()
	textview.set_editable(True)
	textfile = open("popup_scrollbar_37.py", "r")
	buffer = textview.get_buffer()
	buffer.set_text(textfile.read())
	textfile.close()
	
	# Use a ScrolledWindow as this gives us an Adjustment (textview doesn't)
	# Could use a Viewport, except it doesn't trigger value_changed on cursor movement
	# Have to wrap ScrolledWindow to add scroll-wheel handling, as else it doesn't work with policy NONE
	#view = gtk.ScrolledWindow(None, None)
	view = WheeledWindow(None, None)
	view.set_policy(gtk.POLICY_NEVER, gtk.POLICY_NEVER)
	view.set_shadow_type(gtk.SHADOW_NONE)
	view.add(textview)
	
	# Connect view and scroller
	adjustment = view.get_vadjustment()
	scroller = PopupScroller(adjustment)
	adjustment.connect("changed", scroller.apply_adjustment)
	# cursor movement doesn't trigger "changed", so we also need:
	adjustment.connect("value_changed", scroller.apply_adjustment)
	

	# Layout
	window.add(hbox)
	hbox.pack_start(view, True, True)
	hbox.pack_start(scroller, False, False)
	
	# Set scrollbar width and minimum height, the resize window to something practical
	scroller.set_size_request(16, 60)
	view.set_size_request(500, 60)
	window.resize(516, 600)
	
	# Showtime!
	window.connect("destroy", gtk.main_quit)
	window.realize()
	window.show_all()
	
	gtk.main()

# Scrolling via wheel on either scrollbar or textview	
def on_button_scroll_event(adj, event):
	if event.direction == gtk.gdk.SCROLL_UP:
		step_up(adj, True)
	if event.direction == gtk.gdk.SCROLL_DOWN:
		step_down(adj, True)
	
# Move up a step (unless at start already)
def step_up (adj, multiply):
	if adj.value - adj.step_increment * multiplicator(multiply) <= 0:
		adj.value = 0
	else:
		adj.value = adj.value - adj.step_increment * multiplicator(multiply)

# Move down a step (unless at bottom already)
def step_down (adj, multiply):
	maximum = adj.upper - adj.page_size
	if adj.value + adj.step_increment * multiplicator(multiply) > maximum:
		adj.value = maximum
	else:
		adj.value = adj.value + adj.step_increment * multiplicator(multiply)
		
# Multiplicator for scroll-wheel only
def multiplicator(multiply):
	if multiply == False:
		return 1
	else:
		return 4
			 
if __name__ == "__main__":
    main()
