The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.

=head1 NAME

Tk::TableMatrix::Spreadsheet - Table Display with Spreadsheet-like bindings.

=head1 SYNOPSIS

  use Tk;
  use Tk::TableMatrix::Spreadsheet;



  my $t = $top->Scrolled('Spreadsheet', -rows => 21, -cols => 11, 
                              -width => 6, -height => 6,
			      -titlerows => 1, -titlecols => 1,
			      -variable => $arrayVar,
			      -selectmode => 'extended',
			      -titlerows => 1,
			      -titlecols => 1,
			      -bg => 'white',
                    );

=head1 DESCRIPTION

L<Tk::TableMatrix::Spreadsheet> is a L<Tk::TableMatrix>-derived widget that implements
some bindings so the resulting widget behaves more like a spreadsheet.

B<Bindings Added:>

=over 1

=item *

Row/Col resize handles appear when the cursor is placed
over a row/col border line in the rol/col title area. 

Dragging these handles will resize the row or column. If multiple rows or columns
are selected, then the new row/col size will apply to all row/cols selected.

Note: With the base Tk::TableMatrix, it is possible to resize the row/cols by dragging
on any cell border. To be more spreadsheet-like, Tk::TableMatrix::Spreadsheet  defaults to enable row/col
resizing only thru the title row/col dragging. To override this default behavoir, set the -resizeborder option to
'both' at startup.

=item *

A popup menu for row/col insert/delete appears when the mouse is right-clicked in the
row/col title areas. 

=item *

Cells activate (i.e. the contents become edit-able) only when the cell is double-clicked
or the F2 button is pressed. The default L<Tk::TableMatrix> behavior is for the
cell to be activated when the cell is single-clicked.

=item *

The Escape key causes any changes made to a cell to be canceled and the current
selection cleared.

=item *

The return key causes the the current cell to move down.

=item *

The tab (or shift tab) key causes the current cell to be moved to the right (left).

=item *

The delete key will delete the current selection, if no cell is currently active.

=item *

The Mouse button 2 (middle button) paste from the PRIMARY. (Control-v pastes from the
clipboard).

=back

=head1 Additional Information

Widget methods, options, etc, are inherited from the L<Tk::TableMatrix> widget. See its 
docs for additional information.

=cut



package Tk::TableMatrix::Spreadsheet;

use Carp;


use Tk;
use Tk::TableMatrix;
use Tk::Derived;

use base qw/ Tk::Derived Tk::TableMatrix/;

$VERSION = '1.23';


Tk::Widget->Construct("Spreadsheet");


