Coverage for ase / gui / ui.py: 90.52%
496 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 10:20 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 10:20 +0000
1# fmt: off
3# type: ignore
4import platform
5import re
6import tkinter as tk
7import tkinter.ttk as ttk
8from collections import namedtuple
9from functools import partial
10from tkinter.filedialog import LoadFileDialog, SaveFileDialog
11from tkinter.messagebox import askokcancel as ask_question
12from tkinter.messagebox import showerror, showinfo, showwarning
14import numpy as np
16from ase.gui.i18n import _
17from ase.utils.parsemath import eval_expression
19__all__ = [
20 'error', 'ask_question', 'MainWindow', 'LoadFileDialog', 'SaveFileDialog',
21 'ASEGUIWindow', 'Button', 'CheckButton', 'ComboBox', 'Entry', 'Label',
22 'Window', 'Tooltip', 'MenuItem', 'RadioButton', 'RadioButtons', 'Rows',
23 'Scale', 'showinfo', 'showwarning', 'SpinBox', 'Text']
26def error(title, message=None):
27 if message is None:
28 message = title
29 title = _('Error')
30 return showerror(title, message)
33def about(name, version, webpage):
34 text = [name,
35 '',
36 _('Version') + ': ' + version,
37 _('Web-page') + ': ' + webpage]
38 win = Window(_('About'))
39 win.add(Text('\n'.join(text)))
42def helpbutton(text):
43 return Button(_('Help'), helpwindow, text)
46def helpwindow(text):
47 win = Window(_('Help'))
48 win.add(Text(text))
51class BaseWindow:
52 def __init__(self, title, close=None):
53 self.title = title
54 if close:
55 self.win.protocol('WM_DELETE_WINDOW', close)
56 else:
57 self.win.protocol('WM_DELETE_WINDOW', self.close)
59 self.things = []
60 self.exists = True
62 def close(self):
63 self.win.destroy()
64 self.exists = False
66 def title(self, txt):
67 self.win.title(txt)
69 title = property(None, title)
71 def add(self, stuff, anchor='w'): # 'center'):
72 if isinstance(stuff, str):
73 stuff = Label(stuff)
74 elif isinstance(stuff, list):
75 stuff = Row(stuff)
76 stuff.pack(self.win, anchor=anchor)
77 self.things.append(stuff)
80class Window(BaseWindow):
81 def __init__(self, title, close=None):
82 self.win = tk.Toplevel()
83 super().__init__(title, close)
86class Tooltip(BaseWindow):
87 def __init__(self):
88 self._config = {}
89 self.things = []
90 self.exists = False
92 def configure(self, /, **kwargs):
93 self._config.update(**kwargs)
95 def title(self):
96 pass
98 def show(self, key=None):
99 if not self.exists:
100 self.win = tk.Toplevel()
101 self.exists = True
102 self.win.overrideredirect(True)
103 self.label = Label(**self._config)
104 self.add(self.label)
105 pointer_x, pointer_y = self.win.winfo_pointerxy()
106 delta_x = self.label.widget.winfo_reqwidth() + 5
107 delta_y = self.label.widget.winfo_reqheight() + 5
108 self.win.geometry(
109 "+{0}+{1}".format(pointer_x - delta_x, pointer_y - delta_y)
110 )
112 def hide(self, key=None):
113 self.close()
116class Widget:
117 def pack(self, parent, side='top', anchor='center'):
118 widget = self.create(parent)
119 widget.pack(side=side, anchor=anchor)
120 if not isinstance(self, (Rows, RadioButtons)):
121 pass
123 def grid(self, parent):
124 widget = self.create(parent)
125 widget.grid()
127 def create(self, parent):
128 self.widget = self.creator(parent)
129 return self.widget
131 @property
132 def active(self):
133 return self.widget['state'] == 'normal'
135 @active.setter
136 def active(self, value):
137 self.widget['state'] = ['disabled', 'normal'][bool(value)]
140class Row(Widget):
141 def __init__(self, things):
142 self.things = things
144 def create(self, parent):
145 self.widget = tk.Frame(parent)
146 for thing in self.things:
147 if isinstance(thing, str):
148 thing = Label(thing)
149 thing.pack(self.widget, 'left')
150 return self.widget
152 def __getitem__(self, i):
153 return self.things[i]
156class Label(Widget):
157 def __init__(self, text='', color=None, **kwargs):
158 self.creator = partial(tk.Label, text=text, fg=color, **kwargs)
160 @property
161 def text(self):
162 return self.widget['text']
164 @text.setter
165 def text(self, new):
166 self.widget.config(text=new)
169class Text(Widget):
170 def __init__(self, text):
171 self.creator = partial(tk.Text, height=text.count('\n') + 1)
172 s = re.split('<(.*?)>', text)
173 self.text = [(s[0], ())]
174 i = 1
175 tags = []
176 while i < len(s):
177 tag = s[i]
178 if tag[0] != '/':
179 tags.append(tag)
180 else:
181 tags.pop()
182 self.text.append((s[i + 1], tuple(tags)))
183 i += 2
185 def create(self, parent):
186 widget = Widget.create(self, parent)
187 widget.tag_configure('sub', offset=-6)
188 widget.tag_configure('sup', offset=6)
189 widget.tag_configure('c', foreground='blue')
190 for text, tags in self.text:
191 widget.insert('insert', text, tags)
192 widget.configure(state='disabled', background=parent['bg'])
193 widget.bind("<1>", lambda event: widget.focus_set())
194 return widget
197class Button(Widget):
198 def __init__(self, text, callback, *args, **kwargs):
199 self.callback = partial(callback, *args, **kwargs)
200 self.creator = partial(tk.Button,
201 text=text,
202 command=self.callback)
205class CheckButton(Widget):
206 def __init__(self, text, value=False, callback=None):
207 self.text = text
208 self.var = tk.BooleanVar(value=value)
209 self.callback = callback
211 def create(self, parent):
212 self.check = tk.Checkbutton(parent, text=self.text,
213 var=self.var, command=self.callback)
214 return self.check
216 @property
217 def value(self):
218 return self.var.get()
221class SpinBox(Widget):
222 def __init__(self, value, start, end, step, callback=None,
223 rounding=None, width=6):
224 self.callback = callback
225 self.rounding = rounding
226 self.creator = partial(tk.Spinbox,
227 from_=start,
228 to=end,
229 increment=step,
230 command=callback,
231 width=width)
232 self.initial = str(value)
234 def create(self, parent):
235 self.widget = self.creator(parent)
236 bind_enter(self.widget, lambda event: self.parse_and_callback())
237 self.value = self.initial
238 return self.widget
240 def parse_value(self):
241 x = self.widget.get().replace(',', '.')
242 if not x.isnumeric():
243 try:
244 x = eval_expression(x)
245 except TypeError:
246 # eval_expression will throw a TypeError if x is not
247 # math. This is actually fine here, so no cleanup to do
248 pass
249 self.value = x
251 def parse_and_callback(self):
252 self.parse_value()
253 if self.callback:
254 self.callback()
256 @property
257 def value(self):
258 self.parse_value()
259 x = self.widget.get().replace(',', '.')
260 if '.' in x:
261 return float(x)
262 if x == 'None':
263 return None
264 return int(x)
266 @value.setter
267 def value(self, x):
268 self.widget.delete(0, 'end')
269 if '.' in str(x) and self.rounding is not None:
270 try:
271 x = round(float(x), self.rounding)
272 except (ValueError, TypeError):
273 pass
274 self.widget.insert(0, x)
277# Entry and ComboBox use same mechanism (since ttk ComboBox
278# is a subclass of tk Entry).
279def _set_entry_value(widget, value):
280 widget.delete(0, 'end')
281 widget.insert(0, value)
284class Entry(Widget):
285 def __init__(self, value='', width=20, callback=None):
286 self.creator = partial(tk.Entry,
287 width=width)
288 if callback is not None:
289 self.callback = lambda event: callback()
290 else:
291 self.callback = None
292 self.initial = value
294 def create(self, parent):
295 self.entry = self.creator(parent)
296 self.value = self.initial
297 if self.callback:
298 bind_enter(self.entry, self.callback)
299 return self.entry
301 @property
302 def value(self):
303 return self.entry.get()
305 @value.setter
306 def value(self, x):
307 _set_entry_value(self.entry, x)
310class Scale(Widget):
311 def __init__(self, value, start, end, callback):
312 def command(val):
313 callback(int(val))
315 self.creator = partial(tk.Scale,
316 from_=start,
317 to=end,
318 orient='horizontal',
319 command=command)
320 self.initial = value
322 def create(self, parent):
323 self.scale = self.creator(parent)
324 self.value = self.initial
325 return self.scale
327 @property
328 def value(self):
329 return self.scale.get()
331 @value.setter
332 def value(self, x):
333 self.scale.set(x)
336class RadioButtons(Widget):
337 def __init__(self, labels, values=None, callback=None, vertical=False):
338 self.var = tk.IntVar()
340 if callback:
341 def callback2():
342 callback(self.value)
343 else:
344 callback2 = None
346 self.values = values or list(range(len(labels)))
347 self.buttons = [RadioButton(label, i, self.var, callback2)
348 for i, label in enumerate(labels)]
349 self.vertical = vertical
351 def create(self, parent):
352 self.widget = frame = tk.Frame(parent)
353 side = 'top' if self.vertical else 'left'
354 for button in self.buttons:
355 button.create(frame).pack(side=side)
356 return frame
358 @property
359 def value(self):
360 return self.values[self.var.get()]
362 @value.setter
363 def value(self, value):
364 self.var.set(self.values.index(value))
366 def __getitem__(self, value):
367 return self.buttons[self.values.index(value)]
370class RadioButton(Widget):
371 def __init__(self, label, i, var, callback):
372 self.creator = partial(tk.Radiobutton,
373 text=label,
374 var=var,
375 value=i,
376 command=callback)
379if ttk is not None:
380 class ComboBox(Widget):
381 def __init__(self, labels, values=None, callback=None):
382 self.values = values or list(range(len(labels)))
383 self.callback = callback
384 self.creator = partial(ttk.Combobox,
385 values=labels)
387 def create(self, parent):
388 widget = Widget.create(self, parent)
389 widget.current(0)
390 if self.callback:
391 def callback(event):
392 self.callback(self.value)
393 widget.bind('<<ComboboxSelected>>', callback)
395 return widget
397 @property
398 def value(self):
399 return self.values[self.widget.current()]
401 @value.setter
402 def value(self, val):
403 _set_entry_value(self.widget, val)
404else:
405 # Use Entry object when there is no ttk:
406 def ComboBox(labels, values, callback):
407 return Entry(values[0], callback=callback)
410class Rows(Widget):
411 def __init__(self, rows=None):
412 self.rows_to_be_added = rows or []
413 self.creator = tk.Frame
414 self.rows = []
416 def create(self, parent):
417 widget = Widget.create(self, parent)
418 for row in self.rows_to_be_added:
419 self.add(row)
420 self.rows_to_be_added = []
421 return widget
423 def add(self, row):
424 if isinstance(row, str):
425 row = Label(row)
426 elif isinstance(row, list):
427 row = Row(row)
428 row.grid(self.widget)
429 self.rows.append(row)
431 def clear(self):
432 while self.rows:
433 del self[0]
435 def __getitem__(self, i):
436 return self.rows[i]
438 def __delitem__(self, i):
439 widget = self.rows.pop(i).widget
440 widget.grid_remove()
441 widget.destroy()
443 def __len__(self):
444 return len(self.rows)
447class MenuItem:
448 def __init__(self, label, callback=None, key=None,
449 value=None, choices=None, submenu=None, disabled=False):
450 self.underline = label.find('_')
451 self.label = label.replace('_', '')
453 is_macos = platform.system() == 'Darwin'
455 if key:
456 parts = key.split('+')
457 modifiers = []
458 key_char = None
460 for part in parts:
461 if part in ('Alt', 'Shift'):
462 modifiers.append(part)
463 elif part == 'Ctrl':
464 modifiers.append('Control')
465 elif len(part) == 1 and 'Shift' in modifiers:
466 # If shift and letter, uppercase
467 key_char = part
468 else:
469 # Lower case
470 key_char = part.lower()
472 if is_macos:
473 modifiers = ['Command' if m == 'Alt' else m for m in modifiers]
475 if modifiers and key_char:
476 self.keyname = f"<{'-'.join(modifiers)}-{key_char}>"
477 else:
478 # Handle special non-modifier keys
479 self.keyname = {
480 'Home': '<Home>',
481 'End': '<End>',
482 'PageUp': '<Prior>',
483 'PageDown': '<Next>',
484 'Backspace': '<BackSpace>'
485 }.get(key, key.lower())
486 else:
487 self.keyname = None
489 if key:
490 def callback2(event=None):
491 callback(key)
493 callback2.__name__ = callback.__name__
494 self.callback = callback2
495 else:
496 self.callback = callback
498 if is_macos and key is not None:
499 self.key = key.replace('Alt', 'Command')
500 else:
501 self.key = key
502 self.value = value
503 self.choices = choices
504 self.submenu = submenu
505 self.disabled = disabled
507 def addto(self, menu, window, stuff=None):
508 callback = self.callback
509 if self.label == '---':
510 menu.add_separator()
511 elif self.value is not None:
512 var = tk.BooleanVar(value=self.value)
513 stuff[self.callback.__name__.replace('_', '-')] = var
515 menu.add_checkbutton(label=self.label,
516 underline=self.underline,
517 command=self.callback,
518 accelerator=self.key,
519 var=var)
521 def callback(key): # noqa: F811
522 var.set(not var.get())
523 self.callback()
525 elif self.choices:
526 submenu = tk.Menu(menu)
527 menu.add_cascade(label=self.label, menu=submenu)
528 var = tk.IntVar()
529 var.set(0)
530 stuff[self.callback.__name__.replace('_', '-')] = var
531 for i, choice in enumerate(self.choices):
532 submenu.add_radiobutton(label=choice.replace('_', ''),
533 underline=choice.find('_'),
534 command=self.callback,
535 value=i,
536 var=var)
537 elif self.submenu:
538 submenu = tk.Menu(menu)
539 menu.add_cascade(label=self.label,
540 menu=submenu)
541 for thing in self.submenu:
542 thing.addto(submenu, window)
543 else:
544 state = 'normal'
545 if self.disabled:
546 state = 'disabled'
547 menu.add_command(label=self.label,
548 underline=self.underline,
549 command=self.callback,
550 accelerator=self.key,
551 state=state)
552 if self.key:
553 window.bind(self.keyname, callback)
556class MainWindow(BaseWindow):
557 def __init__(self, title, close=None, menu=[]):
558 self.win = tk.Tk()
559 super().__init__(title, close)
561 # self.win.tk.call('tk', 'scaling', 3.0)
562 # self.win.tk.call('tk', 'scaling', '-displayof', '.', 7)
564 self.menu = {}
566 if menu:
567 self.create_menu(menu)
569 def create_menu(self, menu_description):
570 menu = tk.Menu(self.win)
571 self.win.config(menu=menu)
573 for label, things in menu_description:
574 submenu = tk.Menu(menu)
575 menu.add_cascade(label=label.replace('_', ''),
576 underline=label.find('_'),
577 menu=submenu)
578 for thing in things:
579 thing.addto(submenu, self.win, self.menu)
581 def resize_event(self):
582 # self.scale *= sqrt(1.0 * self.width * self.height / (w * h))
583 self.draw()
584 self.configured = True
586 def run(self):
587 # Workaround for nasty issue with tkinter on Mac:
588 # https://gitlab.com/ase/ase/issues/412
589 #
590 # It is apparently a compatibility issue between Python and Tkinter.
591 # Some day we should remove this hack.
592 while True:
593 try:
594 tk.mainloop()
595 break
596 except UnicodeDecodeError:
597 pass
599 def __getitem__(self, name):
600 return self.menu[name].get()
602 def __setitem__(self, name, value):
603 return self.menu[name].set(value)
606def bind(callback, modifier=None):
607 def handle(event):
608 event.button = event.num
609 event.key = event.keysym.lower()
610 event.modifier = modifier
611 callback(event)
612 return handle
615class ASEFileChooser(LoadFileDialog):
616 def __init__(self, win, formatcallback=lambda event: None):
617 from ase.io.formats import all_formats, get_ioformat
618 super().__init__(win, _('Open ...'))
619 labels = [_('Automatic')]
620 values = ['']
622 def key(item):
623 return item[1][0]
625 for format, (description, code) in sorted(all_formats.items(),
626 key=key):
627 io = get_ioformat(format)
628 if io.can_read and description != '?':
629 labels.append(_(description))
630 values.append(format)
632 self.format = None
634 def callback(value):
635 self.format = value
637 Label(_('Choose parser:')).pack(self.top)
638 formats = ComboBox(labels, values, callback)
639 formats.pack(self.top)
642def show_io_error(filename, err):
643 showerror(_('Read error'),
644 _(f'Could not read {filename}: {err}'))
647class ASEGUIWindow(MainWindow):
648 def __init__(self, close, menu, config,
649 scroll, scroll_event,
650 press, move, release, resize):
651 super().__init__('ASE-GUI', close, menu)
653 self.size = np.array([450, 450])
655 self.fg = config['gui_foreground_color']
656 self.bg = config['gui_background_color']
658 self.canvas = tk.Canvas(self.win,
659 width=self.size[0],
660 height=self.size[1],
661 bg=self.bg,
662 highlightthickness=0)
663 self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
665 self.status = tk.Label(self.win, text='', anchor=tk.W)
666 self.status.pack(side=tk.BOTTOM, fill=tk.X)
668 self.canvas.bind('<ButtonPress>', bind(press))
669 for button in range(1, 4):
670 self.canvas.bind(f'<B{button}-Motion>', bind(move))
671 self.canvas.bind('<ButtonRelease>', bind(release))
672 self.canvas.bind('<Control-ButtonRelease>', bind(release, 'ctrl'))
673 self.canvas.bind('<Shift-ButtonRelease>', bind(release, 'shift'))
674 self.canvas.bind('<Configure>', resize)
675 if not config['swap_mouse']:
676 for button in (2, 3):
677 self.canvas.bind(f'<Shift-B{button}-Motion>',
678 bind(scroll))
679 else:
680 self.canvas.bind('<Shift-B1-Motion>',
681 bind(scroll))
683 self.win.bind('<MouseWheel>', bind(scroll_event))
684 self.win.bind('<Key>', bind(scroll))
685 self.win.bind('<Shift-Key>', bind(scroll, 'shift'))
686 self.win.bind('<Control-Key>', bind(scroll, 'ctrl'))
688 def update_status_line(self, text):
689 self.status.config(text=text)
691 def run(self):
692 MainWindow.run(self)
694 def click(self, name):
695 self.callbacks[name]()
697 def clear(self):
698 self.canvas.delete(tk.ALL)
700 def update(self):
701 self.canvas.update_idletasks()
703 def circle(self, color, selected, *bbox):
704 if selected:
705 outline = '#004500'
706 width = 3
707 else:
708 outline = 'black'
709 width = 1
710 self.canvas.create_oval(*tuple(int(x) for x in bbox), fill=color,
711 outline=outline, width=width)
713 def arc(self, color, selected, start, extent, *bbox):
714 if selected:
715 outline = '#004500'
716 width = 3
717 else:
718 outline = 'black'
719 width = 1
720 self.canvas.create_arc(*tuple(int(x) for x in bbox),
721 start=start,
722 extent=extent,
723 fill=color,
724 outline=outline,
725 width=width)
727 def line(self, bbox, width=1):
728 self.canvas.create_line(*tuple(int(x) for x in bbox), width=width,
729 fill='black')
731 def text(self, x, y, txt, anchor=tk.CENTER, color='black'):
732 anchor = {'SE': tk.SE}.get(anchor, anchor)
733 self.canvas.create_text((x, y), text=txt, anchor=anchor, fill=color)
735 def show_widget(self, widget, x, y, anchor=tk.CENTER,):
736 """Places a given widget on self.canvas"""
737 if isinstance(anchor, str):
738 anchor = anchor.lower()
739 self.canvas.create_window(x, y, anchor=anchor, window=widget)
741 def after(self, time, callback):
742 id = self.win.after(int(time * 1000), callback)
743 # Quick'n'dirty object with a cancel() method:
744 return namedtuple('Timer', 'cancel')(lambda: self.win.after_cancel(id))
747def bind_enter(widget, callback):
748 """Preferred incantation for binding Return/Enter.
750 Bindings work differently on different OSes. This ensures that
751 keypad and normal Return work the same on Linux particularly."""
753 widget.bind('<Return>', callback)
754 widget.bind('<KP_Enter>', callback)