Coverage for /builds/kinetik161/ase/ase/gui/ui.py: 90.58%

446 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-12-10 11:04 +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 else: 

425 self.keyname = { 

426 'Home': '<Home>', 

427 'End': '<End>', 

428 'Page-Up': '<Prior>', 

429 'Page-Down': '<Next>', 

430 'Backspace': '<BackSpace>'}.get(key, key.lower()) 

431 

432 if key: 

433 def callback2(event=None): 

434 callback(key) 

435 

436 callback2.__name__ = callback.__name__ 

437 self.callback = callback2 

438 else: 

439 self.callback = callback 

440 

441 self.key = key 

442 self.value = value 

443 self.choices = choices 

444 self.submenu = submenu 

445 self.disabled = disabled 

446 

447 def addto(self, menu, window, stuff=None): 

448 callback = self.callback 

449 if self.label == '---': 

450 menu.add_separator() 

451 elif self.value is not None: 

452 var = tk.BooleanVar(value=self.value) 

453 stuff[self.callback.__name__.replace('_', '-')] = var 

454 

455 menu.add_checkbutton(label=self.label, 

456 underline=self.underline, 

457 command=self.callback, 

458 accelerator=self.key, 

459 var=var) 

460 

461 def callback(key): # noqa: F811 

462 var.set(not var.get()) 

463 self.callback() 

464 

465 elif self.choices: 

466 submenu = tk.Menu(menu) 

467 menu.add_cascade(label=self.label, menu=submenu) 

468 var = tk.IntVar() 

469 var.set(0) 

470 stuff[self.callback.__name__.replace('_', '-')] = var 

471 for i, choice in enumerate(self.choices): 

472 submenu.add_radiobutton(label=choice.replace('_', ''), 

473 underline=choice.find('_'), 

474 command=self.callback, 

475 value=i, 

476 var=var) 

477 elif self.submenu: 

478 submenu = tk.Menu(menu) 

479 menu.add_cascade(label=self.label, 

480 menu=submenu) 

481 for thing in self.submenu: 

482 thing.addto(submenu, window) 

483 else: 

484 state = 'normal' 

485 if self.disabled: 

486 state = 'disabled' 

487 menu.add_command(label=self.label, 

488 underline=self.underline, 

489 command=self.callback, 

490 accelerator=self.key, 

491 state=state) 

492 if self.key: 

493 window.bind(self.keyname, callback) 

494 

495 

496class MainWindow(BaseWindow): 

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

498 self.win = tk.Tk() 

499 BaseWindow.__init__(self, title, close) 

500 

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

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

503 

504 self.menu = {} 

505 

506 if menu: 

507 self.create_menu(menu) 

508 

509 def create_menu(self, menu_description): 

510 menu = tk.Menu(self.win) 

511 self.win.config(menu=menu) 

512 

513 for label, things in menu_description: 

514 submenu = tk.Menu(menu) 

515 menu.add_cascade(label=label.replace('_', ''), 

516 underline=label.find('_'), 

517 menu=submenu) 

518 for thing in things: 

519 thing.addto(submenu, self.win, self.menu) 

520 

521 def resize_event(self): 

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

523 self.draw() 

524 self.configured = True 

525 

526 def run(self): 

527 # Workaround for nasty issue with tkinter on Mac: 

528 # https://gitlab.com/ase/ase/issues/412 

529 # 

530 # It is apparently a compatibility issue between Python and Tkinter. 

531 # Some day we should remove this hack. 

532 while True: 

533 try: 

534 tk.mainloop() 

535 break 

536 except UnicodeDecodeError: 

537 pass 

538 

539 def __getitem__(self, name): 

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

541 

542 def __setitem__(self, name, value): 

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

544 

545 

546def bind(callback, modifier=None): 

547 def handle(event): 

548 event.button = mouse_buttons.get(event.num, event.num) 

549 event.key = event.keysym.lower() 

550 event.modifier = modifier 

551 callback(event) 

552 return handle 

553 

554 

555class ASEFileChooser(LoadFileDialog): 

556 def __init__(self, win, formatcallback=lambda event: None): 

557 from ase.io.formats import all_formats, get_ioformat 

558 LoadFileDialog.__init__(self, win, _('Open ...')) 

559 # fix tkinter not automatically setting dialog type 