sub ClassInit{
	my ($class,$mw) = @_;

	$class->SUPER::ClassInit($mw);
	
	#  Bind our motion routine to change cursors for row/column resize
	$mw->bind($class,'<Motion>',['GeneralMotion',$mw]);

	# Over-ride default button release binding
	#  so a cell won't activate by just clicking
	$mw->bind($class,'<ButtonRelease-1>',['Button1Release', $mw]);

	# Edit (activate) a cell if it is double-clicked
	#   Or F2 is pressed
	$mw->bind($class,'<Double-1>',
		sub
		 {
		  my $w = shift;
		  my $Ev = $w->XEvent;
		  if ($w->exists)
		   {
		    $w->CancelRepeat;
		    $w->activate('@' . $Ev->x.",".$Ev->y);
		   }
		 }
	);
	$mw->bind($class,'<F2>',
		sub
		 {
		  my $w = shift;
		  my $Ev = $w->XEvent;
		  if ($w->exists)
		   {
		    $w->CancelRepeat;
		    my $location = '@' . $Ev->x.",".$Ev->y;
		    #print "location = $location\n";
		    if( $w->selectionIncludes($location)){
		    	$w->activate('@' . $Ev->x.",".$Ev->y);
		    }
		   }
		 }
	);




	$mw->bind($class,'<Escape>',
		sub
		 {
		  my $w = shift;
		  $w->reread; # undo any changes if editing a cell
    		  my $upperLeft = $w->cget(-roworigin).",".$w->cget(-colorigin);
		  $w->activate($upperLeft);
		  $w->selectionClear('all');
		  
		 }
	);


	# Make the return key enter and move down
	$mw->bind($class,'<Return>',['MoveCell',1,0]);
	$mw->bind($class,'<KP_Enter>',['MoveCell',1,0]);
	
	# Make the tab key enter and move right
 	$mw->bind($class,'<Tab>',
			sub{ 
				my $w = shift;
				$w->MoveCell(0,1);
				Tk->break;
			}
	);
 	$mw->bind($class,'<Shift-KP_Tab>',['MoveCell',0,-1]);

        # Make the delete key delete the selection, if no active cell
 	$mw->bind($class,'<Delete>',
		sub{
			my $self = shift;
			my $active;
			# Get the current active cell, if one exists
			eval { $active = $self->index('active'); }; 

			$active = '' if( $@); # No Active cell found;

			# No Active cell if it is set to the upper left column (esc key pressed)
    			my $upperLeft = $self->cget(-roworigin).",".$self->cget(-colorigin);

			$active = '' if( $active eq $upperLeft); # No Active cell found;
			
			if( $active eq ''){  # No Active Cell, delete the selection
				   eval
				    {
				     $self->curselection(undef);# Clear whatever is selected
				     $self->selectionClear();
				     }
			}
			else{  # There is a current active cell, perform delete in that
				$self->deleteActive('insert');
			}
		}
		
	);
	
	# Button2 release pastes from PRIMARY (control v pastes from clipboard
	 $mw->bind($class,'<ButtonRelease-2>',
		  sub
		   {
		    my $w = shift;
		    my $Ev = $w->XEvent;
		    $w->Paste($w->index('@' . $Ev->x.",".$Ev->y),'PRIMARY') unless ($Tk::TableMatrix::tkPriv{'mouseMoved'});
		   }
		 );


};


sub Populate {
    my ($cw, $args) = @_;
    
    # Set Default Args:
    $args->{-bg} = 'white' unless defined( $args->{-bg});
    
    $args->{-colstretchmode} = 'unset' unless defined( $args->{-colstretchmode});

    # Default behavior is to not allow cell resizing, just at the row/col titles (like Excel)
    $args->{-resizeborders}  = 'none' unless defined( $args->{-resizeborders});
    
    
    $cw->SUPER::Populate($args);
    
    # default Tags
    $cw->tagConfigure('active', -bg => 'gray90', -relief => 'sunken', -fg => 'black');
    $cw->tagConfigure( 'title', -bg => 'gray85', -fg => 'black', -relief => 'sunken');
   
   
    # setup Popup Menu (right mouse-button press) for common operations
    my $popup = $cw->Menu('-tearoff' => 0);
    $popup->command('-label' => 'Insert', -bg => 'gray85', '-command' => ['insertRowCol',$cw] );
    $popup->command('-label' => 'Delete', -bg => 'gray85','-command' => ['deleteRowCol',$cw] );
    $popup->command('-label' => 'Clear Contents', -bg => 'gray85','-command' => ['curselection', $cw,''] );
 
 
 
 
    # Bind a sub for button 3 press
    $cw->bind('<ButtonPress-3>', 

	sub {
	

	    	my $Ev = $cw->XEvent;

		# Don't Do anything if we are on a cell border
		#  This keeps the right-click menu from pop-ing up
		#  when starting a cell re-size
		my @border = $cw->border('mark',$Ev->x,$Ev->y);
		# print "border = ".join(", ",@border)." size = ".scalar(@stuff)."\n";
		
		# return if on a border or if not in edit mode
	        return if( scalar(@border) || ( $cw->cget(-state) =~ /disabled/i ));
		

		my $inTitleArea = 0;  # Flag = 1 if we are in a title Area
		my $inSelectedArea = 0; # Flag = 1 if we are in a selected area

		my ($x,$y) = ($Ev->x, $Ev->y);	
			
		my $pointerLoc = $cw->index('@'."$x,$y");
		$cw->{pointerLoc} = $pointerLoc; # Save pointer location for the insert/delete row routines
					         # After the local menu pops up and a item is selected, the pointer
						 #  location won't be valid anymore
		
		# print "Pointer over = '$pointerLoc'\n";
		
		if( $cw->tagIncludes('title',$pointerLoc) && $pointerLoc ne '0,0' ){
			# print "Pointer over a title area\n";
			$inTitleArea = 1;
			
		}
		if( $cw->selectionIncludes($pointerLoc)){
			$inSelectedArea = 1;
			# print "In Selected Area\n";
		}

		if( $inTitleArea && !$inSelectedArea){ # select the row/col if
						       # in title area and not selected
			$cw->BeginSelect($pointerLoc);
		}
			
		if( $inTitleArea ){
			$popup->Popup('-popover' => 'cursor', '-popanchor' => 'nw');
		}
		
	}
     );

    
}

