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

404 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-04 10:20 +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='#ffffff' 

158 ) 

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

160 hint.qm = ui.tk.Label( 

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

162 bg='#ffffff', activeforeground="#ffb617" 

163 ) 

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

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

166 hint.tooltip = ui.Tooltip() 

167 hint.qm.bind( 

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

169 ) 

170 hint.qm.bind( 

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

172 ) 

173 hint.exists = False 

174 return hint 

175 

176 def step(self, key): 

177 d = {'Home': -10000000, 

178 'PageUp': -1, 

179 'PageDown': 1, 

180 'End': 10000000}[key] 

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

182 self.set_frame(i) 

183 

184 def copy_image(self, key=None): 

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

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

187 self.images.filenames.append(None) 

188 

189 if self.movie_window is not None: 

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

191 self.step('End') 

192 

193 def _do_zoom(self, x): 

194 """Utility method for zooming""" 

195 self.scale *= x 

196 self.draw() 

197 

198 def zoom(self, key): 

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

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

201 self._do_zoom(x) 

202 

203 def scroll_event(self, event): 

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

205 SHIFT = event.modifier == 'shift' 

206 x = 1.0 

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

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

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

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

211 self._do_zoom(x) 

212 

213 def settings(self): 

214 return Settings(self) 

215 

216 def scroll(self, event): 

217 shift = 0x1 

218 ctrl = 0x4 

219 alt_l = 0x8 # Also Mac Command Key 

220 mac_option_key = 0x10 

221 

222 self.remove_bothersome_key_states(event) 

223 

224 use_small_step = bool(event.state & shift) 

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

226 

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

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

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

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

231 

232 # Get scroll direction using shift + right mouse button 

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

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

235 if event.type == '6': 

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

237 # Continue scroll if button has not been released 

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

239 self.prev_pos = cur_pos 

240 self.last_scroll_time = time() 

241 else: 

242 dxdy = cur_pos - self.prev_pos 

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

244 self.prev_pos = cur_pos 

245 self.last_scroll_time = time() 

246 

247 if dxdydz is None: 

248 return 

249 

250 if self.arrowkey_mode == self.ARROWKEY_ROTATE: 

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

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

253 dxdydz = np.dot(mod_m, dxdydz) 

254 

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

256 if use_small_step: 

257 vec *= 0.1 

258 

259 if self.arrowkey_mode == self.ARROWKEY_MOVE: 

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

261 self.set_frame() 

262 elif self.arrowkey_mode == self.ARROWKEY_ROTATE: 

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

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

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

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

267 tmp_atoms = self.atoms[mask] 

268 tmp_atoms.positions -= center 

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

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

271 self.set_frame() 

272 else: 

273 # The displacement vector is scaled 

274 # so that the cursor follows the structure 

275 # Scale by a third works for some reason 

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

277 self.center -= vec * scale 

278 

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

280 

281 self.draw() 

282 

283 def remove_bothersome_key_states(self, event): 

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

