Ticket #4983: tool.py

File tool.py, 27.6 KB (added by Eric Pettersen, 4 years ago)

Added by email2trac

Line 
1# vim: set expandtab ts=4 sw=4:
2
3# === UCSF ChimeraX Copyright ===
4# Copyright 2016 Regents of the University of California.
5# All rights reserved. This software provided pursuant to a
6# license agreement containing restrictions on its disclosure,
7# duplication and use. For details see:
8# http://www.rbvi.ucsf.edu/chimerax/docs/licensing.html
9# This notice must be embedded in or attached to all copies,
10# including partial copies, of the software or any revisions
11# or derivations thereof.
12# === UCSF ChimeraX Copyright ===
13
14from chimerax.core.tools import ToolInstance
15
16
17class CommandLine(ToolInstance):
18
19 SESSION_ENDURING = True
20
21 show_history_label = "Command History..."
22 compact_label = "Remove duplicate consecutive commands"
23 help = "help:user/tools/cli.html"
24
25 def __init__(self, session, tool_name):
26 ToolInstance.__init__(self, session, tool_name)
27
28 self._in_init = True
29 from .settings import settings
30 self.settings = settings
31 from chimerax.ui import MainToolWindow
32 self.tool_window = MainToolWindow(self, close_destroys=False, hide_title_bar=True)
33 parent = self.tool_window.ui_area
34 self.tool_window.fill_context_menu = self.fill_context_menu
35 self.history_dialog = _HistoryDialog(self, self.settings.typed_only)
36 from Qt.QtWidgets import QComboBox, QHBoxLayout, QLabel
37 label = QLabel(parent)
38 label.setText("Command:")
39 import sys
40 class CmdText(QComboBox):
41 def __init__(self, parent, tool):
42 self.tool = tool
43 QComboBox.__init__(self, parent)
44 self._processing_key = False
45 from Qt.QtCore import Qt
46 # defer context menu to parent
47 self.setContextMenuPolicy(Qt.NoContextMenu)
48 self.setAcceptDrops(True)
49 self._out_selection = None
50 # horrible hack needed for Linux...
51 self._drop_hack = False
52
53 def dragEnterEvent(self, event):
54 if event.mimeData().text():
55 event.acceptProposedAction()
56 if sys.platform == "linux":
57 if "file://" not in self.lineEdit().text():
58 self._drop_hack = True
59 self.editTextChanged.connect(self._drop_hack_cb)
60
61 def dragLeaveEvent(self, event):
62 if self._drop_hack:
63 self._drop_hack = False
64 self.editTextChanged.disconnect(self._drop_hack_cb)
65
66 def dropEvent(self, event):
67 text = event.mimeData().text()
68 if text.startswith("file://"):
69 text = text[7:]
70 if sys.platform.startswith("win") and text.startswith('/'):
71 # Windows seems to provide /C:/...
72 text = text[1:]
73 from chimerax.core.commands import StringArg
74 self.lineEdit().insert(StringArg.unparse(text))
75 event.acceptProposedAction()
76
77 def _drop_hack_cb(self, new_text):
78 self._drop_hack = False
79 self.editTextChanged.disconnect(self._drop_hack_cb)
80 if "file://" in new_text:
81 self.lineEdit().setText(new_text.replace("file://", ""))
82
83 def focusInEvent(self, event):
84 self._out_selection = None
85 QComboBox.focusInEvent(self, event)
86
87 def focusOutEvent(self, event):
88 le = self.lineEdit()
89 self._out_selection = (sel_start, sel_length, txt) = (le.selectionStart(),
90 len(le.selectedText()), le.text())
91 QComboBox.focusOutEvent(self, event)
92 if sel_start >= 0:
93 le.setSelection(sel_start, sel_length)
94
95 def keyPressEvent(self, event, forwarded=False):
96 self._processing_key = True
97 from Qt.QtCore import Qt
98 from Qt.QtGui import QKeySequence
99
100 if session.ui.key_intercepted(event.key()):
101 return
102
103 want_focus = forwarded and event.key() not in [Qt.Key_Control,
104 Qt.Key_Shift,
105 Qt.Key_Meta,
106 Qt.Key_Alt]
107 import sys
108 control_key = Qt.MetaModifier if sys.platform == "darwin" else Qt.ControlModifier
109 shifted = event.modifiers() & Qt.ShiftModifier
110 if event.key() == Qt.Key_Up: # up arrow
111 self.tool.history_dialog.up(shifted)
112 elif event.key() == Qt.Key_Down: # down arrow
113 self.tool.history_dialog.down(shifted)
114 elif event.matches(QKeySequence.Undo):
115 want_focus = False
116 session.undo.undo()
117 elif event.matches(QKeySequence.Redo):
118 want_focus = False
119 session.undo.redo()
120 elif event.modifiers() & control_key:
121 if event.key() == Qt.Key_N:
122 self.tool.history_dialog.down(shifted)
123 elif event.key() == Qt.Key_P:
124 self.tool.history_dialog.up(shifted)
125 elif event.key() == Qt.Key_U:
126 self.tool.cmd_clear()
127 self.tool.history_dialog.search_reset()
128 elif event.key() == Qt.Key_K:
129 self.tool.cmd_clear_to_end_of_line()
130 self.tool.history_dialog.search_reset()
131 else:
132 QComboBox.keyPressEvent(self, event)
133 else:
134 QComboBox.keyPressEvent(self, event)
135 if want_focus:
136 # Give command line the focus, so that up/down arrow work as
137 # expected rather than changing the selection level
138 self.setFocus()
139 self._processing_key = False
140
141 def sizeHint(self):
142 # prevent super-long commands from making the whole interface super wide
143 return self.minimumSizeHint()
144
145 self.text = CmdText(parent, self)
146 self.text.setEditable(True)
147 self.text.setCompleter(None)
148 def sel_change_correction():
149 # don't allow selection to change while focus is out
150 if self.text._out_selection is not None:
151 start, length, text = self.text._out_selection
152 le = self.text.lineEdit()
153 if text != le.text():
154 self.text._out_selection = (le.selectionStart(), len(le.selectedText()), le.text())
155 return
156 if start >= 0 and (start, length) != (le.selectionStart(), len(le.selectedText())):
157 le.setSelection(start, length)
158 self.text.lineEdit().selectionChanged.connect(sel_change_correction)
159 # pastes can have a trailing newline, which is problematic when appending to the pasted command...
160 def strip_trailing_newlines():
161 le = self.text.lineEdit()
162 while le.text().endswith('\n'):
163 le.setText(le.text()[:-1])
164 self.text.lineEdit().textEdited.connect(strip_trailing_newlines)
165 self.text.lineEdit().textEdited.connect(self.history_dialog.search_reset)
166 def text_change(*args):
167 # if text changes while focus is out, remember new selection
168 if self.text._out_selection is not None:
169 le = self.text.lineEdit()
170 self.text._out_selection = (le.selectionStart(), len(le.selectedText()), le.text())
171 self.text.lineEdit().selectionChanged.connect(text_change)
172 layout = QHBoxLayout(parent)
173 layout.setSpacing(1)
174 layout.setContentsMargins(2, 0, 0, 0)
175 layout.addWidget(label)
176 layout.addWidget(self.text, 1)
177 parent.setLayout(layout)
178 # lineEdit() seems to be None during entire CmdText constructor, so connect here...
179 self.text.lineEdit().returnPressed.connect(self.execute)
180 self.text.currentTextChanged.connect(self.text_changed)
181 self.text.forwarded_keystroke = lambda e: self.text.keyPressEvent(e, forwarded=True)
182 session.ui.register_for_keystrokes(self.text)
183 self.history_dialog.populate()
184 self._just_typed_command = None
185 self._command_started_handler = session.triggers.add_handler("command started",
186 self._command_started_cb)
187 self.tool_window.manage(placement="bottom")
188 self._in_init = False
189 self._processing_command = False
190 if self.settings.startup_commands:
191 # prevent the startup command output from being summarized into 'startup messages' table
192 session.ui.triggers.add_handler('ready', self._run_startup_commands)
193
194 def cmd_clear(self):
195 self.text.lineEdit().clear()
196
197 def cmd_clear_to_end_of_line(self):
198 le = self.text.lineEdit()
199 t = le.text()[:le.cursorPosition()]
200 le.setText(t)
201
202 def cmd_replace(self, cmd):
203 line_edit = self.text.lineEdit()
204 line_edit.setText(cmd)
205 line_edit.setCursorPosition(len(cmd))
206
207 def delete(self):
208 self.session.ui.deregister_for_keystrokes(self.text)
209 self.session.triggers.remove_handler(self._command_started_handler)
210 super().delete()
211
212 def fill_context_menu(self, menu, x, y):
213 # avoid having actions destroyed when this routine returns
214 # by stowing a reference in the menu itself
215 from Qt.QtWidgets import QAction
216 filter_action = QAction("Typed Commands Only", menu)
217 filter_action.setCheckable(True)
218 filter_action.setChecked(self.settings.typed_only)
219 filter_action.toggled.connect(lambda arg, f=self._set_typed_only: f(arg))
220 menu.addAction(filter_action)
221 select_action = QAction("Leave Failed Command Highlighted", menu)
222 select_action.setCheckable(True)
223 select_action.setChecked(self.settings.select_failed)
224 select_action.toggled.connect(lambda arg, f=self._set_select_failed: f(arg))
225 menu.addAction(select_action)
226
227 def on_combobox(self, event):
228 val = self.text.GetValue()
229 if val == self.show_history_label:
230 self.cmd_clear()
231 self.history_dialog.window.shown = True
232 elif val == self.compact_label:
233 self.cmd_clear()
234 prev_cmd = None
235 unique_cmds = []
236 for cmd in self.history_dialog._history:
237 if cmd != prev_cmd:
238 unique_cmds.append(cmd)
239 prev_cmd = cmd
240 self.history_dialog._history.replace(unique_cmds)
241 self.history_dialog.populate()
242 else:
243 event.Skip()
244
245 def text_changed(self, text):
246 if text == self.show_history_label:
247 self.cmd_clear()
248 if not self._in_init:
249 self.history_dialog.window.shown = True
250 elif text == self.compact_label:
251 self.cmd_clear()
252 prev_cmd = None
253 unique_cmds = []
254 for cmd in self.history_dialog._history:
255 if cmd != prev_cmd:
256 unique_cmds.append(cmd)
257 prev_cmd = cmd
258 self.history_dialog._history.replace(unique_cmds)
259 self.history_dialog.populate()
260
261 def execute(self):
262 from contextlib import contextmanager
263 @contextmanager
264 def processing_command(line_edit, cmd_text, command_worked, select_failed):
265 line_edit.blockSignals(True)
266 self._processing_command = True
267 # as per the docs for contextmanager, the yield needs
268 # to be in a try/except if the exit code is to execute
269 # after errors
270 try:
271 yield
272 finally:
273 line_edit.blockSignals(False)
274 line_edit.setText(cmd_text)
275 if command_worked[0] or select_failed:
276 line_edit.selectAll()
277 self._processing_command = False
278 session = self.session
279 logger = session.logger
280 text = self.text.lineEdit().text()
281 logger.status("")
282 from chimerax.core import errors
283 from chimerax.core.commands import Command
284 from html import escape
285 for cmd_text in text.split("\n"):
286 if not cmd_text:
287 continue
288 # don't select the text if the command failed, so that
289 # an accidental keypress won't erase the command, which
290 # probably needs to be edited to work
291 command_worked = [False]
292 with processing_command(self.text.lineEdit(), cmd_text, command_worked,
293 self.settings.select_failed):
294 try:
295 self._just_typed_command = cmd_text
296 cmd = Command(session)
297 cmd.run(cmd_text)
298 command_worked[0] = True
299 except SystemExit:
300 # TODO: somehow quit application
301 raise
302 except errors.UserError as err:
303 logger.status(str(err), color="crimson")
304 from chimerax.core.logger import error_text_format
305 logger.info(error_text_format % escape(str(err)), is_html=True)
306 except BaseException:
307 raise
308 self.set_focus()
309
310 def set_focus(self):
311 from Qt.QtCore import Qt
312 self.text.lineEdit().setFocus(Qt.OtherFocusReason)
313
314 @classmethod
315 def get_singleton(cls, session, **kw):
316 from chimerax.core import tools
317 return tools.get_singleton(session, CommandLine, 'Command Line Interface', **kw)
318
319 def _command_started_cb(self, trig_name, cmd_text):
320 # the self._processing_command test is necessary when multiple commands
321 # separated by semicolons are typed in order to prevent putting the
322 # second and later commands into the command history, since we will get
323 # triggers for each command in the line
324 if self._just_typed_command or not self._processing_command:
325 self.history_dialog.add(self._just_typed_command or cmd_text,
326 typed=self._just_typed_command is not None)
327 self.text.lineEdit().selectAll()
328 self._just_typed_command = None
329
330 def _run_startup_commands(self, *args):
331 # log the commands; but prevent them from going into command history...
332 self._processing_command = True
333 from chimerax.core.commands import run
334 from chimerax.core.errors import UserError
335 try:
336 for cmd_text in self.settings.startup_commands:
337 run(self.session, cmd_text)
338 except UserError as err:
339 self.session.logger.status("Error running startup command '%s': %s" % (cmd_text, str(err)),
340 color="crimson", log=True)
341 except Exception:
342 self._processing_command = False
343 raise
344 self._processing_command = False
345
346 def _set_select_failed(self, select_failed):
347 self.settings.select_failed = select_failed
348
349 def _set_typed_only(self, typed_only):
350 self.settings.typed_only = typed_only
351 self.history_dialog.set_typed_only(typed_only)
352
353class _HistoryDialog:
354
355 record_label = "Save..."
356 execute_label = "Execute"
357
358 def __init__(self, controller, typed_only):
359 # make dialog hidden initially
360 self.controller = controller
361 self.typed_only = typed_only
362
363 self.window = controller.tool_window.create_child_window(
364 "Command History", close_destroys=False)
365 self.window.fill_context_menu = self.fill_context_menu
366
367 parent = self.window.ui_area
368 from Qt.QtWidgets import QListWidget, QVBoxLayout, QFrame, QHBoxLayout, QPushButton, QLabel
369 self.listbox = QListWidget(parent)
370 self.listbox.setSelectionMode(QListWidget.ExtendedSelection)
371 self.listbox.itemSelectionChanged.connect(self.select)
372 main_layout = QVBoxLayout(parent)
373 main_layout.setContentsMargins(0,0,0,0)
374 main_layout.addWidget(self.listbox)
375 num_cmd_frame = QFrame(parent)
376 main_layout.addWidget(num_cmd_frame)
377 num_cmd_layout = QHBoxLayout(num_cmd_frame)
378 num_cmd_layout.setContentsMargins(0,0,0,0)
379 remem_label = QLabel("Remember")
380 from Qt.QtCore import Qt
381 remem_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
382 num_cmd_layout.addWidget(remem_label, 1)
383 from Qt.QtWidgets import QSpinBox, QSizePolicy
384 class ShorterQSpinBox(QSpinBox):
385 max_val = 1000000
386 def textFromValue(self, val):
387 # kludge to make the damn entry field shorter
388 if val == self.max_val:
389 return "1 mil"
390 return str(val)
391
392 spin_box = ShorterQSpinBox()
393 spin_box.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
394 spin_box.setRange(100, spin_box.max_val)
395 spin_box.setSingleStep(100)
396 spin_box.setValue(controller.settings.num_remembered)
397 spin_box.valueChanged.connect(self._num_remembered_changed)
398 num_cmd_layout.addWidget(spin_box, 0)
399 num_cmd_layout.addWidget(QLabel("commands"), 1)
400 num_cmd_frame.setLayout(num_cmd_layout)
401 button_frame = QFrame(parent)
402 main_layout.addWidget(button_frame)
403 button_layout = QHBoxLayout(button_frame)
404 button_layout.setContentsMargins(0,0,0,0)
405 for but_name in [self.record_label, self.execute_label, "Delete", "Copy", "Help"]:
406 but = QPushButton(but_name, button_frame)
407 but.setAutoDefault(False)
408 but.clicked.connect(lambda *args, txt=but_name: self.button_clicked(txt))
409 button_layout.addWidget(but)
410 button_frame.setLayout(button_layout)
411 self.window.manage(placement=None, initially_hidden=True)
412 from chimerax.core.history import FIFOHistory
413 self._history = FIFOHistory(controller.settings.num_remembered, controller.session, "commands")
414 self._record_dialog = None
415 self._search_cache = (False, None)
416
417 def add(self, item, *, typed=False):
418 if len(self._history) >= self.controller.settings.num_remembered:
419 if not self.typed_only or self._history[0][1]:
420 self.listbox.takeItem(0)
421 if typed or not self.typed_only:
422 self.listbox.addItem(item)
423 self._history.enqueue((item, typed))
424 # 'if typed:' to avoid clearing any partially entered command text
425 if typed:
426 self.listbox.clearSelection()
427 self.listbox.setCurrentRow(len(self.history()) - 1)
428 self.update_list()
429
430 def button_clicked(self, label):
431 session = self.controller.session
432 if label == self.record_label:
433 from chimerax.ui.open_save import SaveDialog
434 if self._record_dialog is None:
435 fmt = session.data_formats["ChimeraX commands"]
436 self._record_dialog = dlg = SaveDialog(session, self.window.ui_area,
437 "Save Commands", data_formats=[fmt])
438 from Qt.QtWidgets import QFrame, QLabel, QHBoxLayout, QVBoxLayout, QComboBox
439 from Qt.QtWidgets import QCheckBox
440 from Qt.QtCore import Qt
441 options_frame = dlg.custom_area
442 options_layout = QVBoxLayout(options_frame)
443 options_frame.setLayout(options_layout)
444 amount_frame = QFrame(options_frame)
445 options_layout.addWidget(amount_frame, Qt.AlignCenter)
446 amount_layout = QHBoxLayout(amount_frame)
447 amount_layout.addWidget(QLabel("Save", amount_frame))
448 self.save_amount_widget = saw = QComboBox(amount_frame)
449 saw.addItems(["all", "selected"])
450 amount_layout.addWidget(saw)
451 amount_layout.addWidget(QLabel("commands", amount_frame))
452 amount_frame.setLayout(amount_layout)
453 self.append_checkbox = QCheckBox("Append to file", options_frame)
454 self.append_checkbox.stateChanged.connect(self.append_changed)
455 options_layout.addWidget(self.append_checkbox, Qt.AlignCenter)
456 self.overwrite_disclaimer = disclaimer = QLabel(
457 "<small><i>(ignore overwrite warning)</i></small>", options_frame)
458 options_layout.addWidget(disclaimer, Qt.AlignCenter)
459 disclaimer.hide()
460 else:
461 dlg = self._record_dialog
462 if not dlg.exec():
463 return
464 path = dlg.selectedFiles()[0]
465 if not path:
466 from chimerax.core.errors import UserError
467 raise UserError("No file specified for saving command history")
468 if self.save_amount_widget.currentText() == "all":
469 cmds = [cmd for cmd in self.history()]
470 else:
471 # listbox.selectedItems() may not be in order, so...
472 items = [self.listbox.item(i) for i in range(self.listbox.count())
473 if self.listbox.item(i).isSelected()]
474 cmds = [item.text() for item in items]
475 from chimerax.io import open_output
476 f = open_output(path, encoding='utf-8', append=self.append_checkbox.isChecked())
477 for cmd in cmds:
478 print(cmd, file=f)
479 f.close()
480 return
481 if label == self.execute_label:
482 for item in self.listbox.selectedItems():
483 self.controller.cmd_replace(item.text())
484 self.controller.execute()
485 return
486 if label == "Delete":
487 retain = []
488 listbox_index = 0
489 for h_item in self._history:
490 if self.typed_only and not h_item[1]:
491 retain.append(h_item)
492 continue
493 if not self.listbox.item(listbox_index).isSelected():
494 # not selected for deletion
495 retain.append(h_item)
496 listbox_index += 1
497 self._history.replace(retain)
498 self.populate()
499 return
500 if label == "Copy":
501 clipboard = session.ui.clipboard()
502 clipboard.setText("\n".join([item.text() for item in self.listbox.selectedItems()]))
503 return
504 if label == "Help":
505 from chimerax.core.commands import run
506 run(session, 'help help:user/tools/cli.html#history')
507 return
508
509 def down(self, shifted):
510 sels = self.listbox.selectedIndexes()
511 if len(sels) != 1:
512 self._search_cache = (False, None)
513 return
514 sel = sels[0].row()
515 orig_text = self.controller.text.currentText()
516 match_against = None
517 if shifted:
518 was_searching, prev_search = self._search_cache
519 if was_searching:
520 match_against = prev_search
521 else:
522 words = orig_text.strip().split()
523 if words:
524 match_against = words[0]
525 self._search_cache = (True, match_against)
526 else:
527 self._search_cache = (False, None)
528 else:
529 self._search_cache = (False, None)
530 if match_against:
531 last = self.listbox.count() - 1
532 while sel < last:
533 if self.listbox.item(sel + 1).text().startswith(match_against):
534 break
535 sel += 1
536 if sel == self.listbox.count() - 1:
537 return
538 self.listbox.clearSelection()
539 self.listbox.setCurrentRow(sel + 1)
540 new_text = self.listbox.item(sel + 1).text()
541 self.controller.cmd_replace(new_text)
542 if orig_text == new_text:
543 self.down(shifted)
544
545 def fill_context_menu(self, menu, x, y):
546 # avoid having actions destroyed when this routine returns
547 # by stowing a reference in the menu itself
548 from Qt.QtWidgets import QAction
549 filter_action = QAction("Typed commands only", menu)
550 filter_action.setCheckable(True)
551 filter_action.setChecked(self.controller.settings.typed_only)
552 filter_action.toggled.connect(lambda arg, f=self.controller._set_typed_only: f(arg))
553 menu.addAction(filter_action)
554
555 def on_append_change(self, event):
556 self.overwrite_disclaimer.Show(self.save_append_CheckBox.Value)
557
558 def append_changed(self, append):
559 if append:
560 self.overwrite_disclaimer.show()
561 else:
562 self.overwrite_disclaimer.hide()
563
564 def on_listbox(self, event):
565 self.select()
566
567 def populate(self):
568 self.listbox.clear()
569 history = self.history()
570 self.listbox.addItems([cmd for cmd in history])
571 self.listbox.setCurrentRow(len(history) - 1)
572 self.update_list()
573 self.select()
574 self.controller.text.lineEdit().setFocus()
575 self.controller.text.lineEdit().selectAll()
576 cursels = self.listbox.scrollToBottom()
577
578 def search_reset(self):
579 searching, target = self._search_cache
580 if searching:
581 self._search_cache = (False, None)
582 self.listbox.blockSignals(True)
583 self.listbox.clearSelection()
584 self.listbox.setCurrentRow(self.listbox.count() - 1)
585 self.listbox.blockSignals(False)
586
587 def select(self):
588 sels = self.listbox.selectedItems()
589 if len(sels) != 1:
590 return
591 self.controller.cmd_replace(sels[0].text())
592
593 def up(self, shifted):
594 sels = self.listbox.selectedIndexes()
595 if len(sels) != 1:
596 self._search_cache = (False, None)
597 return
598 sel = sels[0].row()
599 orig_text = self.controller.text.currentText()
600 match_against = None
601 if shifted:
602 was_searching, prev_search = self._search_cache
603 if was_searching:
604 match_against = prev_search
605 else:
606 words = orig_text.strip().split()
607 if words:
608 match_against = words[0]
609 self._search_cache = (True, match_against)
610 else:
611 self._search_cache = (False, None)
612 else:
613 self._search_cache = (False, None)
614 if match_against:
615 while sel > 0:
616 if self.listbox.item(sel - 1).text().startswith(match_against):
617 break
618 sel -= 1
619 if sel == 0:
620 return
621 self.listbox.clearSelection()
622 self.listbox.setCurrentRow(sel - 1)
623 new_text = self.listbox.item(sel - 1).text()
624 self.controller.cmd_replace(new_text)
625 if orig_text == new_text:
626 self.up(shifted)
627
628 def update_list(self):
629 c = self.controller
630 last8 = list(reversed(self.history()[-8:]))
631 # without blocking signals, if the command list is empty then
632 # "Command History" (the first entry) will execute...
633 c.text.blockSignals(True)
634 c.text.clear()
635 c.text.addItems(last8 + [c.show_history_label, c.compact_label])
636 if not last8:
637 c.text.lineEdit().setText("")
638 c.text.blockSignals(False)
639
640 def history(self):
641 if self.typed_only:
642 return [h[0] for h in self._history if h[1]]
643 return [h[0] for h in self._history]
644
645 def set_typed_only(self, typed_only):
646 self.typed_only = typed_only
647 self.populate()
648
649 def _num_remembered_changed(self, new_hist_len):
650 if len(self._history) > new_hist_len:
651 self._history.replace(self._history[-new_hist_len:])
652 self.populate()
653 self.controller.settings.num_remembered = new_hist_len
654