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

1# fmt: off 

2 

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 

13 

14import numpy as np 

15 

16from ase.gui.i18n import _ 

17from ase.utils.parsemath import eval_expression 

18 

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'] 

24 

25 

26def error(title, message=None): 

27 if message is None: 

28 message = title 

29 title = _('Error') 

30 return showerror(title, message) 

31 

32 

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))) 

40 

41 

42def helpbutton(text): 

43 return Button(_('Help'), helpwindow, text) 

44 

45 

46def helpwindow(text): 

47 win = Window(_('Help')) 

48 win.add(Text(text)) 

49 

50 

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) 

58 

59 self.things = [] 

60 self.exists = True 

61 

62 def close(self): 

63 self.win.destroy() 

64 self.exists = False 

65 

66 def title(self, txt): 

67 self.win.title(txt) 

68 

69 title = property(None, title) 

70 

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) 

78 

79 

80class Window(BaseWindow): 

81 def __init__(self, title, close=None): 

82 self.win = tk.Toplevel() 

83 super().__init__(title, close) 

84 

85 

86class Tooltip(BaseWindow): 

87 def __init__(self): 

88 self._config = {} 

89 self.things = [] 

90 self.exists = False 

91 

92 def configure(self, /, **kwargs): 

93 self._config.update(**kwargs) 

94 

95 def title(self): 

96 pass 

97 

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 ) 

111 

112 def hide(self, key=None): 

113 self.close() 

114 

115 

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 

122 

123 def grid(self, parent): 

124 widget = self.create(parent) 

125 widget.grid() 

126 

127 def create(self, parent): 

128 self.widget = self.creator(parent) 

129 return self.widget 

130 

131 @property 

132 def active(self): 

133 return self.widget['state'] == 'normal' 

134 

135 @active.setter 

136 def active(self, value): 

137 self.widget['state'] = ['disabled', 'normal'][bool(value)] 

138 

139 

140class Row(Widget): 

141 def __init__(self, things): 

142 self.things = things 

143 

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 

151 

152 def __getitem__(self, i): 

153 return self.things[i] 

154 

155 

156class Label(Widget): 

157 def __init__(self, text='', color=None, **kwargs): 

158 self.creator = partial(tk.Label, text=text, fg=color, **kwargs) 

159 

160 @property 

161 def text(self): 

162 return self.widget['text'] 

163 

164 @text.setter 

165 def text(self, new): 

166 self.widget.config(text=new) 

167 

168 

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 

184 

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 

195 

196 

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) 

203 

204 

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 

210 

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 

215 

216 @property 

217 def value(self): 

218 return self.var.get() 

219 

220 

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) 

233 

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 

239 

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 

250 

251 def parse_and_callback(self): 

252 self.parse_value() 

253 if self.callback: 

254 self.callback() 

255 

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) 

265 

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) 

275 

276 

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) 

282 

283 

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 

293 

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 

300 

301 @property 

302 def value(self): 

303 return self.entry.get() 

304 

305 @value.setter 

306 def value(self, x): 

307 _set_entry_value(self.entry, x) 

308 

309 

310class Scale(Widget): 

311 def __init__(self, value, start, end, callback): 

312 def command(val): 

313 callback(int(val)) 

314 

315 self.creator = partial(tk.Scale, 

316 from_=start, 

317 to=end, 

318 orient='horizontal', 

319 command=command) 

320 self.initial = value 

321 

322 def create(self, parent): 

323 self.scale = self.creator(parent) 

324 self.value = self.initial 

325 return self.scale 

326 

327 @property 

328 def value(self): 

329 return self.scale.get() 

330 

331 @value.setter 

332 def value(self, x): 

333 self.scale.set(x) 

334 

335 

336class RadioButtons(Widget): 

337 def __init__(self, labels, values=None, callback=None, vertical=False): 

338 self.var = tk.IntVar() 

339 

340 if callback: 

341 def callback2(): 

342 callback(self.value) 

343 else: 

344 callback2 = None 

345 

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 

350 

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 

357 

358 @property 

359 def value(self): 

360 return self.values[self.var.get()] 

361 

362 @value.setter 

363 def value(self, value): 

364 self.var.set(self.values.index(value)) 

365 

366 def __getitem__(self, value): 

367 return self.buttons[self.values.index(value)] 

368 

369 

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) 

377 

378 

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) 

