Coverage for ase / gui / gui.py: 66.58%

404 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-21 15:52 +0000

1# fmt: off 

2 

3import functools 

4import pickle 

5import platform 

6import subprocess 

7import sys 

8from functools import partial 

9from time import time 

10 

11import numpy as np 

12 

13import ase.gui.ui as ui 

14from ase import Atoms, __version__ 

15from ase.gui.defaults import read_defaults 

16from ase.gui.i18n import _ 

17from ase.gui.images import Images 

18from ase.gui.nanoparticle import SetupNanoparticle 

19from ase.gui.nanotube import SetupNanotube 

20from ase.gui.observer import Observers 

21from ase.gui.save import save_dialog 

22from ase.gui.settings import Settings 

23from ase.gui.status import Status 

24from ase.gui.surfaceslab import SetupSurfaceSlab 

25from ase.gui.view import View 

26 

27 

28class GUIObservers: 

29 def __init__(self): 

30 self.new_atoms = Observers() 

31 self.set_atoms = Observers() 

32 self.change_atoms = Observers() 

33 

34 

35class GUI(View): 

36 ARROWKEY_SCAN = 0 

37 ARROWKEY_MOVE = 1 

38 ARROWKEY_ROTATE = 2 

39 

40 def __init__(self, images=None, 

41 rotations='', 

42 show_bonds=False, expr=None): 

43 

44 if not isinstance(images, Images): 

45 images = Images(images) 

46 self.images = images 

47 self.images.history.initialize_history() 

48 

49 self.system = platform.system() 

50 

51 # Ordinary observers seem unused now, delete? 

52 self.observers = [] 

53 self.obs = GUIObservers() 

54 

55 self.config = read_defaults() 

56 if show_bonds: 

57 self.config['show_bonds'] = True 

58 

59 menu = self.get_menu_data() 

60 

61 self.window = ui.ASEGUIWindow(close=self.exit, menu=menu, 

62 config=self.config, scroll=self.scroll, 

63 scroll_event=self.scroll_event, 

64 press=self.press, move=self.move, 

65 release=self.release, 

66 resize=self.resize) 

67 

68 super().__init__(rotations) 

69 self.status = Status(self) 

70 

71 self.subprocesses = [] # list of external processes 

72 self.movie_window = None 

73 self.simulation = {} # Used by modules on Calculate menu. 

74 self.module_state = {} # Used by modules to store their state. 

75 

76 self.arrowkey_mode = self.ARROWKEY_SCAN 

77 self.move_atoms_mask = None 

78 

79 self.set_frame(len(self.images) - 1, focus=True) 

80 

81 # Used to move the structure with the mouse 

82 self.prev_pos = None 

83 self.last_scroll_time = time() 

84 self.orig_scale = self.scale 

85 

86 if len(self.images) > 1: 

87 self.movie() 

88 

89 if expr is None: 

90 expr = self.config['gui_graphs_string'] 

91 

92 if expr is not None and expr != '' and len(self.images) > 1: 

93 self.plot_graphs(expr=expr, ignore_if_nan=True) 

94 

95 def redo_history(self, key=None): 

96 self.images.history.redo_history(self.frame) 

97 self.set_frame() 

98 self.draw() 

99 

100 def undo_history(self, key=None): 

101 # Let's end any rotate/move mode that may still be active so the 

102 # current positions are saved to the history: 

103 if self.moving: 

104 self.toggle_arrowkey_mode(self.arrowkey_mode) 

105 self.images.history.undo_history(self.frame) 

106 self.set_frame() 

107 self.draw() 

108 

109 def update_history(self, mask=None): 

110 if mask is not None: 

111 for i, update in enumerate(mask): 

112 if update: 

113 self.images.history.update_history(i) 

114 else: 

115 self.images.history.update_history(self.frame) 

116 

117 def clear_history(self): 

118 self.images.history.initialize_history() 

119 

120 @property 

121 def moving(self): 

122 return self.arrowkey_mode != self.ARROWKEY_SCAN 

123 

124 def run(self): 

125 self.window.run() 

126 

127 def toggle_move_mode(self, key=None): 

128 self.toggle_arrowkey_mode(self.ARROWKEY_MOVE) 

129 

130 def toggle_rotate_mode(self, key=None): 

