├── example2.bat ├── screenshot.png ├── example2.command ├── setup.py ├── example1.py ├── example3.py ├── example2.py ├── readme.md └── tkform.py /example2.bat: -------------------------------------------------------------------------------- 1 | python example2.py %1 %2 %3 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boscoh/tkform/HEAD/screenshot.png -------------------------------------------------------------------------------- /example2.command: -------------------------------------------------------------------------------- 1 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 2 | cd $DIR 3 | python example2.py $* 4 | if [ "$(uname)" == "Darwin" ]; then 5 | echo -n -e "]0;example2.command" 6 | osascript -e 'tell application "Terminal" to close (every window whose name contains "example2.command")' & 7 | fi 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | description = "Docs at http://github.com/boscoh/tkform" 5 | 6 | setup( 7 | name='tkform', 8 | version='1.0', 9 | author='Bosco Ho', 10 | author_email='boscoh@gmail.com', 11 | url='http://github.com/boscoh/tkform', 12 | description='a tkinter form-based GUI that wraps python scripts', 13 | long_description=description, 14 | license='BSD', 15 | install_requires=[], 16 | py_modules=['tkform'], 17 | scripts=[] 18 | ) 19 | -------------------------------------------------------------------------------- /example1.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: iso-8859-1 -*- 3 | 4 | 5 | import tkform 6 | 7 | 8 | def make_index(params): 9 | """ 10 | Our example run function that takes the params, converts 11 | it json, constructs a webpage, opens the webpage, and 12 | exits the original form. 13 | """ 14 | import os 15 | import json 16 | import webbrowser 17 | fname = os.path.abspath('tkform_ex2.html') 18 | 19 | # make the webpage 20 | template = "

Tkform Params to JSON

