├── .gitignore ├── LICENSE ├── README.md ├── demo.py ├── dfgui ├── __init__.py ├── dfgui.py ├── dnd_list.py └── listmixin.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /venv*/ 3 | 4 | *.pyc 5 | *.egg-info 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pandas DataFrame GUI 2 | 3 | A minimalistic GUI for analyzing Pandas DataFrames based on wxPython. 4 | 5 | **Update:** I'm currently working on a successor [tabloo](https://github.com/bluenote10/tabloo) which avoids native dependencies and offers a more modern user interface. 6 | 7 | ## Usage 8 | 9 | ```python 10 | import dfgui 11 | dfgui.show(df) 12 | ``` 13 | 14 | ## Features 15 | 16 | - Tabular view of data frame 17 | - Columns are sortable (by clicking column header) 18 | - Columns can be enabled/disabled (left click on 'Columns' tab) 19 | - Columns can be rearranged (right click drag on 'Columns' tab) 20 | - Generic filtering: Write arbitrary Python expression to filter rows. *Warning:* Uses Python's `eval` -- use with care. 21 | - Histogram plots 22 | - Scatter plots 23 | 24 | ## Demo & Docs 25 | 26 | The default view: Nothing fancy, just scrolling and sorting. The value of cell can be copied to clipboard by right clicking on a cell. 27 | 28 | ![screen1](/../screenshots/screenshots/screen1.png) 29 | 30 | The column selection view: Left clicking enables or disables a column in the data frame view. Columns can be dragged with a right click to rearrange them. 31 | 32 | ![screen2](/../screenshots/screenshots/screen2.png) 33 | 34 | The filter view: Allows to write arbitrary Pandas selection expressions. The syntax is: An underscore `_` will be replaced by the corresponding data frame column. That is, setting the combo box to a column named "A" and adding the condition `_ == 1` would result in an expression like `df[df["A"] == 1, :]`. The following example filters the data frame to rows which have the value 669944 in column "UserID" and `datetime.date` value between 2016-01-01 and 2016-03-01. 35 | 36 | ![screen3](/../screenshots/screenshots/screen3.png) 37 | 38 | Histogram view: 39 | 40 | ![screen4](/../screenshots/screenshots/screen4.png) 41 | 42 | Scatter plot view: 43 | 44 | ![screen5](/../screenshots/screenshots/screen5.png) 45 | 46 | ## Requirements 47 | 48 | Since wxPython is not pip-installable, dfgui does not handle dependencies automatically. You have to make sure the following packages are installed: 49 | 50 | - pandas/numpy 51 | - matplotlib 52 | - wx 53 | 54 | ## Installation Instructions 55 | 56 | I haven't submitted dfgui to PyPI (yet), but you can install directly from git (having met all requirements). For instance: 57 | 58 | ```bash 59 | pip install git+https://github.com/bluenote10/PandasDataFrameGUI 60 | ``` 61 | 62 | or if you prefer a regular git clone: 63 | 64 | ```bash 65 | git clone git@github.com:bluenote10/PandasDataFrameGUI.git dfgui 66 | cd dfgui 67 | pip install -e . 68 | # and to check if everything works: 69 | ./demo.py 70 | ``` 71 | 72 | In fact, dfgui only consists of a single module, so you might as well just download the file [`dfgui/dfgui.py`](dfgui/dfgui.py). 73 | 74 | ### Anaconda/Windows Instructions 75 | 76 | Install wxpython through conda or the Anaconda GUI. 77 | 78 | "Open terminal" in the Anaconda GUI environment. 79 | 80 | ```bash 81 | git clone "https://github.com/bluenote10/PandasDataFrameGUI.git" 82 | cd dfgui 83 | pip install -e . 84 | conda package --pkg-name=dfgui --pkg-version=0.1 # this should create a package file 85 | conda install --offline dfgui-0.1-py27_0.tar.bz2 # this should install into your conda environment 86 | ``` 87 | Then restart your Jupyter kernel. 88 | 89 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | 4 | from __future__ import absolute_import, division, print_function 5 | 6 | """ 7 | If you are getting wx related import errors when running in a virtualenv: 8 | Either make sure that the virtualenv has been created using 9 | `virtualenv --system-site-packages venv` or manually add the wx library 10 | path (e.g. /usr/lib/python2.7/dist-packages/wx-2.8-gtk2-unicode) to the 11 | python path. 12 | """ 13 | 14 | import datetime 15 | import pandas as pd 16 | import numpy as np 17 | import dfgui 18 | 19 | 20 | def create_dummy_data(size): 21 | 22 | user_ids = np.random.randint(1, 1000000, 10) 23 | product_ids = np.random.randint(1, 1000000, 100) 24 | 25 | def choice(*values): 26 | return np.random.choice(values, size) 27 | 28 | random_dates = [ 29 | datetime.date(2016, 1, 1) + datetime.timedelta(days=int(delta)) 30 | for delta in np.random.randint(1, 50, size) 31 | ] 32 | 33 | return pd.DataFrame.from_items([ 34 | ("Date", random_dates), 35 | ("UserID", choice(*user_ids)), 36 | ("ProductID", choice(*product_ids)), 37 | ("IntColumn", choice(1, 2, 3)), 38 | ("FloatColumn", choice(np.nan, 1.0, 2.0, 3.0)), 39 | ("StringColumn", choice("A", "B", "C")), 40 | ("Gaussian 1", np.random.normal(0, 1, size)), 41 | ("Gaussian 2", np.random.normal(0, 1, size)), 42 | ("Uniform", np.random.uniform(0, 1, size)), 43 | ("Binomial", np.random.binomial(20, 0.1, size)), 44 | ("Poisson", np.random.poisson(1.0, size)), 45 | ]) 46 | 47 | df = create_dummy_data(1000) 48 | 49 | dfgui.show(df) 50 | -------------------------------------------------------------------------------- /dfgui/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from dfgui.dfgui import show 4 | 5 | __all__ = [ 6 | "show" 7 | ] 8 | -------------------------------------------------------------------------------- /dfgui/dfgui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | 4 | from __future__ import absolute_import, division, print_function 5 | 6 | try: 7 | import wx 8 | except ImportError: 9 | import sys 10 | sys.path += [ 11 | "/usr/lib/python2.7/dist-packages/wx-2.8-gtk2-unicode", 12 | "/usr/lib/python2.7/dist-packages" 13 | ] 14 | import wx 15 | 16 | import matplotlib 17 | matplotlib.use('WXAgg') 18 | from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas 19 | from matplotlib.backends.backend_wx import NavigationToolbar2Wx 20 | from matplotlib.figure import Figure 21 | from bisect import bisect 22 | 23 | import numpy as np 24 | import pandas as pd 25 | 26 | # unused import required to allow 'eval' of date filters 27 | import datetime 28 | from datetime import date 29 | 30 | # try to get nicer plotting styles 31 | try: 32 | import seaborn 33 | seaborn.set() 34 | except ImportError: 35 | try: 36 | from matplotlib import pyplot as plt 37 | plt.style.use('ggplot') 38 | except AttributeError: 39 | pass 40 | 41 | 42 | class ListCtrlDataFrame(wx.ListCtrl): 43 | 44 | # TODO: we could do something more sophisticated to come 45 | # TODO: up with a reasonable column width... 46 | DEFAULT_COLUMN_WIDTH = 100 47 | TMP_SELECTION_COLUMN = 'tmp_selection_column' 48 | 49 | def __init__(self, parent, df, status_bar_callback): 50 | wx.ListCtrl.__init__( 51 | self, parent, -1, 52 | style=wx.LC_REPORT | wx.LC_VIRTUAL | wx.LC_HRULES | wx.LC_VRULES | wx.LB_MULTIPLE 53 | ) 54 | self.status_bar_callback = status_bar_callback 55 | 56 | self.df_orig = df 57 | self.original_columns = self.df_orig.columns[:] 58 | if isinstance(self.original_columns,(pd.RangeIndex,pd.Int64Index)): 59 | # RangeIndex is not supported by self._update_columns 60 | self.original_columns = pd.Index([str(i) for i in self.original_columns]) 61 | self.current_columns = self.df_orig.columns[:] 62 | 63 | self.sort_by_column = None 64 | 65 | self._reset_mask() 66 | 67 | # prepare attribute for alternating colors of rows 68 | self.attr_light_blue = wx.ListItemAttr() 69 | self.attr_light_blue.SetBackgroundColour("#D6EBFF") 70 | 71 | self.Bind(wx.EVT_LIST_COL_CLICK, self._on_col_click) 72 | self.Bind(wx.EVT_RIGHT_DOWN, self._on_right_click) 73 | 74 | self.df = pd.DataFrame({}) # init empty to force initial update 75 | self._update_rows() 76 | self._update_columns(self.original_columns) 77 | 78 | def _reset_mask(self): 79 | #self.mask = [True] * self.df_orig.shape[0] 80 | self.mask = pd.Series([True] * self.df_orig.shape[0], index=self.df_orig.index) 81 | 82 | def _update_columns(self, columns): 83 | self.ClearAll() 84 | for i, col in enumerate(columns): 85 | self.InsertColumn(i, col) 86 | self.SetColumnWidth(i, self.DEFAULT_COLUMN_WIDTH) 87 | # Note that we have to reset the count as well because ClearAll() 88 | # not only deletes columns but also the count... 89 | self.SetItemCount(len(self.df)) 90 | 91 | def set_columns(self, columns_to_use): 92 | """ 93 | External interface to set the column projections. 94 | """ 95 | self.current_columns = columns_to_use 96 | self._update_rows() 97 | self._update_columns(columns_to_use) 98 | 99 | def _update_rows(self): 100 | old_len = len(self.df) 101 | self.df = self.df_orig.loc[self.mask.values, self.current_columns] 102 | new_len = len(self.df) 103 | if old_len != new_len: 104 | self.SetItemCount(new_len) 105 | self.status_bar_callback(0, "Number of rows: {}".format(new_len)) 106 | 107 | def apply_filter(self, conditions): 108 | """ 109 | External interface to set a filter. 110 | """ 111 | old_mask = self.mask.copy() 112 | 113 | if len(conditions) == 0: 114 | self._reset_mask() 115 | 116 | else: 117 | self._reset_mask() # set all to True for destructive conjunction 118 | 119 | no_error = True 120 | for column, condition in conditions: 121 | if condition.strip() == '': 122 | continue 123 | condition = condition.replace("_", "self.df_orig['{}']".format(column)) 124 | print("Evaluating condition:", condition) 125 | try: 126 | tmp_mask = eval(condition) 127 | if isinstance(tmp_mask, pd.Series) and tmp_mask.dtype == np.bool: 128 | self.mask &= tmp_mask 129 | except Exception as e: 130 | print("Failed with:", e) 131 | no_error = False 132 | self.status_bar_callback( 133 | 1, 134 | "Evaluating '{}' failed with: {}".format(condition, e) 135 | ) 136 | 137 | if no_error: 138 | self.status_bar_callback(1, "") 139 | 140 | has_changed = any(old_mask != self.mask) 141 | if has_changed: 142 | self._update_rows() 143 | 144 | return len(self.df), has_changed 145 | 146 | def get_selected_items(self): 147 | """ 148 | Gets the selected items for the list control. 149 | Selection is returned as a list of selected indices, 150 | low to high. 151 | """ 152 | selection = [] 153 | current = -1 # start at -1 to get the first selected item 154 | while True: 155 | next = self.GetNextItem(current, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED) 156 | if next == -1: 157 | return selection 158 | else: 159 | selection.append(next) 160 | current = next 161 | 162 | def get_filtered_df(self): 163 | return self.df_orig.loc[self.mask, :] 164 | 165 | def _on_col_click(self, event): 166 | """ 167 | Sort data frame by selected column. 168 | """ 169 | # get currently selected items 170 | selected = self.get_selected_items() 171 | 172 | # append a temporary column to store the currently selected items 173 | self.df[self.TMP_SELECTION_COLUMN] = False 174 | self.df.iloc[selected, -1] = True 175 | 176 | # get column name to use for sorting 177 | col = event.GetColumn() 178 | 179 | # determine if ascending or descending 180 | if self.sort_by_column is None or self.sort_by_column[0] != col: 181 | ascending = True 182 | else: 183 | ascending = not self.sort_by_column[1] 184 | 185 | # store sort column and sort direction 186 | self.sort_by_column = (col, ascending) 187 | 188 | try: 189 | # pandas 0.17 190 | self.df.sort_values(self.df.columns[col], inplace=True, ascending=ascending) 191 | except AttributeError: 192 | # pandas 0.16 compatibility 193 | self.df.sort(self.df.columns[col], inplace=True, ascending=ascending) 194 | 195 | # deselect all previously selected 196 | for i in selected: 197 | self.Select(i, on=False) 198 | 199 | # determine indices of selection after sorting 200 | selected_bool = self.df.iloc[:, -1] == True 201 | selected = self.df.reset_index().index[selected_bool] 202 | 203 | # select corresponding rows 204 | for i in selected: 205 | self.Select(i, on=True) 206 | 207 | # delete temporary column 208 | del self.df[self.TMP_SELECTION_COLUMN] 209 | 210 | def _on_right_click(self, event): 211 | """ 212 | Copies a cell into clipboard on right click. Unfortunately, 213 | determining the clicked column is not straightforward. This 214 | appraoch is inspired by the TextEditMixin in: 215 | /usr/lib/python2.7/dist-packages/wx-2.8-gtk2-unicode/wx/lib/mixins/listctrl.py 216 | More references: 217 | - http://wxpython-users.1045709.n5.nabble.com/Getting-row-col-of-selected-cell-in-ListCtrl-td2360831.html 218 | - https://groups.google.com/forum/#!topic/wxpython-users/7BNl9TA5Y5U 219 | - https://groups.google.com/forum/#!topic/wxpython-users/wyayJIARG8c 220 | """ 221 | if self.HitTest(event.GetPosition()) != wx.NOT_FOUND: 222 | x, y = event.GetPosition() 223 | row, flags = self.HitTest((x, y)) 224 | 225 | col_locs = [0] 226 | loc = 0 227 | for n in range(self.GetColumnCount()): 228 | loc = loc + self.GetColumnWidth(n) 229 | col_locs.append(loc) 230 | 231 | scroll_pos = self.GetScrollPos(wx.HORIZONTAL) 232 | # this is crucial step to get the scroll pixel units 233 | unit_x, unit_y = self.GetMainWindow().GetScrollPixelsPerUnit() 234 | 235 | col = bisect(col_locs, x + scroll_pos * unit_x) - 1 236 | 237 | value = self.df.iloc[row, col] 238 | # print(row, col, scroll_pos, value) 239 | 240 | clipdata = wx.TextDataObject() 241 | clipdata.SetText(str(value)) 242 | wx.TheClipboard.Open() 243 | wx.TheClipboard.SetData(clipdata) 244 | wx.TheClipboard.Close() 245 | 246 | def OnGetItemText(self, item, col): 247 | """ 248 | Implements the item getter for a "virtual" ListCtrl. 249 | """ 250 | value = self.df.iloc[item, col] 251 | # print("retrieving %d %d %s" % (item, col, value)) 252 | return str(value) 253 | 254 | def OnGetItemAttr(self, item): 255 | """ 256 | Implements the attribute getter for a "virtual" ListCtrl. 257 | """ 258 | if item % 2 == 0: 259 | return self.attr_light_blue 260 | else: 261 | return None 262 | 263 | 264 | class DataframePanel(wx.Panel): 265 | """ 266 | Panel providing the main data frame table view. 267 | """ 268 | def __init__(self, parent, df, status_bar_callback): 269 | wx.Panel.__init__(self, parent) 270 | 271 | self.df_list_ctrl = ListCtrlDataFrame(self, df, status_bar_callback) 272 | 273 | sizer = wx.BoxSizer(wx.VERTICAL) 274 | sizer.Add(self.df_list_ctrl, 1, wx.ALL | wx.EXPAND | wx.GROW, 5) 275 | self.SetSizer(sizer) 276 | self.Show() 277 | 278 | 279 | class ListBoxDraggable(wx.ListBox): 280 | """ 281 | Helper class to provide ListBox with extended behavior. 282 | """ 283 | def __init__(self, parent, size, data, *args, **kwargs): 284 | 285 | wx.ListBox.__init__(self, parent, size, **kwargs) 286 | 287 | if isinstance(data,(pd.RangeIndex,pd.Int64Index)): 288 | # RangeIndex is not supported by self._update_columns 289 | data = pd.Index([str(i) for i in data]) 290 | self.data = data 291 | 292 | self.InsertItems(self.data, 0) 293 | 294 | self.Bind(wx.EVT_LISTBOX, self.on_selection_changed) 295 | 296 | self.Bind(wx.EVT_LEFT_DOWN, self.on_left_down) 297 | 298 | self.Bind(wx.EVT_RIGHT_DOWN, self.on_right_down) 299 | self.Bind(wx.EVT_RIGHT_UP, self.on_right_up) 300 | self.Bind(wx.EVT_MOTION, self.on_move) 301 | 302 | self.index_iter = range(len(self.data)) 303 | 304 | self.selected_items = [True] * len(self.data) 305 | self.index_mapping = list(range(len(self.data))) 306 | 307 | self.drag_start_index = None 308 | 309 | self.update_selection() 310 | self.SetFocus() 311 | 312 | def on_left_down(self, event): 313 | if self.HitTest(event.GetPosition()) != wx.NOT_FOUND: 314 | index = self.HitTest(event.GetPosition()) 315 | self.selected_items[index] = not self.selected_items[index] 316 | # doesn't really work to update selection direclty (focus issues) 317 | # instead we wait for the EVT_LISTBOX event and fix the selection 318 | # there... 319 | # self.update_selection() 320 | # TODO: we could probably use wx.CallAfter 321 | event.Skip() 322 | 323 | def update_selection(self): 324 | # self.SetFocus() 325 | # print(self.selected_items) 326 | for i in self.index_iter: 327 | if self.IsSelected(i) and not self.selected_items[i]: 328 | #print("Deselecting", i) 329 | self.Deselect(i) 330 | elif not self.IsSelected(i) and self.selected_items[i]: 331 | #print("Selecting", i) 332 | self.Select(i) 333 | 334 | def on_selection_changed(self, evt): 335 | self.update_selection() 336 | evt.Skip() 337 | 338 | def on_right_down(self, event): 339 | if self.HitTest(event.GetPosition()) != wx.NOT_FOUND: 340 | index = self.HitTest(event.GetPosition()) 341 | self.drag_start_index = index 342 | 343 | def on_right_up(self, event): 344 | self.drag_start_index = None 345 | event.Skip() 346 | 347 | def on_move(self, event): 348 | if self.drag_start_index is not None: 349 | if self.HitTest(event.GetPosition()) != wx.NOT_FOUND: 350 | index = self.HitTest(event.GetPosition()) 351 | if self.drag_start_index != index: 352 | self.swap(self.drag_start_index, index) 353 | self.drag_start_index = index 354 | 355 | def swap(self, i, j): 356 | self.index_mapping[i], self.index_mapping[j] = self.index_mapping[j], self.index_mapping[i] 357 | self.SetString(i, self.data[self.index_mapping[i]]) 358 | self.SetString(j, self.data[self.index_mapping[j]]) 359 | self.selected_items[i], self.selected_items[j] = self.selected_items[j], self.selected_items[i] 360 | # self.update_selection() 361 | # print("Updated mapping:", self.index_mapping) 362 | new_event = wx.PyCommandEvent(wx.EVT_LISTBOX.typeId, self.GetId()) 363 | self.GetEventHandler().ProcessEvent(new_event) 364 | 365 | def get_selected_data(self): 366 | selected = [] 367 | for i, col in enumerate(self.data): 368 | if self.IsSelected(i): 369 | index = self.index_mapping[i] 370 | value = self.data[index] 371 | selected.append(value) 372 | # print("Selected data:", selected) 373 | return selected 374 | 375 | 376 | class ColumnSelectionPanel(wx.Panel): 377 | """ 378 | Panel for selecting and re-arranging columns. 379 | """ 380 | def __init__(self, parent, columns, df_list_ctrl): 381 | wx.Panel.__init__(self, parent) 382 | 383 | self.columns = columns 384 | self.df_list_ctrl = df_list_ctrl 385 | 386 | self.list_box = ListBoxDraggable(self, -1, columns, style=wx.LB_EXTENDED) 387 | self.Bind(wx.EVT_LISTBOX, self.update_selected_columns) 388 | 389 | sizer = wx.BoxSizer(wx.VERTICAL) 390 | sizer.Add(self.list_box, 1, wx.ALL | wx.EXPAND | wx.GROW, 5) 391 | self.SetSizer(sizer) 392 | self.list_box.SetFocus() 393 | 394 | def update_selected_columns(self, evt): 395 | selected = self.list_box.get_selected_data() 396 | self.df_list_ctrl.set_columns(selected) 397 | 398 | 399 | class FilterPanel(wx.Panel): 400 | """ 401 | Panel for defining filter expressions. 402 | """ 403 | def __init__(self, parent, columns, df_list_ctrl, change_callback): 404 | wx.Panel.__init__(self, parent) 405 | 406 | columns_with_neutral_selection = [''] + list(columns) 407 | self.columns = columns 408 | self.df_list_ctrl = df_list_ctrl 409 | self.change_callback = change_callback 410 | 411 | self.num_filters = 10 412 | 413 | self.main_sizer = wx.BoxSizer(wx.VERTICAL) 414 | 415 | self.combo_boxes = [] 416 | self.text_controls = [] 417 | 418 | for i in range(self.num_filters): 419 | combo_box = wx.ComboBox(self, choices=columns_with_neutral_selection, style=wx.CB_READONLY) 420 | text_ctrl = wx.TextCtrl(self, wx.ID_ANY, '') 421 | 422 | self.Bind(wx.EVT_COMBOBOX, self.on_combo_box_select) 423 | self.Bind(wx.EVT_TEXT, self.on_text_change) 424 | 425 | row_sizer = wx.BoxSizer(wx.HORIZONTAL) 426 | row_sizer.Add(combo_box, 0, wx.ALL, 5) 427 | row_sizer.Add(text_ctrl, 1, wx.ALL | wx.EXPAND | wx.ALIGN_RIGHT, 5) 428 | 429 | self.combo_boxes.append(combo_box) 430 | self.text_controls.append(text_ctrl) 431 | self.main_sizer.Add(row_sizer, 0, wx.EXPAND) 432 | 433 | self.SetSizer(self.main_sizer) 434 | 435 | def on_combo_box_select(self, event): 436 | self.update_conditions() 437 | 438 | def on_text_change(self, event): 439 | self.update_conditions() 440 | 441 | def update_conditions(self): 442 | # print("Updating conditions") 443 | conditions = [] 444 | for i in range(self.num_filters): 445 | column_index = self.combo_boxes[i].GetSelection() 446 | condition = self.text_controls[i].GetValue() 447 | if column_index != wx.NOT_FOUND and column_index != 0: 448 | # since we have added a dummy column for "deselect", we have to subtract one 449 | column = self.columns[column_index - 1] 450 | conditions += [(column, condition)] 451 | num_matching, has_changed = self.df_list_ctrl.apply_filter(conditions) 452 | if has_changed: 453 | self.change_callback() 454 | # print("Num matching:", num_matching) 455 | 456 | 457 | class HistogramPlot(wx.Panel): 458 | """ 459 | Panel providing a histogram plot. 460 | """ 461 | def __init__(self, parent, columns, df_list_ctrl): 462 | wx.Panel.__init__(self, parent) 463 | 464 | columns_with_neutral_selection = [''] + list(columns) 465 | self.columns = columns 466 | self.df_list_ctrl = df_list_ctrl 467 | 468 | self.figure = Figure(facecolor="white", figsize=(1, 1)) 469 | self.axes = self.figure.add_subplot(111) 470 | self.canvas = FigureCanvas(self, -1, self.figure) 471 | 472 | chart_toolbar = NavigationToolbar2Wx(self.canvas) 473 | 474 | self.combo_box1 = wx.ComboBox(self, choices=columns_with_neutral_selection, style=wx.CB_READONLY) 475 | 476 | self.Bind(wx.EVT_COMBOBOX, self.on_combo_box_select) 477 | 478 | row_sizer = wx.BoxSizer(wx.HORIZONTAL) 479 | row_sizer.Add(self.combo_box1, 0, wx.ALL | wx.ALIGN_CENTER, 5) 480 | row_sizer.Add(chart_toolbar, 0, wx.ALL, 5) 481 | 482 | sizer = wx.BoxSizer(wx.VERTICAL) 483 | sizer.Add(self.canvas, 1, flag=wx.EXPAND, border=5) 484 | sizer.Add(row_sizer) 485 | self.SetSizer(sizer) 486 | 487 | def on_combo_box_select(self, event): 488 | self.redraw() 489 | 490 | def redraw(self): 491 | column_index1 = self.combo_box1.GetSelection() 492 | if column_index1 != wx.NOT_FOUND and column_index1 != 0: 493 | # subtract one to remove the neutral selection index 494 | column_index1 -= 1 495 | 496 | df = self.df_list_ctrl.get_filtered_df() 497 | 498 | if len(df) > 0: 499 | self.axes.clear() 500 | 501 | column = df.iloc[:, column_index1] 502 | is_string_col = column.dtype == np.object and isinstance(column.values[0], str) 503 | if is_string_col: 504 | value_counts = column.value_counts().sort_index() 505 | value_counts.plot(kind='bar', ax=self.axes) 506 | else: 507 | self.axes.hist(column.values, bins=100) 508 | 509 | self.canvas.draw() 510 | 511 | 512 | class ScatterPlot(wx.Panel): 513 | """ 514 | Panel providing a scatter plot. 515 | """ 516 | def __init__(self, parent, columns, df_list_ctrl): 517 | wx.Panel.__init__(self, parent) 518 | 519 | columns_with_neutral_selection = [''] + list(columns) 520 | self.columns = columns 521 | self.df_list_ctrl = df_list_ctrl 522 | 523 | self.figure = Figure(facecolor="white", figsize=(1, 1)) 524 | self.axes = self.figure.add_subplot(111) 525 | self.canvas = FigureCanvas(self, -1, self.figure) 526 | 527 | chart_toolbar = NavigationToolbar2Wx(self.canvas) 528 | 529 | self.combo_box1 = wx.ComboBox(self, choices=columns_with_neutral_selection, style=wx.CB_READONLY) 530 | self.combo_box2 = wx.ComboBox(self, choices=columns_with_neutral_selection, style=wx.CB_READONLY) 531 | 532 | self.Bind(wx.EVT_COMBOBOX, self.on_combo_box_select) 533 | 534 | row_sizer = wx.BoxSizer(wx.HORIZONTAL) 535 | row_sizer.Add(self.combo_box1, 0, wx.ALL | wx.ALIGN_CENTER, 5) 536 | row_sizer.Add(self.combo_box2, 0, wx.ALL | wx.ALIGN_CENTER, 5) 537 | row_sizer.Add(chart_toolbar, 0, wx.ALL, 5) 538 | 539 | sizer = wx.BoxSizer(wx.VERTICAL) 540 | sizer.Add(self.canvas, 1, flag=wx.EXPAND, border=5) 541 | sizer.Add(row_sizer) 542 | self.SetSizer(sizer) 543 | 544 | def on_combo_box_select(self, event): 545 | self.redraw() 546 | 547 | def redraw(self): 548 | column_index1 = self.combo_box1.GetSelection() 549 | column_index2 = self.combo_box2.GetSelection() 550 | if column_index1 != wx.NOT_FOUND and column_index1 != 0 and \ 551 | column_index2 != wx.NOT_FOUND and column_index2 != 0: 552 | # subtract one to remove the neutral selection index 553 | column_index1 -= 1 554 | column_index2 -= 1 555 | df = self.df_list_ctrl.get_filtered_df() 556 | 557 | # It looks like using pandas dataframe.plot causes something weird to 558 | # crash in wx internally. Therefore we use plain axes.plot functionality. 559 | # column_name1 = self.columns[column_index1] 560 | # column_name2 = self.columns[column_index2] 561 | # df.plot(kind='scatter', x=column_name1, y=column_name2) 562 | 563 | if len(df) > 0: 564 | self.axes.clear() 565 | self.axes.plot(df.iloc[:, column_index1].values, df.iloc[:, column_index2].values, 'o', clip_on=False) 566 | 567 | self.canvas.draw() 568 | 569 | 570 | class MainFrame(wx.Frame): 571 | """ 572 | The main GUI window. 573 | """ 574 | def __init__(self, df): 575 | wx.Frame.__init__(self, None, -1, "Pandas DataFrame GUI") 576 | 577 | # Here we create a panel and a notebook on the panel 578 | p = wx.Panel(self) 579 | nb = wx.Notebook(p) 580 | self.nb = nb 581 | 582 | columns = df.columns[:] 583 | if isinstance(columns,(pd.RangeIndex,pd.Int64Index)): 584 | # RangeIndex is not supported 585 | columns = pd.Index([str(i) for i in columns]) 586 | self.CreateStatusBar(2, style=0) 587 | self.SetStatusWidths([200, -1]) 588 | 589 | # create the page windows as children of the notebook 590 | self.page1 = DataframePanel(nb, df, self.status_bar_callback) 591 | self.page2 = ColumnSelectionPanel(nb, columns, self.page1.df_list_ctrl) 592 | self.page3 = FilterPanel(nb, columns, self.page1.df_list_ctrl, self.selection_change_callback) 593 | self.page4 = HistogramPlot(nb, columns, self.page1.df_list_ctrl) 594 | self.page5 = ScatterPlot(nb, columns, self.page1.df_list_ctrl) 595 | 596 | # add the pages to the notebook with the label to show on the tab 597 | nb.AddPage(self.page1, "Data Frame") 598 | nb.AddPage(self.page2, "Columns") 599 | nb.AddPage(self.page3, "Filters") 600 | nb.AddPage(self.page4, "Histogram") 601 | nb.AddPage(self.page5, "Scatter Plot") 602 | 603 | nb.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.on_tab_change) 604 | 605 | # finally, put the notebook in a sizer for the panel to manage 606 | # the layout 607 | sizer = wx.BoxSizer() 608 | sizer.Add(nb, 1, wx.EXPAND) 609 | p.SetSizer(sizer) 610 | 611 | self.SetSize((800, 600)) 612 | self.Center() 613 | 614 | def on_tab_change(self, event): 615 | self.page2.list_box.SetFocus() 616 | page_to_select = event.GetSelection() 617 | wx.CallAfter(self.fix_focus, page_to_select) 618 | event.Skip(True) 619 | 620 | def fix_focus(self, page_to_select): 621 | page = self.nb.GetPage(page_to_select) 622 | page.SetFocus() 623 | if isinstance(page, DataframePanel): 624 | self.page1.df_list_ctrl.SetFocus() 625 | elif isinstance(page, ColumnSelectionPanel): 626 | self.page2.list_box.SetFocus() 627 | 628 | def status_bar_callback(self, i, new_text): 629 | self.SetStatusText(new_text, i) 630 | 631 | def selection_change_callback(self): 632 | self.page4.redraw() 633 | self.page5.redraw() 634 | 635 | 636 | def show(df): 637 | """ 638 | The main function to start the data frame GUI. 639 | """ 640 | 641 | app = wx.App(False) 642 | frame = MainFrame(df) 643 | frame.Show() 644 | app.MainLoop() 645 | -------------------------------------------------------------------------------- /dfgui/dnd_list.py: -------------------------------------------------------------------------------- 1 | """ DnD demo with listctrl. """ 2 | import sys 3 | sys.path.append("/usr/lib/python2.7/dist-packages/wx-2.8-gtk2-unicode") 4 | 5 | import wx 6 | 7 | class DragList(wx.ListCtrl): 8 | def __init__(self, *arg, **kw): 9 | if 'style' in kw and (kw['style']&wx.LC_LIST or kw['style']&wx.LC_REPORT): 10 | kw['style'] |= wx.LC_SINGLE_SEL 11 | else: 12 | kw['style'] = wx.LC_SINGLE_SEL|wx.LC_LIST 13 | 14 | wx.ListCtrl.__init__(self, *arg, **kw) 15 | 16 | self.Bind(wx.EVT_LIST_BEGIN_DRAG, self._startDrag) 17 | 18 | dt = ListDrop(self._insert) 19 | self.SetDropTarget(dt) 20 | 21 | def _startDrag(self, e): 22 | """ Put together a data object for drag-and-drop _from_ this list. """ 23 | 24 | # Create the data object: Just use plain text. 25 | data = wx.PyTextDataObject() 26 | idx = e.GetIndex() 27 | text = self.GetItem(idx).GetText() 28 | data.SetText(text) 29 | 30 | # Create drop source and begin drag-and-drop. 31 | dropSource = wx.DropSource(self) 32 | dropSource.SetData(data) 33 | res = dropSource.DoDragDrop(flags=wx.Drag_DefaultMove) 34 | 35 | # If move, we want to remove the item from this list. 36 | if res == wx.DragMove: 37 | # It's possible we are dragging/dropping from this list to this list. In which case, the 38 | # index we are removing may have changed... 39 | 40 | # Find correct position. 41 | pos = self.FindItem(idx, text) 42 | self.DeleteItem(pos) 43 | 44 | def _insert(self, x, y, text): 45 | """ Insert text at given x, y coordinates --- used with drag-and-drop. """ 46 | 47 | # Clean text. 48 | import string 49 | text = filter(lambda x: x in (string.letters + string.digits + string.punctuation + ' '), text) 50 | 51 | # Find insertion point. 52 | index, flags = self.HitTest((x, y)) 53 | 54 | if index == wx.NOT_FOUND: 55 | if flags & wx.LIST_HITTEST_NOWHERE: 56 | index = self.GetItemCount() 57 | else: 58 | return 59 | 60 | # Get bounding rectangle for the item the user is dropping over. 61 | rect = self.GetItemRect(index) 62 | 63 | # If the user is dropping into the lower half of the rect, we want to insert _after_ this item. 64 | if y > rect.y + rect.height/2: 65 | index += 1 66 | 67 | self.InsertStringItem(index, text) 68 | 69 | class ListDrop(wx.PyDropTarget): 70 | """ Drop target for simple lists. """ 71 | 72 | def __init__(self, setFn): 73 | """ Arguments: 74 | - setFn: Function to call on drop. 75 | """ 76 | wx.PyDropTarget.__init__(self) 77 | 78 | self.setFn = setFn 79 | 80 | # specify the type of data we will accept 81 | self.data = wx.PyTextDataObject() 82 | self.SetDataObject(self.data) 83 | 84 | # Called when OnDrop returns True. We need to get the data and 85 | # do something with it. 86 | def OnData(self, x, y, d): 87 | # copy the data from the drag source to our data object 88 | if self.GetData(): 89 | self.setFn(x, y, self.data.GetText()) 90 | 91 | # what is returned signals the source what to do 92 | # with the original data (move, copy, etc.) In this 93 | # case we just return the suggested value given to us. 94 | return d 95 | 96 | if __name__ == '__main__': 97 | items = ['Foo', 'Bar', 'Baz', 'Zif', 'Zaf', 'Zof'] 98 | 99 | class MyApp(wx.App): 100 | def OnInit(self): 101 | self.frame = wx.Frame(None, title='Main Frame') 102 | self.frame.Show(True) 103 | self.SetTopWindow(self.frame) 104 | return True 105 | 106 | app = MyApp(redirect=False) 107 | dl1 = DragList(app.frame) 108 | dl2 = DragList(app.frame) 109 | sizer = wx.BoxSizer() 110 | app.frame.SetSizer(sizer) 111 | sizer.Add(dl1, proportion=1, flag=wx.EXPAND) 112 | sizer.Add(dl2, proportion=1, flag=wx.EXPAND) 113 | for item in items: 114 | dl1.InsertStringItem(99, item) 115 | dl2.InsertStringItem(99, item) 116 | app.frame.Layout() 117 | app.MainLoop() -------------------------------------------------------------------------------- /dfgui/listmixin.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------------- 2 | # Name: wxPython.lib.mixins.listctrl 3 | # Purpose: Helpful mix-in classes for wxListCtrl 4 | # 5 | # Author: Robin Dunn 6 | # 7 | # Created: 15-May-2001 8 | # RCS-ID: $Id: listctrl.py 63322 2010-01-30 00:59:55Z RD $ 9 | # Copyright: (c) 2001 by Total Control Software 10 | # Licence: wxWindows license 11 | #---------------------------------------------------------------------------- 12 | # 12/14/2003 - Jeff Grimmett (grimmtooth@softhome.net) 13 | # 14 | # o 2.5 compatability update. 15 | # o ListCtrlSelectionManagerMix untested. 16 | # 17 | # 12/21/2003 - Jeff Grimmett (grimmtooth@softhome.net) 18 | # 19 | # o wxColumnSorterMixin -> ColumnSorterMixin 20 | # o wxListCtrlAutoWidthMixin -> ListCtrlAutoWidthMixin 21 | # ... 22 | # 13/10/2004 - Pim Van Heuven (pim@think-wize.com) 23 | # o wxTextEditMixin: Support Horizontal scrolling when TAB is pressed on long 24 | # ListCtrls, support for WXK_DOWN, WXK_UP, performance improvements on 25 | # very long ListCtrls, Support for virtual ListCtrls 26 | # 27 | # 15-Oct-2004 - Robin Dunn 28 | # o wxTextEditMixin: Added Shift-TAB support 29 | # 30 | # 2008-11-19 - raf 31 | # o ColumnSorterMixin: Added GetSortState() 32 | # 33 | 34 | import locale 35 | import wx 36 | 37 | #---------------------------------------------------------------------------- 38 | 39 | class ColumnSorterMixin: 40 | """ 41 | A mixin class that handles sorting of a wx.ListCtrl in REPORT mode when 42 | the column header is clicked on. 43 | 44 | There are a few requirments needed in order for this to work genericly: 45 | 46 | 1. The combined class must have a GetListCtrl method that 47 | returns the wx.ListCtrl to be sorted, and the list control 48 | must exist at the time the wx.ColumnSorterMixin.__init__ 49 | method is called because it uses GetListCtrl. 50 | 51 | 2. Items in the list control must have a unique data value set 52 | with list.SetItemData. 53 | 54 | 3. The combined class must have an attribute named itemDataMap 55 | that is a dictionary mapping the data values to a sequence of 56 | objects representing the values in each column. These values 57 | are compared in the column sorter to determine sort order. 58 | 59 | Interesting methods to override are GetColumnSorter, 60 | GetSecondarySortValues, and GetSortImages. See below for details. 61 | """ 62 | 63 | def __init__(self, numColumns, preSortCallback = None): 64 | self.SetColumnCount(numColumns) 65 | self.preSortCallback = preSortCallback 66 | list = self.GetListCtrl() 67 | if not list: 68 | raise ValueError, "No wx.ListCtrl available" 69 | list.Bind(wx.EVT_LIST_COL_CLICK, self.__OnColClick, list) 70 | 71 | 72 | def SetColumnCount(self, newNumColumns): 73 | self._colSortFlag = [0] * newNumColumns 74 | self._col = -1 75 | 76 | 77 | def SortListItems(self, col=-1, ascending=1): 78 | """Sort the list on demand. Can also be used to set the sort column and order.""" 79 | oldCol = self._col 80 | if col != -1: 81 | self._col = col 82 | self._colSortFlag[col] = ascending 83 | self.GetListCtrl().SortItems(self.GetColumnSorter()) 84 | self.__updateImages(oldCol) 85 | 86 | 87 | def GetColumnWidths(self): 88 | """ 89 | Returns a list of column widths. Can be used to help restore the current 90 | view later. 91 | """ 92 | list = self.GetListCtrl() 93 | rv = [] 94 | for x in range(len(self._colSortFlag)): 95 | rv.append(list.GetColumnWidth(x)) 96 | return rv 97 | 98 | 99 | def GetSortImages(self): 100 | """ 101 | Returns a tuple of image list indexesthe indexes in the image list for an image to be put on the column 102 | header when sorting in descending order. 103 | """ 104 | return (-1, -1) # (decending, ascending) image IDs 105 | 106 | 107 | def GetColumnSorter(self): 108 | """Returns a callable object to be used for comparing column values when sorting.""" 109 | return self.__ColumnSorter 110 | 111 | 112 | def GetSecondarySortValues(self, col, key1, key2): 113 | """Returns a tuple of 2 values to use for secondary sort values when the 114 | items in the selected column match equal. The default just returns the 115 | item data values.""" 116 | return (key1, key2) 117 | 118 | 119 | def __OnColClick(self, evt): 120 | if self.preSortCallback is not None: 121 | self.preSortCallback() 122 | oldCol = self._col 123 | self._col = col = evt.GetColumn() 124 | self._colSortFlag[col] = int(not self._colSortFlag[col]) 125 | self.GetListCtrl().SortItems(self.GetColumnSorter()) 126 | if wx.Platform != "__WXMAC__" or wx.SystemOptions.GetOptionInt("mac.listctrl.always_use_generic") == 1: 127 | self.__updateImages(oldCol) 128 | evt.Skip() 129 | self.OnSortOrderChanged() 130 | 131 | 132 | def OnSortOrderChanged(self): 133 | """ 134 | Callback called after sort order has changed (whenever user 135 | clicked column header). 136 | """ 137 | pass 138 | 139 | 140 | def GetSortState(self): 141 | """ 142 | Return a tuple containing the index of the column that was last sorted 143 | and the sort direction of that column. 144 | Usage: 145 | col, ascending = self.GetSortState() 146 | # Make changes to list items... then resort 147 | self.SortListItems(col, ascending) 148 | """ 149 | return (self._col, self._colSortFlag[self._col]) 150 | 151 | 152 | def __ColumnSorter(self, key1, key2): 153 | col = self._col 154 | ascending = self._colSortFlag[col] 155 | item1 = self.itemDataMap[key1][col] 156 | item2 = self.itemDataMap[key2][col] 157 | 158 | #--- Internationalization of string sorting with locale module 159 | if type(item1) == unicode and type(item2) == unicode: 160 | cmpVal = locale.strcoll(item1, item2) 161 | elif type(item1) == str or type(item2) == str: 162 | cmpVal = locale.strcoll(str(item1), str(item2)) 163 | else: 164 | cmpVal = cmp(item1, item2) 165 | #--- 166 | 167 | # If the items are equal then pick something else to make the sort value unique 168 | if cmpVal == 0: 169 | cmpVal = apply(cmp, self.GetSecondarySortValues(col, key1, key2)) 170 | 171 | if ascending: 172 | return cmpVal 173 | else: 174 | return -cmpVal 175 | 176 | 177 | def __updateImages(self, oldCol): 178 | sortImages = self.GetSortImages() 179 | if self._col != -1 and sortImages[0] != -1: 180 | img = sortImages[self._colSortFlag[self._col]] 181 | list = self.GetListCtrl() 182 | if oldCol != -1: 183 | list.ClearColumnImage(oldCol) 184 | list.SetColumnImage(self._col, img) 185 | 186 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='dfgui', 5 | version='0.1', 6 | description='Pandas DataFrame GUI', 7 | url='http://github.com/bluenote10/PandasDataFrameGUI', 8 | author='Fabian Keller', 9 | author_email='fabian.keller@blue-yonder.com', 10 | license='MIT', 11 | packages=['dfgui'], 12 | zip_safe=False 13 | ) 14 | --------------------------------------------------------------------------------