#!/usr/bin/ruby
#
# peted.rb, GUI for pet.rb -- a tiny electronics schematics editor
# Currently this is (only) a demo for zooming, panning, scrolling with Ruby/GTK/Cairo
# v 0.03
# (c) S. Salewski, 21-DEC-2010
# License GPL

require 'gtk2'
require 'cairo'

ZOOM_FACTOR_MOUSE_WHEEL = 1.1
ZOOM_FACTOR_SELECT_MAX = 10 # ignore zooming in tiny selection
ZOOM_NEAR_MOUSEPOINTER = true # mouse wheel zooming -- to mousepointer or center
SELECT_RECT_COL = [0, 0, 1, 0.5] # blue with transparency

=begin
Zooming, scrolling, panning...

|-------------------------|
|<-------- A ------------>|
|                         |
|  |---------------|      |
|  | <---- a ----->|      |
|  |    visible    |      |
|  |---------------|      |
|                         |
|                         |
|-------------------------|

a is the visible, zoomed in area == @darea.allocation.width
A is the total data range
A/a == @user_zoom >= 1
For horizontal adjustment we use
@hadjustment.set_upper(@darea.allocation.width * @user_zoom) == A
@hadjustment.set_page_size(@darea.allocation.width) == a
So @hadjustment.value == left side of visible area

Initially, we set @user_zoom = 1, scale our data to fit into @darea.allocation.width
and translate the origin of our data to (0, 0)

=end

=begin
Zooming: Mouse wheel or selecting a rectangle with left mouse button pressed
Scrolling: Scrollbars
Panning: Moving mouse while middle mouse button pressed 
=end


# drawing area and scroll bars in 2x2 table (PDA == Peted Drawing Area)

class Pos_Adj < Gtk::Adjustment
  attr_accessor :handler_ID
  def initialize
    super(0, 0, 1, 1, 10, 1) # value, lower, upper, step_increment, page_increment, page_size
  end
end

