Coverage for /builds/kinetik161/ase/ase/gui/ui.py: 90.58%
446 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-12-10 11:04 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-12-10 11:04 +0000
1# type: ignore
2import re
3import sys
4import tkinter as tk
5import tkinter.ttk as ttk
6from collections import namedtuple
7from functools import partial
8from tkinter.filedialog import LoadFileDialog, SaveFileDialog
9from tkinter.messagebox import askokcancel as ask_question
10from tkinter.messagebox import showerror, showinfo, showwarning
12import numpy as np
14from ase.gui.i18n import _
16__all__ = [
17 'error', 'ask_question', 'MainWindow', 'LoadFileDialog', 'SaveFileDialog',
18 'ASEGUIWindow', 'Button', 'CheckButton', 'ComboBox', 'Entry', 'Label',
19 'Window', 'MenuItem', 'RadioButton', 'RadioButtons', 'Rows', 'Scale',
20 'showinfo', 'showwarning', 'SpinBox', 'Text', 'set_windowtype']
23if sys.platform == 'darwin':
24 mouse_buttons = {2: 3, 3: 2}
25else:
26 mouse_buttons = {}
29def error(title, message=None):
30 if message is None:
31 message = title
32 title = _('Error')
33 return showerror(title, message)
36def about(name, version, webpage):
37 text = [name,
38 '',
39 _('Version') + ': ' + version,
40 _('Web-page') + ': ' + webpage]
41 win = Window(_('About'))
42 set_windowtype(win.win, 'dialog')
43 win.add(Text('\n'.join(text)))
46def helpbutton(text):
47 return Button(_('Help'), helpwindow, text)
50def helpwindow(text):
51 win = Window(_('Help'))
52 set_windowtype(win.win, 'dialog')
53 win.add(Text(text))
56def set_windowtype(win, wmtype):
57 # only on X11
58 # WM_TYPE, for possible settings see
59 # https://specifications.freedesktop.org/wm-spec/wm-spec-latest.html#idm45623487848608
60 # you want dialog, normal or utility most likely
61 if win._windowingsystem == "x11":
62 win.wm_attributes('-type', wmtype)
65class BaseWindow:
66 def __init__(self, title, close=None, wmtype='normal'):
67 self.title = title
68 if close:
69 self.win.protocol('WM_DELETE_WINDOW', close)
70 else:
71 self.win.protocol('WM_DELETE_WINDOW', self.close)
73 self.things = []
74 self.exists = True
75 set_windowtype(self.win, wmtype)
77 def close(self):
78 self.win.destroy()
79 self.exists = False
81 def title(self, txt):
82 self.win.title(txt)
84 title = property(None, title)
86 def add(self, stuff, anchor='w'): # 'center'):
87 if isinstance(stuff, str):
88 stuff = Label(stuff)
89 elif isinstance(stuff, list):
90 stuff = Row(stuff)
91 stuff.pack(self.win, anchor=anchor)
92 self.things.append(stuff)
95class Window(BaseWindow):
96 def __init__(self, title, close=None, wmtype='normal'):
97 self.win = tk.Toplevel()
98 BaseWindow.__init__(self, title, close, wmtype)
101class Widget:
102 def pack(self, parent, side='top', anchor='center'):
103 widget = self.create(parent)
104 widget.pack(side=side, anchor=anchor)
105 if not isinstance(self, (Rows, RadioButtons)):
106 pass
108 def grid(self, parent):
109 widget = self.create(parent)
110 widget.grid()
112 def create(self, parent):
113 self.widget = self.creator(parent)
114 return self.widget
116 @property
117 def active(self):
118 return self.widget['state'] == 'normal'
120 @active.setter
121 def active(self, value):
122 self.widget['state'] = ['disabled', 'normal'][bool(value)]
125class Row(Widget):
126 def __init__(self, things):
127 self.things = things
129 def create(self, parent):
130 self.widget = tk.Frame(parent)
131 for thing in self.things:
132 if isinstance(thing, str):
133 thing = Label(thing)
134 thing.pack(self.widget, 'left')
135 return self.widget
137 def __getitem__(self, i):
138 return self.things[i]
141class Label(Widget):
142 def __init__(self, text='', color=None):
143 self.creator = partial(tk.Label, text=text, fg=color)
145 @property
146 def text(self):
147 return self.widget['text']
149 @text.setter
150 def text(self, new):
151 self.widget.config(text=new)
154class Text(Widget):
155 def __init__(self, text):
156 self.creator = partial(tk.Text, height=text.count('\n') + 1)
157 s = re.split('<(.*?)>', text)
158 self.text = [(s[0], ())]
159 i = 1
160 tags = []
161 while i < len(s):
162 tag = s[i]
163 if tag[0] != '/':
164 tags.append(tag)
165 else:
166 tags.pop()
167 self.text.append((s[i + 1], tuple(tags)))
168 i += 2
170 def create(self, parent):
171 widget = Widget.create(self, parent)
172 widget.tag_configure('sub', offset=-6)
173 widget.tag_configure('sup', offset=6)
174 widget.tag_configure('c', foreground='blue')
175 for text, tags in self.text:
176 widget.insert('insert', text, tags)
177 widget.configure(state='disabled', background=parent['bg'])
178 widget.bind("<1>", lambda event: widget.focus_set())
179 return widget
182class Button(Widget):
183 def __init__(self, text, callback, *args, **kwargs):
184 self.callback = partial(callback, *args, **kwargs)
185 self.creator = partial(tk.Button,
186 text=text,
187 command=self.callback)
190class CheckButton(Widget):
191 def __init__(self, text, value=False, callback=None):
192 self.text = text
193 self.var = tk.BooleanVar(value=value)
194 self.callback = callback
196 def create(self, parent):
197 self.check = tk.Checkbutton(parent, text=self.text,
198 var=self.var, command=self.callback)
199 return self.check
201 @property
202 def value(self):
203 return self.var.get()
206class SpinBox(Widget):
207 def __init__(self, value, start, end, step, callback=None,
208 rounding=None, width=6):
209 self.callback = callback
210 self.rounding = rounding
211 self.creator = partial(tk.Spinbox,
212 from_=start,
213 to=end,
214 increment=step,
215 command=callback,
216 width=width)
217 self.initial = str(value)
219 def create(self, parent):
220 self.widget = self.creator(parent)
221 self.widget.bind('<Return>', lambda event: self.callback())
222 self.value = self.initial
223 return self.widget
225 @property
226 def value(self):
227 x = self.widget.get().replace(',', '.')
228 if '.' in x:
229 return float(x)
230 if x == 'None':
231 return None
232 return int(x)
234 @value.setter
235 def value(self, x):
236 self.widget.delete(0, 'end')
237 if '.' in str(x) and self.rounding is not None:
238 try:
239 x = round(float(x), self.rounding)
240 except (ValueError, TypeError):
241 pass
242 self.widget.insert(0, x)
245# Entry and ComboBox use same mechanism (since ttk ComboBox
246# is a subclass of tk Entry).
247def _set_entry_value(widget, value):
248 widget.delete(0, 'end')
249 widget.insert(0, value)
252class Entry(Widget):
253 def __init__(self, value='', width=20, callback=None):
254 self.creator = partial(tk.Entry,
255 width=width)
256 if callback is not None:
257 self.callback = lambda event: callback()
258 else:
259 self.callback = None
260 self.initial = value
262 def create(self, parent):
263 self.entry = self.creator(parent)
264 self.value = self.initial
265 if self.callback:
266 self.entry.bind('<Return>', self.callback)
267 return self.entry
269 @property
270 def value(self):
271 return self.entry.get()
273 @value.setter
274 def value(self, x):
275 _set_entry_value(self.entry, x)
278class Scale(Widget):
279 def __init__(self, value, start, end, callback):
280 def command(val):
281 callback(int(val))
283 self.creator = partial(tk.Scale,
284 from_=start,
285 to=end,
286 orient='horizontal',
287 command=command)
288 self.initial = value
290 def create(self, parent):
291 self.scale = self.creator(parent)
292 self.value = self.initial
293 return self.scale
295 @property
296 def value(self):
297 return self.scale.get()
299 @value.setter
300 def value(self, x):
301 self.scale.set(x)
304class RadioButtons(Widget):
305 def __init__(self, labels, values=None, callback=None, vertical=False):
306 self.var = tk.IntVar()
308 if callback:
309 def callback2():
310 callback(self.value)
311 else:
312 callback2 = None
314 self.values = values or list(range(len(labels)))
315 self.buttons = [RadioButton(label, i, self.var, callback2)
316 for i, label in enumerate(labels)]
317 self.vertical = vertical
319 def create(self, parent):
320 self.widget = frame = tk.Frame(parent)
321 side = 'top' if self.vertical else 'left'
322 for button in self.buttons:
323 button.create(frame).pack(side=side)
324 return frame
326 @property
327 def value(self):
328 return self.values[self.var.get()]
330 @value.setter
331 def value(self, value):
332 self.var.set(self.values.index(value))
334 def __getitem__(self, value):
335 return self.buttons[self.values.index(value)]
338class RadioButton(Widget):
339 def __init__(self, label, i, var, callback):
340 self.creator = partial(tk.Radiobutton,
341 text=label,
342 var=var,
343 value=i,
344 command=callback)
347if ttk is not None:
348 class ComboBox(Widget):
349 def __init__(self, labels, values=None, callback=None):
350 self.values = values or list(range(len(labels)))
351 self.callback = callback
352 self.creator = partial(ttk.Combobox,
353 values=labels)
355 def create(self, parent):
356 widget = Widget.create(self, parent)
357 widget.current(0)
358 if self.callback:
359 def callback(event):
360 self.callback(self.value)
361 widget.bind('<<ComboboxSelected>>', callback)
363 return widget
365 @property
366 def value(self):
367 return self.values[self.widget.current()]
369 @value.setter
370 def value(self, val):
371 _set_entry_value(self.widget, val)
372else:
373 # Use Entry object when there is no ttk:
374 def ComboBox(labels, values, callback):
375 return Entry(values[0], callback=callback)
378class Rows(Widget):
379 def __init__(self, rows=None):
380 self.rows_to_be_added = rows or []
381 self.creator = tk.Frame
382 self.rows = []
384 def create(self, parent):
385 widget = Widget.create(self, parent)
386 for row in self.rows_to_be_added:
387 self.add(row)
388 self.rows_to_be_added = []
389 return widget
391 def add(self, row):
392 if isinstance(row, str):
393 row = Label(row)
394 elif isinstance(row, list):
395 row = Row(row)
396 row.grid(self.widget)
397 self.rows.append(row)
399 def clear(self):
400 while self.rows:
401 del self[0]
403 def __getitem__(self, i):
404 return self.rows[i]
406 def __delitem__(self, i):
407 widget = self.rows.pop(i).widget
408 widget.grid_remove()
409 widget.destroy()
411 def __len__(self):
412 return len(self.rows)
415class MenuItem:
416 def __init__(self, label, callback=None, key=None,
417 value=None, choices=None, submenu=None, disabled=False):
418 self.underline = label.find('_')
419 self.label = label.replace('_', '')
421 if key:
422 if key[:4] == 'Ctrl':
423 self.keyname = f'<Control-{key[-1].lower()}>'
424 else:
425 self.keyname = {
426 'Home': '<Home>',
427 'End': '<End>',
428 'Page-Up': '<Prior>',
429 'Page-Down': '<Next>',
430 'Backspace': '<BackSpace>'}.get(key, key.lower())
432 if key:
433 def callback2(event=None):
434 callback(key)
436 callback2.__name__ = callback.__name__
437 self.callback = callback2
438 else:
439 self.callback = callback
441 self.key = key
442 self.value = value
443 self.choices = choices
444 self.submenu = submenu
445 self.disabled = disabled
447 def addto(self, menu, window, stuff=None):
448 callback = self.callback
449 if self.label == '---':
450 menu.add_separator()
451 elif self.value is not None:
452 var = tk.BooleanVar(value=self.value)
453 stuff[self.callback.__name__.replace('_', '-')] = var
455 menu.add_checkbutton(label=self.label,
456 underline=self.underline,
457 command=self.callback,
458 accelerator=self.key,
459 var=var)
461 def callback(key): # noqa: F811
462 var.set(not var.get())
463 self.callback()
465 elif self.choices:
466 submenu = tk.Menu(menu)
467 menu.add_cascade(label=self.label, menu=submenu)
468 var = tk.IntVar()
469 var.set(0)
470 stuff[self.callback.__name__.replace('_', '-')] = var
471 for i, choice in enumerate(self.choices):
472 submenu.add_radiobutton(label=choice.replace('_', ''),
473 underline=choice.find('_'),
474 command=self.callback,
475 value=i,
476 var=var)
477 elif self.submenu:
478 submenu = tk.Menu(menu)
479 menu.add_cascade(label=self.label,
480 menu=submenu)
481 for thing in self.submenu:
482 thing.addto(submenu, window)
483 else:
484 state = 'normal'
485 if self.disabled:
486 state = 'disabled'
487 menu.add_command(label=self.label,
488 underline=self.underline,
489 command=self.callback,
490 accelerator=self.key,
491 state=state)
492 if self.key:
493 window.bind(self.keyname, callback)
496class MainWindow(BaseWindow):
497 def __init__(self, title, close=None, menu=[]):
498 self.win = tk.Tk()
499 BaseWindow.__init__(self, title, close)
501 # self.win.tk.call('tk', 'scaling', 3.0)
502 # self.win.tk.call('tk', 'scaling', '-displayof', '.', 7)
504 self.menu = {}
506 if menu:
507 self.create_menu(menu)
509 def create_menu(self, menu_description):
510 menu = tk.Menu(self.win)
511 self.win.config(menu=menu)
513 for label, things in menu_description:
514 submenu = tk.Menu(menu)
515 menu.add_cascade(label=label.replace('_', ''),
516 underline=label.find('_'),
517 menu=submenu)
518 for thing in things:
519 thing.addto(submenu, self.win, self.menu)
521 def resize_event(self):
522 # self.scale *= sqrt(1.0 * self.width * self.height / (w * h))
523 self.draw()
524 self.configured = True
526 def run(self):
527 # Workaround for nasty issue with tkinter on Mac:
528 # https://gitlab.com/ase/ase/issues/412
529 #
530 # It is apparently a compatibility issue between Python and Tkinter.
531 # Some day we should remove this hack.
532 while True:
533 try:
534 tk.mainloop()
535 break
536 except UnicodeDecodeError:
537 pass
539 def __getitem__(self, name):
540 return self.menu[name].get()
542 def __setitem__(self, name, value):
543 return self.menu[name].set(value)
546def bind(callback, modifier=None):
547 def handle(event):
548 event.button = mouse_buttons.get(event.num, event.num)
549 event.key = event.keysym.lower()
550 event.modifier = modifier
551 callback(event)
552 return handle
555class ASEFileChooser(LoadFileDialog):
556 def __init__(self, win, formatcallback=lambda event: None):
557 from ase.io.formats import all_formats, get_ioformat
558 LoadFileDialog.__init__(self, win, _('Open ...'))
559 # fix tkinter not automatically setting dialog type
560 # remove from Python3.8+
561 # see https://github.com/python/cpython/pull/25187
562 # and https://bugs.python.org/issue43655
563 # and https://github.com/python/cpython/pull/25592
564 set_windowtype(self.top, 'dialog')
565 labels = [_('Automatic')]
566 values = ['']
568 def key(item):
569 return item[1][0]
571 for format, (description, code) in sorted(all_formats.items(),
572 key=key):
573 io = get_ioformat(format)
574 if io.can_read and description != '?':
575 labels.append(_(description))
576 values.append(format)
578 self.format = None
580 def callback(value):
581 self.format = value
583 Label(_('Choose parser:')).pack(self.top)
584 formats = ComboBox(labels, values, callback)
585 formats.pack(self.top)
588def show_io_error(filename, err):
589 showerror(_('Read error'),
590 _(f'Could not read {filename}: {err}'))
593class ASEGUIWindow(MainWindow):
594 def __init__(self, close, menu, config,
595 scroll, scroll_event,
596 press, move, release, resize):
597 MainWindow.__init__(self, 'ASE-GUI', close, menu)
599 self.size = np.array([450, 450])
601 self.fg = config['gui_foreground_color']
602 self.bg = config['gui_background_color']
604 self.canvas = tk.Canvas(self.win,
605 width=self.size[0],
606 height=self.size[1],
607 bg=self.bg,
608 highlightthickness=0)
609 self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
611 self.status = tk.Label(self.win, text='', anchor=tk.W)
612 self.status.pack(side=tk.BOTTOM, fill=tk.X)
614 right = mouse_buttons.get(3, 3)
615 self.canvas.bind('<ButtonPress>', bind(press))
616 self.canvas.bind('<B1-Motion>', bind(move))
617 self.canvas.bind(f'<B{right}-Motion>', bind(move))
618 self.canvas.bind('<ButtonRelease>', bind(release))
619 self.canvas.bind('<Control-ButtonRelease>', bind(release, 'ctrl'))
620 self.canvas.bind('<Shift-ButtonRelease>', bind(release, 'shift'))
621 self.canvas.bind('<Configure>', resize)
622 if not config['swap_mouse']:
623 self.canvas.bind(f'<Shift-B{right}-Motion>',
624 bind(scroll))
625 else:
626 self.canvas.bind('<Shift-B1-Motion>',
627 bind(scroll))
629 self.win.bind('<MouseWheel>', bind(scroll_event))
630 self.win.bind('<Key>', bind(scroll))
631 self.win.bind('<Shift-Key>', bind(scroll, 'shift'))
632 self.win.bind('<Control-Key>', bind(scroll, 'ctrl'))
634 def update_status_line(self, text):
635 self.status.config(text=text)
637 def run(self):
638 MainWindow.run(self)
640 def click(self, name):
641 self.callbacks[name]()
643 def clear(self):
644 self.canvas.delete(tk.ALL)
646 def update(self):
647 self.canvas.update_idletasks()
649 def circle(self, color, selected, *bbox):
650 if selected:
651 outline = '#004500'
652 width = 3
653 else:
654 outline = 'black'
655 width = 1
656 self.canvas.create_oval(*tuple(int(x) for x in bbox), fill=color,
657 outline=outline, width=width)
659 def arc(self, color, selected, start, extent, *bbox):
660 if selected:
661 outline = '#004500'
662 width = 3
663 else:
664 outline = 'black'
665 width = 1
666 self.canvas.create_arc(*tuple(int(x) for x in bbox),
667 start=start,
668 extent=extent,
669 fill=color,
670 outline=outline,
671 width=width)
673 def line(self, bbox, width=1):
674 self.canvas.create_line(*tuple(int(x) for x in bbox), width=width)
676 def text(self, x, y, txt, anchor=tk.CENTER, color='black'):
677 anchor = {'SE': tk.SE}.get(anchor, anchor)
678 self.canvas.create_text((x, y), text=txt, anchor=anchor, fill=color)
680 def after(self, time, callback):
681 id = self.win.after(int(time * 1000), callback)
682 # Quick'n'dirty object with a cancel() method:
683 return namedtuple('Timer', 'cancel')(lambda: self.win.after_cancel(id))