131 self.toggle_arrowkey_mode(self.ARROWKEY_ROTATE) 

132 

133 def toggle_arrowkey_mode(self, mode): 

134 # If not currently in given mode, activate it. 

135 # Else, deactivate it (go back to SCAN mode) 

136 assert mode != self.ARROWKEY_SCAN 

137 

138 if self.arrowkey_mode == mode: 

139 self.arrowkey_mode = self.ARROWKEY_SCAN 

140 self.move_atoms_mask = None 

141 self.update_history() 

142 elif np.any(self.images.selected): 

143 self.arrowkey_mode = mode 

144 self.move_atoms_mask = self.images.selected.copy() 

145 else: 

146 from ase.gui.ui import showwarning 

147 showwarning( 

148 _('No atoms selected!'), 

149 _('You need to select one or more atoms to do this') 

150 ) 

151 

152 self.draw() 

153 

154 @functools.cached_property 

155 def arrowkey_hint(self): 

156 hint = ui.tk.Frame( 

157 self.window.canvas, bg=self.window.bg 

158 ) 

159 hint.label = ui.tk.Label(hint) 

160 hint.qm = ui.tk.Label( 

161 hint, text='(?)', padx=3, 

162 bg=self.window.bg, 

163 fg=self.window.fg 

164 ) 

165 hint.qm.grid(row=0, column=0) 

166 hint.label.grid(row=0, column=1) 

167 hint.tooltip = ui.Tooltip() 

168 hint.qm.bind( 

169 '<Enter>', hint.tooltip.show 

170 ) 

171 hint.qm.bind( 

172 '<Leave>', hint.tooltip.hide 

173 ) 

174 hint.exists = False 

175 return hint 

176 

177 def step(self, key): 

178 d = {'Home': -10000000, 

179 'PageUp': -1, 

180 'PageDown': 1, 

181 'End': 10000000}[key] 

182 i = max(0, min(len(self.images) - 1, self.frame + d)) 

183 self.set_frame(i) 

184 

185 def copy_image(self, key=None): 

186 self.images._images.append(self.atoms.copy()) 

187 self.images.history.append_image(self.images._images[-1]) 

188 self.images.filenames.append(None) 

189 

190 if self.movie_window is not None: 

191 self.movie_window.frame_number.scale.configure(to=len(self.images)) 

192 self.step('End') 

193 

194 def _do_zoom(self, x): 

195 """Utility method for zooming""" 

196 self.scale *= x 

197 self.draw() 

198 

199 def zoom(self, key): 

200 """Zoom in/out on keypress or clicking menu item""" 

201 x = {'+': 1.2, '-': 1 / 1.2}[key] 

202 self._do_zoom(x) 

203 

204 def scroll_event(self, event): 

205 """Zoom in/out when using mouse wheel""" 

206 SHIFT = event.modifier == 'shift' 

207 x = 1.0 

208 if event.button == 4 or event.delta > 0: 

209 x = 1.0 + (1 - SHIFT) * 0.2 + SHIFT * 0.01 

210 elif event.button == 5 or event.delta < 0: 

211 x = 1.0 / (1.0 + (1 - SHIFT) * 0.2 + SHIFT * 0.01) 

212 self._do_zoom(x) 

213 

214 def settings(self): 

215 return Settings(self) 

216 

217 def scroll(self, event): 

218 shift = 0x1 

219 ctrl = 0x4 

220 alt_l = 0x8 # Also Mac Command Key 

221 mac_option_key = 0x10 

222 

223 self.remove_bothersome_key_states(event) 

224 

225 use_small_step = bool(event.state & shift) 

226 rotate_into_plane = bool(event.state & (ctrl | alt_l | mac_option_key)) 

227 

228 dxdydz = {'up': (0, 1 - rotate_into_plane, rotate_into_plane), 

229 'down': (0, -1 + rotate_into_plane, -rotate_into_plane), 

230 'right': (1, 0, 0), 

231 'left': (-1, 0, 0)}.get(event.key, None) 

232 

233 # Get scroll direction using shift + right mouse button 

234 # event.type == '6' is mouse motion, see: 

235 # http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/event-types.html 

236 if event.type == '6': 

237 cur_pos = np.array([event.x, -event.y]) 

238 # Continue scroll if button has not been released 

239 if self.prev_pos is None or time() - self.last_scroll_time > .5: 