560 # remove from Python3.8+ 

561 # see https://github.com/python/cpython/pull/25187 

562 # and https://bugs.python.org/issue43655 

563 # and https://github.com/python/cpython/pull/25592 

564 set_windowtype(self.top, 'dialog') 

565 labels = [_('Automatic')] 

566 values = [''] 

567 

568 def key(item): 

569 return item[1][0] 

570 

571 for format, (description, code) in sorted(all_formats.items(), 

572 key=key): 

573 io = get_ioformat(format) 

574 if io.can_read and description != '?': 

575 labels.append(_(description)) 

576 values.append(format) 

577 

578 self.format = None 

579 

580 def callback(value): 

581 self.format = value 

582 

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

584 formats = ComboBox(labels, values, callback) 

585 formats.pack(self.top) 

586 

587 

588def show_io_error(filename, err): 

589 showerror(_('Read error'), 

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

591 

592 

593class ASEGUIWindow(MainWindow): 

594 def __init__(self, close, menu, config, 

595 scroll, scroll_event, 

596 press, move, release, resize): 

597 MainWindow.__init__(self, 'ASE-GUI', close, menu) 

598 

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

600 

601 self.fg = config['gui_foreground_color'] 

602 self.bg = config['gui_background_color'] 

603 

604 self.canvas = tk.Canvas(self.win, 

605 width=self.size[0], 

606 height=self.size[1], 

607 bg=self.bg, 

608 highlightthickness=0) 

609 self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) 

610 

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

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

613 

614 right = mouse_buttons.get(3, 3) 

615 self.canvas.bind('<ButtonPress>', bind(press)) 

616 self.canvas.bind('<B1-Motion>', bind(move)) 

617 self.canvas.bind(f'<B{right}-Motion>', bind(move)) 

618 self.canvas.bind('<ButtonRelease>', bind(release)) 

619 self.canvas.bind('<Control-ButtonRelease>', bind(release, 'ctrl')) 

620 self.canvas.bind('<Shift-ButtonRelease>', bind(release, 'shift')) 

621 self.canvas.bind('<Configure>', resize) 

622 if not config['swap_mouse']: 

623 self.canvas.bind(f'<Shift-B{right}-Motion>', 

624 bind(scroll)) 

625 else: 

626 self.canvas.bind('<Shift-B1-Motion>', 

627 bind(scroll)) 

628 

629 self.win.bind('<MouseWheel>', bind(scroll_event)) 

630 self.win.bind('<Key>', bind(scroll)) 

631 self.win.bind('<Shift-Key>', bind(scroll, 'shift')) 

632 self.win.bind('<Control-Key>', bind(scroll, 'ctrl')) 

633 

634 def update_status_line(self, text): 

635 self.status.config(text=text) 

636 

637 def run(self): 

638 MainWindow.run(self) 

639 

640 def click(self, name): 

641 self.callbacks[name]() 

642 

643 def clear(self): 

644 self.canvas.delete(tk.ALL) 

645 

646 def update(self): 

647 self.canvas.update_idletasks() 

648 

649 def circle(self, color, selected, *bbox): 

650 if selected: 

651 outline = '#004500' 

652 width = 3 

653 else: 

654 outline = 'black' 

655 width = 1 

656 self.canvas.create_oval(*tuple(int(x) for x in bbox), fill=color, 

657 outline=outline, width=width) 

658 

659 def arc(self, color, selected, start, extent, *bbox): 

660 if selected: 

661 outline = '#004500' 

662 width = 3 

663 else: 

664 outline = 'black' 

665 width = 1 

666 self.canvas.create_arc(*tuple(int(x) for x in bbox), 

667 start=start, 

668 extent=extent, 

669 fill=color, 

670 outline=outline, 

671 width=width) 

672 

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

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

675 

676 def text(self, x, y, txt, anchor=tk.CENTER, color='black'): 

677 anchor = {'SE': tk.SE}.get(anchor, anchor) 

678 self.canvas.create_text((x, y), text=txt, anchor=anchor, fill=color) 

679 

680 def after(self, time, callback): 

681 id = self.win.after(int(time * 1000), callback) 

682 # Quick'n'dirty object with a cancel() method: 

683 return namedtuple('Timer', 'cancel')(lambda: self.win.after_cancel(id))