class DlgGitHubLogin(QDialog): """Dialog to submit error reports to Github.""" def __init__(self, parent, username, password, token, remember=False, remember_token=False): QDialog.__init__(self, parent) title = _("Sign in to Github") self.resize(415, 375) self.setWindowTitle(title) self.setWindowFlags( self.windowFlags() & ~Qt.WindowContextHelpButtonHint) # Header html = ('<html><head/><body><p align="center">' '{title}</p></body></html>') lbl_html = QLabel(html.format(title=title)) lbl_html.setStyleSheet('font-size: 16px;') # Tabs self.tabs = QTabWidget() # Basic form layout basic_form_layout = QFormLayout() basic_form_layout.setContentsMargins(-1, 0, -1, -1) basic_lbl_msg = QLabel(_("For regular users, i.e. users <b>without</b>" " two-factor authentication enabled")) basic_lbl_msg.setWordWrap(True) basic_lbl_msg.setAlignment(Qt.AlignJustify) lbl_user = QLabel(_("Username:"******"", QWidget()) lbl_password = QLabel(_("Password: "******"Remember me")) self.cb_remember.setToolTip(_("Spyder will save your credentials " "safely")) self.cb_remember.setChecked(remember) basic_form_layout.setWidget(4, QFormLayout.FieldRole, self.cb_remember) # Basic auth tab basic_auth = QWidget() basic_layout = QVBoxLayout() basic_layout.addSpacerItem(QSpacerItem(QSpacerItem(0, 8))) basic_layout.addWidget(basic_lbl_msg) basic_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 50, vPolicy=QSizePolicy.Expanding))) basic_layout.addLayout(basic_form_layout) basic_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 50, vPolicy=QSizePolicy.Expanding))) basic_auth.setLayout(basic_layout) self.tabs.addTab(basic_auth, _("Password Only")) # Token form layout token_form_layout = QFormLayout() token_form_layout.setContentsMargins(-1, 0, -1, -1) token_lbl_msg = QLabel(_("For users <b>with</b> two-factor " "authentication enabled, or who prefer a " "per-app token authentication.<br><br>" "You can go <b><a href=\"{}\">here</a></b> " "and click \"Generate token\" at the bottom " "to create a new token to use for this, with " "the appropriate permissions.").format( TOKEN_URL)) token_lbl_msg.setOpenExternalLinks(True) token_lbl_msg.setWordWrap(True) token_lbl_msg.setAlignment(Qt.AlignJustify) lbl_token = QLabel("Token: ") token_form_layout.setWidget(1, QFormLayout.LabelRole, lbl_token) self.le_token = QLineEdit() self.le_token.setEchoMode(QLineEdit.Password) self.le_token.textChanged.connect(self.update_btn_state) token_form_layout.setWidget(1, QFormLayout.FieldRole, self.le_token) self.cb_remember_token = None # Same validation as with cb_remember if self.is_keyring_available() and valid_py_os: self.cb_remember_token = QCheckBox(_("Remember token")) self.cb_remember_token.setToolTip(_("Spyder will save your " "token safely")) self.cb_remember_token.setChecked(remember_token) token_form_layout.setWidget(3, QFormLayout.FieldRole, self.cb_remember_token) # Token auth tab token_auth = QWidget() token_layout = QVBoxLayout() token_layout.addSpacerItem(QSpacerItem(QSpacerItem(0, 8))) token_layout.addWidget(token_lbl_msg) token_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 50, vPolicy=QSizePolicy.Expanding))) token_layout.addLayout(token_form_layout) token_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 50, vPolicy=QSizePolicy.Expanding))) token_auth.setLayout(token_layout) self.tabs.addTab(token_auth, _("Access Token")) # Sign in button self.bt_sign_in = QPushButton(_("Sign in")) self.bt_sign_in.clicked.connect(self.accept) self.bt_sign_in.setDisabled(True) # Main layout layout = QVBoxLayout() layout.addWidget(lbl_html) layout.addWidget(self.tabs) layout.addWidget(self.bt_sign_in) self.setLayout(layout) # Final adjustments if username and password: self.le_user.setText(username) self.le_password.setText(password) self.bt_sign_in.setFocus() elif username: self.le_user.setText(username) self.le_password.setFocus() elif token: self.le_token.setText(token) else: self.le_user.setFocus() self.setFixedSize(self.width(), self.height()) self.le_password.installEventFilter(self) self.le_user.installEventFilter(self) self.tabs.currentChanged.connect(self.update_btn_state) def eventFilter(self, obj, event): interesting_objects = [self.le_password, self.le_user] if obj in interesting_objects and event.type() == QEvent.KeyPress: if (event.key() == Qt.Key_Return and event.modifiers() & Qt.ControlModifier and self.bt_sign_in.isEnabled()): self.accept() return True return False def update_btn_state(self): user = to_text_string(self.le_user.text()).strip() != '' password = to_text_string(self.le_password.text()).strip() != '' token = to_text_string(self.le_token.text()).strip() != '' enable = ((user and password and self.tabs.currentIndex() == 0) or (token and self.tabs.currentIndex() == 1)) self.bt_sign_in.setEnabled(enable) def is_keyring_available(self): """Check if keyring is available for password storage.""" try: import keyring # analysis:ignore return True except Exception: return False @classmethod def login(cls, parent, username, password, token, remember, remember_token): dlg = DlgGitHubLogin(parent, username, password, token, remember, remember_token) if dlg.exec_() == dlg.Accepted: user = dlg.le_user.text() password = dlg.le_password.text() token = dlg.le_token.text() if dlg.cb_remember: remember = dlg.cb_remember.isChecked() else: remember = False if dlg.cb_remember_token: remember_token = dlg.cb_remember_token.isChecked() else: remember_token = False credentials = dict(username=user, password=password, token=token, remember=remember, remember_token=remember_token) return credentials return dict(username=None, password=None, token=None, remember=False, remember_token=False)
class FileSwitcher(QDialog): """A Sublime-like file switcher.""" sig_goto_file = Signal(int) sig_close_file = Signal(int) # Constants that define the mode in which the list widget is working # FILE_MODE is for a list of files, SYMBOL_MODE if for a list of symbols # in a given file when using the '@' symbol. FILE_MODE, SYMBOL_MODE = [1, 2] def __init__(self, parent, tabs, data): QDialog.__init__(self, parent) # Variables self.tabs = tabs # Editor stack tabs self.data = data # Editor data self.mode = self.FILE_MODE # By default start in this mode self.initial_cursors = None # {fullpath: QCursor} self.initial_path = None # Fullpath of initial active editor self.initial_editor = None # Initial active editor self.line_number = None # Selected line number in filer self.is_visible = False # Is the switcher visible? help_text = _("Press <b>Enter</b> to switch files or <b>Esc</b> to " "cancel.<br><br>Type to filter filenames.<br><br>" "Use <b>:number</b> to go to a line, e.g. " "<b><code>main:42</code></b><br>" "Use <b>@symbol_text</b> to go to a symbol, e.g. " "<b><code>@init</code></b>" "<br><br> Press <b>Ctrl+W</b> to close current tab.<br>") # Either allow searching for a line number or a symbol but not both regex = QRegExp("([A-Za-z0-9_]{0,100}@[A-Za-z0-9_]{0,100})|" + "([A-Za-z]{0,100}:{0,1}[0-9]{0,100})") # Widgets self.edit = QLineEdit(self) self.help = HelperToolButton() self.list = QListWidget(self) self.filter = KeyPressFilter() regex_validator = QRegExpValidator(regex, self.edit) # Widgets setup self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setWindowOpacity(0.95) self.edit.installEventFilter(self.filter) self.edit.setValidator(regex_validator) self.help.setToolTip(help_text) self.list.setItemDelegate(HTMLDelegate(self)) # Layout edit_layout = QHBoxLayout() edit_layout.addWidget(self.edit) edit_layout.addWidget(self.help) layout = QVBoxLayout() layout.addLayout(edit_layout) layout.addWidget(self.list) self.setLayout(layout) # Signals self.rejected.connect(self.restore_initial_state) self.filter.sig_up_key_pressed.connect(self.previous_row) self.filter.sig_down_key_pressed.connect(self.next_row) self.edit.returnPressed.connect(self.accept) self.edit.textChanged.connect(self.setup) self.list.itemSelectionChanged.connect(self.item_selection_changed) self.list.clicked.connect(self.edit.setFocus) # Setup self.save_initial_state() self.set_dialog_position() self.setup() # --- Properties @property def editors(self): return [self.tabs.widget(index) for index in range(self.tabs.count())] @property def line_count(self): return [editor.get_line_count() for editor in self.editors] @property def save_status(self): return [getattr(td, 'newly_created', False) for td in self.data] @property def paths(self): return [getattr(td, 'filename', None) for td in self.data] @property def filenames(self): return [self.tabs.tabText(index) for index in range(self.tabs.count())] @property def current_path(self): return self.paths_by_editor[self.get_editor()] @property def paths_by_editor(self): return dict(zip(self.editors, self.paths)) @property def editors_by_path(self): return dict(zip(self.paths, self.editors)) @property def filter_text(self): """Get the normalized (lowecase) content of the filter text.""" return to_text_string(self.edit.text()).lower() def save_initial_state(self): """Saves initial cursors and initial active editor.""" paths = self.paths self.initial_editor = self.get_editor() self.initial_cursors = {} for i, editor in enumerate(self.editors): if editor is self.initial_editor: self.initial_path = paths[i] self.initial_cursors[paths[i]] = editor.textCursor() def accept(self): self.is_visible = False QDialog.accept(self) self.list.clear() def restore_initial_state(self): """Restores initial cursors and initial active editor.""" self.list.clear() self.is_visible = False editors = self.editors_by_path for path in self.initial_cursors: cursor = self.initial_cursors[path] if path in editors: self.set_editor_cursor(editors[path], cursor) if self.initial_editor in self.paths_by_editor: index = self.paths.index(self.initial_path) self.sig_goto_file.emit(index) def set_dialog_position(self): """Positions the file switcher dialog in the center of the editor.""" parent = self.parent() geo = parent.geometry() width = self.list.width() # This has been set in setup left = parent.geometry().width()/2 - width/2 top = 0 while parent: geo = parent.geometry() top += geo.top() left += geo.left() parent = parent.parent() # Note: the +1 pixel on the top makes it look better self.move(left, top + self.tabs.tabBar().geometry().height() + 1) def fix_size(self, content, extra=50): """Adjusts the width of the file switcher, based on the content.""" # Update size of dialog based on longest shortened path strings = [] if content: for rich_text in content: label = QLabel(rich_text) label.setTextFormat(Qt.PlainText) strings.append(label.text()) fm = label.fontMetrics() max_width = max([fm.width(s)*1.3 for s in strings]) self.list.setMinimumWidth(max_width + extra) self.set_dialog_position() # --- Helper methods: List widget def count(self): """Gets the item count in the list widget.""" return self.list.count() def current_row(self): """Returns the current selected row in the list widget.""" return self.list.currentRow() def set_current_row(self, row): """Sets the current selected row in the list widget.""" return self.list.setCurrentRow(row) def select_row(self, steps): """Select row in list widget based on a number of steps with direction. Steps can be positive (next rows) or negative (previous rows). """ row = self.current_row() + steps if 0 <= row < self.count(): self.set_current_row(row) def previous_row(self): """Select previous row in list widget.""" self.select_row(-1) def next_row(self): """Select next row in list widget.""" self.select_row(+1) # --- Helper methods: Editor def get_editor(self, index=None, path=None): """Get editor by index or path. If no path or index specified the current active editor is returned """ if index: return self.tabs.widget(index) elif path: return self.tabs.widget(index) else: return self.parent().get_current_editor() def set_editor_cursor(self, editor, cursor): """Set the cursor of an editor.""" pos = cursor.position() anchor = cursor.anchor() new_cursor = QTextCursor() if pos == anchor: new_cursor.movePosition(pos) else: new_cursor.movePosition(anchor) new_cursor.movePosition(pos, QTextCursor.KeepAnchor) editor.setTextCursor(cursor) def goto_line(self, line_number): """Go to specified line number in current active editor.""" if line_number: line_number = int(line_number) editor = self.get_editor() editor.go_to_line(min(line_number, editor.get_line_count())) # --- Helper methods: Outline explorer def get_symbol_list(self): """Get the object explorer data.""" return self.get_editor().highlighter.get_outlineexplorer_data() # --- Handlers def item_selection_changed(self): """List widget item selection change handler.""" row = self.current_row() if self.count() and row >= 0: if self.mode == self.FILE_MODE: try: stack_index = self.paths.index(self.filtered_path[row]) self.sig_goto_file.emit(stack_index) self.goto_line(self.line_number) self.edit.setFocus() except ValueError: pass else: line_number = self.filtered_symbol_lines[row] self.goto_line(line_number) def setup_file_list(self, filter_text, current_path): """Setup list widget content for file list display.""" short_paths = shorten_paths(self.paths, self.save_status) paths = self.paths results = [] trying_for_line_number = ':' in filter_text # Get optional line number if trying_for_line_number: filter_text, line_number = filter_text.split(':') else: line_number = None # Get all available filenames and get the scores for "fuzzy" matching scores = get_search_scores(filter_text, self.filenames, template="<b>{0}</b>") # Build the text that will appear on the list widget for index, score in enumerate(scores): text, rich_text, score_value = score if score_value != -1: text_item = '<big>' + rich_text + '</big>' if trying_for_line_number: text_item += " [{0:} {1:}]".format(self.line_count[index], _("lines")) text_item += "<br><i>{0:}</i>".format( short_paths[index]) results.append((score_value, index, text_item)) # Sort the obtained scores and populate the list widget self.filtered_path = [] for result in sorted(results): index = result[1] text = result[-1] path = paths[index] item = QListWidgetItem(self.tabs.tabIcon(index), text) item.setToolTip(path) item.setSizeHint(QSize(0, 25)) self.list.addItem(item) self.filtered_path.append(path) # Move selected item in list accordingly and update list size if current_path in self.filtered_path: self.set_current_row(self.filtered_path.index(current_path)) elif self.filtered_path: self.set_current_row(0) self.fix_size(short_paths) # If a line number is searched look for it self.line_number = line_number self.goto_line(line_number) def setup_symbol_list(self, filter_text, current_path): """Setup list widget content for symbol list display.""" # Get optional symbol name filter_text, symbol_text = filter_text.split('@') # Fetch the Outline explorer data, get the icons and values oedata = self.get_symbol_list() icons = get_python_symbol_icons(oedata) symbol_list = process_python_symbol_data(oedata) line_fold_token = [(item[0], item[2], item[3]) for item in symbol_list] choices = [item[1] for item in symbol_list] scores = get_search_scores(symbol_text, choices, template="<b>{0}</b>") # Build the text that will appear on the list widget results = [] lines = [] self.filtered_symbol_lines = [] for index, score in enumerate(scores): text, rich_text, score_value = score line, fold_level, token = line_fold_token[index] lines.append(text) if score_value != -1: results.append((score_value, line, text, rich_text, fold_level, icons[index], token)) template_1 = '<code>{0}<big>{1} {2}</big></code>' template_2 = '<br><code>{0}</code><i>[Line {1}]</i>' for (score, line, text, rich_text, fold_level, icon, token) in sorted(results): fold_space = ' '*(fold_level) line_number = line + 1 self.filtered_symbol_lines.append(line_number) textline = template_1.format(fold_space, token, rich_text) textline += template_2.format(fold_space, line_number) item = QListWidgetItem(icon, textline) item.setSizeHint(QSize(0, 16)) self.list.addItem(item) # Move selected item in list accordingly # NOTE: Doing this is causing two problems: # 1. It makes the cursor to auto-jump to the last selected # symbol after opening or closing a different file # 2. It moves the cursor to the first symbol by default, # which is very distracting. # That's why this line is commented! # self.set_current_row(0) # Update list size self.fix_size(lines, extra=125) def setup(self): """Setup list widget content.""" if not self.tabs.count(): self.close() return self.list.clear() current_path = self.current_path filter_text = self.filter_text # Get optional line or symbol to define mode and method handler trying_for_symbol = ('@' in self.filter_text) if trying_for_symbol: self.mode = self.SYMBOL_MODE self.setup_symbol_list(filter_text, current_path) else: self.mode = self.FILE_MODE self.setup_file_list(filter_text, current_path)
class QtPluginDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.installer = Installer() self.setup_ui() self.installer.set_output_widget(self.stdout_text) self.installer.process.started.connect(self._on_installer_start) self.installer.process.finished.connect(self._on_installer_done) self.refresh() def _on_installer_start(self): self.show_status_btn.setChecked(True) self.working_indicator.show() self.process_error_indicator.hide() def _on_installer_done(self, exit_code, exit_status): self.working_indicator.hide() if exit_code: self.process_error_indicator.show() else: self.show_status_btn.setChecked(False) self.refresh() self.plugin_sorter.refresh() def refresh(self): self.installed_list.clear() self.available_list.clear() # fetch installed from ...plugins import plugin_manager plugin_manager.discover() # since they might not be loaded yet already_installed = set() for plugin_name, mod_name, distname in plugin_manager.iter_available(): # not showing these in the plugin dialog if plugin_name in ('napari_plugin_engine',): continue if distname: already_installed.add(distname) meta = standard_metadata(distname) else: meta = {} self.installed_list.addItem( ProjectInfo( normalized_name(distname or ''), meta.get('version', ''), meta.get('url', ''), meta.get('summary', ''), meta.get('author', ''), meta.get('license', ''), ), plugin_name=plugin_name, enabled=plugin_name in plugin_manager.plugins, ) # self.v_splitter.setSizes([70 * self.installed_list.count(), 10, 10]) # fetch available plugins self.worker = create_worker(iter_napari_plugin_info) def _handle_yield(project_info): if project_info.name in already_installed: self.installed_list.tag_outdated(project_info) else: self.available_list.addItem(project_info) self.worker.yielded.connect(_handle_yield) self.worker.finished.connect(self.working_indicator.hide) self.worker.start() def setup_ui(self): self.resize(1080, 640) vlay_1 = QVBoxLayout(self) self.h_splitter = QSplitter(self) vlay_1.addWidget(self.h_splitter) self.h_splitter.setOrientation(Qt.Horizontal) self.v_splitter = QSplitter(self.h_splitter) self.v_splitter.setOrientation(Qt.Vertical) self.v_splitter.setMinimumWidth(500) self.plugin_sorter = QtPluginSorter(parent=self.h_splitter) self.plugin_sorter.layout().setContentsMargins(2, 0, 0, 0) self.plugin_sorter.hide() installed = QWidget(self.v_splitter) lay = QVBoxLayout(installed) lay.setContentsMargins(0, 2, 0, 2) lay.addWidget(QLabel("Installed Plugins")) self.installed_list = QPluginList(installed, self.installer) lay.addWidget(self.installed_list) uninstalled = QWidget(self.v_splitter) lay = QVBoxLayout(uninstalled) lay.setContentsMargins(0, 2, 0, 2) lay.addWidget(QLabel("Available Plugin Packages")) self.available_list = QPluginList(uninstalled, self.installer) lay.addWidget(self.available_list) self.stdout_text = QTextEdit(self.v_splitter) self.stdout_text.setReadOnly(True) self.stdout_text.setObjectName("pip_install_status") self.stdout_text.hide() buttonBox = QHBoxLayout() self.working_indicator = QLabel("loading ...", self) sp = self.working_indicator.sizePolicy() sp.setRetainSizeWhenHidden(True) self.working_indicator.setSizePolicy(sp) self.process_error_indicator = QLabel(self) self.process_error_indicator.setObjectName("error_label") self.process_error_indicator.hide() load_gif = str(Path(napari.resources.__file__).parent / "loading.gif") mov = QMovie(load_gif) mov.setScaledSize(QSize(18, 18)) self.working_indicator.setMovie(mov) mov.start() self.direct_entry_edit = QLineEdit(self) self.direct_entry_edit.installEventFilter(self) self.direct_entry_edit.setPlaceholderText( 'install by name/url, or drop file...' ) self.direct_entry_btn = QPushButton("Install", self) self.direct_entry_btn.clicked.connect(self._install_packages) self.show_status_btn = QPushButton("Show Status", self) self.show_status_btn.setFixedWidth(100) self.show_sorter_btn = QPushButton("<< Show Sorter", self) self.close_btn = QPushButton("Close", self) self.close_btn.clicked.connect(self.reject) buttonBox.addWidget(self.show_status_btn) buttonBox.addWidget(self.working_indicator) buttonBox.addWidget(self.direct_entry_edit) buttonBox.addWidget(self.direct_entry_btn) buttonBox.addWidget(self.process_error_indicator) buttonBox.addSpacing(60) buttonBox.addWidget(self.show_sorter_btn) buttonBox.addWidget(self.close_btn) buttonBox.setContentsMargins(0, 0, 4, 0) vlay_1.addLayout(buttonBox) self.show_status_btn.setCheckable(True) self.show_status_btn.setChecked(False) self.show_status_btn.toggled.connect(self._toggle_status) self.show_sorter_btn.setCheckable(True) self.show_sorter_btn.setChecked(False) self.show_sorter_btn.toggled.connect(self._toggle_sorter) self.v_splitter.setStretchFactor(1, 2) self.h_splitter.setStretchFactor(0, 2) def eventFilter(self, watched, event): if event.type() == QEvent.DragEnter: # we need to accept this event explicitly to be able # to receive QDropEvents! event.accept() if event.type() == QEvent.Drop: md = event.mimeData() if md.hasUrls(): files = [url.toLocalFile() for url in md.urls()] self.direct_entry_edit.setText(files[0]) return True return super().eventFilter(watched, event) def _toggle_sorter(self, show): if show: self.show_sorter_btn.setText(">> Hide Sorter") self.plugin_sorter.show() else: self.show_sorter_btn.setText("<< Show Sorter") self.plugin_sorter.hide() def _toggle_status(self, show): if show: self.show_status_btn.setText("Hide Status") self.stdout_text.show() else: self.show_status_btn.setText("Show Status") self.stdout_text.hide() def _install_packages(self, packages: Sequence[str] = ()): if not packages: _packages = self.direct_entry_edit.text() if os.path.exists(_packages): packages = [_packages] else: packages = _packages.split() self.direct_entry_edit.clear() if packages: self.installer.install(packages)
class QtPluginDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.refresh_state = RefreshState.DONE self.already_installed = set() installer_type = "mamba" if running_as_constructor_app() else "pip" self.installer = Installer(installer=installer_type) self.setup_ui() self.installer.set_output_widget(self.stdout_text) self.installer.started.connect(self._on_installer_start) self.installer.finished.connect(self._on_installer_done) self.refresh() def _on_installer_start(self): self.cancel_all_btn.setVisible(True) self.working_indicator.show() self.process_error_indicator.hide() self.close_btn.setDisabled(True) def _on_installer_done(self, exit_code): self.working_indicator.hide() if exit_code: self.process_error_indicator.show() self.cancel_all_btn.setVisible(False) self.close_btn.setDisabled(False) self.refresh() def closeEvent(self, event): if self.close_btn.isEnabled(): super().closeEvent(event) event.ignore() def refresh(self): if self.refresh_state != RefreshState.DONE: self.refresh_state = RefreshState.OUTDATED return self.refresh_state = RefreshState.REFRESHING self.installed_list.clear() self.available_list.clear() # fetch installed from npe2 import PluginManager from ...plugins import plugin_manager plugin_manager.discover() # since they might not be loaded yet self.already_installed = set() def _add_to_installed(distname, enabled, npe_version=1): norm_name = normalized_name(distname or '') if distname: try: meta = metadata(distname) except PackageNotFoundError: self.refresh_state = RefreshState.OUTDATED return # a race condition has occurred and the package is uninstalled by another thread if len(meta) == 0: # will not add builtins. return self.already_installed.add(norm_name) else: meta = {} self.installed_list.addItem( PackageMetadata( metadata_version="1.0", name=norm_name, version=meta.get('version', ''), summary=meta.get('summary', ''), home_page=meta.get('url', ''), author=meta.get('author', ''), license=meta.get('license', ''), ), installed=True, enabled=enabled, npe_version=npe_version, ) pm2 = PluginManager.instance() for manifest in pm2.iter_manifests(): distname = normalized_name(manifest.name or '') if distname in self.already_installed or distname == 'napari': continue enabled = not pm2.is_disabled(manifest.name) # if it's an Npe1 adaptor, call it v1 npev = 'shim' if 'npe1' in type(manifest).__name__.lower() else 2 _add_to_installed(distname, enabled, npe_version=npev) for ( plugin_name, _mod_name, distname, ) in plugin_manager.iter_available(): # not showing these in the plugin dialog if plugin_name in ('napari_plugin_engine', ): continue if distname in self.already_installed: continue _add_to_installed(distname, not plugin_manager.is_blocked(plugin_name)) self.installed_label.setText( trans._( "Installed Plugins ({amount})", amount=len(self.already_installed), )) # fetch available plugins settings = get_settings() use_hub = (running_as_bundled_app() or running_as_constructor_app() or settings.plugins.plugin_api.name == "napari_hub") if use_hub: conda_forge = running_as_constructor_app() self.worker = create_worker(iter_hub_plugin_info, conda_forge=conda_forge) else: self.worker = create_worker(iter_napari_plugin_info) self.worker.yielded.connect(self._handle_yield) self.worker.finished.connect(self.working_indicator.hide) self.worker.finished.connect(self._update_count_in_label) self.worker.finished.connect(self._end_refresh) self.worker.start() def setup_ui(self): self.resize(1080, 640) vlay_1 = QVBoxLayout(self) self.h_splitter = QSplitter(self) vlay_1.addWidget(self.h_splitter) self.h_splitter.setOrientation(Qt.Horizontal) self.v_splitter = QSplitter(self.h_splitter) self.v_splitter.setOrientation(Qt.Vertical) self.v_splitter.setMinimumWidth(500) installed = QWidget(self.v_splitter) lay = QVBoxLayout(installed) lay.setContentsMargins(0, 2, 0, 2) self.installed_label = QLabel(trans._("Installed Plugins")) self.packages_filter = QLineEdit() self.packages_filter.setPlaceholderText(trans._("filter...")) self.packages_filter.setMaximumWidth(350) self.packages_filter.setClearButtonEnabled(True) mid_layout = QVBoxLayout() mid_layout.addWidget(self.packages_filter) mid_layout.addWidget(self.installed_label) lay.addLayout(mid_layout) self.installed_list = QPluginList(installed, self.installer) self.packages_filter.textChanged.connect(self.installed_list.filter) lay.addWidget(self.installed_list) uninstalled = QWidget(self.v_splitter) lay = QVBoxLayout(uninstalled) lay.setContentsMargins(0, 2, 0, 2) self.avail_label = QLabel(trans._("Available Plugins")) mid_layout = QHBoxLayout() mid_layout.addWidget(self.avail_label) mid_layout.addStretch() lay.addLayout(mid_layout) self.available_list = QPluginList(uninstalled, self.installer) self.packages_filter.textChanged.connect(self.available_list.filter) lay.addWidget(self.available_list) self.stdout_text = QTextEdit(self.v_splitter) self.stdout_text.setReadOnly(True) self.stdout_text.setObjectName("pip_install_status") self.stdout_text.hide() buttonBox = QHBoxLayout() self.working_indicator = QLabel(trans._("loading ..."), self) sp = self.working_indicator.sizePolicy() sp.setRetainSizeWhenHidden(True) self.working_indicator.setSizePolicy(sp) self.process_error_indicator = QLabel(self) self.process_error_indicator.setObjectName("error_label") self.process_error_indicator.hide() load_gif = str(Path(napari.resources.__file__).parent / "loading.gif") mov = QMovie(load_gif) mov.setScaledSize(QSize(18, 18)) self.working_indicator.setMovie(mov) mov.start() visibility_direct_entry = not running_as_constructor_app() self.direct_entry_edit = QLineEdit(self) self.direct_entry_edit.installEventFilter(self) self.direct_entry_edit.setPlaceholderText( trans._('install by name/url, or drop file...')) self.direct_entry_edit.setVisible(visibility_direct_entry) self.direct_entry_btn = QPushButton(trans._("Install"), self) self.direct_entry_btn.setVisible(visibility_direct_entry) self.direct_entry_btn.clicked.connect(self._install_packages) self.show_status_btn = QPushButton(trans._("Show Status"), self) self.show_status_btn.setFixedWidth(100) self.cancel_all_btn = QPushButton(trans._("cancel all actions"), self) self.cancel_all_btn.setObjectName("remove_button") self.cancel_all_btn.setVisible(False) self.cancel_all_btn.clicked.connect(lambda: self.installer.cancel()) self.close_btn = QPushButton(trans._("Close"), self) self.close_btn.clicked.connect(self.accept) self.close_btn.setObjectName("close_button") buttonBox.addWidget(self.show_status_btn) buttonBox.addWidget(self.working_indicator) buttonBox.addWidget(self.direct_entry_edit) buttonBox.addWidget(self.direct_entry_btn) if not visibility_direct_entry: buttonBox.addStretch() buttonBox.addWidget(self.process_error_indicator) buttonBox.addSpacing(20) buttonBox.addWidget(self.cancel_all_btn) buttonBox.addSpacing(20) buttonBox.addWidget(self.close_btn) buttonBox.setContentsMargins(0, 0, 4, 0) vlay_1.addLayout(buttonBox) self.show_status_btn.setCheckable(True) self.show_status_btn.setChecked(False) self.show_status_btn.toggled.connect(self._toggle_status) self.v_splitter.setStretchFactor(1, 2) self.h_splitter.setStretchFactor(0, 2) self.packages_filter.setFocus() def _update_count_in_label(self): count = self.available_list.count() self.avail_label.setText( trans._("Available Plugins ({count})", count=count)) def _end_refresh(self): refresh_state = self.refresh_state self.refresh_state = RefreshState.DONE if refresh_state == RefreshState.OUTDATED: self.refresh() def eventFilter(self, watched, event): if event.type() == QEvent.DragEnter: # we need to accept this event explicitly to be able # to receive QDropEvents! event.accept() if event.type() == QEvent.Drop: md = event.mimeData() if md.hasUrls(): files = [url.toLocalFile() for url in md.urls()] self.direct_entry_edit.setText(files[0]) return True return super().eventFilter(watched, event) def _toggle_status(self, show): if show: self.show_status_btn.setText(trans._("Hide Status")) self.stdout_text.show() else: self.show_status_btn.setText(trans._("Show Status")) self.stdout_text.hide() def _install_packages(self, packages: Sequence[str] = ()): if not packages: _packages = self.direct_entry_edit.text() if os.path.exists(_packages): packages = [_packages] else: packages = _packages.split() self.direct_entry_edit.clear() if packages: self.installer.install(packages) def _handle_yield(self, data: Tuple[PackageMetadata, bool]): project_info, is_available = data if project_info.name in self.already_installed: self.installed_list.tag_outdated(project_info, is_available) else: self.available_list.addItem(project_info) if not is_available: self.available_list.tag_unavailable(project_info) self.filter() def filter(self, text: str = None) -> None: """Filter by text or set current text as filter.""" if text is None: text = self.packages_filter.text() else: self.packages_filter.setText(text) self.installed_list.filter(text) self.available_list.filter(text)
class Switcher(QDialog): """ A multi purpose switcher. Example ------- SwitcherItem: [title description <shortcut> section] SwitcherItem: [title description <shortcut> section] SwitcherSeparator: [---------------------------------------] SwitcherItem: [title description <shortcut> section] SwitcherItem: [title description <shortcut> section] """ # Search/Filter text changes sig_text_changed = Signal(TEXT_TYPES[-1]) # List item selected, mode and cleaned search text sig_item_selected = Signal( object, TEXT_TYPES[-1], TEXT_TYPES[-1], ) sig_mode_selected = Signal(TEXT_TYPES[-1]) _MIN_WIDTH = 500 def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, item_separator_styles=ITEM_SEPARATOR_STYLES): """Multi purpose switcher.""" super(Switcher, self).__init__(parent) self._visible_rows = 0 self._modes = {} self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles # Widgets self.edit = QLineEdit(self) self.list = QListView(self) self.model = QStandardItemModel(self.list) self.proxy = SwitcherProxyModel(self.list) self.filter = KeyPressFilter() # Widgets setup # self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setWindowOpacity(0.95) self.edit.installEventFilter(self.filter) self.edit.setPlaceholderText(help_text if help_text else '') self.list.setMinimumWidth(self._MIN_WIDTH) self.list.setItemDelegate(HTMLDelegate(self)) self.list.setFocusPolicy(Qt.NoFocus) self.list.setSelectionBehavior(self.list.SelectRows) self.list.setSelectionMode(self.list.SingleSelection) self.proxy.setSourceModel(self.model) self.list.setModel(self.proxy) # Layout layout = QVBoxLayout() layout.addWidget(self.edit) layout.addWidget(self.list) self.setLayout(layout) # Signals self.filter.sig_up_key_pressed.connect(self.previous_row) self.filter.sig_down_key_pressed.connect(self.next_row) self.filter.sig_enter_key_pressed.connect(self.enter) self.edit.textChanged.connect(self.setup) self.edit.textChanged.connect(self.sig_text_changed) self.edit.returnPressed.connect(self.enter) self.list.clicked.connect(self.enter) self.list.clicked.connect(self.edit.setFocus) # --- Helper methods def _add_item(self, item): """Perform common actions when adding items.""" item.set_width(self._MIN_WIDTH) self.model.appendRow(item) self.set_current_row(0) self._visible_rows = self.model.rowCount() self.setup_sections() # --- API def clear(self): """Remove all items from the list and clear the search text.""" self.set_placeholder_text('') self.model.beginResetModel() self.model.clear() self.model.endResetModel() def set_placeholder_text(self, text): """Set the text appearing on the empty line edit.""" self.edit.setPlaceholderText(text) def add_mode(self, token, description): """Add mode by token key and description.""" if len(token) == 1: self._modes[token] = description else: raise Exception('Token must be of length 1!') def remove_mode(self, token): """Remove mode by token key.""" if token in self._modes: self._modes.pop(token) def clear_modes(self): """Delete all modes spreviously defined.""" del self._modes self._modes = {} def add_item(self, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, action_item=False): """Add switcher list item.""" item = SwitcherItem(parent=self.list, icon=icon, title=title, description=description, data=data, shortcut=shortcut, section=section, action_item=action_item, tool_tip=tool_tip, styles=self._item_styles) self._add_item(item) def add_separator(self): """Add separator item.""" item = SwitcherSeparatorItem(parent=self.list, styles=self._item_separator_styles) self._add_item(item) def setup(self): """Set-up list widget content based on the filtering.""" # Check exited mode mode = self._mode_on if mode: search_text = self.search_text()[len(mode):] else: search_text = self.search_text() # Check exited mode if mode and self.search_text() == '': self._mode_on = '' self.sig_mode_selected.emit(self._mode_on) return # Check entered mode for key in self._modes: if self.search_text().startswith(key) and not mode: self._mode_on = key self.sig_mode_selected.emit(key) return # Filter by text titles = [] for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): title = item.get_title() else: title = '' titles.append(title) search_text = clean_string(search_text) scores = get_search_scores(to_text_string(search_text), titles, template=u"<b>{0}</b>") self._visible_rows = self.model.rowCount() for idx, score in enumerate(scores): title, rich_title, score_value = score item = self.model.item(idx) if not self._is_separator(item): item.set_rich_title(rich_title) item.set_score(score_value) proxy_index = self.proxy.mapFromSource(self.model.index(idx, 0)) if not item.is_action_item(): self.list.setRowHidden(proxy_index.row(), score_value == -1) if score_value == -1: self._visible_rows -= 1 if self._visible_rows: self.set_current_row(0) else: self.set_current_row(-1) self.setup_sections() def setup_sections(self): """Set-up which sections appear on the item list.""" mode = self._mode_on if mode: search_text = self.search_text()[len(mode):] else: search_text = self.search_text() if search_text: for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): item.set_section_visible(False) else: sections = [] for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): sections.append(item.get_section()) item.set_section_visible(bool(search_text)) else: sections.append('') if row != 0: visible = sections[row] != sections[row - 1] if not self._is_separator(item): item.set_section_visible(visible) else: item.set_section_visible(True) self.proxy.sortBy('_score') # --- Qt overrides # ------------------------------------------------------------------------ @Slot() @Slot(QListWidgetItem) def enter(self, itemClicked=None): """Override Qt method.""" row = self.current_row() model_index = self.proxy.mapToSource(self.proxy.index(row, 0)) item = self.model.item(model_index.row()) if item: mode = self._mode_on self.sig_item_selected.emit(item, mode, self.search_text()[len(mode):]) def accept(self): """Override Qt method.""" super(Switcher, self).accept() def resizeEvent(self, event): """Override Qt method.""" super(Switcher, self).resizeEvent(event) # --- Helper methods: Lineedit widget def search_text(self): """Get the normalized (lowecase) content of the search text.""" return to_text_string(self.edit.text()).lower() def set_search_text(self, string): """Set the content of the search text.""" self.edit.setText(string) # --- Helper methods: List widget def _is_separator(self, item): """Check if item is an separator item (SwitcherSeparatorItem).""" return isinstance(item, SwitcherSeparatorItem) def _select_row(self, steps): """Select row in list widget based on a number of steps with direction. Steps can be positive (next rows) or negative (previous rows). """ row = self.current_row() + steps if 0 <= row < self.count(): self.set_current_row(row) def count(self): """Get the item count in the list widget.""" return self._visible_rows def current_row(self): """Return the current selected row in the list widget.""" return self.list.currentIndex().row() def set_current_row(self, row): """Set the current selected row in the list widget.""" index = self.model.index(row, 0) selection_model = self.list.selectionModel() # https://doc.qt.io/qt-5/qitemselectionmodel.html#SelectionFlag-enum selection_model.setCurrentIndex(index, selection_model.ClearAndSelect) def previous_row(self): """Select previous row in list widget.""" steps = 1 prev_row = self.current_row() - steps if prev_row == -1: self.set_current_row(self.count() - 1) else: if prev_row >= 0: # Need to map the filtered list to the actual model items list_index = self.proxy.index(prev_row, 0) model_index = self.proxy.mapToSource(list_index) item = self.model.item(model_index.row(), 0) if self._is_separator(item): steps += 1 self._select_row(-steps) def next_row(self): """Select next row in list widget.""" steps = 1 next_row = self.current_row() + steps # Need to map the filtered list to the actual model items list_index = self.proxy.index(next_row, 0) model_index = self.proxy.mapToSource(list_index) item = self.model.item(model_index.row(), 0) if next_row >= self.count(): self.set_current_row(0) else: if item: if self._is_separator(item): steps += 1 self._select_row(steps)
class DlgGitHubLogin(QDialog): """Dialog to submit error reports to Github.""" def __init__(self, parent, username, password, token, remember=False, remember_token=False): super(DlgGitHubLogin, self).__init__(parent) title = _("Sign in to Github") self.resize(415, 375) self.setWindowTitle(title) self.setWindowFlags( self.windowFlags() & ~Qt.WindowContextHelpButtonHint) # Header html = ('<html><head/><body><p align="center"><img src="{mark}"/></p>' '<p align="center">{title}</p></body></html>') mark = GH_MARK_NORMAL if self.palette().base().color().lightness() < 128: mark = GH_MARK_LIGHT lbl_html = QLabel(html.format(mark=mark, title=title)) # Tabs self.tabs = QTabWidget() # Basic form layout basic_form_layout = QFormLayout() basic_form_layout.setContentsMargins(-1, 0, -1, -1) basic_lbl_msg = QLabel(_("For regular users, i.e. users <b>without</b>" " two-factor authentication enabled")) basic_lbl_msg.setWordWrap(True) basic_lbl_msg.setAlignment(Qt.AlignJustify) lbl_user = QLabel(_("Username:"******"", QWidget()) lbl_password = QLabel(_("Password: "******"Remember me")) self.cb_remember.setToolTip(_("Spyder will save your credentials " "safely")) self.cb_remember.setChecked(remember) basic_form_layout.setWidget(4, QFormLayout.FieldRole, self.cb_remember) # Basic auth tab basic_auth = QWidget() basic_layout = QVBoxLayout() basic_layout.addSpacerItem(QSpacerItem(QSpacerItem(0, 8))) basic_layout.addWidget(basic_lbl_msg) basic_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 1000, vPolicy=QSizePolicy.Expanding))) basic_layout.addLayout(basic_form_layout) basic_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 1000, vPolicy=QSizePolicy.Expanding))) basic_auth.setLayout(basic_layout) self.tabs.addTab(basic_auth, _("Password Only")) # Token form layout token_form_layout = QFormLayout() token_form_layout.setContentsMargins(-1, 0, -1, -1) token_lbl_msg = QLabel(_("For users <b>with</b> two-factor " "authentication enabled, or who prefer a " "per-app token authentication.<br><br>" "You can go <b><a href=\"{}\">here</a></b> " "and click \"Generate token\" at the bottom " "to create a new token to use for this, with " "the appropriate permissions.").format( TOKEN_URL)) token_lbl_msg.setOpenExternalLinks(True) token_lbl_msg.setWordWrap(True) token_lbl_msg.setAlignment(Qt.AlignJustify) lbl_token = QLabel("Token: ") token_form_layout.setWidget(1, QFormLayout.LabelRole, lbl_token) self.le_token = QLineEdit() self.le_token.setEchoMode(QLineEdit.Password) self.le_token.textChanged.connect(self.update_btn_state) token_form_layout.setWidget(1, QFormLayout.FieldRole, self.le_token) self.cb_remember_token = None # Same validation as with cb_remember if self.is_keyring_available() and valid_py_os: self.cb_remember_token = QCheckBox(_("Remember token")) self.cb_remember_token.setToolTip(_("Spyder will save your " "token safely")) self.cb_remember_token.setChecked(remember_token) token_form_layout.setWidget(3, QFormLayout.FieldRole, self.cb_remember_token) # Token auth tab token_auth = QWidget() token_layout = QVBoxLayout() token_layout.addSpacerItem(QSpacerItem(QSpacerItem(0, 8))) token_layout.addWidget(token_lbl_msg) token_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 1000, vPolicy=QSizePolicy.Expanding))) token_layout.addLayout(token_form_layout) token_layout.addSpacerItem( QSpacerItem(QSpacerItem(0, 1000, vPolicy=QSizePolicy.Expanding))) token_auth.setLayout(token_layout) self.tabs.addTab(token_auth, _("Access Token")) # Sign in button self.bt_sign_in = QPushButton(_("Sign in")) self.bt_sign_in.clicked.connect(self.accept) self.bt_sign_in.setDisabled(True) # Main layout layout = QVBoxLayout() layout.addWidget(lbl_html) layout.addWidget(self.tabs) layout.addWidget(self.bt_sign_in) self.setLayout(layout) # Final adjustments if username and password: self.le_user.setText(username) self.le_password.setText(password) self.bt_sign_in.setFocus() elif username: self.le_user.setText(username) self.le_password.setFocus() elif token: self.le_token.setText(token) else: self.le_user.setFocus() self.setFixedSize(self.width(), self.height()) self.le_password.installEventFilter(self) self.le_user.installEventFilter(self) self.tabs.currentChanged.connect(self.update_btn_state) def eventFilter(self, obj, event): interesting_objects = [self.le_password, self.le_user] if obj in interesting_objects and event.type() == QEvent.KeyPress: if (event.key() == Qt.Key_Return and event.modifiers() & Qt.ControlModifier and self.bt_sign_in.isEnabled()): self.accept() return True return False def update_btn_state(self): user = to_text_string(self.le_user.text()).strip() != '' password = to_text_string(self.le_password.text()).strip() != '' token = to_text_string(self.le_token.text()).strip() != '' enable = ((user and password and self.tabs.currentIndex() == 0) or (token and self.tabs.currentIndex() == 1)) self.bt_sign_in.setEnabled(enable) def is_keyring_available(self): """Check if keyring is available for password storage.""" try: import keyring # analysis:ignore return True except Exception: return False @classmethod def login(cls, parent, username, password, token, remember, remember_token): dlg = DlgGitHubLogin(parent, username, password, token, remember, remember_token) if dlg.exec_() == dlg.Accepted: user = dlg.le_user.text() password = dlg.le_password.text() token = dlg.le_token.text() if dlg.cb_remember: remember = dlg.cb_remember.isChecked() else: remember = False if dlg.cb_remember_token: remember_token = dlg.cb_remember_token.isChecked() else: remember_token = False credentials = dict(username=user, password=password, token=token, remember=remember, remember_token=remember_token) return credentials return dict(username=None, password=None, token=None, remember=False, remember_token=False)
def createEditor(self, parent, option, index): tnode = self._model.nodeFromPath(self._parent._data[index.row()]) if tnode.sidsIsCGNSTree(): return None if tnode.sidsIsLink(): return None if tnode.sidsIsLinkChild(): return None ws = option.rect.width() hs = option.rect.height() + 4 xs = option.rect.x() ys = option.rect.y() - 2 if index.column() == 2: self._lastCol = COLUMN_SIDS if self._parent.cForce.checkState() != Qt.Checked: self._mode = CELLTEXT editor = QLineEdit(parent) editor.transgeometry = (xs, ys, ws, hs) else: self._mode = CELLCOMBO editor = QComboBox(parent) editor.transgeometry = (xs, ys, ws, hs) itemslist = tnode.sidsTypeList() editor.addItems(itemslist) try: tix = itemslist.index(tnode.sidsType()) except ValueError: editor.insertItem(0, tnode.sidsType()) tix = 0 editor.setCurrentIndex(tix) editor.installEventFilter(self) self.setEditorData(editor, index) return editor if index.column() == 3: self._lastCol = COLUMN_DATATYPE self._mode = CELLCOMBO editor = QComboBox(parent) editor.transgeometry = (xs, ys, ws, hs) itemslist = tnode.sidsDataType(all=True) editor.addItems(itemslist) editor.setCurrentIndex(0) editor.installEventFilter(self) self.setEditorData(editor, index) return editor if index.column() == 4: self._lastCol = COLUMN_VALUE if tnode.hasValueView(): pt = tnode.sidsPath().split('/')[1:] lt = tnode.sidsTypePath() fc = self._parent._control.userFunctionFromPath(pt, lt) if fc is not None: en = fc.getEnumerate(pt, lt) else: en = tnode.sidsValueEnum() if en is None: self._mode = CELLTEXT editor = QLineEdit(parent) editor.transgeometry = (xs, ys, ws, hs) else: self._mode = CELLCOMBO editor = QComboBox(parent) editor.transgeometry = (xs, ys, ws, hs) editor.addItems(en) try: tix = en.index( tnode.sidsValue().tostring().decode('ascii')) except ValueError: editor.insertItem( 0, tnode.sidsValue().tostring().decode('ascii')) tix = 0 editor.setCurrentIndex(tix) editor.installEventFilter(self) self.setEditorData(editor, index) return editor return None
class FileSwitcher(QDialog): """A Sublime-like file switcher.""" sig_goto_file = Signal(int) sig_close_file = Signal(int) # Constants that define the mode in which the list widget is working # FILE_MODE is for a list of files, SYMBOL_MODE if for a list of symbols # in a given file when using the '@' symbol. FILE_MODE, SYMBOL_MODE = [1, 2] def __init__(self, parent, tabs, data): QDialog.__init__(self, parent) # Variables self.tabs = tabs # Editor stack tabs self.data = data # Editor data self.mode = self.FILE_MODE # By default start in this mode self.initial_cursors = None # {fullpath: QCursor} self.initial_path = None # Fullpath of initial active editor self.initial_editor = None # Initial active editor self.line_number = None # Selected line number in filer self.is_visible = False # Is the switcher visible? help_text = _("Press <b>Enter</b> to switch files or <b>Esc</b> to " "cancel.<br><br>Type to filter filenames.<br><br>" "Use <b>:number</b> to go to a line, e.g. " "<b><code>main:42</code></b><br>" "Use <b>@symbol_text</b> to go to a symbol, e.g. " "<b><code>@init</code></b>" "<br><br> Press <b>Ctrl+W</b> to close current tab.<br>") # Either allow searching for a line number or a symbol but not both regex = QRegExp("([A-Za-z0-9_]{0,100}@[A-Za-z0-9_]{0,100})|" + "([A-Za-z0-9_]{0,100}:{0,1}[0-9]{0,100})") # Widgets self.edit = QLineEdit(self) self.help = HelperToolButton() self.list = QListWidget(self) self.filter = KeyPressFilter() regex_validator = QRegExpValidator(regex, self.edit) # Widgets setup self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setWindowOpacity(0.95) self.edit.installEventFilter(self.filter) self.edit.setValidator(regex_validator) self.help.setToolTip(help_text) self.list.setItemDelegate(HTMLDelegate(self)) # Layout edit_layout = QHBoxLayout() edit_layout.addWidget(self.edit) edit_layout.addWidget(self.help) layout = QVBoxLayout() layout.addLayout(edit_layout) layout.addWidget(self.list) self.setLayout(layout) # Signals self.rejected.connect(self.restore_initial_state) self.filter.sig_up_key_pressed.connect(self.previous_row) self.filter.sig_down_key_pressed.connect(self.next_row) self.edit.returnPressed.connect(self.accept) self.edit.textChanged.connect(self.setup) self.list.itemSelectionChanged.connect(self.item_selection_changed) self.list.clicked.connect(self.edit.setFocus) # Setup self.save_initial_state() self.set_dialog_position() self.setup() # --- Properties @property def editors(self): return [self.tabs.widget(index) for index in range(self.tabs.count())] @property def line_count(self): return [editor.get_line_count() for editor in self.editors] @property def save_status(self): return [getattr(td, 'newly_created', False) for td in self.data] @property def paths(self): return [getattr(td, 'filename', None) for td in self.data] @property def filenames(self): return [self.tabs.tabText(index) for index in range(self.tabs.count())] @property def current_path(self): return self.paths_by_editor[self.get_editor()] @property def paths_by_editor(self): return dict(zip(self.editors, self.paths)) @property def editors_by_path(self): return dict(zip(self.paths, self.editors)) @property def filter_text(self): """Get the normalized (lowecase) content of the filter text.""" return to_text_string(self.edit.text()).lower() def save_initial_state(self): """Saves initial cursors and initial active editor.""" paths = self.paths self.initial_editor = self.get_editor() self.initial_cursors = {} for i, editor in enumerate(self.editors): if editor is self.initial_editor: self.initial_path = paths[i] self.initial_cursors[paths[i]] = editor.textCursor() def accept(self): self.is_visible = False QDialog.accept(self) self.list.clear() def restore_initial_state(self): """Restores initial cursors and initial active editor.""" self.list.clear() self.is_visible = False editors = self.editors_by_path for path in self.initial_cursors: cursor = self.initial_cursors[path] if path in editors: self.set_editor_cursor(editors[path], cursor) if self.initial_editor in self.paths_by_editor: index = self.paths.index(self.initial_path) self.sig_goto_file.emit(index) def set_dialog_position(self): """Positions the file switcher dialog in the center of the editor.""" parent = self.parent() geo = parent.geometry() width = self.list.width() # This has been set in setup left = parent.geometry().width() / 2 - width / 2 top = 0 while parent: geo = parent.geometry() top += geo.top() left += geo.left() parent = parent.parent() # Note: the +1 pixel on the top makes it look better self.move(left, top + self.tabs.tabBar().geometry().height() + 1) def fix_size(self, content, extra=50): """ Adjusts the width and height of the file switcher, based on its content. """ # Update size of dialog based on longest shortened path strings = [] if content: for rich_text in content: label = QLabel(rich_text) label.setTextFormat(Qt.PlainText) strings.append(label.text()) fm = label.fontMetrics() # Max width max_width = max([fm.width(s) * 1.3 for s in strings]) self.list.setMinimumWidth(max_width + extra) # Max height if len(strings) < 8: max_entries = len(strings) else: max_entries = 8 max_height = fm.height() * max_entries * 2.5 self.list.setMinimumHeight(max_height) # Set position according to size self.set_dialog_position() # --- Helper methods: List widget def count(self): """Gets the item count in the list widget.""" return self.list.count() def current_row(self): """Returns the current selected row in the list widget.""" return self.list.currentRow() def set_current_row(self, row): """Sets the current selected row in the list widget.""" return self.list.setCurrentRow(row) def select_row(self, steps): """Select row in list widget based on a number of steps with direction. Steps can be positive (next rows) or negative (previous rows). """ row = self.current_row() + steps if 0 <= row < self.count(): self.set_current_row(row) def previous_row(self): """Select previous row in list widget.""" self.select_row(-1) def next_row(self): """Select next row in list widget.""" self.select_row(+1) # --- Helper methods: Editor def get_editor(self, index=None, path=None): """Get editor by index or path. If no path or index specified the current active editor is returned """ if index: return self.tabs.widget(index) elif path: return self.tabs.widget(index) else: return self.parent().get_current_editor() def set_editor_cursor(self, editor, cursor): """Set the cursor of an editor.""" pos = cursor.position() anchor = cursor.anchor() new_cursor = QTextCursor() if pos == anchor: new_cursor.movePosition(pos) else: new_cursor.movePosition(anchor) new_cursor.movePosition(pos, QTextCursor.KeepAnchor) editor.setTextCursor(cursor) def goto_line(self, line_number): """Go to specified line number in current active editor.""" if line_number: line_number = int(line_number) editor = self.get_editor() editor.go_to_line(min(line_number, editor.get_line_count())) # --- Helper methods: Outline explorer def get_symbol_list(self): """Get the list of symbols present in the file.""" try: oedata = self.get_editor().get_outlineexplorer_data() except AttributeError: oedata = {} return oedata # --- Handlers def item_selection_changed(self): """List widget item selection change handler.""" row = self.current_row() if self.count() and row >= 0: if self.mode == self.FILE_MODE: try: stack_index = self.paths.index(self.filtered_path[row]) self.sig_goto_file.emit(stack_index) self.goto_line(self.line_number) self.edit.setFocus() except ValueError: pass else: line_number = self.filtered_symbol_lines[row] self.goto_line(line_number) def setup_file_list(self, filter_text, current_path): """Setup list widget content for file list display.""" short_paths = shorten_paths(self.paths, self.save_status) paths = self.paths results = [] trying_for_line_number = ':' in filter_text # Get optional line number if trying_for_line_number: filter_text, line_number = filter_text.split(':') else: line_number = None # Get all available filenames and get the scores for "fuzzy" matching scores = get_search_scores(filter_text, self.filenames, template="<b>{0}</b>") # Build the text that will appear on the list widget for index, score in enumerate(scores): text, rich_text, score_value = score if score_value != -1: text_item = '<big>' + rich_text.replace('&', '') + '</big>' if trying_for_line_number: text_item += " [{0:} {1:}]".format(self.line_count[index], _("lines")) text_item += u"<br><i>{0:}</i>".format(short_paths[index]) results.append((score_value, index, text_item)) # Sort the obtained scores and populate the list widget self.filtered_path = [] for result in sorted(results): index = result[1] text = result[-1] path = paths[index] item = QListWidgetItem(ima.icon('FileIcon'), text) item.setToolTip(path) item.setSizeHint(QSize(0, 25)) self.list.addItem(item) self.filtered_path.append(path) # To adjust the delegate layout for KDE themes self.list.files_list = True # Move selected item in list accordingly and update list size if current_path in self.filtered_path: self.set_current_row(self.filtered_path.index(current_path)) elif self.filtered_path: self.set_current_row(0) self.fix_size(short_paths, extra=200) # If a line number is searched look for it self.line_number = line_number self.goto_line(line_number) def setup_symbol_list(self, filter_text, current_path): """Setup list widget content for symbol list display.""" # Get optional symbol name filter_text, symbol_text = filter_text.split('@') # Fetch the Outline explorer data, get the icons and values oedata = self.get_symbol_list() icons = get_python_symbol_icons(oedata) symbol_list = process_python_symbol_data(oedata) line_fold_token = [(item[0], item[2], item[3]) for item in symbol_list] choices = [item[1] for item in symbol_list] scores = get_search_scores(symbol_text, choices, template="<b>{0}</b>") # Build the text that will appear on the list widget results = [] lines = [] self.filtered_symbol_lines = [] for index, score in enumerate(scores): text, rich_text, score_value = score line, fold_level, token = line_fold_token[index] lines.append(text) if score_value != -1: results.append((score_value, line, text, rich_text, fold_level, icons[index], token)) template = '{0}{1}' for (score, line, text, rich_text, fold_level, icon, token) in sorted(results): fold_space = ' ' * (fold_level) line_number = line + 1 self.filtered_symbol_lines.append(line_number) textline = template.format(fold_space, rich_text) item = QListWidgetItem(icon, textline) item.setSizeHint(QSize(0, 16)) self.list.addItem(item) # To adjust the delegate layout for KDE themes self.list.files_list = False # Move selected item in list accordingly # NOTE: Doing this is causing two problems: # 1. It makes the cursor to auto-jump to the last selected # symbol after opening or closing a different file # 2. It moves the cursor to the first symbol by default, # which is very distracting. # That's why this line is commented! # self.set_current_row(0) # Update list size self.fix_size(lines, extra=125) def setup(self): """Setup list widget content.""" if not self.tabs.count(): self.close() return self.list.clear() current_path = self.current_path filter_text = self.filter_text # Get optional line or symbol to define mode and method handler trying_for_symbol = ('@' in self.filter_text) if trying_for_symbol: self.mode = self.SYMBOL_MODE self.setup_symbol_list(filter_text, current_path) else: self.mode = self.FILE_MODE self.setup_file_list(filter_text, current_path)
class Switcher(QDialog): """ A multi purpose switcher. Example ------- SwitcherItem: [title description <shortcut> section] SwitcherItem: [title description <shortcut> section] SwitcherSeparator: [---------------------------------------] SwitcherItem: [title description <shortcut> section] SwitcherItem: [title description <shortcut> section] """ # Dismissed switcher sig_rejected = Signal() # Search/Filter text changes sig_text_changed = Signal(TEXT_TYPES[-1]) # Current item changed sig_item_changed = Signal(object) # List item selected, mode and cleaned search text sig_item_selected = Signal( object, TEXT_TYPES[-1], TEXT_TYPES[-1], ) sig_mode_selected = Signal(TEXT_TYPES[-1]) _MAX_NUM_ITEMS = 15 _MIN_WIDTH = 580 _MIN_HEIGHT = 200 _MAX_HEIGHT = 390 _ITEM_WIDTH = _MIN_WIDTH - 20 def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, item_separator_styles=ITEM_SEPARATOR_STYLES): """Multi purpose switcher.""" super(Switcher, self).__init__(parent) self._modes = {} self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles # Widgets self.edit = QLineEdit(self) self.list = QListView(self) self.model = QStandardItemModel(self.list) self.proxy = SwitcherProxyModel(self.list) self.filter = KeyPressFilter() # Widgets setup self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setWindowOpacity(0.95) # self.setMinimumHeight(self._MIN_HEIGHT) self.setMaximumHeight(self._MAX_HEIGHT) self.edit.installEventFilter(self.filter) self.edit.setPlaceholderText(help_text if help_text else '') self.list.setMinimumWidth(self._MIN_WIDTH) self.list.setItemDelegate(SwitcherDelegate(self)) self.list.setFocusPolicy(Qt.NoFocus) self.list.setSelectionBehavior(self.list.SelectItems) self.list.setSelectionMode(self.list.SingleSelection) self.list.setVerticalScrollMode(QAbstractItemView.ScrollPerItem) self.proxy.setSourceModel(self.model) self.list.setModel(self.proxy) # Layout layout = QVBoxLayout() layout.addWidget(self.edit) layout.addWidget(self.list) self.setLayout(layout) # Signals self.filter.sig_up_key_pressed.connect(self.previous_row) self.filter.sig_down_key_pressed.connect(self.next_row) self.filter.sig_enter_key_pressed.connect(self.enter) self.edit.textChanged.connect(self.setup) self.edit.textChanged.connect(self.sig_text_changed) self.edit.returnPressed.connect(self.enter) self.list.clicked.connect(self.enter) self.list.clicked.connect(self.edit.setFocus) self.list.selectionModel().currentChanged.connect( self.current_item_changed) self.edit.setFocus() # --- Helper methods def _add_item(self, item, last_item=True): """Perform common actions when adding items.""" item.set_width(self._ITEM_WIDTH) self.model.appendRow(item) if last_item: # Only set the current row to the first item when the added item is # the last one in order to prevent performance issues when # adding multiple items self.set_current_row(0) self.set_height() self.setup_sections() # --- API def clear(self): """Remove all items from the list and clear the search text.""" self.set_placeholder_text('') self.model.beginResetModel() self.model.clear() self.model.endResetModel() self.setMinimumHeight(self._MIN_HEIGHT) def set_placeholder_text(self, text): """Set the text appearing on the empty line edit.""" self.edit.setPlaceholderText(text) def add_mode(self, token, description): """Add mode by token key and description.""" if len(token) == 1: self._modes[token] = description else: raise Exception('Token must be of length 1!') def get_mode(self): """Get the current mode the switcher is in.""" return self._mode_on def remove_mode(self, token): """Remove mode by token key.""" if token in self._modes: self._modes.pop(token) def clear_modes(self): """Delete all modes spreviously defined.""" del self._modes self._modes = {} def add_item(self, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, action_item=False, last_item=True): """Add switcher list item.""" item = SwitcherItem(parent=self.list, icon=icon, title=title, description=description, data=data, shortcut=shortcut, section=section, action_item=action_item, tool_tip=tool_tip, styles=self._item_styles) self._add_item(item, last_item=last_item) def add_separator(self): """Add separator item.""" item = SwitcherSeparatorItem(parent=self.list, styles=self._item_separator_styles) self._add_item(item) def setup(self): """Set-up list widget content based on the filtering.""" # Check exited mode mode = self._mode_on if mode: search_text = self.search_text()[len(mode):] else: search_text = self.search_text() # Check exited mode if self.search_text() == '': self._mode_on = '' self.clear() self.proxy.set_filter_by_score(False) self.sig_mode_selected.emit(self._mode_on) return # Check entered mode for key in self._modes: if self.search_text().startswith(key) and not mode: self._mode_on = key self.sig_mode_selected.emit(key) return # Filter by text titles = [] for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): title = item.get_title() else: title = '' titles.append(title) search_text = clean_string(search_text) scores = get_search_scores(to_text_string(search_text), titles, template=u"<b>{0}</b>") for idx, (title, rich_title, score_value) in enumerate(scores): item = self.model.item(idx) if not self._is_separator(item) and not item.is_action_item(): rich_title = rich_title.replace(" ", " ") item.set_rich_title(rich_title) item.set_score(score_value) self.proxy.set_filter_by_score(True) self.setup_sections() if self.count(): self.set_current_row(0) else: self.set_current_row(-1) self.set_height() def setup_sections(self): """Set-up which sections appear on the item list.""" mode = self._mode_on if mode: search_text = self.search_text()[len(mode):] else: search_text = self.search_text() if search_text: for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): item.set_section_visible(False) else: sections = [] for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): sections.append(item.get_section()) item.set_section_visible(bool(search_text)) else: sections.append('') if row != 0: visible = sections[row] != sections[row - 1] if not self._is_separator(item): item.set_section_visible(visible) else: item.set_section_visible(True) self.proxy.sortBy('_score') self.sig_item_changed.emit(self.current_item()) def set_height(self): """Set height taking into account the number of items.""" if self.count() >= self._MAX_NUM_ITEMS: switcher_height = self._MAX_HEIGHT elif self.count() != 0 and self.current_item(): current_item = self.current_item() item_height = current_item.get_height() list_height = item_height * (self.count() + 3) edit_height = self.edit.height() spacing_height = self.layout().spacing() * 4 switcher_height = list_height + edit_height + spacing_height switcher_height = max(switcher_height, self._MIN_HEIGHT) else: switcher_height = self._MIN_HEIGHT self.setFixedHeight(switcher_height) def set_position(self, top): """Set the position of the dialog.""" parent = self.parent() if parent is not None: geo = parent.geometry() width = self.list.width() # This has been set in setup left = parent.geometry().width() / 2 - width / 2 while parent: geo = parent.geometry() top += geo.top() left += geo.left() parent = parent.parent() self.move(round(left), top) @Slot(QModelIndex, QModelIndex) def current_item_changed(self, current, previous): """Handle item selection.""" self.sig_item_changed.emit(self.current_item()) # --- Qt overrides # ------------------------------------------------------------------------ @Slot() @Slot(QListWidgetItem) def enter(self, itemClicked=None): """Override Qt method.""" row = self.current_row() model_index = self.proxy.mapToSource(self.proxy.index(row, 0)) item = self.model.item(model_index.row()) if item: mode = self._mode_on self.sig_item_selected.emit(item, mode, self.search_text()[len(mode):]) def accept(self): """Override Qt method.""" super(Switcher, self).accept() def reject(self): """Override Qt method.""" self.set_search_text('') self.sig_rejected.emit() super(Switcher, self).reject() def resizeEvent(self, event): """Override Qt method.""" super(Switcher, self).resizeEvent(event) # --- Helper methods: Lineedit widget def search_text(self): """Get the normalized (lowecase) content of the search text.""" return to_text_string(self.edit.text()).lower() def set_search_text(self, string): """Set the content of the search text.""" self.edit.setText(string) # --- Helper methods: List widget def _is_separator(self, item): """Check if item is an separator item (SwitcherSeparatorItem).""" return isinstance(item, SwitcherSeparatorItem) def _select_row(self, steps): """Select row in list widget based on a number of steps with direction. Steps can be positive (next rows) or negative (previous rows). """ row = self.current_row() + steps if 0 <= row < self.count(): self.set_current_row(row) def count(self): """Get the item count in the list widget.""" return self.proxy.rowCount() def current_row(self): """Return the current selected row in the list widget.""" return self.list.currentIndex().row() def current_item(self): """Return the current selected item in the list widget.""" row = self.current_row() model_index = self.proxy.mapToSource(self.proxy.index(row, 0)) item = self.model.item(model_index.row()) return item def set_current_row(self, row): """Set the current selected row in the list widget.""" proxy_index = self.proxy.index(row, 0) selection_model = self.list.selectionModel() # https://doc.qt.io/qt-5/qitemselectionmodel.html#SelectionFlag-enum selection_model.setCurrentIndex(proxy_index, selection_model.ClearAndSelect) # Ensure that the selected item is visible self.list.scrollTo(proxy_index, QAbstractItemView.EnsureVisible) def previous_row(self): """Select previous row in list widget.""" steps = 1 prev_row = self.current_row() - steps if prev_row == -1: self.set_current_row(self.count() - 1) else: if prev_row >= 0: # Need to map the filtered list to the actual model items list_index = self.proxy.index(prev_row, 0) model_index = self.proxy.mapToSource(list_index) item = self.model.item(model_index.row(), 0) if self._is_separator(item): steps += 1 self._select_row(-steps) def next_row(self): """Select next row in list widget.""" steps = 1 next_row = self.current_row() + steps # Need to map the filtered list to the actual model items list_index = self.proxy.index(next_row, 0) model_index = self.proxy.mapToSource(list_index) item = self.model.item(model_index.row(), 0) if next_row >= self.count(): self.set_current_row(0) else: if item: if self._is_separator(item): steps += 1 self._select_row(steps)