240 self.prev_pos = cur_pos 

241 self.last_scroll_time = time() 

242 else: 

243 dxdy = cur_pos - self.prev_pos 

244 dxdydz = np.append(dxdy, [0]) 

245 self.prev_pos = cur_pos 

246 self.last_scroll_time = time() 

247 

248 if dxdydz is None: 

249 return 

250 

251 if self.arrowkey_mode == self.ARROWKEY_ROTATE: 

252 # A little tweak to make rotation more intuitive for users: 

253 mod_m = np.array([[0, -1, 0], [1, 0, 0], [0, 0, -1]]) 

254 dxdydz = np.dot(mod_m, dxdydz) 

255 

256 vec = 0.1 * np.dot(self.axes, dxdydz) 

257 if use_small_step: 

258 vec *= 0.1 

259 

260 if self.arrowkey_mode == self.ARROWKEY_MOVE: 

261 self.atoms.positions[self.move_atoms_mask[:len(self.atoms)]] += vec 

262 self.set_frame() 

263 elif self.arrowkey_mode == self.ARROWKEY_ROTATE: 

264 # For now we use atoms.rotate having the simplest interface. 

265 # (Better to use something more minimalistic, obviously.) 

266 mask = self.move_atoms_mask[:len(self.atoms)] 

267 center = self.atoms.positions[mask].mean(axis=0) 

268 tmp_atoms = self.atoms[mask] 

269 tmp_atoms.positions -= center 

270 tmp_atoms.rotate(50 * np.linalg.norm(vec), vec) 

271 self.atoms.positions[mask] = tmp_atoms.positions + center 

272 self.set_frame() 

273 else: 

274 # The displacement vector is scaled 

275 # so that the cursor follows the structure 

276 # Scale by a third works for some reason 

277 scale = self.orig_scale / (3 * self.scale) 

278 self.center -= vec * scale 

279 

280 # dx * 0.1 * self.axes[:, 0] - dy * 0.1 * self.axes[:, 1]) 

281 

282 self.draw() 

283 

284 def remove_bothersome_key_states(self, event): 

285 """Modify event.state so that Num Lock doesn't get caught by 

286 bitmasks""" 

287 # We need to strip away Num Lock (and other bothersome key 

288 # events) that interfere with bitmasking or else the scrolling 

289 # will behave as if ctrl is always pressed down. 

290 nl_windows = 0x0008 

291 nl_linux = 0x0010 

292 

293 if self.system == 'Linux': 

294 if event.state & nl_linux: 

295 event.state -= nl_linux 

296 elif self.system == 'Windows': 

297 # Not completely sure what 0x40000 is but it seems to haunt Windows 

298 if event.state & 0x40000: 

299 event.state -= 0x40000 

300 if event.state & nl_windows: 

301 event.state -= nl_windows 

302 

303 def delete_selected_atoms(self, widget=None, data=None): 

304 import ase.gui.ui as ui 

305 nselected = sum(self.images.selected) 

306 if nselected and ui.ask_question(_('Delete atoms'), 

307 _('Delete selected atoms?')): 

308 self.really_delete_selected_atoms() 

309 

310 def really_delete_selected_atoms(self): 

311 mask = self.images.selected[:len(self.atoms)] 

312 del self.atoms[mask] 

313 

314 # Will remove selection in other images, too 

315 self.images.selected[:] = False 

316 self.set_frame() 

317 self.draw() 

318 self.update_history() 

319 

320 def constraints_window(self): 

321 from ase.gui.constraints import Constraints 

322 return Constraints(self) 

323 

324 def set_selected_atoms(self, selected): 

325 newmask = np.zeros(len(self.images.selected), bool) 

326 newmask[selected] = True 

327 

328 if np.array_equal(newmask, self.images.selected): 

329 return 

330 

331 # (By creating newmask, we can avoid resetting the selection in 

332 # case the selected indices are invalid) 

333 self.images.selected[:] = newmask 

334 self.draw() 

335 

336 def select_all(self, key=None): 

337 self.images.selected[:] = True 

338 self.draw() 

339 

340 def invert_selection(self, key=None): 

341 self.images.selected[:] = ~self.images.selected 

342 self.draw() 

343 

344 def select_constrained_atoms(self, key=None): 

