| 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 |
|
|---|
| 14 | from chimerax.core.tools import ToolInstance
|
|---|
| 15 |
|
|---|
| 16 |
|
|---|
| 17 | class 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 |
|
|---|
| 353 | class _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 |
|
|---|