Coverage for /builds/debichem-team/python-ase/ase/gui/ui.py: 91.07%
448 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-03-06 04:00 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-03-06 04:00 +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 elif key[:3] == 'Alt':
425 self.keyname = f'<Alt-{key[-1].lower()}>'
426 else:
427 self.keyname = {
428 'Home': '<Home>',
429 'End': '<End>',
430 'Page-Up': '<Prior>',
431 'Page-Down': '<Next>',
432 'Backspace': '<BackSpace>'}.get(key, key.lower())
434 if key:
435 def callback2(event=None):
436 callback(key)
438 callback2.__name__ = callback.__name__
439 self.callback = callback2
440 else:
441 self.callback = callback
443 self.key = key
444 self.value = value
445 self.choices = choices
446 self.submenu = submenu
447 self.disabled = disabled
449 def addto(self, menu, window, stuff=None):
450 callback = self.callback
451 if self.label == '---':
452 menu.add_separator()
453 elif self.value is not None:
454 var = tk.BooleanVar(value=self.value)
455 stuff[self.callback.__name__.replace('_', '-')] = var
457 menu.add_checkbutton(label=self.label,
458 underline=self.underline,
459 command=self.callback,
460 accelerator=self.key,
461 var=var)
463 def callback(key): # noqa: F811
464 var.set(not var.get())
465 self.callback()
467 elif self.choices:
468 submenu = tk.Menu(menu)
469 menu.add_cascade(label=self.label, menu=submenu)
470 var = tk.IntVar()
471 var.set(0)
472 stuff[self.callback.__name__.replace('_', '-')] = var
473 for i, choice in enumerate(self.choices):
474 submenu.add_radiobutton(label=choice.replace('_', ''),
475 underline=choice.find('_'),
476 command=self.callback,
477 value=i,
478 var=var)
479 elif self.submenu:
480 submenu = tk.Menu(menu)
481 menu.add_cascade(label=self.label,
482 menu=submenu)
483 for thing in self.submenu:
484 thing.addto(submenu, window)
485 else:
486 state = 'normal'
487 if self.disabled:
488 state = 'disabled'
489 menu.add_command(label=self.label,
490 underline=self.underline,
491 command=self.callback,
492 accelerator=self.key,
493 state=state)
494 if self.key:
495 window.bind(self.keyname, callback)
498class MainWindow(BaseWindow):
499 def __init__(self, title, close=None, menu=[]):
500 self.win = tk.Tk()
501 BaseWindow.__init__(self, title, close)
503 # self.win.tk.call('tk', 'scaling', 3.0)
504 # self.win.tk.call('tk', 'scaling', '-displayof', '.', 7)
506 self.menu = {}
508 if menu:
509 self.create_menu(menu)
511 def create_menu(self, menu_description):
512 menu = tk.Menu(self.win)
513 self.win.config(menu=menu)
515 for label, things in menu_description:
516 submenu = tk.Menu(menu)
517 menu.add_cascade(label=label.replace('_', ''),
518 underline=label.find('_'),
519 menu=submenu)
520 for thing in things:
521 thing.addto(submenu, self.win, self.menu)
523 def resize_event(self):
524 # self.scale *= sqrt(1.0 * self.width * self.height / (w * h))
525 self.draw()
526 self.configured = True
528 def run(self):
529 # Workaround for nasty issue with tkinter on Mac:
530 # https://gitlab.com/ase/ase/issues/412
531 #
532 # It is apparently a compatibility issue between Python and Tkinter.
533 # Some day we should remove this hack.
534 while True:
535 try:
536 tk.mainloop()
537 break
538 except UnicodeDecodeError:
539 pass
541 def __getitem__(self, name):
542 return self.menu[name].get()
544 def __setitem__(self, name, value):
545 return self.menu[name].set(value)
548def bind(callback, modifier=None):
549 def handle(event):
550 event.button = mouse_buttons.get(event.num, event.num)
551 event.key = event.keysym.lower()
552 event.modifier = modifier
553 callback(event)
554 return handle
557class ASEFileChooser(LoadFileDialog):
558 def __init__(self, win, formatcallback=lambda event: None):
559 from ase.io.formats import all_formats, get_ioformat
560 LoadFileDialog.__init__(self, win, _('Open ...'))
561 # fix tkinter not automatically setting dialog type
562 # remove from Python3.8+
563 # see https://github.com/python/cpython/pull/25187
564 # and https://bugs.python.org/issue43655
565 # and https://github.com/python/cpython/pull/25592
566 set_windowtype(self.top, 'dialog')
567 labels = [_('Automatic')]
568 values = ['']
570 def key(item):
571 return item[1][0]
573 for format, (description, code) in sorted(all_formats.items(),
574 key=key):
575 io = get_ioformat(format)
576 if io.can_read and description != '?':
577 labels.append(_(description))
578 values.append(format)
580 self.format = None
582 def callback(value):
583 self.format = value
585 Label(_('Choose parser:')).pack(self.top)
586 formats = ComboBox(labels, values, callback)
587 formats.pack(self.top)
590def show_io_error(filename, err):
591 showerror(_('Read error'),
592 _(f'Could not read {filename}: {err}'))
595class ASEGUIWindow(MainWindow):
596 def __init__(self, close, menu, config,
597 scroll, scroll_event,
598 press, move, release, resize):
599 MainWindow.__init__(self, 'ASE-GUI', close, menu)
601 self.size = np.array([450, 450])
603 self.fg = config['gui_foreground_color']
604 self.bg = config['gui_background_color']
606 self.canvas = tk.Canvas(self.win,
607 width=self.size[0],
608 height=self.size[1],
609 bg=self.bg,
610 highlightthickness=0)
611 self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
613 self.status = tk.Label(self.win, text='', anchor=tk.W)
614 self.status.pack(side=tk.BOTTOM, fill=tk.X)
616 right = mouse_buttons.get(3, 3)
617 self.canvas.bind('<ButtonPress>', bind(press))
618 self.canvas.bind('<B1-Motion>', bind(move))
619 self.canvas.bind(f'<B{right}-Motion>', bind(move))
620 self.canvas.bind('<ButtonRelease>', bind(release))
621 self.canvas.bind('<Control-ButtonRelease>', bind(release, 'ctrl'))
622 self.canvas.bind('<Shift-ButtonRelease>', bind(release, 'shift'))
623 self.canvas.bind('<Configure>', resize)
624 if not config['swap_mouse']:
625 self.canvas.bind(f'<Shift-B{right}-Motion>',
626 bind(scroll))
627 else:
628 self.canvas.bind('<Shift-B1-Motion>',
629 bind(scroll))
631 self.win.bind('<MouseWheel>', bind(scroll_event))
632 self.win.bind('<Key>', bind(scroll))
633 self.win.bind('<Shift-Key>', bind(scroll, 'shift'))
634 self.win.bind('<Control-Key>', bind(scroll, 'ctrl'))
636 def update_status_line(self, text):
637 self.status.config(text=text)
639 def run(self):
640 MainWindow.run(self)
642 def click(self, name):
643 self.callbacks[name]()
645 def clear(self):
646 self.canvas.delete(tk.ALL)
648 def update(self):
649 self.canvas.update_idletasks()
651 def circle(self, color, selected, *bbox):
652 if selected:
653 outline = '#004500'
654 width = 3
655 else:
656 outline = 'black'
657 width = 1
658 self.canvas.create_oval(*tuple(int(x) for x in bbox), fill=color,
659 outline=outline, width=width)
661 def arc(self, color, selected, start, extent, *bbox):
662 if selected:
663 outline = '#004500'
664 width = 3
665 else:
666 outline = 'black'
667 width = 1
668 self.canvas.create_arc(*tuple(int(x) for x in bbox),
669 start=start,
670 extent=extent,
671 fill=color,
672 outline=outline,
673 width=width)
675 def line(self, bbox, width=1):
676 self.canvas.create_line(*tuple(int(x) for x in bbox), width=width,
677 fill='black')
679 def text(self, x, y, txt, anchor=tk.CENTER, color='black'):
680 anchor = {'SE': tk.SE}.get(anchor, anchor)
681 self.canvas.create_text((x, y), text=txt, anchor=anchor, fill=color)
683 def after(self, time, callback):
684 id = self.win.after(int(time * 1000), callback)
685 # Quick'n'dirty object with a cancel() method:
686 return namedtuple('Timer', 'cancel')(lambda: self.win.after_cancel(id))