345 self.images.selected[:] = ~self.images.get_dynamic(self.atoms) 

346 self.draw() 

347 

348 def select_immobile_atoms(self, key=None): 

349 if len(self.images) > 1: 

350 R0 = self.images[0].positions 

351 for atoms in self.images[1:]: 

352 R = atoms.positions 

353 self.images.selected[:] = ~(np.abs(R - R0) > 1.0e-10).any(1) 

354 self.draw() 

355 

356 def movie(self): 

357 from ase.gui.movie import Movie 

358 self.movie_window = Movie(self) 

359 

360 def plot_graphs(self, key=None, expr=None, ignore_if_nan=False): 

361 from ase.gui.graphs import Graphs 

362 g = Graphs(self) 

363 if expr is not None: 

364 g.plot(expr=expr, ignore_if_nan=ignore_if_nan) 

365 

366 def pipe(self, task, data): 

367 process = subprocess.Popen([sys.executable, '-m', 'ase.gui.pipe'], 

368 stdout=subprocess.PIPE, 

369 stdin=subprocess.PIPE) 

370 pickle.dump((task, data), process.stdin) 

371 process.stdin.close() 

372 # Either process writes a line, or it crashes and line becomes '' 

373 line = process.stdout.readline().decode('utf8').strip() 

374 

375 if line != 'GUI:OK': 

376 if line == '': # Subprocess probably crashed 

377 line = _('Failure in subprocess') 

378 self.bad_plot(line) 

379 else: 

380 self.subprocesses.append(process) 

381 return process 

382 

383 def bad_plot(self, err, msg=''): 

384 ui.error(_('Plotting failed'), '\n'.join([str(err), msg]).strip()) 

385 

386 def neb(self): 

387 from ase.utils.forcecurve import fit_images 

388 try: 

389 forcefit = fit_images(self.images) 

390 except Exception as err: 

391 self.bad_plot(err, _('Images must have energies and forces, ' 

392 'and atoms must not be stationary.')) 

393 else: 

394 self.pipe('neb', forcefit) 

395 

396 def bulk_modulus(self): 

397 try: 

398 v = [abs(np.linalg.det(atoms.cell)) for atoms in self.images] 

399 e = [self.images.get_energy(a) for a in self.images] 

400 from ase.eos import EquationOfState 

401 eos = EquationOfState(v, e) 

402 plotdata = eos.getplotdata() 

403 except Exception as err: 

404 self.bad_plot(err, _('Images must have energies ' 

405 'and varying cell.')) 

406 else: 

407 self.pipe('eos', plotdata) 

408 

409 def reciprocal(self): 

410 if self.atoms.cell.rank != 3: 

411 self.bad_plot(_('Requires 3D cell.')) 

412 return None 

413 

414 cell = self.atoms.cell.uncomplete(self.atoms.pbc) 

415 bandpath = cell.bandpath(npoints=0) 

416 return self.pipe('reciprocal', bandpath) 

417 

418 def open(self, button=None, filename=None): 

419 chooser = ui.ASEFileChooser(self.window.win) 

420 

421 filename = filename or chooser.go() 

422 format = chooser.format 

423 if filename: 

424 try: 

425 self.images.read([filename], slice(None), format) 

426 except Exception as err: 

427 ui.show_io_error(filename, err) 

428 return # Hmm. Is self.images in a consistent state? 

429 self.set_frame(len(self.images) - 1, focus=True) 

430 

431 def modify_atoms(self, key=None): 

432 from ase.gui.modify import ModifyAtoms 

433 return ModifyAtoms(self) 

434 

435 def add_atoms(self, key=None): 

436 from ase.gui.add import AddAtoms 

437 return AddAtoms(self) 

438 

439 def cell_editor(self, key=None): 

440 from ase.gui.celleditor import CellEditor 

441 return CellEditor(self) 

442 

443 def atoms_editor(self, key=None): 

444 from ase.gui.atomseditor import AtomsEditor 

445 return AtomsEditor(self) 

446 

447 def quick_info_window(self, key=None): 

448 from ase.gui.quickinfo import info 

449 info_win = ui.Window(_('Quick Info')) 

450 info_win.add(info(self)) 

451 

452 # Update quickinfo window when we change frame 

453 def update(window): 

454 exists = window.exists 

455 if exists: 