285 bitmasks""" 

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

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

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

289 nl_windows = 0x0008 

290 nl_linux = 0x0010 

291 

292 if self.system == 'Linux': 

293 if event.state & nl_linux: 

294 event.state -= nl_linux 

295 elif self.system == 'Windows': 

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

297 if event.state & 0x40000: 

298 event.state -= 0x40000 

299 if event.state & nl_windows: 

300 event.state -= nl_windows 

301 

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

303 import ase.gui.ui as ui 

304 nselected = sum(self.images.selected) 

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

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

307 self.really_delete_selected_atoms() 

308 

309 def really_delete_selected_atoms(self): 

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

311 del self.atoms[mask] 

312 

313 # Will remove selection in other images, too 

314 self.images.selected[:] = False 

315 self.set_frame() 

316 self.draw() 

317 self.update_history() 

318 

319 def constraints_window(self): 

320 from ase.gui.constraints import Constraints 

321 return Constraints(self) 

322 

323 def set_selected_atoms(self, selected): 

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

325 newmask[selected] = True 

326 

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

328 return 

329 

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

331 # case the selected indices are invalid) 

332 self.images.selected[:] = newmask 

333 self.draw() 

334 

335 def select_all(self, key=None): 

336 self.images.selected[:] = True 

337 self.draw() 

338 

339 def invert_selection(self, key=None): 

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

341 self.draw() 

342 

343 def select_constrained_atoms(self, key=None): 

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

345 self.draw() 

346 

347 def select_immobile_atoms(self, key=None): 

348 if len(self.images) > 1: 

349 R0 = self.images[0].positions 

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

351 R = atoms.positions 

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

353 self.draw() 

354 

355 def movie(self): 

356 from ase.gui.movie import Movie 

357 self.movie_window = Movie(self) 

358 

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

360 from ase.gui.graphs import Graphs 

361 g = Graphs(self) 

362 if expr is not None: 

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

364 

365 def pipe(self, task, data): 

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

367 stdout=subprocess.PIPE, 

368 stdin=subprocess.PIPE) 

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

370 process.stdin.close() 

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

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

373 

374 if line != 'GUI:OK': 

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

376 line = _('Failure in subprocess') 

377 self.bad_plot(line) 

378 else: 

379 self.subprocesses.append(process) 

380 return process 

381 

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

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

384 

385 def neb(self): 

386 from ase.utils.forcecurve import fit_images 

387 try: 

388 forcefit = fit_images(self.images) 

389 except Exception as err: 

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

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

392 else: 

393 self.pipe('neb', forcefit) 

394 

395 def bulk_modulus(self): 

396 try: 

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

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

399 from ase.eos import EquationOfState 

400 eos = EquationOfState(v, e) 

401 plotdata = eos.getplotdata() 

402 except Exception as err: 

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

404 'and varying cell.')) 

405 else: 

406 self.pipe('eos', plotdata) 

407 

408 def reciprocal(self): 

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

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

411 return None 

412 

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

414 bandpath = cell.bandpath(npoints=0) 

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

416 

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

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

419 

420 filename = filename or chooser.go() 

421 format = chooser.format 

422 if filename: 

423 try: 

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

425 except Exception as err: 

426 ui.show_io_error(filename, err) 

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

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

429 

430 def modify_atoms(self, key=None): 

431 from ase.gui.modify import ModifyAtoms 

432 return ModifyAtoms(self) 

433 

434 def add_atoms(self, key=None): 

435 from ase.gui.add import AddAtoms 

436 return AddAtoms(self) 

437 

438 def cell_editor(self, key=None): 

439 from ase.gui.celleditor import CellEditor 

440 return CellEditor(self) 

441 

442 def atoms_editor(self, key=None): 

443 from ase.gui.atomseditor import AtomsEditor 

444 return AtomsEditor(self) 

445 

446 def quick_info_window(self, key=None): 

447 from ase.gui.quickinfo import info 

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

449 info_win.add(info(self)) 

450 

451 # Update quickinfo window when we change frame 

452 def update(window): 

453 exists = window.exists 

454 if exists: 

455 # Only update if we exist 

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

457 return exists 

458 self.attach(update, info_win) 

459 return info_win 

460 

461 def surface_window(self): 

462 return SetupSurfaceSlab(self) 

463 

464 def nanoparticle_window(self): 

465 return SetupNanoparticle(self) 

466 

467 def nanotube_window(self): 

468 return SetupNanotube(self) 

469 

470 def new_atoms(self, atoms): 

471 "Set a new atoms object." 

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

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

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

475 self.images.initialize([atoms]) 

476 self.frame = 0 # Prevent crashes 

477 self.images.repeat_images(rpt) 

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

479 self.obs.new_atoms.notify() 

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

481 

482 def exit(self, event=None): 

483 for process in self.subprocesses: 

484 process.terminate() 

485 self.window.close() 

486 

487 def new(self, key=None): 

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

489 

490 def save(self, key=None): 

491 return save_dialog(self) 

492 

493 def external_viewer(self, name): 

494 from ase.visualize import view 

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

496 

497 def selected_atoms(self): 

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

499 return self.atoms[selection_mask] 

500 

501 def wrap_atoms(self, key=None): 

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

503 for atoms in self.images: 

504 atoms.wrap() 

505 self.set_frame() 

506 self.update_history() 

507 

508 @property 

509 def clipboard(self): 

510 from ase.gui.clipboard import AtomsClipboard 

511 return AtomsClipboard(self.window.win) 

512 

513 def cut_atoms_to_clipboard(self, event=None): 

514 self.copy_atoms_to_clipboard(event) 

515 self.really_delete_selected_atoms() 

516 

517 def copy_atoms_to_clipboard(self, event=None): 

518 atoms = self.selected_atoms() 

519 self.clipboard.set_atoms(atoms) 

520 

521 def paste_atoms_from_clipboard(self, event=None): 

522 try: 

523 atoms = self.clipboard.get_atoms() 

524 except Exception as err: 

525 ui.error( 

526 'Cannot paste atoms', 

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

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

529 return 

530 

531 if self.atoms == Atoms(): 

532 self.atoms.cell = atoms.cell 

533 self.atoms.pbc = atoms.pbc 

534 self.paste_atoms_onto_existing(atoms) 

535 

536 def paste_atoms_onto_existing(self, atoms): 

537 selection = self.selected_atoms() 

538 if len(selection): 

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

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

541 # But we actually want the thing centered nevertheless! 

542 # Therefore we have to set the cell. 

543 atoms = atoms.copy() 

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

545 atoms.center(about=paste_center) 

546 

547 self.add_atoms_and_select(atoms) 

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

549 self.arrowkey_mode = self.ARROWKEY_MOVE 

550 self.draw() 

551 

552 def add_atoms_and_select(self, new_atoms): 

553 atoms = self.atoms 

554 atoms += new_atoms 

555 

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

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

558 self.images.filenames) 

559 

560 selected = self.images.selected 

561 selected[:] = False 

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

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

564 

565 self.set_frame() 

566 self.draw() 

567 

568 def get_menu_data(self): 

569 M = ui.MenuItem 

570 return [ 

571 (_('_File'), 

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

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

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

575 M('---'), 

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

577 

578 (_('_Edit'), 

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

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

581 M('---'), 

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

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

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

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

586 # M('---'), 

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

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

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

590 M('---'), 

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

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

593 M('---'), 

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

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

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

597 'Backspace'), 

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

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

600 M('---'), 

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

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

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

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

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

606 

607 (_('_View'), 

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

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

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

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

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

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

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

615 value=False), 

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

617 value=False), 

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

619 value=False), 

620 M(_('Show _Labels'), self.show_labels, 

621 choices=[_('_None'), 

622 _('Atom _Index'), 

623 _('_Magnetic Moments'), # XXX check if exist 

624 _('_Element Symbol'), 

625 _('_Initial Charges'), # XXX check if exist 

626 ]), 

627 M('---'), 

628 M(_('Quick Info ...'), self.quick_info_window, 'Ctrl+I'), 

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

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

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

632 # TRANSLATORS: verb 

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

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

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

636 M(_('Change View'), 

637 submenu=[ 

638 M(_('Reset View'), self.reset_view, '='), 

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

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

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

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

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

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

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

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

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

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

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

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

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

652 M('---'), 

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

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

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

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

657 

658 (_('_Tools'), 

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

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

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

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

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

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

665 'Ctrl+R'), 

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

667 M(_('B_ulk Modulus'), self.bulk_modulus), 

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

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

670 

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

672 (_('_Setup'), 

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

674 M(_('_Nanoparticle'), 

675 self.nanoparticle_window), 

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

677 

678 # (_('_Calculate'), 

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

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

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

682 # disabled=True)]), 

683 

684 (_('_Help'), 

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

686 ui.about, 'ASE-GUI', 

687 version=__version__, 

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

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

690 

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

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

693 

694 def call_observers(self): 

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

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

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

698 

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

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

701 

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

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

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

705 

706 Polling stops if the callback function raises StopIteration. 

707 

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

709 

710 from ase.collections import g2 

711 from ase.gui.gui import GUI 

712 

713 names = iter(g2.names) 

714 

715 def main(gui): 

716 try: 

717 name = next(names) 

718 except StopIteration: 

719 gui.window.win.quit() 

720 else: 

721 atoms = g2[name] 

722 gui.images.initialize([atoms]) 

723 

724 gui = GUI() 

725 gui.repeat_poll(main, 30) 

726 gui.run()""" 

727 

728 def callbackwrapper(): 

729 try: 

730 callback(gui=self) 

731 except StopIteration: 

732 pass 

733 finally: 

734 # Reinsert self so we get called again: 

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

736 

737 if ensure_update: 

738 self.set_frame() 

739 self.draw() 

740 

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

742 

743 

744def webpage(): 

745 import webbrowser 

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