# Sub to insert row/cols
sub insertRowCol{

	my $cw = shift;

	my $pointerLoc = $cw->{pointerLoc}; # use the pointer locatin from before the popup window came up
	my ($r,$c) = split(",",$pointerLoc);
	#print "pointerLoc = $r, $c\n";
	
	if( $r <= 0){ # Insert Col
		my %cols;
		@cols{map /(\d+)$/, $cw->tagCell('sel')} = 1;
		my @cols = sort {$a <=> $b} keys %cols;
		
		my $minCol = $cols[0];
		my $colCount = $cols[-1] - $minCol + 1;
		$cw->insertCols($minCol,-$colCount);
		
		# Make selection and clear
		my $lastRow = $cw->index('end','row');
		$cw->selectionSet("0,$minCol","$lastRow,".$cols[-1]);
		$cw->curselection('');		
	}
	elsif( $c <= 0 ){
		my %rows;
		@rows{map /^(\d+)/, $cw->tagCell('sel')} = 1;
		my @rows = sort {$a <=> $b} keys %rows;
		
		my $minRow = $rows[0];
		my $rowCount = $rows[-1] - $minRow + 1;
		$cw->insertRows($minRow,-$rowCount);
		
		# Make selection and clear
		my $lastCol = $cw->index('end','col');
		$cw->selectionSet("$minRow,0",$rows[-1].",$lastCol");
		$cw->curselection('');		
		
	}
	
}

# Sub to delete row/cols
sub deleteRowCol{

	my $cw = shift;

	my $pointerLoc = $cw->{pointerLoc}; # use the pointer locatin from before the popup window came up
	my ($r,$c) = split(",",$pointerLoc);
	#print "pointerLoc = $r, $c\n";
	
	if( $r <= 0){ # Delete Col
		my %cols;
		@cols{map /(\d+)$/, $cw->tagCell('sel')} = 1;
		my @cols = sort {$a <=> $b} keys %cols;
		
		my $minCol = $cols[0];
		my $colCount = $cols[-1] - $minCol + 1;
		$cw->deleteCols($minCol,$colCount);
		
		# Make selection
		my $lastRow = $cw->index('end','row');
		$cw->selectionSet("0,$minCol","$lastRow,".$cols[-1]);
	}
	elsif( $c <= 0 ){
		my %rows;
		@rows{map /^(\d+)/, $cw->tagCell('sel')} = 1;
		my @rows = sort {$a <=> $b} keys %rows;
		
		my $minRow = $rows[0];
		my $rowCount = $rows[-1] - $minRow + 1;
		$cw->deleteRows($minRow,$rowCount);
		
		# Make selection
		my $lastCol = $cw->index('end','col');
		$cw->selectionSet("$minRow,0",$rows[-1].",$lastCol");
		
	}
	
}

# General Motion routine. Sets the border cursor to <-> if on a row border.
#  or vertical resize cursor if on a col border

