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

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 

11 

12import numpy as np 

13 

14from ase.gui.i18n import _ 

15 

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

21 

22 

23if sys.platform == 'darwin': 

24 mouse_buttons = {2: 3, 3: 2} 

25else: 

26 mouse_buttons = {} 

27 

28 

29def error(title, message=None): 

30 if message is None: 

31 message = title 

32 title = _('Error') 

33 return showerror(title, message) 

34 

35 

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

44 

45 

46def helpbutton(text): 

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

48 

49 

50def helpwindow(text): 

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

52 set_windowtype(win.win, 'dialog') 

53 win.add(Text(text)) 

54 

55 

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) 

63 

64 

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) 

72 

73 self.things = [] 

74 self.exists = True 

75 set_windowtype(self.win, wmtype) 

76 

77 def close(self): 

78 self.win.destroy() 

79 self.exists = False 

80 

81 def title(self, txt): 

82 self.win.title(txt) 

83 

84 title = property(None, title) 

85 

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) 

93 

94 

95class Window(BaseWindow): 

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

97 self.win = tk.Toplevel() 

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

99 

100 

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 

107 

108 def grid(self, parent): 

109 widget = self.create(parent) 

110 widget.grid() 

111 

112 def create(self, parent): 

113 self.widget = self.creator(parent) 

114 return self.widget 

115 

116 @property 

117 def active(self): 

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

119 

120 @active.setter 

121 def active(self, value): 

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

123 

124 

125class Row(Widget): 

126 def __init__(self, things): 

127 self.things = things 

128 

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 

136 

137 def __getitem__(self, i): 

138 return self.things[i] 

139 

140 

141class Label(Widget): 

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

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

144 

145 @property 

146 def text(self): 

147 return self.widget['text'] 

148 

149 @text.setter 

150 def text(self, new): 

151 self.widget.config(text=new) 

152 

153 

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 

169 

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 

180 

181 

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) 

188 

189 

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 

195 

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 

200 

201 @property 

202 def value(self): 

203 return self.var.get() 

204 

205 

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) 

218 

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 

224 

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) 

233 

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) 

243 

244 

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) 

250 

251 

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 

261 

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 

268 

269 @property 

270 def value(self): 

271 return self.entry.get() 

272 

273 @value.setter 

274 def value(self, x): 

275 _set_entry_value(self.entry, x) 

276 

277 

278class Scale(Widget): 

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

280 def command(val): 

281 callback(int(val)) 

282 

283 self.creator = partial(tk.Scale, 

284 from_=start, 

285 to=end, 

286 orient='horizontal', 

287 command=command) 

288 self.initial = value 

289 

290 def create(self, parent): 

291 self.scale = self.creator(parent) 

292 self.value = self.initial 

293 return self.scale 

294 

295 @property 

296 def value(self): 

297 return self.scale.get() 

298 

299 @value.setter 

300 def value(self, x): 

301 self.scale.set(x) 

302 

303 

304class RadioButtons(Widget): 

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

306 self.var = tk.IntVar() 

307 

308 if callback: 

309 def callback2(): 

310 callback(self.value) 

311 else: 

312 callback2 = None 

313 

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 

318 

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 

325 

326 @property 

327 def value(self): 

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

329 

330 @value.setter 

331 def value(self, value): 

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

333 

334 def __getitem__(self, value): 

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

336 

337 

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) 

345 

346 

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) 

354 

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) 

362 

363 return widget 

364 

365 @property 

366 def value(self): 

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

368 

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) 

376 

377 

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

383 

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 

390 

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) 

398 

399 def clear(self): 

400 while self.rows: 

401 del self[0] 

402 

403 def __getitem__(self, i): 

404 return self.rows[i] 

405 

406 def __delitem__(self, i): 

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

408 widget.grid_remove() 

409 widget.destroy() 

410 

411 def __len__(self): 

412 return len(self.rows) 

413 

414 

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

420 

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

433 

434 if key: 

435 def callback2(event=None): 

436 callback(key) 

437 

438 callback2.__name__ = callback.__name__ 

439 self.callback = callback2 

440 else: 

441 self.callback = callback 

442 

443 self.key = key 

444 self.value = value 

445 self.choices = choices 

446 self.submenu = submenu 

447 self.disabled = disabled 

448 

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 

456 

457 menu.add_checkbutton(label=self.label, 

458 underline=self.underline, 

459 command=self.callback, 

460 accelerator=self.key, 

461 var=var) 

462 

463 def callback(key): # noqa: F811 

464 var.set(not var.get()) 

465 self.callback() 

466 

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) 

496 

497 

498class MainWindow(BaseWindow): 

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

500 self.win = tk.Tk() 

501 BaseWindow.__init__(self, title, close) 

502 

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

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

505 

506 self.menu = {} 

507 

508 if menu: 

509 self.create_menu(menu) 

510 

511 def create_menu(self, menu_description): 

512 menu = tk.Menu(self.win) 

513 self.win.config(menu=menu) 

514 

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) 

522 

523 def resize_event(self): 

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

525 self.draw() 

526 self.configured = True 

527 

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 

540 

541 def __getitem__(self, name): 

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

543 

544 def __setitem__(self, name, value): 

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

546 

547 

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 

555 

556 

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

569 

570 def key(item): 

571 return item[1][0] 

572 

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) 

579 

580 self.format = None 

581 

582 def callback(value): 

583 self.format = value 

584 

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

586 formats = ComboBox(labels, values, callback) 

587 formats.pack(self.top) 

588 

589 

590def show_io_error(filename, err): 

591 showerror(_('Read error'), 

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

593 

594 

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) 

600 

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

602 

603 self.fg = config['gui_foreground_color'] 

604 self.bg = config['gui_background_color'] 

605 

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) 

612 

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

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

615 

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

630 

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

635 

636 def update_status_line(self, text): 

637 self.status.config(text=text) 

638 

639 def run(self): 

640 MainWindow.run(self) 

641 

642 def click(self, name): 

643 self.callbacks[name]() 

644 

645 def clear(self): 

646 self.canvas.delete(tk.ALL) 

647 

648 def update(self): 

649 self.canvas.update_idletasks() 

650 

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) 

660 

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) 

674 

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

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

677 fill='black') 

678 

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) 

682 

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