Source code for pandastable.headers
#!/usr/bin/env python
"""
Implements the pandastable headers classes.
Created Jan 2014
Copyright (C) Damien Farrell
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 3
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
"""
#from __future__ import absolute_import, division, print_function
import sys
import math, time
import os, types, string
try:
from tkinter import *
from tkinter.ttk import *
except:
from Tkinter import *
from ttk import *
import numpy as np
import pandas as pd
from . import util
from .dialogs import *
import textwrap
[docs]def createSubMenu(parent, label, commands):
menu = Menu(parent, tearoff = 0)
parent.add_cascade(label=label,menu=menu)
for action in commands:
menu.add_command(label=action, command=commands[action])
applyStyle(menu)
return menu
[docs]class ColumnHeader(Canvas):
"""Class that takes it's size and rendering from a parent table
and column names from the table model."""
def __init__(self, parent=None, table=None, bg='gray25'):
Canvas.__init__(self, parent, bg=bg, width=500, height=25)
self.thefont = 'Arial 14'
self.textcolor = 'white'
self.bgcolor = bg
if table != None:
self.table = table
self.model = self.table.model
if util.check_multiindex(self.model.df.columns) == 1:
self.height = 40
else:
self.height = self.table.rowheight
self.config(width=self.table.width, height=self.height)
self.columnlabels = self.model.df.columns
self.draggedcol = None
self.bind('<Button-1>',self.handle_left_click)
self.bind("<ButtonRelease-1>", self.handle_left_release)
self.bind('<B1-Motion>', self.handle_mouse_drag)
self.bind('<Motion>', self.handle_mouse_move)
self.bind('<Shift-Button-1>', self.handle_left_shift_click)
self.bind('<Control-Button-1>', self.handle_left_ctrl_click)
self.bind("<Double-Button-1>",self.handle_double_click)
self.bind('<Leave>', self.leave)
if self.table.ostyp=='darwin':
#For mac we bind Shift, left-click to right click
self.bind("<Button-2>", self.handle_right_click)
self.bind('<Shift-Button-1>',self.handle_right_click)
else:
self.bind("<Button-3>", self.handle_right_click)
self.thefont = self.table.thefont
self.wrap = False
self.setDefaults()
return
[docs] def redraw(self, align='w'):
"""Redraw column header"""
df = self.model.df
multiindex = util.check_multiindex(df.columns)
wrap = self.wrap
cols = self.model.getColumnCount()
colwidths = self.table.columnwidths
scale = self.table.getScale() * 1.5
self.height = self.table.rowheight
if wrap is True:
#set height from longest column wrapped
try:
c = list(df.columns.map(str).str.len())
except:
c = [len(str(i)) for i in df.columns]
idx = c.index(max(c))
longest = str(df.columns[idx].encode('utf-8').decode('utf-8'))
if longest in colwidths:
cw = colwidths[longest]
else:
cw = self.table.cellwidth
tw,l = util.getTextLength(longest, cw)
tr = len(textwrap.wrap(longest, l))
if tr > 1:
self.height = tr*self.height
#print (tr, longest, textwrap.wrap(longest, l))
if self.height>250:
self.height=250
self.tablewidth = self.table.tablewidth
self.thefont = self.table.thefont
self.configure(scrollregion=(0,0,
self.table.tablewidth+self.table.x_start,
self.height))
self.config(height=self.height, bg=self.bgcolor)
self.delete('gridline','text')
self.delete('rect')
self.delete('dragrect')
self.atdivider = None
font = self.thefont
anchor = align
pad = 5
x_start = self.table.x_start
if cols == 0:
return
if multiindex == 1:
anchor = 'nw'
y=2
levels = df.columns.levels
h = self.height
self.height *= len(levels)
y=3
else:
levels = [df.columns.values]
h = self.height
y = h/2
i=0
#iterate over index levels
col=0
for level in levels:
values = df.columns.get_level_values(i)
for col in self.table.visiblecols:
colname = values[col]
try:
colstr = colname.encode('utf-8','ignore').decode('utf-8')
except:
colstr = str(colname)
if colstr in colwidths:
w = colwidths[colstr]
else:
w = self.table.cellwidth
if w<=8:
colname=''
x = self.table.col_positions[col]
if anchor in ['w','nw']:
xt = x+pad
elif anchor == 'e':
xt = x+w-pad
elif anchor == 'center':
xt = x+w/2
colname = colstr
tw,length = util.getTextLength(colstr, w-pad, font=font)
#print (colstr, tw)
if wrap is True:
colname = textwrap.fill(colstr, length-1)
y=3
anchor = 'nw'
else:
colname = colname[0:int(length)]
line = self.create_line(x, 0, x, self.height, tag=('gridline', 'vertline'),
fill='white', width=1)
self.create_text(xt,y,
text=colname,
fill=self.textcolor,
font=self.thefont,
tag='text', anchor=anchor)
x = self.table.col_positions[col+1]
self.create_line(x,0, x, self.height, tag='gridline',
fill='white', width=2)
i+=1
y=y+h-2
self.config(height=self.height)
return
[docs] def handle_left_click(self,event):
"""Does cell selection when left mouse button is clicked"""
self.delete('rect')
self.table.delete('entry')
self.table.delete('multicellrect')
colclicked = self.table.get_col_clicked(event)
if colclicked == None:
return
#set all rows for plotting if no multi selection
if len(self.table.multiplerowlist) <= 1:
self.table.allrows = True
self.table.setSelectedCol(colclicked)
if self.atdivider == 1:
return
self.drawRect(self.table.currentcol)
#also draw a copy of the rect to be dragged
self.draggedcol = None
self.drawRect(self.table.currentcol, tag='dragrect',
color='lightblue', outline='white')
if hasattr(self, 'rightmenu') and self.rightmenu != None:
self.rightmenu.destroy()
#finally, draw the selected col on the table
self.table.drawSelectedCol()
self.table.drawMultipleCells()
self.table.drawMultipleRows(self.table.multiplerowlist)
return
[docs] def handle_left_release(self,event):
"""When mouse released implement resize or col move"""
self.delete('dragrect')
#if ctrl selection return
if len(self.table.multiplecollist) > 1:
return
#column resized
if self.atdivider == 1:
x = int(self.canvasx(event.x))
col = self.nearestcol
x1,y1,x2,y2 = self.table.getCellCoords(0,col)
newwidth = x - x1
if newwidth < 5:
newwidth=5
self.table.resizeColumn(col, newwidth)
self.table.delete('resizeline')
self.delete('resizeline')
self.delete('resizesymbol')
self.atdivider = 0
return
self.delete('resizesymbol')
#move column
if self.draggedcol != None and self.table.currentcol != self.draggedcol:
self.model.moveColumn(self.table.currentcol, self.draggedcol)
self.table.setSelectedCol(self.draggedcol)
self.table.redraw()
self.table.drawSelectedCol(self.table.currentcol)
self.drawRect(self.table.currentcol)
return
[docs] def handle_right_click(self, event):
"""respond to a right click"""
if self.table.enable_menus == False:
return
colclicked = self.table.get_col_clicked(event)
multicollist = self.table.multiplecollist
if len(multicollist) > 1:
pass
else:
self.handle_left_click(event)
self.rightmenu = self.popupMenu(event)
return
[docs] def handle_mouse_drag(self, event):
"""Handle column drag, will be either to move cols or resize"""
x=int(self.canvasx(event.x))
if self.atdivider == 1:
self.table.delete('resizeline')
self.delete('resizeline')
self.table.create_line(x, 0, x, self.table.rowheight*self.table.rows,
width=2, fill='gray', tag='resizeline')
self.create_line(x, 0, x, self.height,
width=2, fill='gray', tag='resizeline')
return
else:
w = self.table.cellwidth
self.draggedcol = self.table.get_col_clicked(event)
coords = self.coords('dragrect')
if len(coords)==0:
return
x1, y1, x2, y2 = coords
x=int(self.canvasx(event.x))
y = self.canvasy(event.y)
self.move('dragrect', x-x1-w/2, 0)
return
[docs] def within(self, val, l, d):
"""Utility funtion to see if val is within d of any
items in the list l"""
for v in l:
if abs(val-v) <= d:
return v
return None
[docs] def handle_mouse_move(self, event):
"""Handle mouse moved in header, if near divider draw resize symbol"""
if len(self.model.df.columns) == 0:
return
self.delete('resizesymbol')
w = self.table.cellwidth
h = self.height
x_start = self.table.x_start
#x = event.x
x = int(self.canvasx(event.x))
if not hasattr(self, 'tablewidth'):
return
if x > self.tablewidth+w:
return
#if event x is within x pixels of divider, draw resize symbol
nearest = self.within(x, self.table.col_positions, 4)
if x != x_start and nearest != None:
#col = self.table.get_col_clicked(event)
col = self.table.col_positions.index(nearest)-1
self.nearestcol = col
#print (nearest,col,self.model.df.columns[col])
if col == None:
return
self.draw_resize_symbol(col)
self.atdivider = 1
else:
self.atdivider = 0
return
[docs] def handle_left_shift_click(self, event):
"""Handle shift click, for selecting multiple cols"""
self.table.delete('colrect')
self.delete('rect')
currcol = self.table.currentcol
colclicked = self.table.get_col_clicked(event)
if colclicked > currcol:
self.table.multiplecollist = list(range(currcol, colclicked+1))
elif colclicked < currcol:
self.table.multiplecollist = list(range(colclicked, currcol+1))
else:
return
for c in self.table.multiplecollist:
self.drawRect(c, delete=0)
self.table.drawSelectedCol(c, delete=0)
self.table.drawMultipleCells()
return
[docs] def handle_left_ctrl_click(self, event):
"""Handle ctrl clicks - for multiple column selections"""
currcol = self.table.currentcol
colclicked = self.table.get_col_clicked(event)
multicollist = self.table.multiplecollist
if 0 <= colclicked < self.table.cols:
if colclicked not in multicollist:
multicollist.append(colclicked)
else:
multicollist.remove(colclicked)
self.table.delete('colrect')
self.delete('rect')
for c in self.table.multiplecollist:
self.drawRect(c, delete=0)
self.table.drawSelectedCol(c, delete=0)
self.table.drawMultipleCells()
return
[docs] def handle_double_click(self, event):
"""Double click sorts by this column. """
colclicked = self.table.get_col_clicked(event)
if self.sort_ascending == 1:
self.sort_ascending = 0
else:
self.sort_ascending = 1
self.table.sortTable(ascending=self.sort_ascending)
return
[docs] def popupMenu(self, event):
"""Add left and right click behaviour for column header"""
df = self.table.model.df
if len(df.columns)==0:
return
ismulti = util.check_multiindex(df.columns)
colname = str(df.columns[self.table.currentcol])
currcol = self.table.currentcol
multicols = self.table.multiplecollist
colnames = list(df.columns[multicols])[:4]
colnames = [str(i)[:20] for i in colnames]
if len(colnames)>2:
colnames = ','.join(colnames[:2])+'+%s others' %str(len(colnames)-2)
else:
colnames = ','.join(colnames)
popupmenu = Menu(self, tearoff = 0)
def popupFocusOut(event):
popupmenu.unpost()
columncommands = {"Rename": self.renameColumn,
"Add": self.table.addColumn,
#"Delete": self.table.deleteColumn,
"Copy": self.table.copyColumn,
"Move to Start": self.table.moveColumns,
"Move to End": lambda: self.table.moveColumns(pos='end')
}
formatcommands = {'Set Color': self.table.setColumnColors,
'Color by Value': self.table.setColorbyValue,
'Alignment': self.table.setAlignment,
'Wrap Header' : self.table.setWrap
}
popupmenu.add_command(label="Sort by " + colnames + ' \u2193',
command=lambda : self.table.sortTable(ascending=[1 for i in multicols]))
popupmenu.add_command(label="Sort by " + colnames + ' \u2191',
command=lambda : self.table.sortTable(ascending=[0 for i in multicols]))
popupmenu.add_command(label="Set %s as Index" %colnames, command=self.table.setindex)
popupmenu.add_command(label="Delete Column(s)", command=self.table.deleteColumn)
if ismulti == True:
popupmenu.add_command(label="Flatten Index", command=self.table.flattenIndex)
popupmenu.add_command(label="Fill With Data", command=self.table.fillColumn)
popupmenu.add_command(label="Create Categorical", command=self.table.createCategorical)
popupmenu.add_command(label="Apply Function", command=self.table.applyColumnFunction)
popupmenu.add_command(label="Resample/Transform", command=self.table.applyTransformFunction)
popupmenu.add_command(label="Value Counts", command=self.table.valueCounts)
popupmenu.add_command(label="String Operation", command=self.table.applyStringMethod)
popupmenu.add_command(label="Date/Time Conversion", command=self.table.convertDates)
popupmenu.add_command(label="Set Data Type", command=self.table.setColumnType)
createSubMenu(popupmenu, 'Column', columncommands)
createSubMenu(popupmenu, 'Format', formatcommands)
popupmenu.bind("<FocusOut>", popupFocusOut)
popupmenu.focus_set()
popupmenu.post(event.x_root, event.y_root)
applyStyle(popupmenu)
return popupmenu
[docs] def renameColumn(self):
"""Rename column"""
col = self.table.currentcol
df = self.model.df
name = df.columns[col]
new = simpledialog.askstring("New column name?", "Enter new name:",
initialvalue=name)
if new != None:
if new == '':
messagebox.showwarning("Error", "Name should not be blank.")
return
else:
df.rename(columns={df.columns[col]: new}, inplace=True)
self.table.tableChanged()
self.redraw()
return
[docs] def draw_resize_symbol(self, col):
"""Draw a symbol to show that col can be resized when mouse here"""
self.delete('resizesymbol')
w=self.table.cellwidth
h=25
wdth=1
hfac1=0.2
hfac2=0.4
x_start=self.table.x_start
x1,y1,x2,y2 = self.table.getCellCoords(0,col)
self.create_polygon(x2-3,h/4, x2-10,h/2, x2-3,h*3/4, tag='resizesymbol',
fill='white', outline='gray', width=wdth)
self.create_polygon(x2+2,h/4, x2+10,h/2, x2+2,h*3/4, tag='resizesymbol',
fill='white', outline='gray', width=wdth)
return
[docs] def drawRect(self,col, tag=None, color=None, outline=None, delete=1):
"""User has clicked to select a col"""
if tag == None:
tag = 'rect'
if color == None:
color = self.colselectedcolor
if outline == None:
outline = 'gray25'
if delete == 1:
self.delete(tag)
w=1
x1,y1,x2,y2 = self.table.getCellCoords(0,col)
rect = self.create_rectangle(x1,y1-w,x2,self.height,
fill=color,
outline=outline,
width=w,
tag=tag)
self.lower(tag)
return
[docs]class RowHeader(Canvas):
"""Class that displays the row headings (or DataFrame index).
Takes it's size and rendering from the parent table.
This also handles row/record selection as opposed to cell
selection"""
def __init__(self, parent=None, table=None, width=50, bg='gray75'):
Canvas.__init__(self, parent, bg=bg, width=width, height=None)
if table != None:
self.table = table
self.width = width
self.inset = 1
#self.color = '#C8C8C8'
self.textcolor = 'black'
self.bgcolor = bg
self.showindex = False
self.maxwidth = 500
self.config(height = self.table.height)
self.startrow = self.endrow = None
self.model = self.table.model
self.bind('<Button-1>',self.handle_left_click)
self.bind("<ButtonRelease-1>", self.handle_left_release)
self.bind("<Control-Button-1>", self.handle_left_ctrl_click)
if self.table.ostyp == 'darwin':
# For mac we bind Shift, left-click to right click
self.bind("<Button-2>", self.handle_right_click)
self.bind('<Shift-Button-1>', self.handle_right_click)
else:
self.bind("<Button-3>", self.handle_right_click)
self.bind('<B1-Motion>', self.handle_mouse_drag)
self.bind('<Shift-Button-1>', self.handle_left_shift_click)
return
[docs] def redraw(self, align='w', showkeys=False):
"""Redraw row header"""
self.height = self.table.rowheight * self.table.rows+10
self.configure(scrollregion=(0,0, self.width, self.height))
self.delete('rowheader','text')
self.delete('rect')
xstart = 1
pad = 5
maxw = self.maxwidth
v = self.table.visiblerows
if len(v) == 0:
return
scale = self.table.getScale()
h = self.table.rowheight
index = self.model.df.index
names = index.names
if self.table.showindex == True:
if util.check_multiindex(index) == 1:
ind = index.values[v]
cols = [pd.Series(i).astype('object').astype(str)\
.replace('nan','') for i in list(zip(*ind))]
nl = [len(n) if n is not None else 0 for n in names]
l = [c.str.len().max() for c in cols]
#pick higher of index names and row data
l = list(np.maximum(l,nl))
widths = [i * scale + 6 for i in l]
xpos = [0]+list(np.cumsum(widths))[:-1]
else:
ind = index[v]
dtype = ind.dtype
#print (type(ind))
if type(ind) is pd.CategoricalIndex:
ind = ind.astype('str')
r = ind.fillna('').astype('object').astype('str')
l = r.str.len().max()
widths = [l * scale + 6]
cols = [r]
xpos = [xstart]
w = np.sum(widths)
else:
rows = [i+1 for i in v]
cols = [rows]
l = max([len(str(i)) for i in rows])
w = l * scale + 6
widths = [w]
xpos = [xstart]
self.widths = widths
if w>maxw:
w = maxw
elif w<45:
w = 45
if self.width != w:
self.config(width=w)
self.width = w
i=0
for col in cols:
r=v[0]
x = xpos[i]
i+=1
#col=pd.Series(col.tolist()).replace('nan','')
for row in col:
text = row
x1,y1,x2,y2 = self.table.getCellCoords(r,0)
self.create_rectangle(x,y1,w-1,y2, #fill=self.color,
outline='white', width=1,
tag='rowheader')
self.create_text(x+pad,y1+h/2, text=text,
fill=self.textcolor, font=self.table.thefont,
tag='text', anchor=align)
r+=1
self.config(bg=self.bgcolor)
return
[docs] def handle_left_click(self, event):
"""Handle left click"""
rowclicked = self.table.get_row_clicked(event)
self.startrow = rowclicked
if 0 <= rowclicked < self.table.rows:
self.delete('rect')
self.table.delete('entry')
self.table.delete('multicellrect')
#set row selected
self.table.setSelectedRow(rowclicked)
self.table.drawSelectedRow()
self.drawSelectedRows(self.table.currentrow)
return
[docs] def handle_left_ctrl_click(self, event):
"""Handle ctrl clicks - for multiple row selections"""
rowclicked = self.table.get_row_clicked(event)
multirowlist = self.table.multiplerowlist
if 0 <= rowclicked < self.table.rows:
if rowclicked not in multirowlist:
multirowlist.append(rowclicked)
else:
multirowlist.remove(rowclicked)
self.table.drawMultipleRows(multirowlist)
self.drawSelectedRows(multirowlist)
return
[docs] def handle_left_shift_click(self, event):
"""Handle shift click"""
if self.startrow == None:
self.startrow = self.table.currentrow
self.handle_mouse_drag(event)
return
[docs] def handle_right_click(self, event):
"""respond to a right click"""
if self.table.enable_menus == False:
return
self.delete('tooltip')
if hasattr(self, 'rightmenu'):
self.rightmenu.destroy()
self.rightmenu = self.popupMenu(event, outside=1)
return
[docs] def handle_mouse_drag(self, event):
"""Handle mouse moved with button held down, multiple selections"""
if hasattr(self, 'cellentry'):
self.cellentry.destroy()
rowover = self.table.get_row_clicked(event)
colover = self.table.get_col_clicked(event)
if rowover == None:
return
if rowover >= self.table.rows or self.startrow > self.table.rows:
return
else:
self.endrow = rowover
#draw the selected rows
if self.endrow != self.startrow:
if self.endrow < self.startrow:
rowlist=list(range(self.endrow, self.startrow+1))
else:
rowlist=list(range(self.startrow, self.endrow+1))
self.drawSelectedRows(rowlist)
self.table.multiplerowlist = rowlist
self.table.drawMultipleRows(rowlist)
self.table.drawMultipleCells()
self.table.allrows = False
else:
self.table.multiplerowlist = []
self.table.multiplerowlist.append(rowover)
self.drawSelectedRows(rowover)
self.table.drawMultipleRows(self.table.multiplerowlist)
return
[docs] def toggleIndex(self):
"""Toggle index display"""
if self.table.showindex == True:
self.table.showindex = False
else:
self.table.showindex = True
self.redraw()
self.table.rowindexheader.redraw()
return
[docs] def popupMenu(self, event, rows=None, cols=None, outside=None):
"""Add left and right click behaviour for canvas, should not have to override
this function, it will take its values from defined dicts in constructor"""
defaultactions = {"Sort by index" : lambda: self.table.sortTable(index=True),
"Reset index" : lambda: self.table.resetIndex(),
"Toggle index" : lambda: self.toggleIndex(),
"Copy index to column" : lambda: self.table.copyIndex(),
"Rename index" : lambda: self.table.renameIndex(),
"Sort columns by row" : lambda: self.table.sortColumnIndex(),
"Select All" : self.table.selectAll,
"Add Row(s)" : lambda: self.table.addRows(),
"Delete Row(s)" : lambda: self.table.deleteRow(ask=True),
"Duplicate Row(s)": lambda: self.table.duplicateRows(),
"Set Row Color" : lambda: self.table.setRowColors(cols='all')}
main = ["Sort by index","Reset index","Toggle index",
"Rename index","Sort columns by row","Copy index to column",
"Add Row(s)","Delete Row(s)", "Duplicate Row(s)", "Set Row Color"]
popupmenu = Menu(self, tearoff = 0)
def popupFocusOut(event):
popupmenu.unpost()
for action in main:
popupmenu.add_command(label=action, command=defaultactions[action])
popupmenu.bind("<FocusOut>", popupFocusOut)
popupmenu.focus_set()
popupmenu.post(event.x_root, event.y_root)
applyStyle(popupmenu)
return popupmenu
[docs] def drawSelectedRows(self, rows=None):
"""Draw selected rows, accepts a list or integer"""
self.delete('rect')
if type(rows) is not list:
rowlist=[]
rowlist.append(rows)
else:
rowlist = rows
for r in rowlist:
if r not in self.table.visiblerows:
continue
self.drawRect(r, delete=0)
return
[docs] def drawRect(self, row=None, tag=None, color=None, outline=None, delete=1):
"""Draw a rect representing row selection"""
if tag==None:
tag='rect'
if color==None:
color='#0099CC'
if outline==None:
outline='gray25'
if delete == 1:
self.delete(tag)
w=0
i = self.inset
x1,y1,x2,y2 = self.table.getCellCoords(row, 0)
rect = self.create_rectangle(0+i,y1+i,self.width-i,y2,
fill=color,
outline=outline,
width=w,
tag=tag)
self.lift('text')
return
[docs]class IndexHeader(Canvas):
"""Class that displays the row index headings."""
def __init__(self, parent=None, table=None, width=40, height=25, bg='gray50'):
Canvas.__init__(self, parent, bg=bg, width=width, height=height)
if table != None:
self.table = table
self.width = width
self.height = self.table.rowheight
self.config(height=self.height)
self.textcolor = 'white'
self.bgcolor = bg
self.startrow = self.endrow = None
self.model = self.table.model
self.bind('<Button-1>',self.handle_left_click)
return
[docs] def redraw(self, align='w'):
"""Redraw row index header"""
df = self.model.df
rowheader = self.table.rowheader
self.width = rowheader.width
self.delete('text','rect')
if self.table.showindex == False:
return
xstart = 1
pad = 5
scale = self.table.getScale()
h = self.table.rowheight
self.config(height=h)
index = df.index
names = index.names
if names[0] == None:
widths = [self.width]
else:
widths = rowheader.widths
if util.check_multiindex(df.columns) == 1:
levels = df.columns.levels
h = self.table.rowheight * len(levels)
y = self.table.rowheight/2 + 2
else:
y=2
i=0; x=1;
for name in names:
if name != None:
w=widths[i]
self.create_text(x+pad,y+h/2,text=name,
fill=self.textcolor, font=self.table.thefont,
tag='text', anchor=align)
x=x+widths[i]
i+=1
#w=sum(widths)
self.config(bg=self.bgcolor)
return
[docs] def handle_left_click(self, event):
"""Handle mouse left mouse click"""
self.table.selectAll()
return