sub GeneralMotion{

	my $self  = shift;
	my $Ev = $self->XEvent;

	my $rc = $self->index('@' . $Ev->x.",".$Ev->y);
	return unless($rc);
	
	my ($row,$col) = split(',',$rc);
	my $rowColResize = $self->{rowColResize};  # Flag = 1 if cursor has been changed for a row/col resize
	my $rowColResizeOldCursor = $self->{rowColResizeOldCursor};          #  name of old cursor that was changed;
	my $rowColResizeOldBDCursor = $self->{rowColResizeBDOldCursor};          #  name of old BD cursor that was changed;
	
	my @border = $self->border('mark',$Ev->x,$Ev->y);
	if( scalar(@border) ){  # we are on a border
		my ($r,$c) = @border;
		
		# print "In motion $r, $c: $row, $col\n";
		
		# my $currentBDCursor = $self->cget(-bordercursor);

		if( ($col <= 0) && ($r =~ /\d/)  ){
			# print "Row Border = $r\n";
			# print "Setting Row Border \n";
			unless($rowColResize){
				$self->{rowColResizeOldCursor} = $self->cget(-cursor);
				$self->{rowColResizeBDOldCursor} = $self->cget(-bordercursor);
				$self->configure(-cursor => 'sb_v_double_arrow',
					-bordercursor => 'sb_v_double_arrow');
				$self->{rowColResize} = 1;
				$self->{rowColResizeRow} = $r;
				$self->{rowColResizeCol} = undef;
			}
			
		}
		elsif( ($row <= 0) && ($c =~ /\d/) ){
			# print "Col Border = $c\n";
			unless($rowColResize){
				$self->{rowColResizeOldCursor} = $self->cget(-cursor);
				$self->{rowColResizeBDOldCursor} = $self->cget(-bordercursor);
				$self->configure(-cursor => 'sb_h_double_arrow',
					-bordercursor => 'sb_h_double_arrow');
				$self->{rowColResize} = 1;
				$self->{rowColResizeRow} = undef;
				$self->{rowColResizeCol} = $c;
			}

		}
		
	}
	else{
		if( $rowColResize && !($self->{rowColResizeDrag}) ){  # Change cursor back if it has been changed, and
									# we aren't currently doing a row/col resize drag.
			#print "Setting to $oldCursor\n";
			$self->configure(-cursor => $rowColResizeOldCursor,
				-bordercursor => $rowColResizeOldBDCursor);
			$self->{rowColResize} = 0;
		}

	}
			
		
}

######################################################################3
## Over-ridden beginselect. Sets the rowColResizeDrag to indicate
## that we are doing a row or column resize operation
sub borderDragto{
	my $self = shift;
	my @args = @_;
	
       if( !$self->{rowColResizeDrag}){
	       #print "StartDrag\n";
	       $self->{oldResizeBorders} = $self->cget(-resizeborders); # save the value of resizeborders so we can restore it later
	       $self->configure(-resizeborders => 'both');
       }
	$self->{rowColResizeDrag} = 1;  # Flag = 1 if we are currently doing a row/col resize drag
	$self->SUPER::borderDragto(@args);
}

##################################################################
# Over-ridden Motion routine. Does a row/col resize if
#   row/col resize cursors are active
#    This is needed for linux for the row resize to work
#      Not sure why
sub Motion{
	my $self  = shift;
	my $rc = shift;

	if( $self->{rowColResize}){ # Do a row/col resize if cursors active
		my $Ev = $self->XEvent;
		
		if( !$self->{rowColResizeDrag}){   # Same as the borderDragTo, somethings this gets called first
			#print "StartDrag\n";
	       		$self->{oldResizeBorders} = $self->cget(-resizeborders); # save the value of resizeborders so we can restore it later
	      	 	$self->configure(-resizeborders => 'both');
		}
		$self->{rowColResizeDrag} = 1;  # Flag = 1 if we are currently doing a row/col resize drag
		$self->SUPER::borderDragto($Ev->x,$Ev->y);
	}
	else{
		
		$self->SUPER::Motion($rc);
	}
}
			
#############################################################
## Over-ridden beginselect. Doesn't select if we are doing a row/col resize
sub BeginSelect{
	my $self  = shift;
	my $rc = shift;
	
	return if( $self->{rowColResize}); # Don't Select if currently doing a row/col resize
	
	# print "Calling inherited BeginSelect\n";
	$self->SUPER::BeginSelect($rc);
	
}