456 # Only update if we exist 

457 window.things[0].text = info(self) 

458 return exists 

459 self.attach(update, info_win) 

460 return info_win 

461 

462 def surface_window(self): 

463 return SetupSurfaceSlab(self) 

464 

465 def nanoparticle_window(self): 

466 return SetupNanoparticle(self) 

467 

468 def nanotube_window(self): 

469 return SetupNanotube(self) 

470 

471 def new_atoms(self, atoms): 

472 "Set a new atoms object." 

473 self.images.history.isolate_history(self.frame) 

474 rpt = getattr(self.images, 'repeat', None) 

475 self.images.repeat_images(np.ones(3, int)) 

476 self.images.initialize([atoms]) 

477 self.frame = 0 # Prevent crashes 

478 self.images.repeat_images(rpt) 

479 self.set_frame(frame=0, focus=True) 

480 self.obs.new_atoms.notify() 

481 self.images.history.update_history(self.frame) 

482 

483 def exit(self, event=None): 

484 for process in self.subprocesses: 

485 process.terminate() 

486 self.window.close() 

487 

488 def new(self, key=None): 

489 subprocess.Popen([sys.executable, '-m', 'ase', 'gui']) 

490 

491 def save(self, key=None): 

492 return save_dialog(self) 

493 

494 def external_viewer(self, name): 

495 from ase.visualize import view 

496 return view(list(self.images), viewer=name) 

497 

498 def selected_atoms(self): 

499 selection_mask = self.images.selected[:len(self.atoms)] 

500 return self.atoms[selection_mask] 

501 

502 def wrap_atoms(self, key=None): 

503 """Wrap atoms around the unit cell.""" 

504 for atoms in self.images: 

505 atoms.wrap() 

506 self.set_frame() 

507 self.update_history() 

508 

509 @property 

510 def clipboard(self): 

511 from ase.gui.clipboard import AtomsClipboard 

512 return AtomsClipboard(self.window.win) 

513 

514 def cut_atoms_to_clipboard(self, event=None): 

515 self.copy_atoms_to_clipboard(event) 

516 self.really_delete_selected_atoms() 

517 

518 def copy_atoms_to_clipboard(self, event=None): 

519 atoms = self.selected_atoms() 

520 self.clipboard.set_atoms(atoms) 

521 

522 def paste_atoms_from_clipboard(self, event=None): 

523 try: 

524 atoms = self.clipboard.get_atoms() 

525 except Exception as err: 

526 ui.error( 

527 'Cannot paste atoms', 

528 'Pasting currently works only with the ASE JSON format.\n\n' 

529 f'Original error:\n\n{err}') 

530 return 

531 

532 if self.atoms == Atoms(): 

533 self.atoms.cell = atoms.cell 

534 self.atoms.pbc = atoms.pbc 

535 self.paste_atoms_onto_existing(atoms) 

536 

537 def paste_atoms_onto_existing(self, atoms): 

538 selection = self.selected_atoms() 

539 if len(selection): 

540 paste_center = selection.positions.sum(axis=0) / len(selection) 

541 # atoms.center() is a no-op in directions without a cell vector. 

542 # But we actually want the thing centered nevertheless! 

543 # Therefore we have to set the cell. 

544 atoms = atoms.copy() 

545 atoms.cell = (1, 1, 1) # arrrgh. 

546 atoms.center(about=paste_center) 

547 

548 self.add_atoms_and_select(atoms) 

549 self.move_atoms_mask = self.images.selected.copy() 

550 self.arrowkey_mode = self.ARROWKEY_MOVE 

551 self.draw() 

552 

553 def add_atoms_and_select(self, new_atoms): 

554 atoms = self.atoms 

555 atoms += new_atoms 

556 

557 if len(atoms) > self.images.maxnatoms: 

558 self.images.initialize(list(self.images), 

559 self.images.filenames) 

560 

561 selected = self.images.selected 

562 selected[:] = False 

563 # 'selected' array may be longer than current atoms 

564 selected[len(atoms) - len(new_atoms):len(atoms)] = True 

565 

566 self.set_frame() 

567 self.draw() 

568 

569 def get_menu_data(self): 

570 M = ui.MenuItem 