%s
" 21 | open(fname, 'w').write(template % json.dumps(params, indent=2)) 22 | 23 | # open the webpage 24 | webbrowser.open('file://' + fname) 25 | 26 | # to quit the form, must use this function 27 | tkform.exit() 28 | 29 | 30 | # Create the form with title, width and height 31 | title = "tkform example: bridge to webpage with json" 32 | width, height = 700, 900 33 | form = tkform.Form(title, width, height) 34 | 35 | # Add some text 36 | form.push_text(title, 20) 37 | 38 | # And a loader for multiple files, registers a 39 | # param with the dictionary key 'files_and_labels' 40 | # this will be returned as params in the 'run' funtion 41 | form.push_file_list_param('files_and_labels', '+ files') 42 | 43 | # Must register the submit button 44 | form.push_submit() 45 | 46 | # When pushed the submit button will trigger `run` 47 | # with parameters setup from above, replace with 48 | # our own function that takes a params dictionary 49 | form.run = make_index 50 | 51 | # All set up? Trigger app. 52 | form.mainloop() 53 | -------------------------------------------------------------------------------- /example3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: iso-8859-1 -*- 3 | 4 | 5 | import os 6 | import json 7 | import webbrowser 8 | 9 | import Tkinter as tk 10 | import tkFileDialog 11 | 12 | import tkform 13 | 14 | 15 | class ExampleForm(tkform.Form): 16 | 17 | """ 18 | Example tkform with Reorderable List 19 | """ 20 | 21 | def __init__(self, width=700, height=800, parent=None): 22 | tkform.Form.__init__(self, parent, width, height) 23 | 24 | self.title('Reorderable List Example') 25 | self.push_text("Reorderable List Example", 30) 26 | self.push_line() 27 | self.push_spacer() 28 | self.push_text(u"Drag \u2630 to reorder filenames") 29 | self.push_custom_loader('filenames_and_labels', '+ files') 30 | self.push_spacer(2) 31 | self.push_text("Output", 16) 32 | self.push_line() 33 | self.push_submit() 34 | self.push_output() 35 | 36 | def push_custom_loader(self, param_id, button_text): 37 | self.reorderable_list = tkform.ReorderableList(self.interior) 38 | self.datas = [] 39 | 40 | def load_peptagram(): 41 | fnames = tkFileDialog.askopenfilenames(title=button_text) 42 | try: 43 | self.print_output('Loading %d filenames... ' % len(fnames)) 44 | except: 45 | self.print_exception() 46 | 47 | for fname in fnames: 48 | basename = os.path.basename(fname) 49 | self.reorderable_list.add_entry_label(fname, basename) 50 | 51 | load_button = tk.Button( 52 | self.interior, 53 | text=button_text, 54 | command=load_peptagram) 55 | self.push_row(load_button) 56 | 57 | self.push_row(self.reorderable_list) 58 | self.mouse_widgets.append(self.reorderable_list) 59 | self.param_entries[param_id] = self.reorderable_list 60 | 61 | def run(self, params): 62 | 63 | self.print_output('\nForm parameters:\n') 64 | 65 | self.print_output(json.dumps(params, indent=2)) 66 | self.print_output('\n\n') 67 | 68 | 69 | ExampleForm(800, -150).mainloop() 70 | -------------------------------------------------------------------------------- /example2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: iso-8859-1 -*- 3 | 4 | 5 | import os 6 | import glob 7 | import json 8 | 9 | import tkform 10 | 11 | 12 | def size_of_fnames(*fnames): 13 | size = 0 14 | for fname in fnames: 15 | if os.path.isdir(fname): 16 | sub_fnames = glob.glob(os.path.join(fname, '*')) 17 | size += size_of_fnames(*sub_fnames) 18 | else: 19 | size += os.path.getsize(fname) 20 | return size 21 | 22 | 23 | def size_str(*fnames): 24 | size = size_of_fnames(*fnames) 25 | if size < 1E6: 26 | return "%.3f MB" % (size / 1E6) 27 | else: 28 | return "%.f MB" % (size / 1E6) 29 | 30 | 31 | class ExampleForm(tkform.Form): 32 | 33 | def __init__(self, width=700, height=800): 34 | 35 | tkform.Form.__init__(self, 'Example Form', width, height) 36 | 37 | # add text 38 | self.push_text("Example with tkform to calculate filesize", 30) 39 | # add dividing line 40 | self.push_line() 41 | # add space between rows 42 | self.push_spacer() 43 | 44 | # add a file loading list object 45 | self.push_text("Params for Reorderable Lists", 16) 46 | self.push_file_list_param('files_and_labels', '+ files') 47 | self.push_dir_list_param('dirs_and_labels', '+ directory') 48 | 49 | self.push_spacer() 50 | self.push_line() 51 | 52 | # some text labels 53 | self.push_text("More Params", 16) 54 | self.push_labeled_param( 55 | 'label1', 'Enter label1', 'label', width=50) 56 | self.push_labeled_param( 57 | 'file2', 'Enter file2', 'default_file', load_file_text='select') 58 | self.push_labeled_param( 59 | 'dir3', 'Enter dir3', 'default_dir', load_dir_text='select') 60 | self.push_checkbox_param('check4', '(Un)check this checkbox') 61 | self.push_text("Choose a radio button:") 62 | self.push_radio_param( 63 | 'radio5', 64 | ['choice 0', 65 | 'choice 1', 66 | 'choice 2', 67 | 'choice 3']) 68 | 69 | self.push_spacer() 70 | self.push_line() 71 | 72 | self.push_text("Output", 16) 73 | self.push_submit() 74 | 75 | # Must register the output if you want to 76 | # display output during the execution of your commands 77 | self.push_output() 78 | 79 | def run(self, params): 80 | """ 81 | Override the command that is run when "submit" is 82 | pressed. 83 | """ 84 | 85 | self.clear_output() 86 | 87 | self.print_output('\nForm parameters:\n') 88 | 89 | self.print_output(json.dumps(params, indent=2)) 90 | self.print_output('\n\n') 91 | 92 | fnames = [e[0] for e in params['files_and_labels']] 93 | 94 | self.print_output('Calculating size of files:\n') 95 | self.print_output('%s' % fnames) 96 | self.print_output('\n') 97 | self.print_output('Size: %s' % size_str(*fnames)) 98 | self.print_output('\n\n') 99 | 100 | dirs = [e[0] for e in params['dirs_and_labels']] 101 | 102 | self.print_output('Calculating size of directories:\n') 103 | self.print_output('%s' % dirs) 104 | self.print_output('\n') 105 | self.print_output('Size: %s' % size_str(*dirs)) 106 | 107 | self.print_output('\n\n') 108 | 109 | # add a text link 110 | self.print_output('Example of link: ') 111 | self.print_output('Close window', tkform.exit) 112 | 113 | 114 | ExampleForm(700, 900).mainloop() 115 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # tkform 4 | 5 | a cross-platform form-based GUI to run command-line utilities using standard Python. 6 | 7 | ![](./screenshot.png) 8 | 9 | ## Quick Start 10 | 11 | ### Installation instructions 12 | 13 | To install tkform: 14 | 15 | pip install tkform 16 | 17 | Or download and unzip: 18 | 19 |       [tkform-master.zip](https://github.com/boscoh/tkform/archive/master.zip) 20 | 21 | Then install: 22 | 23 | > python setup.py install 24 | 25 | ### Examples 26 | 27 | 1. `python example1.py` - loads a filename and sends it a webpage 28 | 2. `python example2.py` - is more involved which involve displaying output 29 | 3. `python example3.py` - shows how to customize reoderable lists 30 | 31 | ### Making a Quick-and-Dirty Form 32 | 33 | Here's how to make a simple form (this is similar to `example1.py`). 34 | 35 | First some house-keeping: 36 | 37 | import tkform, os, webbrowser 38 | 39 | Let's define a function that receives a `params` dictionary: 40 | 41 | def my_run(params): 42 | open('index.html', 'w').write(params['files_and_labels']) 43 | webbrowser.open('file://' + os.path.abspath('index.html') 44 | 45 | Notice the function expects a key of `files_and_labels` in `params` . 46 | 47 | A quick-and-dirty form can be instantiated and populated on the fly. : 48 | 49 | form = tkform.Form('Window title of form', 700, 900) 50 | 51 | Let's populate the form with a header, file/dir list parameter: 52 | 53 | form.push_text(title, 20) 54 | form.push_file_list_param('files_and_labels', '+ files') 55 | 56 | Then add the crucial submit button: 57 | 58 | form.push_submit() 59 | 60 | The submit button will trigger the `run` method, so let's override it with our own: 61 | 62 | form.run = my_run 63 | 64 | Then go: 65 | 66 | form.mainloop() 67 | 68 | ## What is it? 69 | 70 | You want to wrap a simple GUI around a command-line utility. As the idea of a GUI is to make it easy for end-users, you probably also want it to be: 71 | 72 | 1. cross-platform - useful for as many end-users as possible 73 | 2. open files with native file-chooser - familiarity is important for usability 74 | 3. easy to install - which is as important as being easy-to-use 75 | 76 | No existing solution quite satisifiy all three requirements. One solution is [`gooey`](https://github.com/chriskiehl/Gooey), which cleverly wraps a cross-platform `wxPython` GUI around Python scripts. But sadly, it requires `wxPython`, which is difficult to install for end-users. You either need to match binary versions of `wxPython` to your Python package, or install a C compiler ecosystem. 77 | 78 | Another solution is to run a local webbrowser. This is powerful except for one hobbling limitation. Due to the security model of the webbrowser, your native open-file dialog will never give you full pathnames to your scripts. This severely constrains your scripts. 79 | 80 | Instead, we have built `tkform` which provides a base for building a simple GUI around Python scripts that is designed for a non-technical end-user. It is designed against `tkinter`, which is bundled with Python. Your user has only to install Python, which is easy. `tkinter` provides native file-choosers, and as it's all in Python, your GUI will be cross-platform. 81 | 82 | ## How does it work? 83 | 84 | Typically, you will want to sub-class `tkform.Form` to use all the features: callback buttons, integrated text output, filename processing etc. The following is similar to the example `example2.py`. 85 | 86 | ### 1. Subclassing your own Form 87 | 88 | First import the library: 89 | 90 | import tkform 91 | 92 | Then subclass it with your ow: 93 | 94 | class MyForm(tkform.Form): 95 | def __init__(self): 96 | tkform.Form.__init__(self, title, width, height) 97 | 98 | The window will appear in the top-left of the screen. The `width` and the `height` define the size of the window. You can use negative values which will set, for the `width`, to the width of the screen subtracted by the negative value. Similarly for `height`. If you want to maximize the height of the window, a value of -150 seems to work across all platforms. 99 | 100 | ### 2. Creating the form - the grid model 101 | 102 | The way the form is built is with a vertical grid layout, similar to how many HTML forms are laid out. Internally, a tkinter grid is used and all widgets are left-aligned. A vertical scroll-bar will be applied that lets your form grow. 103 | 104 | The form will return a dictionary where each key/value pair is determined by what objects are pushed onto the form. The key/value pairs are determined by what paramaters you push onto the form in the initialization via the `push*_param` methods. 105 | 106 | _Parameters_. Available param types are defined by these methods: 107 | 108 | - multiple file list loader 109 | 110 | push_file_list_param(param_id, load_file_text, is_label=True) 111 | 112 | This creates a button, which, when clicked, triggers an open file dialog box to chose multiple files. Files chosen here will pop up in a table of files. This may include an optional label for each file as determined by the `is_label` flag. This table of files can be reordered, or removed. When the submit button is pressed, the widget will return a list of tuples in `params` of the run function. In the tuple, the first element is the filename, with an optional second element corresponding to the label. 113 | 114 | - directory list loader 115 | 116 | push_file_dir_param(param_id, load_dir_text, is_label=True) 117 | 118 | This is similar to `push_file_list_param` above except it opens directories. Unfortunately tkinter only allows you to select one directory at a time. 119 | 120 | - check box 121 | 122 | push_checkbox_param(param_id, text, init_val='1') 123 | 124 | This creates a checkbox that will return a string `1` if selected, and `0` otherwise. 125 | 126 | - radio button 127 | 128 | push_radio_param(param_id, text_list, init_val=0) 129 | 130 | This creates a radio button from a list of options determined by a list of strings in `text_list`. The return value is an integer that refers to entries in `text_list`. 131 | 132 | - text parameter, optionally as single file/directory 133 | 134 | push_labeled_param(param_id, text, entry_text='', load_file_text=None, load_dir_text=None) 135 | 136 | This creates a text entry that is passed to the `params` dictionary. This is also used for loading a single filename or a single directory. If `load_file_text` is not empty, a button will be created with that label text, which will trigger a file-open dialog box. Similarly for `load_dir_text`. 137 | 138 | _Decorators_. Of course you need other things to make the form readable: 139 | 140 | - text at different sizes: `push_text(text, fontsize=12)` 141 | - lines to divide sections: `push_line(width=500, height=1, color="#999999")` 142 | - and white-space: `push_spacer(self, height=1)` 143 | 144 | _Extra buttons and callbacks_. 145 | 146 | - Although `tkform` is conceived around a single submit button at the end, sometimes you might want to trigger some action before the form is submitted. To do this, first define the action you want to take: 147 | 148 | def callback(): 149 | # do something 150 | 151 | Then push the button onto the form, and link the callback: 152 | 153 | self.push_button('text', callback) 154 | 155 | ### 3. Submitting the job 156 | 157 | Most importantly you need to add a submit button, probably at the bottom of the form: 158 | 159 | push_submit() 160 | 161 | When the submit button is clicked, `tkform` will extract the parameters from all the widgets with `*_param*`, and put them in the `params` dictionary. This will be sent to the `self.run(params)` method. 162 | 163 | In the `Form` class, this is a dummy method that needs to be overriden: 164 | 165 | self.run(params): 166 | # Do your thing with params or run an external function 167 | 168 | At the end of `run` you have a choice: 169 | 170 | 1. you can exit the form with `tkform.exit()` 171 | 2. or the window stays open and the user can submit again _ad nauseum_. 172 | 173 | A nice thing is that if you've carried out some external action, say opening a web-page, that will stay open if you've closed the form. 174 | 175 | When `run` is called, it is wrapped in a try/except clause to catch errors that are optionally sent to the ouptut widget, which will be discussed next. 176 | 177 | 178 | ### 4. Handling output (optional) 179 | 180 | The form provides an (optional) output widget to provide feedback to the user within the form itself. The output area is registered on the form via the method with an optional width parameter: 181 | 182 | self.push_output(width=50) 183 | 184 | During the processing in `run`, you can add text: 185 | 186 | self.print_output('Processing...\n') 187 | 188 | This `print_output` does not include an implicit carriage-return, this allows you to display progressive text ouptut. The form will automatically increase in size to display the output. You can flush the output at different stages of the processing with: 189 | 190 | self.clear_output() 191 | 192 | The `print_output` also has simple hyperlink facility. Simply adding a callback function as a second parameter will turn the text into a hyperlink. 193 | 194 | For instance, if you have written results to a webpage `/user/result/index.html`, you can define a display-results callback: 195 | 196 | import webbrowser 197 | def show_results(): 198 | webbrowser.open('file://user/result.html') 199 | 200 | This will produce a hyperlink that will open the local web-page to your results: 201 | 202 | self.print_output('results page', show_results) 203 | 204 | ### 5. Execute the form 205 | 206 | Instantiate your form: 207 | 208 | form = Myform() 209 | 210 | And run it: 211 | 212 | form.mainloop() 213 | 214 | ## Customizing Forms 215 | 216 | If you want to customize your own widgets then have a look at the widgets instantiated in the `Form` class. 217 | 218 | Any `tkinter` widgets can be displayed in the form by `self.push_row(my_tkinter_widget)`. This could include buttons that trigger functions or any other kind of actions. 219 | 220 | To add key-value pairs to the `params` that is generated when the submit button is pressed, you must add an object to the `self.param_entries` dictionary: 221 | 222 | self.param_entries[param_id] = entry 223 | 224 | The value `entry` must have a `get` method that returns a json compatible data structure (lists, dictionaries, strings and numbers). 225 | 226 | ## Customizing Reorderable Lists 227 | 228 | One feature that `tkform` provides is the ability to preload a list of items before the `submit` button is pressed. An example is the widget generated with the `push_file_list_param` method. This list can be reordered, or truncated, and labels can be attached to them. 229 | 230 | To generated your own editable list, you can create your own widgets based on the `ReorderableList` class. On initialization, you must instantiate an `ReorderableList` on the page. Then you create a button that that triggers an action (say an open file dialog), which will populate your `ReorderableList`. This shows up on the page instantaneously. As well, you must provide an object to return in the `self.param_entries` dictionary. The `ReorderableList` serves this function, with its default `get` method. But you can certainly substitute your own. 231 | 232 | Anyway, check out `example3.py` to see how a customized `ReorderableList` is built. 233 | 234 | ## Processing the submit button 235 | 236 | It's very easy to work the GUI as optional feature within a command-line Python script. If you parameterise the main function in the form: 237 | 238 | def main_processing(params, print_output=std.write.out): 239 | # do something with params 240 | print_output('sucess...') 241 | 242 | This takes a params dictionary, and for output, it writes to the function print_output. This can be put in the `run` method of the form: 243 | 244 | class Myform(tkform.Form): 245 | ... 246 | def run(self, params): 247 | main_processing(params, self.output) 248 | 249 | And `main_processing` lends itself to take in arguments from the command-line parameters. 250 | 251 | ## Making scripts clickable for the End User 252 | 253 | It's useful to wrap the python script with a unix shell script or a Windows batch file so that the end-user can double-click in the file manager. 254 | 255 | For instance we can make for `example1.py`, a batch file `example1.bat`: 256 | 257 | python tkform1_ex1.py %1 %2 %3 258 | 259 | And in *nix, we ca make `example1.command`: 260 | 261 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 262 | cd $DIR 263 | python example1.py $*example 264 | 265 | Make sure the file is `chmod +c example1.command`. The extension of .command allows the MacOS finder to allow double-clicking. Indeed, to close the window that pops after the form is closed, you need to also add, for MacOS: 266 | 267 | if [ "$(uname)" == "Darwin" ]; then 268 | echo -n -e "\033]0;example1.command\007" 269 | osascript -e 'tell application "Terminal" to close (every window whose name contains "example1.command")' & 270 | fi 271 | 272 | ## Changelog 273 | 274 | 1.0 (June 2015) 275 | 276 | - ReorderableList abstraction 277 | - Output auto-sizing 278 | 279 | © Bosco K ho. 280 | 281 | -------------------------------------------------------------------------------- /tkform.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: iso-8859-1 -*- 3 | 4 | """ 5 | tkform provides an HTML-inspired form-based GUI 6 | for Python scripts. 7 | 8 | Includes 9 | - scrollable page 10 | - simple entry of params 11 | - reordable lists of files or directories 12 | """ 13 | 14 | 15 | import os 16 | import traceback 17 | import re 18 | import collections 19 | 20 | import Tkinter as tk 21 | import tkFileDialog 22 | from idlelib.WidgetRedirector import WidgetRedirector 23 | 24 | 25 | class VerticalScrolledFrame(tk.Frame): 26 | 27 | """ 28 | A Tkinter scrollable frame: 29 | 30 | - place widgets in the 'interior' attribute 31 | - construct and pack/place/grid normally 32 | - only allows vertical scrolling 33 | - adapted from http://stackoverflow.com/a/16198198 34 | """ 35 | 36 | def __init__(self, parent, *args, **kw): 37 | tk.Frame.__init__(self, parent, *args, **kw) 38 | 39 | # create a canvas object and a vertical scrollbar for scrolling it 40 | vscrollbar = tk.Scrollbar(self, orient=tk.VERTICAL) 41 | vscrollbar.pack(fill=tk.Y, side=tk.RIGHT, expand=tk.FALSE) 42 | hscrollbar = tk.Scrollbar(self, orient=tk.HORIZONTAL) 43 | hscrollbar.pack(fill=tk.X, side=tk.BOTTOM, expand=tk.FALSE) 44 | 45 | self.canvas = tk.Canvas( 46 | self, bd=0, highlightthickness=0, 47 | yscrollcommand=vscrollbar.set, 48 | xscrollcommand=hscrollbar.set) 49 | self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=tk.TRUE) 50 | vscrollbar.config(command=self.canvas.yview) 51 | hscrollbar.config(command=self.canvas.xview) 52 | 53 | # reset the view 54 | self.canvas.xview_moveto(0) 55 | self.canvas.yview_moveto(0) 56 | 57 | # create a frame inside the canvas which will be scrolled 58 | self.interior = tk.Frame(self.canvas) 59 | self.interior_id = self.canvas.create_window( 60 | 0, 0, window=self.interior, anchor=tk.NW) 61 | 62 | # track changes to canvas, frame and updates scrollbar 63 | self.interior.bind('', self._configure_interior) 64 | self.canvas.bind('', self._configure_canvas) 65 | self.canvas.bind_all("", self._on_mousewheel) 66 | 67 | # pixels '2', millimeters '2m', centimeters '2c', or inches '2i' 68 | self.canvas.configure(yscrollincrement='8') 69 | 70 | def _on_mousewheel(self, event): 71 | self.canvas.yview_scroll(-1 * (event.delta), "units") 72 | 73 | def _configure_interior(self, event): 74 | # update the scrollbars to match the size of the inner frame 75 | size = (self.interior.winfo_reqwidth(), 76 | self.interior.winfo_reqheight()) 77 | self.canvas.config(scrollregion="0 0 %s %s" % size) 78 | if self.interior.winfo_reqwidth() != self.canvas.winfo_width(): 79 | # update the canvas's width to fit the inner frame 80 | self.canvas.config(width=self.interior.winfo_reqwidth()) 81 | 82 | def _configure_canvas(self, event): 83 | if self.interior.winfo_reqwidth() != self.canvas.winfo_width(): 84 | # update the inner frame's width to fill the canvas 85 | self.canvas.itemconfigure( 86 | self.interior_id, width=self.canvas.winfo_width()) 87 | 88 | 89 | def is_event_in_widget(event, widget): 90 | x, y = event.x_root, event.y_root 91 | y0 = widget.winfo_rooty() 92 | y1 = widget.winfo_height() + y0 93 | x0 = widget.winfo_rootx() 94 | x1 = widget.winfo_width() + x0 95 | return y0 <= y <= y1 and x0 <= x <= x1 96 | 97 | 98 | class RowOfWidgets(): 99 | 100 | """ 101 | A row that holds widgets for ReorderableList. Contains a 102 | draggable numbered icon and a closing [x] 103 | """ 104 | 105 | def __init__(self, parent): 106 | self.parent = parent 107 | 108 | self.widgets = [] 109 | self.custom_widgets = [] 110 | self.callbacks = [] 111 | 112 | self.num_stringvar = tk.StringVar() 113 | self.num_widget = tk.Label( 114 | parent, textvariable=self.num_stringvar) 115 | 116 | self.delete_widget = tk.Label(parent, text=" [x]") 117 | 118 | def init_custom_widgets(self): 119 | # Here's where you build widgets 120 | pass 121 | 122 | def add_to_grid(self, i_row): 123 | self.num_stringvar.set(u'\u2630 %d.' % (i_row+1) ) 124 | self.widgets = [] 125 | self.widgets.append(self.num_widget) 126 | self.widgets.extend(self.custom_widgets) 127 | self.widgets.append(self.delete_widget) 128 | for i_widget, widget in enumerate(self.widgets): 129 | widget.grid(column=i_widget, row=i_row, sticky='W') 130 | 131 | def grid_forget(self): 132 | for widget in self.widgets: 133 | widget.grid_forget() 134 | 135 | def in_y(self, event): 136 | y = event.y_root 137 | y0 = self.num_widget.winfo_rooty() 138 | y1 = self.num_widget.winfo_height() + y0 139 | return y0 <= y <= y1 140 | 141 | def contains_event(self, event): 142 | return is_event_in_widget(event, self.num_widget) 143 | 144 | def get(self): 145 | return [callback() for callback in self.callbacks] 146 | 147 | 148 | class ReorderableWidgetList(tk.Frame): 149 | 150 | """ 151 | This is a table that contains rows of RowOfWidgets. The rows 152 | - can be reordered by dragging on the arrow character on 153 | the left 154 | - deleted by clicking on the x on the right 155 | - dynamically added to through `add_row_of_widgets` 156 | """ 157 | 158 | def __init__(self, parent): 159 | tk.Frame.__init__(self, parent) 160 | self.parent = parent 161 | self.grid() 162 | self.rows = [] 163 | self.i_select = -1 164 | 165 | def add_row_of_widgets(self, row_of_widgets): 166 | self.rows.append(row_of_widgets) 167 | self.clear_frame() 168 | self.build_frame() 169 | 170 | def clear_frame(self): 171 | for row in self.rows: 172 | row.grid_forget() 173 | 174 | def delete_param(self, i): 175 | print i 176 | self.clear_frame() 177 | del self.rows[i] 178 | self.build_frame() 179 | 180 | def get_delete_callback(self, i): 181 | return lambda event: self.delete_param(i) 182 | 183 | def build_frame(self): 184 | for i, row in enumerate(self.rows): 185 | row.add_to_grid(i) 186 | row.delete_widget.bind( 187 | "", 188 | self.get_delete_callback(i)) 189 | 190 | def get_i_from_y(self, event): 191 | for i, row in enumerate(self.rows): 192 | if row.in_y(event): 193 | return i 194 | return -1 195 | 196 | def get_i_from_xy(self, event): 197 | for i, row in enumerate(self.rows): 198 | if row.contains_event(event): 199 | return i 200 | return -1 201 | 202 | def contains_event(self, event): 203 | return is_event_in_widget(event, self) 204 | 205 | def mouse_down(self, event): 206 | self.i_select = self.get_i_from_xy(event) 207 | if self.i_select == -1: 208 | return 209 | row = self.rows[self.i_select] 210 | row.num_widget.configure(background='#FF9999') 211 | 212 | def mouse_up(self, event): 213 | if self.i_select == -1: 214 | return 215 | row = self.rows[self.i_select] 216 | row.num_widget.configure(background='white') 217 | 218 | def mouse_drag(self, event): 219 | self.i_mouse_drag = self.get_i_from_y(event) 220 | if self.i_select == -1 or self.i_mouse_drag == -1: 221 | return 222 | if self.i_select == self.i_mouse_drag: 223 | return 224 | i, j = self.i_mouse_drag, self.i_select 225 | self.rows[i], self.rows[j] = self.rows[j], self.rows[i] 226 | self.clear_frame() 227 | self.build_frame() 228 | self.i_select = self.i_mouse_drag 229 | self.i_mouse_drag = -1 230 | 231 | def get(self): 232 | return [e.get() for e in self.rows] 233 | 234 | 235 | class ReorderableList(ReorderableWidgetList): 236 | 237 | def add_entry_label(self, entry, label=None, width=None): 238 | row = RowOfWidgets(self) 239 | 240 | row.entry = entry 241 | row.entry_widget = tk.Label(self, text=row.entry) 242 | row.custom_widgets.append(row.entry_widget) 243 | row.callbacks.append(lambda: row.entry) 244 | 245 | if label is not None: 246 | row.label = label 247 | row.label_stringvar = tk.StringVar() 248 | row.label_stringvar.set(label) 249 | row.label_widget = tk.Entry( 250 | self, 251 | textvariable=row.label_stringvar, 252 | width=width) 253 | row.custom_widgets.append(row.label_widget) 254 | row.callbacks.append(row.label_stringvar.get) 255 | 256 | self.add_row_of_widgets(row) 257 | 258 | 259 | class HyperlinkManager: 260 | 261 | """ 262 | Manager of links that can be clicked in a text object. 263 | Maintains linkages between mouse interactions and commands. 264 | From http://effbot.org/zone/tkinter-text-hyperlink.htm 265 | """ 266 | 267 | def __init__(self, text): 268 | self.text = text 269 | self.text.tag_config("hyper", foreground="blue", underline=1) 270 | self.text.tag_bind("hyper", "", self._enter) 271 | self.text.tag_bind("hyper", "", self._leave) 272 | self.text.tag_bind("hyper", "", self._click) 273 | self.reset() 274 | 275 | def reset(self): 276 | self.links = {} 277 | 278 | def add_new_link(self, action): 279 | "Returns tag for link to use in tk.Text widget" 280 | tag = "hyper-%d" % len(self.links) 281 | self.links[tag] = action 282 | return "hyper", tag 283 | 284 | def _enter(self, event): 285 | self.text.config(cursor="hand2") 286 | 287 | def _leave(self, event): 288 | self.text.config(cursor="") 289 | 290 | def _click(self, event): 291 | for tag in self.text.tag_names(tk.CURRENT): 292 | if tag[:6] == "hyper-": 293 | self.links[tag]() 294 | return 295 | 296 | 297 | class LabeledEntry(tk.Frame): 298 | 299 | """ 300 | Creates a frame that holds a label, a button and a text entry 301 | in a row. The button is used to load a filename or directory. 302 | """ 303 | 304 | def __init__( 305 | self, 306 | parent, 307 | text, 308 | entry_text='', 309 | load_file_text=None, 310 | load_dir_text=None, 311 | width=None): 312 | 313 | self.parent = parent 314 | tk.Frame.__init__(self, parent) 315 | self.grid() 316 | 317 | self.stringvar = tk.StringVar() 318 | self.stringvar.set(entry_text) 319 | 320 | self.label = tk.Label(self, text=text) 321 | i_column = 0 322 | self.label.grid(column=i_column, row=0) 323 | 324 | i_column += 1 325 | self.button = None 326 | if load_file_text: 327 | self.button = tk.Button( 328 | self, text=load_file_text, command=self.load_file) 329 | self.button.grid(column=i_column, row=0) 330 | i_column += 1 331 | if load_dir_text: 332 | self.button = tk.Button( 333 | self, text=load_dir_text, command=self.load_dir) 334 | self.button.grid(column=i_column, row=0) 335 | i_column += 1 336 | 337 | self.entry = tk.Entry(self, textvariable=self.stringvar) 338 | 339 | self.width = width 340 | if self.width: 341 | self.entry.config(width=width) 342 | 343 | self.entry.grid(column=i_column, row=0) 344 | 345 | def load_file(self): 346 | fname = tkFileDialog.askopenfilename() 347 | self.stringvar.set(fname) 348 | 349 | def load_dir(self): 350 | fname = tkFileDialog.askdirectory() 351 | self.stringvar.set(fname) 352 | 353 | def get(self): 354 | return self.stringvar.get() 355 | 356 | 357 | def fix_list(tcl_list): 358 | """ 359 | fix for Windows where askopenfilenames fails to format the list 360 | """ 361 | if isinstance(tcl_list, list) or isinstance(tcl_list, tuple): 362 | return tcl_list 363 | regex = r""" 364 | {.*?} # text found in brackets 365 | | \S+ # or any non-white-space characters 366 | """ 367 | tokens = re.findall(regex, tcl_list, re.X) 368 | # remove '{' from start and '}' from end of string 369 | return [re.sub("^{|}$", "", i) for i in tokens] 370 | 371 | 372 | def askopenfilenames(*args, **kwargs): 373 | """ 374 | Wrap the askopenfilenames dialog to fix the fname list return 375 | for Windows, which returns a formatted string, not a list. 376 | """ 377 | fnames = tkFileDialog.askopenfilenames(*args, **kwargs) 378 | return fix_list(fnames) 379 | 380 | 381 | def exit(): 382 | """ 383 | Convenience function to close Tkinter. 384 | """ 385 | tk.Tk().quit() 386 | 387 | 388 | class ReadOnlyText(tk.Text): 389 | def __init__(self, *args, **kwargs): 390 | kwargs['relief'] = tk.FLAT 391 | kwargs['insertwidth'] = 0 392 | kwargs['highlightthickness'] = 0 393 | tk.Text.__init__(self, *args, **kwargs) 394 | self.redirector = WidgetRedirector(self) 395 | self.insert = \ 396 | self.redirector.register("insert", lambda *args, **kw: "break") 397 | self.delete = \ 398 | self.redirector.register("delete", lambda *args, **kw: "break") 399 | 400 | 401 | class Form(tk.Tk): 402 | 403 | """ 404 | A Form to collect parameters for running scripts in Python. 405 | 406 | Parameters are created in the initialization with: 407 | - push_labeled_param 408 | - push_checkbox_param 409 | - push_radio_param 410 | - push_file_list_param 411 | 412 | `get_params` will return the current set parameters, this is normally 413 | set as a callback from the submit button. 414 | 415 | For decorative purposes, use: 416 | - push_text 417 | - push_spacer 418 | 419 | A submit button to trigger the form 420 | 421 | Other buttons can be added with Pythonic callbacks: 422 | - push_button 423 | 424 | Creates a hyperlinkManager instance to allow links to appear in the form. 425 | 426 | Keeps track of child mouse_widgets to send mouse events. 427 | 428 | Creates an (optional) text output area to pipe messages from the running 429 | of the script. 430 | """ 431 | 432 | def __init__(self, title='', width=700, height=800, parent=None): 433 | self.parent = parent 434 | tk.Tk.__init__(self, parent) 435 | 436 | if width < 0: 437 | width = self.winfo_screenwidth() + width 438 | self.width = width 439 | 440 | if height < 0: 441 | height = self.winfo_screenheight() + height 442 | self.height = height 443 | 444 | self.geometry("%dx%d" % (width, height)) 445 | if title: 446 | self.title(title) 447 | 448 | self.vscroll_frame = VerticalScrolledFrame(self) 449 | self.vscroll_frame.pack(fill=tk.BOTH, expand=tk.TRUE) 450 | 451 | self.interior = self.vscroll_frame.interior 452 | self.interior.configure(bd=30) 453 | self.i_row_interior = 0 454 | 455 | self.output = None 456 | self.output_lines = [] 457 | self.output_link_manager = None 458 | 459 | self.param_entries = collections.OrderedDict() 460 | 461 | self.mouse_widgets = [] 462 | self.bind('', self.mouse_down) 463 | self.bind('', self.mouse_drag) 464 | self.bind('', self.mouse_up) 465 | 466 | # Make sure window starts on top 467 | self.lift() 468 | self.call('wm', 'attributes', '.', '-topmost', True) 469 | self.after_idle(self.call, 'wm', 'attributes', '.', '-topmost', False) 470 | 471 | def mouse_down(self, event): 472 | for widget in reversed(self.mouse_widgets): 473 | if widget.contains_event(event): 474 | widget.mouse_down(event) 475 | 476 | def mouse_up(self, event): 477 | for widget in reversed(self.mouse_widgets): 478 | if widget.contains_event(event): 479 | widget.mouse_up(event) 480 | 481 | def mouse_drag(self, event): 482 | for widget in reversed(self.mouse_widgets): 483 | if widget.contains_event(event): 484 | widget.mouse_drag(event) 485 | 486 | def push_row(self, widget): 487 | self.i_row_interior += 1 488 | widget.grid(row=self.i_row_interior, column=0, sticky=tk.W) 489 | 490 | def push_text(self, text, fontsize=12): 491 | label = tk.Label( 492 | self.interior, font=('defaultFont', fontsize), text=text) 493 | self.push_row(label) 494 | 495 | def push_spacer(self, height=1): 496 | label = tk.Label(self.interior, height=height) 497 | self.push_row(label) 498 | 499 | def push_line(self, width=500, height=1, color="#999999"): 500 | canvas = tk.Canvas(self.interior, width=width, height=height, bg=color) 501 | self.push_row(canvas) 502 | 503 | def push_button(self, text, command_fn): 504 | button = tk.Button(self.interior, text=text, command=command_fn) 505 | self.push_row(button) 506 | 507 | def push_labeled_param( 508 | self, param_id, text, entry_text='', 509 | load_file_text=None, load_dir_text=None, 510 | width=None): 511 | entry = LabeledEntry( 512 | self.interior, text, entry_text, load_file_text=load_file_text, 513 | load_dir_text=load_dir_text, width=width) 514 | self.push_row(entry) 515 | self.param_entries[param_id] = entry 516 | 517 | def push_file_list_param( 518 | self, param_id, load_file_text, is_label=True): 519 | file_list = ReorderableList(self.interior) 520 | 521 | def load_file(): 522 | fnames = askopenfilenames(title=load_file_text) 523 | for fname in fnames: 524 | if is_label: 525 | label = os.path.basename(fname) 526 | else: 527 | label = None 528 | file_list.add_entry_label(fname, label) 529 | 530 | load_files_button = tk.Button( 531 | self.interior, text=load_file_text, command=load_file) 532 | self.push_row(load_files_button) 533 | 534 | self.push_row(file_list) 535 | self.mouse_widgets.append(file_list) 536 | self.param_entries[param_id] = file_list 537 | 538 | def push_dir_list_param( 539 | self, param_id, load_dir_text, is_label=True): 540 | file_list = ReorderableList(self.interior) 541 | 542 | def load_dir(): 543 | the_dir = tkFileDialog.askdirectory(title=load_dir_text) 544 | if is_label: 545 | label = os.path.basename(the_dir) 546 | else: 547 | label = None 548 | file_list.add_entry_label(the_dir, label) 549 | 550 | load_dir_button = tk.Button( 551 | self.interior, text=load_dir_text, command=load_dir) 552 | self.push_row(load_dir_button) 553 | 554 | self.push_row(file_list) 555 | self.mouse_widgets.append(file_list) 556 | self.param_entries[param_id] = file_list 557 | 558 | def push_checkbox_param(self, param_id, text, init_val='1'): 559 | int_var = tk.IntVar() 560 | int_var.set(init_val) 561 | check_button = tk.Checkbutton( 562 | self.interior, text=text, variable=int_var) 563 | self.push_row(check_button) 564 | self.param_entries[param_id] = int_var 565 | 566 | def push_radio_param(self, param_id, text_list, init_val=0): 567 | int_var = tk.IntVar() 568 | buttons = [] 569 | for i, text in enumerate(text_list): 570 | val = i 571 | button = tk.Radiobutton( 572 | self.interior, text=text, value=val, variable=int_var) 573 | buttons.append(button) 574 | int_var.set(str(init_val)) 575 | for button in buttons: 576 | self.push_row(button) 577 | self.param_entries[param_id] = int_var 578 | 579 | def get_params(self): 580 | params = collections.OrderedDict() 581 | for param_id, entry in self.param_entries.items(): 582 | params[param_id] = entry.get() 583 | return params 584 | 585 | def push_output(self, width=70): 586 | if self.output is not None: 587 | raise Error('Error: push_output has been called more than once!') 588 | self.output_width = width 589 | self.output = ReadOnlyText(self.interior, width=self.output_width) 590 | self.push_row(self.output) 591 | self.output_link_manager = HyperlinkManager(self.output) 592 | 593 | def clear_output(self): 594 | if self.output is None: 595 | raise Exception("Output not initialized in Form") 596 | self.output.delete(1.0, tk.END) 597 | self.output_str = [] 598 | self.update() 599 | 600 | def print_output(self, out_str, cmd_fn=None): 601 | # format to output width 602 | new_lines = [] 603 | for line in out_str.splitlines(): 604 | n = self.output_width 605 | sub_lines = [line[i:i+n] for i in range(0, len(line), n)] 606 | if sub_lines: 607 | sub_lines[-1] += "\n" 608 | new_lines.extend(sub_lines) 609 | out_str = ''.join(new_lines) 610 | 611 | if self.output is None: 612 | raise Exception("Output not initialized in Form") 613 | if cmd_fn is not None: 614 | link_tag = self.output_link_manager.add_new_link(cmd_fn) 615 | self.output.insert(tk.INSERT, out_str, link_tag) 616 | else: 617 | self.output.insert(tk.INSERT, out_str) 618 | 619 | self.output_lines.extend(new_lines) 620 | self.output.configure(height=len(self.output_lines)) 621 | self.update() 622 | 623 | def run(self, params): 624 | "Dummy method to be overriden/replaced." 625 | pass 626 | 627 | def print_exception(self): 628 | if self.output is not None: 629 | s = "\nTHERE WERE ERROR(S) IN PROCESSING THE PYTHON.\n" 630 | s += "Specific error described in the last line:\n\n" 631 | s += traceback.format_exc() 632 | s += "\n" 633 | self.print_output(s) 634 | 635 | def submit(self): 636 | if self.output is not None: 637 | self.clear_output() 638 | try: 639 | params = self.get_params() 640 | self.run(params) 641 | except: 642 | self.print_exception() 643 | 644 | def push_submit(self): 645 | self.push_button('submit', self.submit) 646 | --------------------------------------------------------------------------------