#############################################################
## Over-ridden TableInsert. 
##  If a  key is pressed and a cell is not activated. Activate the
##    current cell and insert the key pressed
sub TableInsert{
	my $self  = shift;
	my $key = shift;

	# my $Ev = $self->XEvent;

	# Activate the current anchor position, if 
	#  key pressed, and no cell currently active
	
	# Get the current active cell, if one exists
	eval { $active = $self->index('active'); }; 
		
	$active = '' if( $@); # No Active cell found;

	# No Active cell if it is set to the upper left column (esc key pressed)
    	my $upperLeft = $self->cget(-roworigin).",".$self->cget(-colorigin);

	$active = '' if( $active eq $upperLeft); # No Active cell found;

	if( $key ne '' && $active eq '' ){
        	my $anchor = $self->index('anchor');
		$self->activate($anchor);
		$self->deleteActive(0,'end'); # delete text from the cell
	}
		
	$self->SUPER::TableInsert($key);
	
}


#############################################################
## Over-ridden MoveCell. 
##  This method performs moving cells in a more Excel-like way:
##   1) Moving cell when one is active unactivates the cell and then selects (not activates)
##      the new cell
##   2) Moving cell when none is active moves the anchor point cell, if one exits.
##   3)  Does nothing otherwise

sub MoveCell{

	my $w = shift;
	my $x = shift; # Delta X for moving
	my $y = shift; # Delta y for moving
	my $c;
	my $cell;      # new cell index
	my $true;
	my $r;
	
	my $fromCell; # Cell to move from (Could be an active cell, if present, or selection anchor point
		      #  if present.
		      
	my $active;    # Current active cell

	# Get the current active cell, if one exists
	eval { $active = $w->index('active'); }; 

	$active = '' if( $@); # No Active cell found;

	# No Active cell if it is set to the upper left column (i.e. esc key pressed)
    	my $upperLeft = $w->cget(-roworigin).",".$w->cget(-colorigin);

	$active = '' if( $active eq $upperLeft); # No Active cell found;

	if( $active eq ''){  # no active cell found, see if there is a selection
		my $anchor = $w->index('anchor');

				
		unless( defined($anchor) ){
			# print "Anchor not defined\n";
			return;
		}
		
		$fromCell = $anchor;
	}
	else{
		$fromCell = $active;
	}
			

	($r,$c) = split(',',$fromCell);
	# my $currentCell = "$r,$c";

	$cell = $w->index(($r += $x).",".($c += $y));


	$w->activate($upperLeft) if( $active ne '');
	$w->see($cell);
	if ($w->cget('-selectmode') eq 'browse')
	 {
	  $w->selection('clear','all');
	  $w->selection('set',$cell);
	 }
	elsif ($w->cget('-selectmode') eq 'extended')
	 {
	  $w->selection('clear','all');
	  $w->selection('set',$cell);
	  $w->selection('anchor',$cell);
	  $Tk::TableMatrix::tkPriv{'tablePrev'} = $cell;
	 }
}	
	

#############################################################
## Over-ridden Paste. 
##  This method performs pasting cells in a more Excel-like way:
##   Paste Data will be pasted into the current selection anchor point
##     if no current cell is active, otherwise it pastes starting at the active
##       cell.
##
##   If no current active cell, and no anchor point, does nothing.
sub Paste{
	 my $w = shift;
	 my $cell = shift || ''; 
	 my $source = shift || 'CLIPBOARD';  # Default is to paste from the clipboard
	 my $data;
	 
	 # Check for active cell or anchor cell:
	 unless($cell){


		my $active;    # Current active cell

		# Get the current active cell, if one exists
		eval { $active = $w->index('active'); }; 

		$active = '' if( $@); # No Active cell found;

		# No Active cell if it is set to the upper left column (i.e. esc key pressed)
    		my $upperLeft = $w->cget(-roworigin).",".$w->cget(-colorigin);

		$active = '' if( $active eq $upperLeft); # No Active cell found;

		if( $active eq ''){  # no active cell found, see if there is a selection
			$cell = $w->index('anchor');

			return unless( $cell); # don't paste if no anchor point and no active

		}
		else{
			$cell = $active;
		}

	 }
	 
	 eval{ $data = $w->SelectionGet(-selection => $source); }; return if($@);
 	 $w->PasteHandler($cell,$data);
 	 $w->focus if ($w->cget('-state') eq 'normal');
}