571 return [ 

572 (_('_File'), 

573 [M(_('_Open'), self.open, 'Ctrl+O'), 

574 M(_('_New'), self.new, 'Ctrl+N'), 

575 M(_('_Save'), self.save, 'Ctrl+S'), 

576 M('---'), 

577 M(_('_Quit'), self.exit, 'Ctrl+Q')]), 

578 

579 (_('_Edit'), 

580 [M(_('Undo'), self.undo_history, 'Ctrl+Z'), 

581 M(_('Redo'), self.redo_history, 'Ctrl+Shift+Z'), 

582 M('---'), 

583 M(_('Select _all'), self.select_all), 

584 M(_('_Invert selection'), self.invert_selection), 

585 M(_('Select _constrained atoms'), self.select_constrained_atoms), 

586 M(_('Select _immobile atoms'), self.select_immobile_atoms), 

587 # M('---'), 

588 M(_('_Cut'), self.cut_atoms_to_clipboard, 'Ctrl+X'), 

589 M(_('_Copy'), self.copy_atoms_to_clipboard, 'Ctrl+C'), 

590 M(_('_Paste'), self.paste_atoms_from_clipboard, 'Ctrl+V'), 

591 M('---'), 

592 M(_('Hide selected atoms'), self.hide_selected), 

593 M(_('Show selected atoms'), self.show_selected), 

594 M('---'), 

595 M(_('_Modify'), self.modify_atoms, 'Ctrl+Y'), 

596 M(_('_Add atoms'), self.add_atoms, 'Ctrl+A'), 

597 M(_('_Delete selected atoms'), self.delete_selected_atoms, 

598 'Backspace'), 

599 M(_('Edit _cell …'), self.cell_editor, 'Ctrl+E'), 

600 M(_('Edit _atoms …'), self.atoms_editor, 'A'), 

601 M('---'), 

602 M(_('_First image'), self.step, 'Home'), 

603 M(_('_Previous image'), self.step, 'PageUp'), 

604 M(_('_Next image'), self.step, 'PageDown'), 

605 M(_('_Last image'), self.step, 'End'), 

606 M(_('Append image copy'), self.copy_image)]), 

607 

608 (_('_View'), 

609 [M(_('Show _unit cell'), self.toggle_show_unit_cell, 'Ctrl+U', 

610 value=self.config['show_unit_cell']), 

611 M(_('Show _axes'), self.toggle_show_axes, 

612 value=self.config['show_axes']), 

613 M(_('Show _bonds'), self.toggle_show_bonds, 'Ctrl+B', 

614 value=self.config['show_bonds']), 

615 M(_('Show _velocities'), self.toggle_show_velocities, 'Ctrl+G', 

616 value=False), 

617 M(_('Show _forces'), self.toggle_show_forces, 'Ctrl+F', 

618 value=False), 

619 M(_('Show _magmoms'), self.toggle_show_magmoms, 

620 value=False), 

621 M(_('Show _labels'), self.show_labels, 

622 choices=[_('_None'), 

623 _('Atom _index'), 

624 _('_Magnetic moments'), # XXX check if exist 

625 _('_Element symbol'), 

626 _('_Initial charges'), # XXX check if exist 

627 ]), 

628 M('---'), 

629 M(_('Quick info ...'), self.quick_info_window, 'Ctrl+I'), 

630 M(_('Repeat ...'), self.repeat_window, 'R'), 

631 M(_('Rotate ...'), self.rotate_window), 

632 M(_('Colors ...'), self.colors_window, 'C'), 

633 # TRANSLATORS: verb 

634 M(_('Focus'), self.focus, 'F'), 

635 M(_('Zoom in'), self.zoom, '+'), 

636 M(_('Zoom out'), self.zoom, '-'), 

637 M(_('Change view'), 

638 submenu=[ 

639 M(_('Reset view'), self.reset_view, '='), 

640 M(_('xy-plane'), self.set_view, 'Z'), 

641 M(_('yz-plane'), self.set_view, 'X'), 

642 M(_('zx-plane'), self.set_view, 'Y'), 

643 M(_('yx-plane'), self.set_view, 'Shift+Z'), 

644 M(_('zy-plane'), self.set_view, 'Shift+X'), 

645 M(_('xz-plane'), self.set_view, 'Shift+Y'), 

646 M(_('a2,a3-plane'), self.set_view, 'I'), 

647 M(_('a3,a1-plane'), self.set_view, 'J'), 

648 M(_('a1,a2-plane'), self.set_view, 'K'), 

649 M(_('a3,a2-plane'), self.set_view, 'Shift+I'), 

650 M(_('a1,a3-plane'), self.set_view, 'Shift+J'), 

651 M(_('a2,a1-plane'), self.set_view, 'Shift+K')]), 

652 M(_('Settings ...'), self.settings), 

653 M('---'), 

654 M(_('VMD'), partial(self.external_viewer, 'vmd')), 

655 M(_('RasMol'), partial(self.external_viewer, 'rasmol')), 

656 M(_('xmakemol'), partial(self.external_viewer, 'xmakemol')), 

657 M(_('avogadro'), partial(self.external_viewer, 'avogadro'))]), 

658 

659 (_('_Tools'), 

660 [M(_('Graphs ...'), self.plot_graphs), 

661 M(_('Movie ...'), self.movie), 

662 M(_('Constraints ...'), self.constraints_window), 

663 M(_('Render scene ...'), self.render_window), 

664 M(_('_Move selected atoms'), self.toggle_move_mode, 'Ctrl+M'), 

665 M(_('_Rotate selected atoms'), self.toggle_rotate_mode, 

666 'Ctrl+R'), 

667 M(_('NE_B plot'), self.neb), 

668 M(_('B_ulk modulus'), self.bulk_modulus), 

669 M(_('Reciprocal space ...'), self.reciprocal), 

670 M(_('Wrap atoms'), self.wrap_atoms, 'Ctrl+W')]), 

671 

672 # TRANSLATORS: Set up (i.e. build) surfaces, nanoparticles, ... 

673 (_('_Setup'), 

674 [M(_('_Surface slab'), self.surface_window, disabled=False), 

675 M(_('_Nanoparticle'), 

676 self.nanoparticle_window), 

677 M(_('Nano_tube'), self.nanotube_window)]), 

678 

679 # (_('_Calculate'), 

680 # [M(_('Set _Calculator'), self.calculator_window, disabled=True), 

681 # M(_('_Energy and Forces'), self.energy_window, disabled=True), 

682 # M(_('Energy Minimization'), self.energy_minimize_window, 

683 # disabled=True)]), 

684 

685 (_('_Help'), 

686 [M(_('_About'), partial( 

687 ui.about, 'ASE-GUI', 

688 version=__version__, 

689 webpage='https://ase-lib.org/ase/gui/gui.html')), 

690 M(_('Webpage ...'), webpage)])] 

