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

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 _ 

17 

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

23 

24 

25def error(title, message=None): 

26 if message is None: 

27 message = title 

28 title = _('Error') 

29 return showerror(title, message) 

30 

31 

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

40 

41 

42def helpbutton(text): 

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

44 

45 

46def helpwindow(text): 

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

48 set_windowtype(win.win, 'dialog') 

49 win.add(Text(text)) 

50 

51 

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

62 

63 

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) 

71 

72 self.things = [] 

73 self.exists = True 

74 set_windowtype(self.win, wmtype) 

75 

76 def close(self): 

77 self.win.destroy() 

78 self.exists = False 

79 

80 def title(self, txt): 

81 self.win.title(txt) 

82 

83 title = property(None, title) 

84 

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) 

92 

93 

94class Window(BaseWindow): 

95 def __init__(self, title, close=None, wmtype='normal'): 

96 self.win = tk.Toplevel() 

97 BaseWindow.__init__(self, title, close, wmtype) 

98 

99 

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 

106 

107 def grid(self, parent): 

108 widget = self.create(parent) 

109 widget.grid() 

110 

111 def create(self, parent): 

112 self.widget = self.creator(parent) 

113 return self.widget 

114 

115 @property 

116 def active(self): 

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

118 

119 @active.setter 

120 def active(self, value): 

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

122 

123 

124class Row(Widget): 

125 def __init__(self, things): 

126 self.things = things 

127 

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 

135 

136 def __getitem__(self, i): 

137 return self.things[i] 

138 

139 

140class Label(Widget): 

141 def __init__(self, text='', color=None): 

142 self.creator = partial(tk.Label, text=text, fg=color) 

143 

144 @property 

145 def text(self): 

146 return self.widget['text'] 

147 

148 @text.setter 

149 def text(self, new): 

150 self.widget.config(text=new) 

151 

152 

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 

168 

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 

179 

180 

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) 

187 

188 

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 

194 

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 

199 

200 @property 

201 def value(self): 

202 return self.var.get() 

203 

204 

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) 

217 

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 

223 

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) 

232 

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) 

242 

243 

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) 

249 

250 

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 

260 

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 

267 

268 @property 

269 def value(self): 

270 return self.entry.get() 

271 

272 @value.setter 

273 def value(self, x): 

274 _set_entry_value(self.entry, x) 

275 

276 

277class Scale(Widget): 

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

279 def command(val): 

280 callback(int(val)) 

281 

282 self.creator = partial(tk.Scale, 

283 from_=start, 

284 to=end, 

285 orient='horizontal', 

286 command=command) 

287 self.initial = value 

288 

289 def create(self, parent): 

290 self.scale = self.creator(parent) 

291 self.value = self.initial 

292 return self.scale 

293 

294 @property 

295 def value(self): 

296 return self.scale.get() 

297 

298 @value.setter 

299 def value(self, x): 

300 self.scale.set(x) 

301 

302 

303class RadioButtons(Widget): 

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

305 self.var = tk.IntVar() 

306 

307 if callback: 

308 def callback2(): 

309 callback(self.value) 

310 else: 

311 callback2 = None 

312 

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 

317 

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 

324 

325 @property 

326 def value(self): 

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

328 

329 @value.setter 

330 def value(self, value): 

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

332 

333 def __getitem__(self, value): 

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

335 

336 

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) 

344 

345 

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) 

353 

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) 

361 

362 return widget 

363 

364 @property 

365 def value(self): 

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

367 

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) 

375 

376 

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

382 

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 

389 

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) 

397 

398 def clear(self): 

399 while self.rows: 

400 del self[0] 

401 

402 def __getitem__(self, i): 

403 return self.rows[i] 

404 

405 def __delitem__(self, i): 

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

407 widget.grid_remove() 

408 widget.destroy() 

409 

410 def __len__(self): 

411 return len(self.rows) 

412 

413 

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

419 

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

421 

422 if key: 

423 parts = key.split('+') 

424 modifiers = [] 

425 key_char = None 

426 

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

438 

439 if is_macos: 

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

441 

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 

455 

456 if key: 

457 def callback2(event=None): 

458 callback(key) 

459 

460 callback2.__name__ = callback.__name__ 

461 self.callback = callback2 

462 else: 

463 self.callback = callback 

464 

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 

473 

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 

481 

482 menu.add_checkbutton(label=self.label, 

483 underline=self.underline, 

484 command=self.callback, 

485 accelerator=self.key, 

486 var=var) 

487 

488 def callback(key): # noqa: F811 

489 var.set(not var.get()) 

490 self.callback() 

491 

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) 

521 

522 

523class MainWindow(BaseWindow): 

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

525 self.win = tk.Tk() 

526 BaseWindow.__init__(self, title, close) 

527 

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

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

530 

531 self.menu = {} 

532 

533 if menu: 

534 self.create_menu(menu) 

535 

536 def create_menu(self, menu_description): 

537 menu = tk.Menu(self.win) 

538 self.win.config(menu=menu) 

539 

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) 

547 

548 def resize_event(self): 

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

550 self.draw() 

551 self.configured = True 

552 

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 

565 

566 def __getitem__(self, name): 

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

568 

569 def __setitem__(self, name, value): 

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

571 

572 

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 

580 

581 

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

594 

595 def key(item): 

596 return item[1][0] 

597 

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) 

604 

605 self.format = None 

606 

607 def callback(value): 

608 self.format = value 

609 

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

611 formats = ComboBox(labels, values, callback) 

612 formats.pack(self.top) 

613 

614 

615def show_io_error(filename, err): 

616 showerror(_('Read error'), 

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

618 

619 

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) 

625 

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

627 

628 self.fg = config['gui_foreground_color'] 

629 self.bg = config['gui_background_color'] 

630 

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) 

637 

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

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

640 

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

655 

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

660 

661 def update_status_line(self, text): 

662 self.status.config(text=text) 

663 

664 def run(self): 

665 MainWindow.run(self) 

666 

667 def click(self, name): 

668 self.callbacks[name]() 

669 

670 def clear(self): 

671 self.canvas.delete(tk.ALL) 

672 

673 def update(self): 

674 self.canvas.update_idletasks() 

675 

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) 

685 

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) 

699 

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

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

702 fill='black') 

703 

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) 

707 

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

712 

713 

714def bind_enter(widget, callback): 

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

716 

717 Bindings work differently on different OSes. This ensures that 

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

719 

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

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