class PDA < Gtk::Table
  def initialize
    super(2, 2, false)
    @zoom_near_mousepointer = ZOOM_NEAR_MOUSEPOINTER # mouse wheel zooming
    @selecting = false
    @user_zoom = 1.0
    @surf = nil
    @darea = Gtk::DrawingArea.new
    @darea.signal_connect('expose-event') { darea_expose_callback }
    @darea.signal_connect('configure-event') { darea_configure_callback }
    # @darea.double_buffered = true # we use our own buffering -- but true is useful for selecting rectangle
    # @darea.can_focus = true # catch keyboard events
    # we use Gdk::Event::POINTER_MOTION_HINT_MASK to get not too many event when panning
    @darea.add_events(Gdk::Event::BUTTON_PRESS_MASK | Gdk::Event::BUTTON_RELEASE_MASK | Gdk::Event::SCROLL_MASK |
                      Gdk::Event::BUTTON1_MOTION_MASK | Gdk::Event::BUTTON2_MOTION_MASK | Gdk::Event::POINTER_MOTION_HINT_MASK)
    @darea.signal_connect('motion-notify-event') { |w, e| on_motion(@darea, e) }
    @darea.signal_connect('scroll_event')        { |w, e| scroll_event(@darea, e) }
    @darea.signal_connect('button_press_event')  { |w, e| button_press_event(@darea, e) }
    @darea.signal_connect('button_release_event'){ |w, e| button_release_event(@darea, e) }
    @hadjustment = Pos_Adj.new # @darea.allocation.width is still invalid
    @hadjustment.handler_ID = @hadjustment.signal_connect('value-changed') { on_adjustment_event }
    @vadjustment = Pos_Adj.new
    @vadjustment.handler_ID = @vadjustment.signal_connect('value-changed') { on_adjustment_event }
    @hscrollbar  = Gtk::HScrollbar.new(@hadjustment)
    @vscrollbar  = Gtk::VScrollbar.new(@vadjustment)
    attach(@darea, 0, 1, 0, 1, Gtk::EXPAND | Gtk::FILL, Gtk::EXPAND | Gtk::FILL, 0, 0)
    attach(@hscrollbar, 0, 1, 1, 2, Gtk::EXPAND | Gtk::FILL, 0, 0, 0)
    attach(@vscrollbar, 1, 2, 0, 1, 0, Gtk::EXPAND | Gtk::FILL, 0, 0)
  end

  # event coordinates to user space
  def get_user_coordinates(event_x, event_y)
    [(event_x - @hadjustment.upper * 0.5 + @hadjustment.value) / (@full_scale * @user_zoom) + @data_x + @data_width * 0.5,
     (event_y - @vadjustment.upper * 0.5 + @vadjustment.value) / (@full_scale * @user_zoom) + @data_y + @data_height * 0.5]
  end

  # clamp to correct values, 0 <= value <= (@adjustment.upper - @adjustment.page_size), block calling on_adjustment_event()
  def update_val(adj, d)
    adj.signal_handler_block(adj.handler_ID)
    adj.set_value [0, [adj.value + d, adj.upper - adj.page_size].min].max 
    adj.signal_handler_unblock(adj.handler_ID)
  end

  def update_adjustments(dx, dy)
    @hadjustment.set_upper(@darea.allocation.width * @user_zoom)
    @vadjustment.set_upper(@darea.allocation.height * @user_zoom)
    @hadjustment.set_page_size(@darea.allocation.width)
    @vadjustment.set_page_size(@darea.allocation.height)
    update_val(@hadjustment, dx)
    update_val(@vadjustment, dy)
  end

  def update_adjustments_and_paint(dx, dy)
    update_adjustments(dx, dy)
    paint
    @darea.queue_draw_area(0, 0, @darea.allocation.width, @darea.allocation.height)
  end

  def darea_configure_callback
    update_adjustments(0, 0)
    @data_x, @data_y, @data_width, @data_height = User_World.get_world_extends()
    @full_scale = [@darea.allocation.width.to_f / @data_width, @darea.allocation.height.to_f / @data_height].min
    paint
  end

  # copy content of @surf to darea, draw seletion rectangle
  def darea_expose_callback
    cr = @darea.window.create_cairo_context
    cr.set_source(@surf, 0, 0)
    cr.paint
    if @selecting == true
      cr.rectangle(@last_button_down_pos_x, @last_button_down_pos_y,
                   @zoom_rect_x1 - @last_button_down_pos_x, @zoom_rect_y1 -  @last_button_down_pos_y)
      cr.set_source_rgba SELECT_RECT_COL # 0, 0, 1, 0.5
      cr.fill_preserve
      cr.set_source_rgb 0, 0, 0
      cr.set_line_width 2
      cr.stroke
    end
    cr.destroy
  end

  def button_press_event(area, event)
    x, y = get_user_coordinates(event.x, event.y)
    print 'User coordinates: ', x, ' ', y, "\n" # to verify get_user_coordinates()
    @last_mouse_pos_x = event.x
    @last_mouse_pos_y = event.y
    @last_button_down_pos_x = event.x
    @last_button_down_pos_y = event.y
  end

  # zoom into selected rectangle and center it
  def button_release_event(area, event)
    if event.button == 1
      @selecting = false
      z1 = [@darea.allocation.width.to_f / (@last_button_down_pos_x - event.x).abs, @darea.allocation.height.to_f / (@last_button_down_pos_y - event.y).abs].min
      if z1 < ZOOM_FACTOR_SELECT_MAX # else selection rectangle will persist, we may output a message... 
        @user_zoom *= z1
        update_adjustments_and_paint(
          ((2 * @hadjustment.value + event.x + @last_button_down_pos_x) * z1  - @darea.allocation.width) * 0.5 - @hadjustment.value,
          ((2 * @vadjustment.value + event.y + @last_button_down_pos_y) * z1  - @darea.allocation.height) * 0.5 - @vadjustment.value)
      end
    end
  end

  def on_motion(area, event)
    if (event.state & Gdk::Window::BUTTON1_MASK) != 0 # selecting
      @selecting = true
      @zoom_rect_x1 = event.x
      @zoom_rect_y1 = event.y
      @darea.queue_draw_area(0, 0, @darea.allocation.width, @darea.allocation.height)
    elsif (event.state & Gdk::Window::BUTTON2_MASK) != 0 # panning
      update_adjustments_and_paint(@last_mouse_pos_x - event.x, @last_mouse_pos_y - event.y)
    end
    @last_mouse_pos_x = event.x
    @last_mouse_pos_y = event.y
    event.request # request more motion events
  end

  def on_adjustment_event
    paint
    @darea.queue_draw_area(0, 0, @darea.allocation.width, @darea.allocation.height)
  end

  # zooming with mouse wheel -- data near mouse pointer should not move if possible!
  # @hadjustment.value + event.x is the position in our zoomed_in world, (@user_zoom / z0 - 1) is the relative movement caused by zooming
  def scroll_event(area, event)
    z0 = @user_zoom
    if event.direction == Gdk::EventScroll::UP
      @user_zoom *= ZOOM_FACTOR_MOUSE_WHEEL
    elsif event.direction == Gdk::EventScroll::DOWN
      @user_zoom /= ZOOM_FACTOR_MOUSE_WHEEL
      if (@user_zoom < 1) then
        @user_zoom = 1
      end
    end
    if @zoom_near_mousepointer == true
      update_adjustments_and_paint((@hadjustment.value + event.x) * (@user_zoom / z0 - 1),
                                   (@vadjustment.value + event.y) * (@user_zoom / z0 - 1))
    else # zoom to center
      update_adjustments_and_paint((@hadjustment.value + @darea.allocation.width * 0.5) * (@user_zoom / z0 - 1),
                                   (@vadjustment.value + @darea.allocation.height * 0.5) * (@user_zoom / z0 - 1))
    end
  end

  def paint
    cr0 = @darea.window.create_cairo_context
    other = cr0.target
    cr0.destroy
    if @surf != nil then @surf.destroy end
    #@surf = other.create_similar(Cairo::CONTENT_COLOR_ALPHA, @darea.allocation.width, @darea.allocation.height)
    @surf = other.create_similar(Cairo::CONTENT_COLOR, @darea.allocation.width, @darea.allocation.height)
    cr = Cairo::Context.new(@surf)
    cr.translate(@hadjustment.upper * 0.5 - @hadjustment.value, # our origin is the center
                 @vadjustment.upper * 0.5 - @vadjustment.value)
    cr.scale(@full_scale * @user_zoom, @full_scale * @user_zoom)
    cr.translate(-@data_x - @data_width * 0.5, -@data_y - @data_height * 0.5)
    User_World.draw_world(cr)
    cr.destroy
  end