386 

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) 

394 

395 return widget 

396 

397 @property 

398 def value(self): 

399 return self.values[self.widget.current()] 

400 

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) 

408 

409 

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 = [] 

415 

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 

422 

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) 

430 

431 def clear(self): 

432 while self.rows: 

433 del self[0] 

434 

435 def __getitem__(self, i): 

436 return self.rows[i] 

437 

438 def __delitem__(self, i): 

439 widget = self.rows.pop(i).widget 

440 widget.grid_remove() 

441 widget.destroy() 

442 

443 def __len__(self): 

444 return len(self.rows) 

445 

446 

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('_', '') 

452 

453 is_macos = platform.system() == 'Darwin' 

454 

455 if key: 

456 parts = key.split('+') 

457 modifiers = [] 

458 key_char = None 

459 

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() 

471 

472 if is_macos: 

473 modifiers = ['Command' if m == 'Alt' else m for m in modifiers] 

474 

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 

488 

489 if key: 

490 def callback2(event=None): 

491 callback(key) 

492 

493 callback2.__name__ = callback.__name__ 

494 self.callback = callback2 

495 else: 

496 self.callback = callback 

497 

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 

506 

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 

514 

515 menu.add_checkbutton(label=self.label, 

516 underline=self.underline, 

517 command=self.callback, 

518 accelerator=self.key, 

519 var=var) 

520 

521 def callback(key): # noqa: F811 

522 var.set(not var.get()) 

523 self.callback() 

524 

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) 

554 

555 

556class MainWindow(BaseWindow): 

557 def __init__(self, title, close=None, menu=[]): 

558 self.win = tk.Tk() 

559 super().__init__(title, close) 

560 

561 # self.win.tk.call('tk', 'scaling', 3.0) 

562 # self.win.tk.call('tk', 'scaling', '-displayof', '.', 7) 

563 

564 self.menu = {} 

565 

566 if menu: 

567 self.create_menu(menu) 

568 

569 def create_menu(self, menu_description): 

570 menu = tk.Menu(self.win) 

571 self.win.config(menu=menu) 

572 

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) 

580 

581 def resize_event(self): 

582 # self.scale *= sqrt(1.0 * self.width * self.height / (w * h)) 

583 self.draw() 

584 self.configured = True 

585 

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 

598 

599 def __getitem__(self, name): 

600 return self.menu[name].get() 

601 

602 def __setitem__(self, name, value): 

603 return self.menu[name].set(value) 

604 

605 

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 

613 

614 

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 = [''] 

621 

622 def key(item): 

623 return item[1][0] 

624 

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) 

631 

632 self.format = None 

633 

634 def callback(value): 

635 self.format = value 

636 

637 Label(_('Choose parser:')).pack(self.top) 

638 formats = ComboBox(labels, values, callback) 

639 formats.pack(self.top) 

640 

641 

642def show_io_error(filename, err): 

643 showerror(_('Read error'), 

644 _(f'Could not read {filename}: {err}')) 

645 

646 

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) 

652 

653 self.size = np.array([450, 450]) 

654 

655 self.fg = config['gui_foreground_color'] 

656 self.bg = config['gui_background_color'] 

657 

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) 

664 

665 self.status = tk.Label(self.win, text='', anchor=tk.W) 

666 self.status.pack(side=tk.BOTTOM, fill=tk.X) 

667 

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)) 

682 

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')) 

687 

688 def update_status_line(self, text): 

689 self.status.config(text=text) 

690 

691 def run(self): 

692 MainWindow.run(self) 

693 

694 def click(self, name): 

695 self.callbacks[name]() 

696 

697 def clear(self): 

698 self.canvas.delete(tk.ALL) 

699 

700 def update(self): 

701 self.canvas.update_idletasks() 

702 

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) 

712 

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) 

726 

727 def line(self, bbox, width=1): 

728 self.canvas.create_line(*tuple(int(x) for x in bbox), width=width, 

729 fill='black') 

730 

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) 

734 

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) 

740 

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)) 

745 

746 

747def bind_enter(widget, callback): 

748 """Preferred incantation for binding Return/Enter. 

749 

750 Bindings work differently on different OSes. This ensures that 

751 keypad and normal Return work the same on Linux particularly.""" 

752 

753 widget.bind('<Return>', callback) 

754 widget.bind('<KP_Enter>', callback)