Coverage for /builds/ase/ase/ase/gui/ui.py: 90.73%
464 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-08-02 00:12 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-08-02 00:12 +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 _
18__all__ = [
19 'error', 'ask_question', 'MainWindow', 'LoadFileDialog', 'SaveFileDialog',
20 'ASEGUIWindow', 'Button', 'CheckButton', 'ComboBox', 'Entry', 'Label',
21 'Window', 'MenuItem', 'RadioButton', 'RadioButtons', 'Rows', 'Scale',
22 'showinfo', 'showwarning', 'SpinBox', 'Text', 'set_windowtype']
25def error(title, message=None):
26 if message is None:
27 message = title
28 title = _('Error')
29 return showerror(title, message)
32def about(name, version, webpage):
33 text = [name,
34 '',
35 _('Version') + ': ' + version,
36 _('Web-page') + ': ' + webpage]
37 win = Window(_('About'))
38 set_windowtype(win.win, 'dialog')
39 win.add(Text('\n'.join(text)))
42def helpbutton(text):
43 return Button(_('Help'), helpwindow, text)
46def helpwindow(text):
47 win = Window(_('Help'))
48 set_windowtype(win.win, 'dialog')
49 win.add(Text(text))
52def set_windowtype(win, wmtype):
53 # introduced tweak to fix GUI on WSL, https://gitlab.com/ase/ase/-/issues/1511
54 if (platform.platform().find('WSL') and
55 platform.platform().find('microsoft')) != -1:
56 # only on X11, but not on WSL
57 # WM_TYPE, for possible settings see
58 # https://specifications.freedesktop.org/wm-spec/wm-spec-latest.html#idm45623487848608
59 # you want dialog, normal or utility most likely
60 if win._windowingsystem == "x11":
61 win.wm_attributes('-type', 'normal')
64class BaseWindow:
65 def __init__(self, title, close=None, wmtype='normal'):
66 self.title = title
67 if close:
68 self.win.protocol('WM_DELETE_WINDOW', close)
69 else:
70 self.win.protocol('WM_DELETE_WINDOW', self.close)
72 self.things = []
73 self.exists = True
74 set_windowtype(self.win, wmtype)
76 def close(self):
77 self.win.destroy()
78 self.exists = False
80 def title(self, txt):
81 self.win.title(txt)
83 title = property(None, title)
85 def add(self, stuff, anchor='w'): # 'center'):
86 if isinstance(stuff, str):
87 stuff = Label(stuff)
88 elif isinstance(stuff, list):
89 stuff = Row(stuff)
90 stuff.pack(self.win, anchor=anchor)
91 self.things.append(stuff)
94class Window(BaseWindow):
95 def __init__(self, title, close=None, wmtype='normal'):
96 self.win = tk.Toplevel()
97 BaseWindow.__init__(self, title, close, wmtype)
100class Widget:
101 def pack(self, parent, side='top', anchor='center'):
102 widget = self.create(parent)
103 widget.pack(side=side, anchor=anchor)
104 if not isinstance(self, (Rows, RadioButtons)):
105 pass
107 def grid(self, parent):
108 widget = self.create(parent)
109 widget.grid()
111 def create(self, parent):
112 self.widget = self.creator(parent)
113 return self.widget
115 @property
116 def active(self):
117 return self.widget['state'] == 'normal'
119 @active.setter
120 def active(self, value):
121 self.widget['state'] = ['disabled', 'normal'][bool(value)]
124class Row(Widget):
125 def __init__(self, things):
126 self.things = things
128 def create(self, parent):
129 self.widget = tk.Frame(parent)
130 for thing in self.things:
131 if isinstance(thing, str):
132 thing = Label(thing)
133 thing.pack(self.widget, 'left')
134 return self.widget
136 def __getitem__(self, i):
137 return self.things[i]
140class Label(Widget):
141 def __init__(self, text='', color=None):
142 self.creator = partial(tk.Label, text=text, fg=color)
144 @property
145 def text(self):
146 return self.widget['text']
148 @text.setter
149 def text(self, new):
150 self.widget.config(text=new)
153class Text(Widget):
154 def __init__(self, text):
155 self.creator = partial(tk.Text, height=text.count('\n') + 1)
156 s = re.split('<(.*?)>', text)
157 self.text = [(s[0], ())]
158 i = 1
159 tags = []
160 while i < len(s):
161 tag = s[i]
162 if tag[0] != '/':
163 tags.append(tag)
164 else:
165 tags.pop()
166 self.text.append((s[i + 1], tuple(tags)))
167 i += 2
169 def create(self, parent):
170 widget = Widget.create(self, parent)
171 widget.tag_configure('sub', offset=-6)
172 widget.tag_configure('sup', offset=6)
173 widget.tag_configure('c', foreground='blue')
174 for text, tags in self.text:
175 widget.insert('insert', text, tags)
176 widget.configure(state='disabled', background=parent['bg'])
177 widget.bind("<1>", lambda event: widget.focus_set())
178 return widget
181class Button(Widget):
182 def __init__(self, text, callback, *args, **kwargs):
183 self.callback = partial(callback, *args, **kwargs)
184 self.creator = partial(tk.Button,
185 text=text,
186 command=self.callback)
189class CheckButton(Widget):
190 def __init__(self, text, value=False, callback=None):
191 self.text = text
192 self.var = tk.BooleanVar(value=value)
193 self.callback = callback
195 def create(self, parent):
196 self.check = tk.Checkbutton(parent, text=self.text,
197 var=self.var, command=self.callback)
198 return self.check
200 @property
201 def value(self):
202 return self.var.get()
205class SpinBox(Widget):
206 def __init__(self, value, start, end, step, callback=None,
207 rounding=None, width=6):
208 self.callback = callback
209 self.rounding = rounding
210 self.creator = partial(tk.Spinbox,
211 from_=start,
212 to=end,
213 increment=step,
214 command=callback,
215 width=width)
216 self.initial = str(value)
218 def create(self, parent):
219 self.widget = self.creator(parent)
220 bind_enter(self.widget, lambda event: self.callback())
221 self.value = self.initial
222 return self.widget
224 @property
225 def value(self):
226 x = self.widget.get().replace(',', '.')
227 if '.' in x:
228 return float(x)
229 if x == 'None':
230 return None
231 return int(x)
233 @value.setter
234 def value(self, x):
235 self.widget.delete(0, 'end')
236 if '.' in str(x) and self.rounding is not None:
237 try:
238 x = round(float(x), self.rounding)
239 except (ValueError, TypeError):
240 pass
241 self.widget.insert(0, x)
244# Entry and ComboBox use same mechanism (since ttk ComboBox
245# is a subclass of tk Entry).
246def _set_entry_value(widget, value):
247 widget.delete(0, 'end')
248 widget.insert(0, value)
251class Entry(Widget):
252 def __init__(self, value='', width=20, callback=None):
253 self.creator = partial(tk.Entry,
254 width=width)
255 if callback is not None:
256 self.callback = lambda event: callback()
257 else:
258 self.callback = None
259 self.initial = value
261 def create(self, parent):
262 self.entry = self.creator(parent)
263 self.value = self.initial
264 if self.callback:
265 bind_enter(self.entry, self.callback)
266 return self.entry
268 @property
269 def value(self):
270 return self.entry.get()
272 @value.setter
273 def value(self, x):
274 _set_entry_value(self.entry, x)
277class Scale(Widget):
278 def __init__(self, value, start, end, callback):
279 def command(val):
280 callback(int(val))
282 self.creator = partial(tk.Scale,
283 from_=start,
284 to=end,
285 orient='horizontal',
286 command=command)
287 self.initial = value
289 def create(self, parent):
290 self.scale = self.creator(parent)
291 self.value = self.initial
292 return self.scale
294 @property
295 def value(self):
296 return self.scale.get()
298 @value.setter
299 def value(self, x):
300 self.scale.set(x)
303class RadioButtons(Widget):
304 def __init__(self, labels, values=None, callback=None, vertical=False):
305 self.var = tk.IntVar()
307 if callback:
308 def callback2():
309 callback(self.value)
310 else:
311 callback2 = None
313 self.values = values or list(range(len(labels)))
314 self.buttons = [RadioButton(label, i, self.var, callback2)
315 for i, label in enumerate(labels)]
316 self.vertical = vertical
318 def create(self, parent):
319 self.widget = frame = tk.Frame(parent)
320 side = 'top' if self.vertical else 'left'
321 for button in self.buttons:
322 button.create(frame).pack(side=side)
323 return frame
325 @property
326 def value(self):
327 return self.values[self.var.get()]
329 @value.setter
330 def value(self, value):
331 self.var.set(self.values.index(value))
333 def __getitem__(self, value):
334 return self.buttons[self.values.index(value)]
337class RadioButton(Widget):
338 def __init__(self, label, i, var, callback):
339 self.creator = partial(tk.Radiobutton,
340 text=label,
341 var=var,
342 value=i,
343 command=callback)
346if ttk is not None:
347 class ComboBox(Widget):
348 def __init__(self, labels, values=None, callback=None):
349 self.values = values or list(range(len(labels)))
350 self.callback = callback
351 self.creator = partial(ttk.Combobox,
352 values=labels)
354 def create(self, parent):
355 widget = Widget.create(self, parent)
356 widget.current(0)
357 if self.callback:
358 def callback(event):
359 self.callback(self.value)
360 widget.bind('<<ComboboxSelected>>', callback)
362 return widget
364 @property
365 def value(self):
366 return self.values[self.widget.current()]
368 @value.setter
369 def value(self, val):
370 _set_entry_value(self.widget, val)
371else:
372 # Use Entry object when there is no ttk:
373 def ComboBox(labels, values, callback):
374 return Entry(values[0], callback=callback)
377class Rows(Widget):
378 def __init__(self, rows=None):
379 self.rows_to_be_added = rows or []
380 self.creator = tk.Frame
381 self.rows = []
383 def create(self, parent):
384 widget = Widget.create(self, parent)
385 for row in self.rows_to_be_added:
386 self.add(row)
387 self.rows_to_be_added = []
388 return widget
390 def add(self, row):
391 if isinstance(row, str):
392 row = Label(row)
393 elif isinstance(row, list):
394 row = Row(row)
395 row.grid(self.widget)
396 self.rows.append(row)
398 def clear(self):
399 while self.rows:
400 del self[0]
402 def __getitem__(self, i):
403 return self.rows[i]
405 def __delitem__(self, i):
406 widget = self.rows.pop(i).widget
407 widget.grid_remove()
408 widget.destroy()
410 def __len__(self):
411 return len(self.rows)
414class MenuItem:
415 def __init__(self, label, callback=None, key=None,
416 value=None, choices=None, submenu=None, disabled=False):
417 self.underline = label.find('_')
418 self.label = label.replace('_', '')
420 is_macos = platform.system() == 'Darwin'
422 if key:
423 parts = key.split('+')
424 modifiers = []
425 key_char = None
427 for part in parts:
428 if part in ('Alt', 'Shift'):
429 modifiers.append(part)
430 elif part == 'Ctrl':
431 modifiers.append('Control')
432 elif len(part) == 1 and 'Shift' in modifiers:
433 # If shift and letter, uppercase
434 key_char = part
435 else:
436 # Lower case
437 key_char = part.lower()
439 if is_macos:
440 modifiers = ['Command' if m == 'Alt' else m for m in modifiers]
442 if modifiers and key_char:
443 self.keyname = f"<{'-'.join(modifiers)}-{key_char}>"
444 else:
445 # Handle special non-modifier keys
446 self.keyname = {
447 'Home': '<Home>',
448 'End': '<End>',
449 'Page-Up': '<Prior>',
450 'Page-Down': '<Next>',
451 'Backspace': '<BackSpace>'
452 }.get(key, key.lower())
453 else:
454 self.keyname = None
456 if key:
457 def callback2(event=None):
458 callback(key)
460 callback2.__name__ = callback.__name__
461 self.callback = callback2
462 else:
463 self.callback = callback
465 if is_macos and key is not None:
466 self.key = key.replace('Alt', 'Command')
467 else:
468 self.key = key
469 self.value = value
470 self.choices = choices
471 self.submenu = submenu
472 self.disabled = disabled
474 def addto(self, menu, window, stuff=None):
475 callback = self.callback
476 if self.label == '---':
477 menu.add_separator()
478 elif self.value is not None:
479 var = tk.BooleanVar(value=self.value)
480 stuff[self.callback.__name__.replace('_', '-')] = var
482 menu.add_checkbutton(label=self.label,
483 underline=self.underline,
484 command=self.callback,
485 accelerator=self.key,
486 var=var)
488 def callback(key): # noqa: F811
489 var.set(not var.get())
490 self.callback()
492 elif self.choices:
493 submenu = tk.Menu(menu)
494 menu.add_cascade(label=self.label, menu=submenu)
495 var = tk.IntVar()
496 var.set(0)
497 stuff[self.callback.__name__.replace('_', '-')] = var
498 for i, choice in enumerate(self.choices):
499 submenu.add_radiobutton(label=choice.replace('_', ''),
500 underline=choice.find('_'),
501 command=self.callback,
502 value=i,
503 var=var)
504 elif self.submenu:
505 submenu = tk.Menu(menu)
506 menu.add_cascade(label=self.label,
507 menu=submenu)
508 for thing in self.submenu:
509 thing.addto(submenu, window)
510 else:
511 state = 'normal'
512 if self.disabled:
513 state = 'disabled'
514 menu.add_command(label=self.label,
515 underline=self.underline,
516 command=self.callback,
517 accelerator=self.key,
518 state=state)
519 if self.key:
520 window.bind(self.keyname, callback)
523class MainWindow(BaseWindow):
524 def __init__(self, title, close=None, menu=[]):
525 self.win = tk.Tk()
526 BaseWindow.__init__(self, title, close)
528 # self.win.tk.call('tk', 'scaling', 3.0)
529 # self.win.tk.call('tk', 'scaling', '-displayof', '.', 7)
531 self.menu = {}
533 if menu:
534 self.create_menu(menu)
536 def create_menu(self, menu_description):
537 menu = tk.Menu(self.win)
538 self.win.config(menu=menu)
540 for label, things in menu_description:
541 submenu = tk.Menu(menu)
542 menu.add_cascade(label=label.replace('_', ''),
543 underline=label.find('_'),
544 menu=submenu)
545 for thing in things:
546 thing.addto(submenu, self.win, self.menu)
548 def resize_event(self):
549 # self.scale *= sqrt(1.0 * self.width * self.height / (w * h))
550 self.draw()
551 self.configured = True
553 def run(self):
554 # Workaround for nasty issue with tkinter on Mac:
555 # https://gitlab.com/ase/ase/issues/412
556 #
557 # It is apparently a compatibility issue between Python and Tkinter.
558 # Some day we should remove this hack.
559 while True:
560 try:
561 tk.mainloop()
562 break
563 except UnicodeDecodeError:
564 pass
566 def __getitem__(self, name):
567 return self.menu[name].get()
569 def __setitem__(self, name, value):
570 return self.menu[name].set(value)
573def bind(callback, modifier=None):
574 def handle(event):
575 event.button = event.num
576 event.key = event.keysym.lower()
577 event.modifier = modifier
578 callback(event)
579 return handle
582class ASEFileChooser(LoadFileDialog):
583 def __init__(self, win, formatcallback=lambda event: None):
584 from ase.io.formats import all_formats, get_ioformat
585 LoadFileDialog.__init__(self, win, _('Open ...'))
586 # fix tkinter not automatically setting dialog type
587 # remove from Python3.8+
588 # see https://github.com/python/cpython/pull/25187
589 # and https://bugs.python.org/issue43655
590 # and https://github.com/python/cpython/pull/25592
591 set_windowtype(self.top, 'dialog')
592 labels = [_('Automatic')]
593 values = ['']
595 def key(item):
596 return item[1][0]
598 for format, (description, code) in sorted(all_formats.items(),
599 key=key):
600 io = get_ioformat(format)
601 if io.can_read and description != '?':
602 labels.append(_(description))
603 values.append(format)
605 self.format = None
607 def callback(value):
608 self.format = value
610 Label(_('Choose parser:')).pack(self.top)
611 formats = ComboBox(labels, values, callback)
612 formats.pack(self.top)
615def show_io_error(filename, err):
616 showerror(_('Read error'),
617 _(f'Could not read {filename}: {err}'))
620class ASEGUIWindow(MainWindow):
621 def __init__(self, close, menu, config,
622 scroll, scroll_event,
623 press, move, release, resize):
624 MainWindow.__init__(self, 'ASE-GUI', close, menu)
626 self.size = np.array([450, 450])
628 self.fg = config['gui_foreground_color']
629 self.bg = config['gui_background_color']
631 self.canvas = tk.Canvas(self.win,
632 width=self.size[0],
633 height=self.size[1],
634 bg=self.bg,
635 highlightthickness=0)
636 self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
638 self.status = tk.Label(self.win, text='', anchor=tk.W)
639 self.status.pack(side=tk.BOTTOM, fill=tk.X)
641 self.canvas.bind('<ButtonPress>', bind(press))
642 for button in range(1, 4):
643 self.canvas.bind(f'<B{button}-Motion>', bind(move))
644 self.canvas.bind('<ButtonRelease>', bind(release))
645 self.canvas.bind('<Control-ButtonRelease>', bind(release, 'ctrl'))
646 self.canvas.bind('<Shift-ButtonRelease>', bind(release, 'shift'))
647 self.canvas.bind('<Configure>', resize)
648 if not config['swap_mouse']:
649 for button in (2, 3):
650 self.canvas.bind(f'<Shift-B{button}-Motion>',
651 bind(scroll))
652 else:
653 self.canvas.bind('<Shift-B1-Motion>',
654 bind(scroll))
656 self.win.bind('<MouseWheel>', bind(scroll_event))
657 self.win.bind('<Key>', bind(scroll))
658 self.win.bind('<Shift-Key>', bind(scroll, 'shift'))
659 self.win.bind('<Control-Key>', bind(scroll, 'ctrl'))
661 def update_status_line(self, text):
662 self.status.config(text=text)
664 def run(self):
665 MainWindow.run(self)
667 def click(self, name):
668 self.callbacks[name]()
670 def clear(self):
671 self.canvas.delete(tk.ALL)
673 def update(self):
674 self.canvas.update_idletasks()
676 def circle(self, color, selected, *bbox):
677 if selected:
678 outline = '#004500'
679 width = 3
680 else:
681 outline = 'black'
682 width = 1
683 self.canvas.create_oval(*tuple(int(x) for x in bbox), fill=color,
684 outline=outline, width=width)
686 def arc(self, color, selected, start, extent, *bbox):
687 if selected:
688 outline = '#004500'
689 width = 3
690 else:
691 outline = 'black'
692 width = 1
693 self.canvas.create_arc(*tuple(int(x) for x in bbox),
694 start=start,
695 extent=extent,
696 fill=color,
697 outline=outline,
698 width=width)
700 def line(self, bbox, width=1):
701 self.canvas.create_line(*tuple(int(x) for x in bbox), width=width,
702 fill='black')
704 def text(self, x, y, txt, anchor=tk.CENTER, color='black'):
705 anchor = {'SE': tk.SE}.get(anchor, anchor)
706 self.canvas.create_text((x, y), text=txt, anchor=anchor, fill=color)
708 def after(self, time, callback):
709 id = self.win.after(int(time * 1000), callback)
710 # Quick'n'dirty object with a cancel() method:
711 return namedtuple('Timer', 'cancel')(lambda: self.win.after_cancel(id))
714def bind_enter(widget, callback):
715 """Preferred incantation for binding Return/Enter.
717 Bindings work differently on different OSes. This ensures that
718 keypad and normal Return work the same on Linux particularly."""
720 widget.bind('<Return>', callback)
721 widget.bind('<KP_Enter>', callback)