Coverage for /builds/ase/ase/ase/gui/ui.py : 90.79%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# type: ignore
2import re
3import sys
4from collections import namedtuple
5from functools import partial
7import numpy as np
8import tkinter as tk
9import tkinter.ttk as ttk
10from tkinter.messagebox import askokcancel as ask_question
11from tkinter.messagebox import showerror, showwarning, showinfo
12from tkinter.filedialog import LoadFileDialog, SaveFileDialog
14from ase.gui.i18n import _
17__all__ = [
18 'error', 'ask_question', 'MainWindow', 'LoadFileDialog', 'SaveFileDialog',
19 'ASEGUIWindow', 'Button', 'CheckButton', 'ComboBox', 'Entry', 'Label',
20 'Window', 'MenuItem', 'RadioButton', 'RadioButtons', 'Rows', 'Scale',
21 'showinfo', 'showwarning', 'SpinBox', 'Text', 'set_windowtype']
24if sys.platform == 'darwin':
25 mouse_buttons = {2: 3, 3: 2}
26else:
27 mouse_buttons = {}
30def error(title, message=None):
31 if message is None:
32 message = title
33 title = _('Error')
34 return showerror(title, message)
37def about(name, version, webpage):
38 text = [name,
39 '',
40 _('Version') + ': ' + version,
41 _('Web-page') + ': ' + webpage]
42 win = Window(_('About'))
43 set_windowtype(win.win, 'dialog')
44 win.add(Text('\n'.join(text)))
47def helpbutton(text):
48 return Button(_('Help'), helpwindow, text)
51def helpwindow(text):
52 win = Window(_('Help'))
53 set_windowtype(win.win, 'dialog')
54 win.add(Text(text))
57def set_windowtype(win, wmtype):
58 # only on X11
59 # WM_TYPE, for possible settings see
60 # https://specifications.freedesktop.org/wm-spec/wm-spec-latest.html#idm45623487848608
61 # you want dialog, normal or utility most likely
62 if win._windowingsystem == "x11":
63 win.wm_attributes('-type', wmtype)
66class BaseWindow:
67 def __init__(self, title, close=None, wmtype='normal'):
68 self.title = title
69 if close:
70 self.win.protocol('WM_DELETE_WINDOW', close)
71 else:
72 self.win.protocol('WM_DELETE_WINDOW', self.close)
74 self.things = []
75 self.exists = True
76 set_windowtype(self.win, wmtype)
78 def close(self):
79 self.win.destroy()
80 self.exists = False
82 def title(self, txt):
83 self.win.title(txt)
85 title = property(None, title)
87 def add(self, stuff, anchor='w'): # 'center'):
88 if isinstance(stuff, str):
89 stuff = Label(stuff)
90 elif isinstance(stuff, list):
91 stuff = Row(stuff)
92 stuff.pack(self.win, anchor=anchor)
93 self.things.append(stuff)
96class Window(BaseWindow):
97 def __init__(self, title, close=None, wmtype='normal'):
98 self.win = tk.Toplevel()
99 BaseWindow.__init__(self, title, close, wmtype)
102class Widget:
103 def pack(self, parent, side='top', anchor='center'):
104 widget = self.create(parent)
105 widget.pack(side=side, anchor=anchor)
106 if not isinstance(self, (Rows, RadioButtons)):
107 pass
109 def grid(self, parent):
110 widget = self.create(parent)
111 widget.grid()
113 def create(self, parent):
114 self.widget = self.creator(parent)
115 return self.widget
117 @property
118 def active(self):
119 return self.widget['state'] == 'normal'
121 @active.setter
122 def active(self, value):
123 self.widget['state'] = ['disabled', 'normal'][bool(value)]
126class Row(Widget):
127 def __init__(self, things):
128 self.things = things
130 def create(self, parent):
131 self.widget = tk.Frame(parent)
132 for thing in self.things:
133 if isinstance(thing, str):
134 thing = Label(thing)
135 thing.pack(self.widget, 'left')
136 return self.widget
138 def __getitem__(self, i):
139 return self.things[i]
142class Label(Widget):
143 def __init__(self, text='', color=None):
144 self.creator = partial(tk.Label, text=text, fg=color)
146 @property
147 def text(self):
148 return self.widget['text']
150 @text.setter
151 def text(self, new):
152 self.widget.config(text=new)
155class Text(Widget):
156 def __init__(self, text):
157 self.creator = partial(tk.Text, height=text.count('\n') + 1)
158 s = re.split('<(.*?)>', text)
159 self.text = [(s[0], ())]
160 i = 1
161 tags = []
162 while i < len(s):
163 tag = s[i]
164 if tag[0] != '/':
165 tags.append(tag)
166 else:
167 tags.pop()
168 self.text.append((s[i + 1], tuple(tags)))
169 i += 2
171 def create(self, parent):
172 widget = Widget.create(self, parent)
173 widget.tag_configure('sub', offset=-6)
174 widget.tag_configure('sup', offset=6)
175 widget.tag_configure('c', foreground='blue')
176 for text, tags in self.text:
177 widget.insert('insert', text, tags)
178 widget.configure(state='disabled', background=parent['bg'])
179 widget.bind("<1>", lambda event: widget.focus_set())
180 return widget
183class Button(Widget):
184 def __init__(self, text, callback, *args, **kwargs):
185 self.callback = partial(callback, *args, **kwargs)
186 self.creator = partial(tk.Button,
187 text=text,
188 command=self.callback)
191class CheckButton(Widget):
192 def __init__(self, text, value=False, callback=None):
193 self.text = text
194 self.var = tk.BooleanVar(value=value)
195 self.callback = callback
197 def create(self, parent):
198 self.check = tk.Checkbutton(parent, text=self.text,
199 var=self.var, command=self.callback)
200 return self.check
202 @property
203 def value(self):
204 return self.var.get()
207class SpinBox(Widget):
208 def __init__(self, value, start, end, step, callback=None,
209 rounding=None, width=6):
210 self.callback = callback
211 self.rounding = rounding
212 self.creator = partial(tk.Spinbox,
213 from_=start,
214 to=end,
215 increment=step,
216 command=callback,
217 width=width)
218 self.initial = str(value)
220 def create(self, parent):
221 self.widget = self.creator(parent)
222 self.widget.bind('<Return>', lambda event: self.callback())
223 self.value = self.initial
224 return self.widget
226 @property
227 def value(self):
228 x = self.widget.get().replace(',', '.')
229 if '.' in x:
230 return float(x)
231 if x == 'None':
232 return None
233 return int(x)
235 @value.setter
236 def value(self, x):
237 self.widget.delete(0, 'end')
238 if '.' in str(x) and self.rounding is not None:
239 try:
240 x = round(float(x), self.rounding)
241 except (ValueError, TypeError):
242 pass
243 self.widget.insert(0, x)
246# Entry and ComboBox use same mechanism (since ttk ComboBox
247# is a subclass of tk Entry).
248def _set_entry_value(widget, value):
249 widget.delete(0, 'end')
250 widget.insert(0, value)
253class Entry(Widget):
254 def __init__(self, value='', width=20, callback=None):
255 self.creator = partial(tk.Entry,
256 width=width)
257 if callback is not None:
258 self.callback = lambda event: callback()
259 else:
260 self.callback = None
261 self.initial = value
263 def create(self, parent):
264 self.entry = self.creator(parent)
265 self.value = self.initial
266 if self.callback:
267 self.entry.bind('<Return>', self.callback)
268 return self.entry
270 @property
271 def value(self):
272 return self.entry.get()
274 @value.setter
275 def value(self, x):
276 _set_entry_value(self.entry, x)
279class Scale(Widget):
280 def __init__(self, value, start, end, callback):
281 def command(val):
282 callback(int(val))
284 self.creator = partial(tk.Scale,
285 from_=start,
286 to=end,
287 orient='horizontal',
288 command=command)
289 self.initial = value
291 def create(self, parent):
292 self.scale = self.creator(parent)
293 self.value = self.initial
294 return self.scale
296 @property
297 def value(self):
298 return self.scale.get()
300 @value.setter
301 def value(self, x):
302 self.scale.set(x)
305class RadioButtons(Widget):
306 def __init__(self, labels, values=None, callback=None, vertical=False):
307 self.var = tk.IntVar()
309 if callback:
310 def callback2():
311 callback(self.value)
312 else:
313 callback2 = None
315 self.values = values or list(range(len(labels)))
316 self.buttons = [RadioButton(label, i, self.var, callback2)
317 for i, label in enumerate(labels)]
318 self.vertical = vertical
320 def create(self, parent):
321 self.widget = frame = tk.Frame(parent)
322 side = 'top' if self.vertical else 'left'
323 for button in self.buttons:
324 button.create(frame).pack(side=side)
325 return frame
327 @property
328 def value(self):
329 return self.values[self.var.get()]
331 @value.setter
332 def value(self, value):
333 self.var.set(self.values.index(value))
335 def __getitem__(self, value):
336 return self.buttons[self.values.index(value)]
339class RadioButton(Widget):
340 def __init__(self, label, i, var, callback):
341 self.creator = partial(tk.Radiobutton,
342 text=label,
343 var=var,
344 value=i,
345 command=callback)
348if ttk is not None:
349 class ComboBox(Widget):
350 def __init__(self, labels, values=None, callback=None):
351 self.values = values or list(range(len(labels)))
352 self.callback = callback
353 self.creator = partial(ttk.Combobox,
354 values=labels)
356 def create(self, parent):
357 widget = Widget.create(self, parent)
358 widget.current(0)
359 if self.callback:
360 def callback(event):
361 self.callback(self.value)
362 widget.bind('<<ComboboxSelected>>', callback)
364 return widget
366 @property
367 def value(self):
368 return self.values[self.widget.current()]
370 @value.setter
371 def value(self, val):
372 _set_entry_value(self.widget, val)
373else:
374 # Use Entry object when there is no ttk:
375 def ComboBox(labels, values, callback):
376 return Entry(values[0], callback=callback)
379class Rows(Widget):
380 def __init__(self, rows=None):
381 self.rows_to_be_added = rows or []
382 self.creator = tk.Frame
383 self.rows = []
385 def create(self, parent):
386 widget = Widget.create(self, parent)
387 for row in self.rows_to_be_added:
388 self.add(row)
389 self.rows_to_be_added = []
390 return widget
392 def add(self, row):
393 if isinstance(row, str):
394 row = Label(row)
395 elif isinstance(row, list):
396 row = Row(row)
397 row.grid(self.widget)
398 self.rows.append(row)
400 def clear(self):
401 while self.rows:
402 del self[0]
404 def __getitem__(self, i):
405 return self.rows[i]
407 def __delitem__(self, i):
408 widget = self.rows.pop(i).widget
409 widget.grid_remove()
410 widget.destroy()
412 def __len__(self):
413 return len(self.rows)
416class MenuItem:
417 def __init__(self, label, callback=None, key=None,
418 value=None, choices=None, submenu=None, disabled=False):
419 self.underline = label.find('_')
420 self.label = label.replace('_', '')
422 if key:
423 if key[:4] == 'Ctrl':
424 self.keyname = '<Control-{0}>'.format(key[-1].lower())
425 else:
426 self.keyname = {
427 'Home': '<Home>',
428 'End': '<End>',
429 'Page-Up': '<Prior>',
430 'Page-Down': '<Next>',
431 'Backspace': '<BackSpace>'}.get(key, key.lower())
433 if key:
434 def callback2(event=None):
435 callback(key)
437 callback2.__name__ = callback.__name__
438 self.callback = callback2
439 else:
440 self.callback = callback
442 self.key = key
443 self.value = value
444 self.choices = choices
445 self.submenu = submenu
446 self.disabled = disabled
448 def addto(self, menu, window, stuff=None):
449 callback = self.callback
450 if self.label == '---':
451 menu.add_separator()
452 elif self.value is not None:
453 var = tk.BooleanVar(value=self.value)
454 stuff[self.callback.__name__.replace('_', '-')] = var
456 menu.add_checkbutton(label=self.label,
457 underline=self.underline,
458 command=self.callback,
459 accelerator=self.key,
460 var=var)
462 def callback(key):
463 var.set(not var.get())
464 self.callback()
466 elif self.choices:
467 submenu = tk.Menu(menu)
468 menu.add_cascade(label=self.label, menu=submenu)
469 var = tk.IntVar()
470 var.set(0)
471 stuff[self.callback.__name__.replace('_', '-')] = var
472 for i, choice in enumerate(self.choices):
473 submenu.add_radiobutton(label=choice.replace('_', ''),
474 underline=choice.find('_'),
475 command=self.callback,
476 value=i,
477 var=var)
478 elif self.submenu:
479 submenu = tk.Menu(menu)
480 menu.add_cascade(label=self.label,
481 menu=submenu)
482 for thing in self.submenu:
483 thing.addto(submenu, window)
484 else:
485 state = 'normal'
486 if self.disabled:
487 state = 'disabled'
488 menu.add_command(label=self.label,
489 underline=self.underline,
490 command=self.callback,
491 accelerator=self.key,
492 state=state)
493 if self.key:
494 window.bind(self.keyname, callback)
497class MainWindow(BaseWindow):
498 def __init__(self, title, close=None, menu=[]):
499 self.win = tk.Tk()
500 BaseWindow.__init__(self, title, close)
502 # self.win.tk.call('tk', 'scaling', 3.0)
503 # self.win.tk.call('tk', 'scaling', '-displayof', '.', 7)
505 self.menu = {}
507 if menu:
508 self.create_menu(menu)
510 def create_menu(self, menu_description):
511 menu = tk.Menu(self.win)
512 self.win.config(menu=menu)
514 for label, things in menu_description:
515 submenu = tk.Menu(menu)
516 menu.add_cascade(label=label.replace('_', ''),
517 underline=label.find('_'),
518 menu=submenu)
519 for thing in things:
520 thing.addto(submenu, self.win, self.menu)
522 def resize_event(self):
523 # self.scale *= sqrt(1.0 * self.width * self.height / (w * h))
524 self.draw()
525 self.configured = True
527 def run(self):
528 # Workaround for nasty issue with tkinter on Mac:
529 # https://gitlab.com/ase/ase/issues/412
530 #
531 # It is apparently a compatibility issue between Python and Tkinter.
532 # Some day we should remove this hack.
533 while True:
534 try:
535 tk.mainloop()
536 break
537 except UnicodeDecodeError:
538 pass
540 def __getitem__(self, name):
541 return self.menu[name].get()
543 def __setitem__(self, name, value):
544 return self.menu[name].set(value)
547def bind(callback, modifier=None):
548 def handle(event):
549 event.button = mouse_buttons.get(event.num, event.num)
550 event.key = event.keysym.lower()
551 event.modifier = modifier
552 callback(event)
553 return handle
556class ASEFileChooser(LoadFileDialog):
557 def __init__(self, win, formatcallback=lambda event: None):
558 from ase.io.formats import all_formats, get_ioformat
559 LoadFileDialog.__init__(self, win, _('Open ...'))
560 # fix tkinter not automatically setting dialog type
561 # remove from Python3.8+
562 # see https://github.com/python/cpython/pull/25187
563 # and https://bugs.python.org/issue43655
564 # and https://github.com/python/cpython/pull/25592
565 set_windowtype(self.top, 'dialog')
566 labels = [_('Automatic')]
567 values = ['']
569 def key(item):
570 return item[1][0]
572 for format, (description, code) in sorted(all_formats.items(),
573 key=key):
574 io = get_ioformat(format)
575 if io.can_read and description != '?':
576 labels.append(_(description))
577 values.append(format)
579 self.format = None
581 def callback(value):
582 self.format = value
584 Label(_('Choose parser:')).pack(self.top)
585 formats = ComboBox(labels, values, callback)
586 formats.pack(self.top)
589def show_io_error(filename, err):
590 showerror(_('Read error'),
591 _('Could not read {}: {}'.format(filename, err)))
594class ASEGUIWindow(MainWindow):
595 def __init__(self, close, menu, config,
596 scroll, scroll_event,
597 press, move, release, resize):
598 MainWindow.__init__(self, 'ASE-GUI', close, menu)
600 self.size = np.array([450, 450])
602 self.fg = config['gui_foreground_color']
603 self.bg = config['gui_background_color']
605 self.canvas = tk.Canvas(self.win,
606 width=self.size[0],
607 height=self.size[1],
608 bg=self.bg,
609 highlightthickness=0)
610 self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
612 self.status = tk.Label(self.win, text='', anchor=tk.W)
613 self.status.pack(side=tk.BOTTOM, fill=tk.X)
615 right = mouse_buttons.get(3, 3)
616 self.canvas.bind('<ButtonPress>', bind(press))
617 self.canvas.bind('<B1-Motion>', bind(move))
618 self.canvas.bind('<B{right}-Motion>'.format(right=right), bind(move))
619 self.canvas.bind('<ButtonRelease>', bind(release))
620 self.canvas.bind('<Control-ButtonRelease>', bind(release, 'ctrl'))
621 self.canvas.bind('<Shift-ButtonRelease>', bind(release, 'shift'))
622 self.canvas.bind('<Configure>', resize)
623 if not config['swap_mouse']:
624 self.canvas.bind('<Shift-B{right}-Motion>'.format(right=right),
625 bind(scroll))
626 else:
627 self.canvas.bind('<Shift-B1-Motion>',
628 bind(scroll))
630 self.win.bind('<MouseWheel>', bind(scroll_event))
631 self.win.bind('<Key>', bind(scroll))
632 self.win.bind('<Shift-Key>', bind(scroll, 'shift'))
633 self.win.bind('<Control-Key>', bind(scroll, 'ctrl'))
635 def update_status_line(self, text):
636 self.status.config(text=text)
638 def run(self):
639 MainWindow.run(self)
641 def click(self, name):
642 self.callbacks[name]()
644 def clear(self):
645 self.canvas.delete(tk.ALL)
647 def update(self):
648 self.canvas.update_idletasks()
650 def circle(self, color, selected, *bbox):
651 if selected:
652 outline = '#004500'
653 width = 3
654 else:
655 outline = 'black'
656 width = 1
657 self.canvas.create_oval(*tuple(int(x) for x in bbox), fill=color,
658 outline=outline, width=width)
660 def arc(self, color, selected, start, extent, *bbox):
661 if selected:
662 outline = '#004500'
663 width = 3
664 else:
665 outline = 'black'
666 width = 1
667 self.canvas.create_arc(*tuple(int(x) for x in bbox),
668 start=start,
669 extent=extent,
670 fill=color,
671 outline=outline,
672 width=width)
674 def line(self, bbox, width=1):
675 self.canvas.create_line(*tuple(int(x) for x in bbox), width=width)
677 def text(self, x, y, txt, anchor=tk.CENTER, color='black'):
678 anchor = {'SE': tk.SE}.get(anchor, anchor)
679 self.canvas.create_text((x, y), text=txt, anchor=anchor, fill=color)
681 def after(self, time, callback):
682 id = self.win.after(int(time * 1000), callback)
683 # Quick'n'dirty object with a cancel() method:
684 return namedtuple('Timer', 'cancel')(lambda: self.win.after_cancel(id))