691 

692 def attach(self, function, *args, **kwargs): 

693 self.observers.append((function, args, kwargs)) 

694 

695 def call_observers(self): 

696 # Use function return value to determine if we keep observer 

697 self.observers = [(function, args, kwargs) for (function, args, kwargs) 

698 in self.observers if function(*args, **kwargs)] 

699 

700 def repeat_poll(self, callback, ms, ensure_update=True): 

701 """Invoke callback(gui=self) every ms milliseconds. 

702 

703 This is useful for polling a resource for updates to load them 

704 into the GUI. The GUI display will be hence be updated after 

705 each call; pass ensure_update=False to circumvent this. 

706 

707 Polling stops if the callback function raises StopIteration. 

708 

709 Example to run a movie manually, then quit:: 

710 

711 from ase.collections import g2 

712 from ase.gui.gui import GUI 

713 

714 names = iter(g2.names) 

715 

716 def main(gui): 

717 try: 

718 name = next(names) 

719 except StopIteration: 

720 gui.window.win.quit() 

721 else: 

722 atoms = g2[name] 

723 gui.images.initialize([atoms]) 

724 

725 gui = GUI() 

726 gui.repeat_poll(main, 30) 

727 gui.run()""" 

728 

729 def callbackwrapper(): 

730 try: 

731 callback(gui=self) 

732 except StopIteration: 

733 pass 

734 finally: 

735 # Reinsert self so we get called again: 

736 self.window.win.after(ms, callbackwrapper) 

737 

738 if ensure_update: 

739 self.set_frame() 

740 self.draw() 

741 

742 self.window.win.after(ms, callbackwrapper) 

743 

744 

745def webpage(): 

746 import webbrowser 

747 webbrowser.open('https://ase-lib.org/ase/gui/gui.html')