#############################################################
#
# Sub called when button 1 released. 
#   Takes care or row/col border drags. 
#     Also checks to see if more than one row/col is selected during
#     a row/col resize, so those row/cols will be resized as well
sub Button1Release{
    my $w  = shift;
    my $Ev = $w->XEvent;
    #print "Button Release 1\n";
    if ( $w->{rowColResizeDrag} ) {   # Row/Col resize finishing up
        my @selRowCol = $w->curselection;
        if ( $w->{rowColResizeRow} ) { # Row risize, check for other rows selected
            my $row = $w->{rowColResizeRow};

            my $newRowHeight = $w->rowHeight($row);
            #print "Resized row $row to height" . $newRowHeight . "\n";

           # Find other selected rows (must be contiguous selected from the drag row)
            my $rowOrg = $w->cget( -roworigin );
            my $colOrg = $w->cget( -colorigin );
            my $rowMax = $rowOrg + $w->cget( -rows ) - 1;    # max row in table
            my $firstDataRow =
              $rowOrg + $w->cget( -titlerows );              # first Data Row
            my $firstDataCol =
              $colOrg + $w->cget( -titlecols );              # first Data Col
            my @otherRowsSelected;
            foreach my $row ( ( $row + 1 ) .. $rowMax ) {    # Increasing rows
                #print "Checking for inclusion of $row,$firstDataCol\n";
                last unless ( $w->selectionIncludes("$row,$firstDataCol") );
                push @otherRowsSelected, $row;
            }
            foreach my $row ( reverse( $firstDataRow .. ( $row - 1 ) ) )
            {                                                # Decreasing rows
                #print "Checking for inclusion of $row,$firstDataCol\n";
                last unless ( $w->selectionIncludes("$row,$firstDataCol") );
                push @otherRowsSelected, $row;
            }

            # Set New row height for other rows
            if (@otherRowsSelected) {

                #  Set args to row => height, row => height ...
                my @rowHeightArgs =
                  map { $_ => $newRowHeight } @otherRowsSelected;
                $w->rowHeight(@rowHeightArgs);
            }
        }
        elsif ( $w->{rowColResizeCol} ) { # Col risize, check for other Cols selected
            my $col = $w->{rowColResizeCol};

            my $newColWidth = $w->colWidth($col);
            #print "Resized col $col to width" . $newColWidth . "\n";

           # Find other selected cols (must be contiguous selected from the drag col)
            my $rowOrg = $w->cget( -roworigin );
            my $colOrg = $w->cget( -colorigin );
            my $colMax = $colOrg + $w->cget( -cols ) - 1;    # max col in table
            my $firstDataRow =
              $rowOrg + $w->cget( -titlerows );              # first Data Row
            my $firstDataCol =
              $colOrg + $w->cget( -titlecols );              # first Data Col
            my @otherColsSelected;
            foreach my $col ( ( $col + 1 ) .. $colMax ) {    # Increasing cols
                #print "Checking for inclusion of $firstDataRow,$col\n";
                last unless ( $w->selectionIncludes("$firstDataRow,$col") );
                push @otherColsSelected, $col;
            }
            foreach my $col ( reverse( $firstDataCol .. ( $col - 1 ) ) )
            {                                                # Decreasing rows
                #print "Checking for inclusion of $row,$firstDataCol\n";
                last unless ( $w->selectionIncludes("$firstDataRow,$col") );
                push @otherColsSelected, $col;
            }

            # Set Col width height for other rows
            if (@otherColsSelected) {

                #  Set args to row => height, row => height ...
                my @colWidthArgs =
                  map { $_ => $newColWidth } @otherColsSelected;
                $w->colWidth(@colWidthArgs);
            }
        }
    }

    if($w->{rowColResizeDrag}){
    
        # restore the value of resize borders to what is was before
	if( my $oldResizeborders = $w->{oldResizeBorders}){
    		$w->configure(-resizeborders => $oldResizeborders);
		delete $w->{oldResizeBorders};
	}
    
    	#print "Drag Finished\n";
    }
    
    $w->{rowColResizeDrag} = 0;        # reset row/col resize dragging flag
    $w->{rowColResizeRow}  = undef;    # reset row resize flag
    $w->{rowColResizeCol}  = undef;    # reset col resize flag
    if ( $w->exists ) {
        $w->CancelRepeat;

    }
}


1;