end # PDA

class Peted < Gtk::Window
  def initialize
    super
    signal_connect('destroy') { Gtk.main_quit }
    set_title 'PetEd'
    set_size_request 200, 300
    pda = PDA.new
    add pda
    show_all
  end
end # Peted

# the user must provide only two funtions, get_world_extends() and draw_world()
# of course these may be defined in a separate file
module User_World
  # arbitrary bounding box of this small world, don't have to be constant
  Data_x = 150
  Data_y = 250
  Data_width = 200
  Data_height = 120

  # bounding box of user data -- x, y, w, h -- top left corner, width, height
  def self.get_world_extends()
    return Data_x, Data_y, Data_width, Data_height # current extents of our user world 
  end

  # draw to cairo context
  def self.draw_world(cr)
    cr.set_source_rgb 1, 1, 1
    cr.paint
    cr.set_source_rgb 0, 0, 0
    cr.set_line_width 2
    cr.rectangle(Data_x, Data_y, Data_width, Data_height)
    i = 10
    while true
      if [Data_width - 2 * i, Data_height - 2 * i].min <= 0 then break end
      cr.rectangle(Data_x + i, Data_y + i , Data_width - 2 * i, Data_height - 2 * i)
      i += 10
    end 
    cr.stroke
  end

end # User_World

Gtk.init
window = Peted.new
Gtk.main

