napari-plugin-manager 0.1.0a2__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,37 @@
1
+ import contextlib
1
2
  import importlib.metadata
2
3
  import os
3
- import re
4
4
  import sys
5
- from enum import Enum, auto
5
+ import webbrowser
6
6
  from functools import partial
7
7
  from pathlib import Path
8
- from typing import Dict, List, Literal, Optional, Sequence, Tuple
8
+ from typing import Dict, List, Literal, NamedTuple, Optional, Sequence, Tuple
9
9
 
10
10
  import napari.plugins
11
11
  import napari.resources
12
12
  import npe2
13
- from napari._qt.qt_resources import QColoredSVGIcon
13
+ from napari._qt.qt_resources import QColoredSVGIcon, get_current_stylesheet
14
14
  from napari._qt.qthreading import create_worker
15
15
  from napari._qt.widgets.qt_message_popup import WarnPopup
16
16
  from napari._qt.widgets.qt_tooltip import QtToolTipLabel
17
- from napari.plugins.npe2api import iter_napari_plugin_info
18
17
  from napari.plugins.utils import normalized_name
19
18
  from napari.settings import get_settings
20
19
  from napari.utils.misc import (
20
+ StringEnum,
21
21
  parse_version,
22
22
  running_as_constructor_app,
23
23
  )
24
+ from napari.utils.notifications import show_info, show_warning
24
25
  from napari.utils.translations import trans
25
- from qtpy.QtCore import QEvent, QPoint, QSize, Qt, QTimer, Slot
26
- from qtpy.QtGui import QFont, QMovie
26
+ from qtpy.QtCore import QPoint, QSize, Qt, QTimer, Signal, Slot
27
+ from qtpy.QtGui import (
28
+ QAction,
29
+ QActionGroup,
30
+ QFont,
31
+ QKeySequence,
32
+ QMovie,
33
+ QShortcut,
34
+ )
27
35
  from qtpy.QtWidgets import (
28
36
  QCheckBox,
29
37
  QComboBox,
@@ -35,95 +43,136 @@ from qtpy.QtWidgets import (
35
43
  QLineEdit,
36
44
  QListWidget,
37
45
  QListWidgetItem,
46
+ QMenu,
38
47
  QPushButton,
39
48
  QSizePolicy,
40
49
  QSplitter,
41
50
  QTextEdit,
51
+ QToolButton,
42
52
  QVBoxLayout,
43
53
  QWidget,
44
54
  )
45
55
  from superqt import QCollapsible, QElidingLabel
46
56
 
57
+ from napari_plugin_manager.npe2api import (
58
+ cache_clear,
59
+ iter_napari_plugin_info,
60
+ )
47
61
  from napari_plugin_manager.qt_package_installer import (
48
62
  InstallerActions,
49
63
  InstallerQueue,
50
64
  InstallerTools,
65
+ ProcessFinishedData,
51
66
  )
52
-
53
- # TODO: add error icon and handle pip install errors
67
+ from napari_plugin_manager.qt_widgets import ClickableLabel
68
+ from napari_plugin_manager.utils import is_conda_package
54
69
 
55
70
  # Scaling factor for each list widget item when expanding.
56
- SCALE = 1.6
57
-
58
71
  CONDA = 'Conda'
59
72
  PYPI = 'PyPI'
73
+ ON_BUNDLE = running_as_constructor_app()
74
+ IS_NAPARI_CONDA_INSTALLED = is_conda_package('napari')
75
+ STYLES_PATH = Path(__file__).parent / 'styles.qss'
60
76
 
61
77
 
62
- def is_conda_package(pkg: str):
63
- """Determines if plugin was installed through conda.
64
-
65
- Returns
66
- -------
67
- bool: True if a conda package, False if not
68
- """
78
+ def _show_message(widget):
79
+ message = trans._(
80
+ 'When installing/uninstalling npe2 plugins, '
81
+ 'you must restart napari for UI changes to take effect.'
82
+ )
83
+ if widget.isVisible():
84
+ button = widget.action_button
85
+ warn_dialog = WarnPopup(text=message)
86
+ global_point = widget.action_button.mapToGlobal(
87
+ button.rect().topRight()
88
+ )
89
+ global_point = QPoint(
90
+ global_point.x() - button.width(), global_point.y()
91
+ )
92
+ warn_dialog.move(global_point)
93
+ warn_dialog.exec_()
69
94
 
70
- # Installed conda packages within a conda installation and environment can be identified as files
71
- # with the template `<package-name>-<version>-<build-string>.json` saved within a `conda-meta` folder within
72
- # the given environment of interest.
73
95
 
74
- conda_meta_dir = Path(sys.prefix) / 'conda-meta'
75
- return any(
76
- re.match(rf"{pkg}-[^-]+-[^-]+.json", p.name)
77
- for p in conda_meta_dir.glob(f"{pkg}-*-*.json")
78
- )
96
+ class ProjectInfoVersions(NamedTuple):
97
+ metadata: npe2.PackageMetadata
98
+ display_name: str
99
+ pypi_versions: List[str]
100
+ conda_versions: List[str]
79
101
 
80
102
 
81
103
  class PluginListItem(QFrame):
82
104
  """An entry in the plugin dialog. This will include the package name, summary,
83
105
  author, source, version, and buttons to update, install/uninstall, etc."""
84
106
 
107
+ # item, package_name, action_name, version, installer_choice
108
+ actionRequested = Signal(QListWidgetItem, str, StringEnum, str, StringEnum)
109
+
85
110
  def __init__(
86
111
  self,
112
+ item: QListWidgetItem,
87
113
  package_name: str,
114
+ display_name: str,
88
115
  version: str = '',
89
116
  url: str = '',
90
117
  summary: str = '',
91
118
  author: str = '',
92
119
  license: str = "UNKNOWN", # noqa: A002
93
120
  *,
94
- plugin_name: str = None,
121
+ plugin_name: Optional[str] = None,
95
122
  parent: QWidget = None,
96
123
  enabled: bool = True,
97
124
  installed: bool = False,
98
125
  npe_version=1,
99
- versions_conda: List[str] = None,
100
- versions_pypi: List[str] = None,
126
+ versions_conda: Optional[List[str]] = None,
127
+ versions_pypi: Optional[List[str]] = None,
128
+ prefix=None,
101
129
  ) -> None:
102
130
  super().__init__(parent)
131
+ self.prefix = prefix
132
+ self.item = item
103
133
  self.url = url
134
+ self.name = package_name
135
+ self.npe_version = npe_version
136
+ self._version = version
104
137
  self._versions_conda = versions_conda
105
138
  self._versions_pypi = versions_pypi
106
139
  self.setup_ui(enabled)
107
- self.plugin_name.setText(package_name)
140
+
141
+ if package_name == display_name:
142
+ name = package_name
143
+ else:
144
+ name = f"{display_name} <small>({package_name})</small>"
145
+
146
+ self.plugin_name.setText(name)
108
147
 
109
148
  if len(versions_pypi) > 0:
110
149
  self._populate_version_dropdown(PYPI)
111
150
  else:
112
151
  self._populate_version_dropdown(CONDA)
113
152
 
114
- self.package_name.setText(version)
153
+ mod_version = version.replace('.', '․') # noqa: RUF001
154
+ self.version.setWordWrap(True)
155
+ self.version.setText(mod_version)
156
+ self.version.setToolTip(version)
157
+
115
158
  if summary:
116
- self.summary.setText(summary + '<br />')
159
+ self.summary.setText(summary)
160
+
117
161
  if author:
118
162
  self.package_author.setText(author)
163
+
119
164
  self.package_author.setWordWrap(True)
120
165
  self.cancel_btn.setVisible(False)
121
166
 
122
167
  self._handle_npe2_plugin(npe_version)
168
+ self._set_installed(installed, package_name)
169
+ self._populate_version_dropdown(self.get_installer_source())
123
170
 
171
+ def _set_installed(self, installed: bool, package_name):
124
172
  if installed:
125
173
  if is_conda_package(package_name):
126
174
  self.source.setText(CONDA)
175
+
127
176
  self.enabled_checkbox.show()
128
177
  self.action_button.setText(trans._("Uninstall"))
129
178
  self.action_button.setObjectName("remove_button")
@@ -136,28 +185,35 @@ class PluginListItem(QFrame):
136
185
  self.action_button.setObjectName("install_button")
137
186
  self.info_widget.hide()
138
187
  self.install_info_button.addWidget(self.info_choice_wdg)
139
- self.install_info_button.setFixedWidth(170)
140
-
141
188
  self.info_choice_wdg.show()
142
189
 
143
190
  def _handle_npe2_plugin(self, npe_version):
144
191
  if npe_version in (None, 1):
145
192
  return
193
+
146
194
  opacity = 0.4 if npe_version == 'shim' else 1
147
- lbl = trans._('npe1 (adapted)') if npe_version == 'shim' else 'npe2'
148
- npe2_icon = QLabel(self)
195
+ text = trans._('npe1 (adapted)') if npe_version == 'shim' else 'npe2'
149
196
  icon = QColoredSVGIcon.from_resources('logo_silhouette')
150
- npe2_icon.setPixmap(
151
- icon.colored(color='#33F0FF', opacity=opacity).pixmap(20, 20)
197
+ self.set_status(
198
+ icon.colored(color='#33F0FF', opacity=opacity).pixmap(20, 20), text
152
199
  )
153
- self.row1.insertWidget(2, QLabel(lbl))
154
- self.row1.insertWidget(2, npe2_icon)
200
+
201
+ def set_status(self, icon=None, text=''):
202
+ """Set the status icon and text. next to the package name."""
203
+ if icon:
204
+ self.status_icon.setPixmap(icon)
205
+
206
+ if text:
207
+ self.status_label.setText(text)
208
+
209
+ self.status_icon.setVisible(bool(icon))
210
+ self.status_label.setVisible(bool(text))
155
211
 
156
212
  def set_busy(
157
213
  self,
158
214
  text: str,
159
- action_name: Literal[
160
- "install", "uninstall", "cancel", "upgrade"
215
+ action_name: Optional[
216
+ Literal['install', 'uninstall', 'cancel', 'upgrade']
161
217
  ] = None,
162
218
  ):
163
219
  """Updates status text and what buttons are visible when any button is pushed.
@@ -186,16 +242,13 @@ class PluginListItem(QFrame):
186
242
 
187
243
  def setup_ui(self, enabled=True):
188
244
  """Define the layout of the PluginListItem"""
189
-
190
- self.v_lay = QVBoxLayout(self)
191
- self.v_lay.setContentsMargins(-1, 6, -1, 6)
192
- self.v_lay.setSpacing(0)
193
- self.row1 = QHBoxLayout()
194
- self.row1.setSpacing(6)
245
+ # Enabled checkbox
195
246
  self.enabled_checkbox = QCheckBox(self)
196
247
  self.enabled_checkbox.setChecked(enabled)
197
- self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox)
198
248
  self.enabled_checkbox.setToolTip(trans._("enable/disable"))
249
+ self.enabled_checkbox.setText("")
250
+ self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox)
251
+
199
252
  sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
200
253
  sizePolicy.setHorizontalStretch(0)
201
254
  sizePolicy.setVerticalStretch(0)
@@ -204,11 +257,22 @@ class PluginListItem(QFrame):
204
257
  )
205
258
  self.enabled_checkbox.setSizePolicy(sizePolicy)
206
259
  self.enabled_checkbox.setMinimumSize(QSize(20, 0))
207
- self.enabled_checkbox.setText("")
208
- self.row1.addWidget(self.enabled_checkbox)
209
- self.plugin_name = QPushButton(self)
210
- # Do not want to highlight on hover unless there is a website.
260
+
261
+ # Plugin name
262
+ self.plugin_name = ClickableLabel(self) # To style content
263
+ font_plugin_name = QFont()
264
+ font_plugin_name.setPointSize(15)
265
+ font_plugin_name.setUnderline(True)
266
+ self.plugin_name.setFont(font_plugin_name)
267
+
268
+ # Status
269
+ self.status_icon = QLabel(self)
270
+ self.status_icon.setVisible(False)
271
+ self.status_label = QLabel(self)
272
+ self.status_label.setVisible(False)
273
+
211
274
  if self.url and self.url != 'UNKNOWN':
275
+ # Do not want to highlight on hover unless there is a website.
212
276
  self.plugin_name.setObjectName('plugin_name_web')
213
277
  else:
214
278
  self.plugin_name.setObjectName('plugin_name')
@@ -220,14 +284,11 @@ class PluginListItem(QFrame):
220
284
  self.plugin_name.sizePolicy().hasHeightForWidth()
221
285
  )
222
286
  self.plugin_name.setSizePolicy(sizePolicy)
223
- font15 = QFont()
224
- font15.setPointSize(15)
225
- font15.setUnderline(True)
226
- self.plugin_name.setFont(font15)
227
- self.row1.addWidget(self.plugin_name)
228
287
 
288
+ # Warning icon
229
289
  icon = QColoredSVGIcon.from_resources("warning")
230
290
  self.warning_tooltip = QtToolTipLabel(self)
291
+
231
292
  # TODO: This color should come from the theme but the theme needs
232
293
  # to provide the right color. Default warning should be orange, not
233
294
  # red. Code example:
@@ -237,67 +298,58 @@ class PluginListItem(QFrame):
237
298
  icon.colored(color="#E3B617").pixmap(15, 15)
238
299
  )
239
300
  self.warning_tooltip.setVisible(False)
240
- self.row1.addWidget(self.warning_tooltip)
241
301
 
302
+ # Item status
242
303
  self.item_status = QLabel(self)
243
304
  self.item_status.setObjectName("small_italic_text")
244
305
  self.item_status.setSizePolicy(sizePolicy)
245
- self.row1.addWidget(self.item_status)
246
- self.row1.addStretch()
247
- self.v_lay.addLayout(self.row1)
248
306
 
249
- self.row2 = QGridLayout()
250
- self.error_indicator = QPushButton()
251
- self.error_indicator.setObjectName("warning_icon")
252
- self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor)
253
- self.error_indicator.hide()
254
- self.row2.addWidget(
255
- self.error_indicator,
256
- 0,
257
- 0,
258
- 1,
259
- 1,
260
- alignment=Qt.AlignmentFlag.AlignTop,
261
- )
262
- self.row2.setSpacing(4)
307
+ # Summary
263
308
  self.summary = QElidingLabel(parent=self)
264
309
  self.summary.setObjectName('summary_text')
265
310
  self.summary.setWordWrap(True)
266
311
 
267
- sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
312
+ font_summary = QFont()
313
+ font_summary.setPointSize(10)
314
+ self.summary.setFont(font_summary)
268
315
 
316
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
269
317
  sizePolicy.setHorizontalStretch(1)
270
318
  sizePolicy.setVerticalStretch(0)
271
319
  self.summary.setSizePolicy(sizePolicy)
272
- self.row2.addWidget(
273
- self.summary, 0, 1, 1, 3, alignment=Qt.AlignmentFlag.AlignTop
274
- )
320
+ self.summary.setContentsMargins(0, -2, 0, -2)
275
321
 
322
+ # Package author
276
323
  self.package_author = QElidingLabel(self)
277
324
  self.package_author.setObjectName('author_text')
278
325
  self.package_author.setWordWrap(True)
279
326
  self.package_author.setSizePolicy(sizePolicy)
280
- self.row2.addWidget(
281
- self.package_author,
282
- 0,
283
- 4,
284
- 1,
285
- 2,
286
- alignment=Qt.AlignmentFlag.AlignTop,
287
- )
288
327
 
328
+ # Update button
289
329
  self.update_btn = QPushButton('Update', self)
290
- sizePolicy.setRetainSizeWhenHidden(True)
291
- self.update_btn.setSizePolicy(sizePolicy)
292
330
  self.update_btn.setObjectName("install_button")
293
331
  self.update_btn.setVisible(False)
332
+ self.update_btn.clicked.connect(self._update_requested)
333
+ sizePolicy.setRetainSizeWhenHidden(True)
334
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
335
+ self.update_btn.setSizePolicy(sizePolicy)
336
+ self.update_btn.clicked.connect(self._update_requested)
294
337
 
295
- self.row2.addWidget(
296
- self.update_btn, 0, 6, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
297
- )
338
+ # Action Button
339
+ self.action_button = QPushButton(self)
340
+ self.action_button.setFixedWidth(70)
341
+ sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
342
+ self.action_button.setSizePolicy(sizePolicy1)
343
+ self.action_button.clicked.connect(self._action_requested)
298
344
 
299
- self.info_choice_wdg = QWidget(self)
300
- self.info_choice_wdg.setObjectName('install_choice')
345
+ # Cancel
346
+ self.cancel_btn = QPushButton("Cancel", self)
347
+ self.cancel_btn.setObjectName("remove_button")
348
+ self.cancel_btn.setSizePolicy(sizePolicy)
349
+ self.cancel_btn.setFixedWidth(70)
350
+ self.cancel_btn.clicked.connect(self._cancel_requested)
351
+
352
+ # Collapsible button
301
353
  coll_icon = QColoredSVGIcon.from_resources('right_arrow').colored(
302
354
  color='white',
303
355
  )
@@ -307,12 +359,11 @@ class PluginListItem(QFrame):
307
359
  self.install_info_button = QCollapsible(
308
360
  "Installation Info", collapsedIcon=coll_icon, expandedIcon=exp_icon
309
361
  )
362
+ self.install_info_button.setLayoutDirection(
363
+ Qt.RightToLeft
364
+ ) # Make icon appear on the right
310
365
  self.install_info_button.setObjectName("install_info_button")
311
-
312
- # To make the icon appear on the right
313
- self.install_info_button.setLayoutDirection(Qt.RightToLeft)
314
-
315
- # Remove any extra margins
366
+ self.install_info_button.setFixedWidth(180)
316
367
  self.install_info_button.content().layout().setContentsMargins(
317
368
  0, 0, 0, 0
318
369
  )
@@ -322,77 +373,116 @@ class PluginListItem(QFrame):
322
373
  self.install_info_button.layout().setSpacing(2)
323
374
  self.install_info_button.setSizePolicy(sizePolicy)
324
375
 
376
+ # Information widget for available packages
377
+ self.info_choice_wdg = QWidget(self)
378
+ self.info_choice_wdg.setObjectName('install_choice')
379
+
325
380
  self.source_choice_text = QLabel('Source:')
326
381
  self.version_choice_text = QLabel('Version:')
327
382
  self.source_choice_dropdown = QComboBox()
383
+ self.version_choice_dropdown = QComboBox()
328
384
 
329
- if len(self._versions_pypi) is not None:
330
- self.source_choice_dropdown.addItem(PYPI)
331
-
332
- if len(self._versions_conda) is not None:
385
+ if IS_NAPARI_CONDA_INSTALLED and self._versions_conda:
333
386
  self.source_choice_dropdown.addItem(CONDA)
334
387
 
388
+ if self._versions_pypi:
389
+ self.source_choice_dropdown.addItem(PYPI)
390
+
391
+ source = self.get_installer_source()
392
+ self.source_choice_dropdown.setCurrentText(source)
393
+ self._populate_version_dropdown(source)
335
394
  self.source_choice_dropdown.currentTextChanged.connect(
336
395
  self._populate_version_dropdown
337
396
  )
338
- self.version_choice_dropdown = QComboBox()
339
- self.row2.addWidget(
340
- self.install_info_button,
341
- 0,
342
- 7,
343
- 1,
344
- 1,
345
- alignment=Qt.AlignmentFlag.AlignTop,
346
- )
397
+
398
+ # Information widget for installed packages
399
+ self.info_widget = QWidget(self)
400
+ self.info_widget.setLayoutDirection(Qt.LeftToRight)
401
+ self.info_widget.setObjectName("info_widget")
402
+ self.info_widget.setFixedWidth(180)
403
+
404
+ self.source_text = QLabel('Source:')
405
+ self.source = QLabel(PYPI)
406
+ self.version_text = QLabel('Version:')
407
+ self.version = QElidingLabel()
408
+ self.version.setWordWrap(True)
347
409
 
348
410
  info_layout = QGridLayout()
349
411
  info_layout.setContentsMargins(0, 0, 0, 0)
350
412
  info_layout.setVerticalSpacing(0)
351
- info_layout.addWidget(self.source_choice_text, 0, 0, 1, 1)
352
- info_layout.addWidget(self.source_choice_dropdown, 1, 0, 1, 1)
353
- info_layout.addWidget(self.version_choice_text, 0, 1, 1, 1)
354
- info_layout.addWidget(self.version_choice_dropdown, 1, 1, 1, 1)
355
- self.info_choice_wdg.setLayout(info_layout)
356
- self.info_choice_wdg.setLayoutDirection(Qt.LeftToRight)
357
- self.info_choice_wdg.setObjectName("install_choice_widget")
358
- self.info_choice_wdg.hide()
413
+ info_layout.addWidget(self.source_text, 0, 0)
414
+ info_layout.addWidget(self.source, 1, 0)
415
+ info_layout.addWidget(self.version_text, 0, 1)
416
+ info_layout.addWidget(self.version, 1, 1)
417
+ self.info_widget.setLayout(info_layout)
359
418
 
360
- self.cancel_btn = QPushButton("Cancel", self)
361
- self.cancel_btn.setSizePolicy(sizePolicy)
362
- self.cancel_btn.setObjectName("remove_button")
363
- self.row2.addWidget(
364
- self.cancel_btn, 0, 8, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
365
- )
419
+ # Error indicator
420
+ self.error_indicator = QPushButton()
421
+ self.error_indicator.setObjectName("warning_icon")
422
+ self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor)
423
+ self.error_indicator.hide()
366
424
 
367
- self.action_button = QPushButton(self)
368
- self.action_button.setFixedWidth(70)
369
- sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
370
- self.action_button.setSizePolicy(sizePolicy1)
371
- self.row2.addWidget(
372
- self.action_button, 0, 8, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
425
+ # region - Layout
426
+ # -----------------------------------------------------------------
427
+ layout = QHBoxLayout()
428
+ layout.setSpacing(2)
429
+ layout_left = QVBoxLayout()
430
+ layout_right = QVBoxLayout()
431
+ layout_top = QHBoxLayout()
432
+ layout_bottom = QHBoxLayout()
433
+ layout_bottom.setSpacing(4)
434
+
435
+ layout_left.addWidget(
436
+ self.enabled_checkbox, alignment=Qt.AlignmentFlag.AlignTop
373
437
  )
374
438
 
375
- self.v_lay.addLayout(self.row2)
439
+ layout_right.addLayout(layout_top, 1)
440
+ layout_right.addLayout(layout_bottom, 100)
441
+
442
+ layout.addLayout(layout_left)
443
+ layout.addLayout(layout_right)
444
+
445
+ self.setLayout(layout)
446
+
447
+ layout_top.addWidget(self.plugin_name)
448
+ layout_top.addWidget(self.status_icon)
449
+ layout_top.addWidget(self.status_label)
450
+ layout_top.addWidget(self.item_status)
451
+ layout_top.addStretch()
452
+
453
+ layout_bottom.addWidget(
454
+ self.summary, alignment=Qt.AlignmentFlag.AlignTop, stretch=3
455
+ )
456
+ layout_bottom.addWidget(
457
+ self.package_author, alignment=Qt.AlignmentFlag.AlignTop, stretch=1
458
+ )
459
+ layout_bottom.addWidget(
460
+ self.update_btn, alignment=Qt.AlignmentFlag.AlignTop
461
+ )
462
+ layout_bottom.addWidget(
463
+ self.install_info_button, alignment=Qt.AlignmentFlag.AlignTop
464
+ )
465
+ layout_bottom.addWidget(
466
+ self.action_button, alignment=Qt.AlignmentFlag.AlignTop
467
+ )
468
+ layout_bottom.addWidget(
469
+ self.cancel_btn, alignment=Qt.AlignmentFlag.AlignTop
470
+ )
376
471
 
377
- self.info_widget = QWidget(self)
378
- self.info_widget.setLayoutDirection(Qt.LeftToRight)
379
- self.info_widget.setObjectName("info_widget")
380
472
  info_layout = QGridLayout()
381
473
  info_layout.setContentsMargins(0, 0, 0, 0)
382
474
  info_layout.setVerticalSpacing(0)
383
- self.version_text = QLabel('Version:')
384
- self.package_name = QLabel()
385
- self.source_text = QLabel('Source:')
386
- self.source = QLabel(PYPI)
475
+ info_layout.addWidget(self.source_choice_text, 0, 0, 1, 1)
476
+ info_layout.addWidget(self.source_choice_dropdown, 1, 0, 1, 1)
477
+ info_layout.addWidget(self.version_choice_text, 0, 1, 1, 1)
478
+ info_layout.addWidget(self.version_choice_dropdown, 1, 1, 1, 1)
387
479
 
388
- info_layout.addWidget(self.source_text, 0, 0)
389
- info_layout.addWidget(self.source, 1, 0)
390
- info_layout.addWidget(self.version_text, 0, 1)
391
- info_layout.addWidget(self.package_name, 1, 1)
480
+ # endregion - Layout
392
481
 
393
- self.install_info_button.setFixedWidth(150)
394
- self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
395
- self.info_widget.setLayout(info_layout)
482
+ self.info_choice_wdg.setLayout(info_layout)
483
+ self.info_choice_wdg.setLayoutDirection(Qt.LeftToRight)
484
+ self.info_choice_wdg.setObjectName("install_choice_widget")
485
+ self.info_choice_wdg.hide()
396
486
 
397
487
  def _populate_version_dropdown(self, source: Literal["PyPI", "Conda"]):
398
488
  """Display the versions available after selecting a source: pypi or conda."""
@@ -424,20 +514,66 @@ class PluginListItem(QFrame):
424
514
  )
425
515
  return
426
516
 
517
+ def _cancel_requested(self):
518
+ version = self.version_choice_dropdown.currentText()
519
+ tool = self.get_installer_tool()
520
+ self.actionRequested.emit(
521
+ self.item, self.name, InstallerActions.CANCEL, version, tool
522
+ )
523
+
524
+ def _action_requested(self):
525
+ version = self.version_choice_dropdown.currentText()
526
+ tool = self.get_installer_tool()
527
+ action = (
528
+ InstallerActions.INSTALL
529
+ if self.action_button.objectName() == 'install_button'
530
+ else InstallerActions.UNINSTALL
531
+ )
532
+ self.actionRequested.emit(self.item, self.name, action, version, tool)
533
+
534
+ def _update_requested(self):
535
+ version = self.version_choice_dropdown.currentText()
536
+ tool = self.get_installer_tool()
537
+ self.actionRequested.emit(
538
+ self.item, self.name, InstallerActions.UPGRADE, version, tool
539
+ )
540
+
427
541
  def show_warning(self, message: str = ""):
428
542
  """Show warning icon and tooltip."""
429
543
  self.warning_tooltip.setVisible(bool(message))
430
544
  self.warning_tooltip.setToolTip(message)
431
545
 
546
+ def get_installer_source(self):
547
+ return (
548
+ CONDA
549
+ if self.source_choice_dropdown.currentText() == CONDA
550
+ or is_conda_package(self.name)
551
+ else PYPI
552
+ )
553
+
554
+ def get_installer_tool(self):
555
+ return (
556
+ InstallerTools.CONDA
557
+ if self.source_choice_dropdown.currentText() == CONDA
558
+ or is_conda_package(self.name, prefix=self.prefix)
559
+ else InstallerTools.PIP
560
+ )
561
+
432
562
 
433
563
  class QPluginList(QListWidget):
564
+
565
+ _SORT_ORDER_PREFIX = '0-'
566
+
434
567
  def __init__(self, parent: QWidget, installer: InstallerQueue) -> None:
435
568
  super().__init__(parent)
436
569
  self.installer = installer
437
- self.setSortingEnabled(True)
438
570
  self._remove_list = []
571
+ self._data = []
572
+ self._initial_height = None
573
+
574
+ self.setSortingEnabled(True)
439
575
 
440
- def _count_visible(self) -> int:
576
+ def count_visible(self) -> int:
441
577
  """Return the number of visible items.
442
578
 
443
579
  Visible items are the result of the normal `count` method minus
@@ -454,17 +590,13 @@ class QPluginList(QListWidget):
454
590
  @Slot(tuple)
455
591
  def addItem(
456
592
  self,
457
- project_info_versions: Tuple[
458
- npe2.PackageMetadata, List[str], List[str]
459
- ],
593
+ project_info: ProjectInfoVersions,
460
594
  installed=False,
461
595
  plugin_name=None,
462
596
  enabled=True,
463
597
  npe_version=None,
464
598
  ):
465
- project_info, versions_pypi, versions_conda = project_info_versions
466
-
467
- pkg_name = project_info.name
599
+ pkg_name = project_info.metadata.name
468
600
  # don't add duplicates
469
601
  if (
470
602
  self.findItems(pkg_name, Qt.MatchFlag.MatchFixedString)
@@ -473,116 +605,102 @@ class QPluginList(QListWidget):
473
605
  return
474
606
 
475
607
  # including summary here for sake of filtering below.
476
- searchable_text = f"{pkg_name} {project_info.summary}"
608
+ searchable_text = f"{pkg_name} {project_info.display_name} {project_info.metadata.summary}"
477
609
  item = QListWidgetItem(searchable_text, self)
478
- item.version = project_info.version
610
+ item.version = project_info.metadata.version
479
611
  super().addItem(item)
480
612
  widg = PluginListItem(
613
+ item=item,
481
614
  package_name=pkg_name,
482
- version=project_info.version,
483
- url=project_info.home_page,
484
- summary=project_info.summary,
485
- author=project_info.author,
486
- license=project_info.license,
615
+ display_name=project_info.display_name,
616
+ version=project_info.metadata.version,
617
+ url=project_info.metadata.home_page,
618
+ summary=project_info.metadata.summary,
619
+ author=project_info.metadata.author,
620
+ license=project_info.metadata.license,
487
621
  parent=self,
488
622
  plugin_name=plugin_name,
489
623
  enabled=enabled,
490
624
  installed=installed,
491
625
  npe_version=npe_version,
492
- versions_conda=versions_conda,
493
- versions_pypi=versions_pypi,
626
+ versions_conda=project_info.conda_versions,
627
+ versions_pypi=project_info.pypi_versions,
494
628
  )
495
629
  item.widget = widg
496
630
  item.npe_version = npe_version
497
- action_name = 'uninstall' if installed else 'install'
498
631
  item.setSizeHint(widg.sizeHint())
499
632
  self.setItemWidget(item, widg)
500
633
 
501
- if project_info.home_page:
502
- import webbrowser
503
-
504
- # FIXME: Partial may lead to leak memory when connecting to Qt signals.
634
+ if project_info.metadata.home_page:
505
635
  widg.plugin_name.clicked.connect(
506
- partial(webbrowser.open, project_info.home_page)
507
- )
508
-
509
- # FIXME: Partial may lead to leak memory when connecting to Qt signals.
510
- widg.action_button.clicked.connect(
511
- partial(
512
- self.handle_action,
513
- item,
514
- pkg_name,
515
- action_name,
516
- version=widg.version_choice_dropdown.currentText(),
517
- installer_choice=widg.source_choice_dropdown.currentText(),
518
- )
519
- )
520
-
521
- widg.update_btn.clicked.connect(
522
- partial(
523
- self.handle_action,
524
- item,
525
- pkg_name,
526
- InstallerActions.UPGRADE,
636
+ partial(webbrowser.open, project_info.metadata.home_page)
527
637
  )
528
- )
529
- widg.cancel_btn.clicked.connect(
530
- partial(
531
- self.handle_action, item, pkg_name, InstallerActions.CANCEL
532
- )
533
- )
534
638
 
639
+ widg.actionRequested.connect(self.handle_action)
535
640
  item.setSizeHint(item.widget.size())
641
+ if self._initial_height is None:
642
+ self._initial_height = item.widget.size().height()
643
+
536
644
  widg.install_info_button.setDuration(0)
537
645
  widg.install_info_button.toggled.connect(
538
646
  lambda: self._resize_pluginlistitem(item)
539
647
  )
540
648
 
649
+ def removeItem(self, name):
650
+ count = self.count()
651
+ for i in range(count):
652
+ item = self.item(i)
653
+ if item.widget.name == name:
654
+ self.takeItem(i)
655
+ break
656
+
657
+ def refreshItem(self, name, version=None):
658
+ count = self.count()
659
+ for i in range(count):
660
+ item = self.item(i)
661
+ if item.widget.name == name:
662
+ if version is not None:
663
+ item.version = version
664
+ mod_version = version.replace('.', '․') # noqa: RUF001
665
+ item.widget.version.setText(mod_version)
666
+ item.widget.version.setToolTip(version)
667
+ item.widget.set_busy('', InstallerActions.CANCEL)
668
+ if item.text().startswith(self._SORT_ORDER_PREFIX):
669
+ item.setText(item.text()[len(self._SORT_ORDER_PREFIX) :])
670
+ break
671
+
541
672
  def _resize_pluginlistitem(self, item):
542
673
  """Resize the plugin list item, especially after toggling QCollapsible."""
543
- height = item.widget.height()
544
674
  if item.widget.install_info_button.isExpanded():
545
- item.widget.setFixedHeight(int(height * SCALE))
675
+ item.widget.setFixedHeight(self._initial_height + 35)
546
676
  else:
547
- item.widget.setFixedHeight(int(height / SCALE))
548
- item.setSizeHint(item.widget.size())
677
+ item.widget.setFixedHeight(self._initial_height)
678
+
679
+ item.setSizeHint(QSize(0, item.widget.height()))
549
680
 
550
681
  def handle_action(
551
682
  self,
552
683
  item: QListWidgetItem,
553
684
  pkg_name: str,
554
685
  action_name: InstallerActions,
555
- version: str = None,
686
+ version: Optional[str] = None,
556
687
  installer_choice: Optional[str] = None,
557
688
  ):
558
689
  """Determine which action is called (install, uninstall, update, cancel).
559
690
  Update buttons appropriately and run the action."""
560
- tool = (
561
- InstallerTools.CONDA
562
- if item.widget.source_choice_dropdown.currentText() == CONDA
563
- or is_conda_package(pkg_name)
564
- else InstallerTools.PIP
565
- )
566
-
567
691
  widget = item.widget
568
- item.setText(f"0-{item.text()}")
692
+ tool = installer_choice or widget.get_installer_tool()
569
693
  self._remove_list.append((pkg_name, item))
570
694
  self._warn_dialog = None
571
- # TODO: NPE version unknown before installing
572
- if item.npe_version != 1 and action_name == InstallerActions.UNINSTALL:
573
- # show warning pop up dialog
574
- message = trans._(
575
- 'When installing/uninstalling npe2 plugins, you must '
576
- 'restart napari for UI changes to take effect.'
577
- )
578
- self._warn_dialog = WarnPopup(text=message)
695
+ if not item.text().startswith(self._SORT_ORDER_PREFIX):
696
+ item.setText(f"{self._SORT_ORDER_PREFIX}{item.text()}")
579
697
 
580
- delta_x = 75
581
- global_point = widget.action_button.mapToGlobal(
582
- widget.action_button.rect().topLeft()
583
- )
584
- global_point = QPoint(global_point.x() - delta_x, global_point.y())
585
- self._warn_dialog.move(global_point)
698
+ # TODO: NPE version unknown before installing
699
+ if (
700
+ widget.npe_version != 1
701
+ and action_name == InstallerActions.UNINSTALL
702
+ ):
703
+ _show_message(widget)
586
704
 
587
705
  if action_name == InstallerActions.INSTALL:
588
706
  if version:
@@ -596,6 +714,7 @@ class QPluginList(QListWidget):
596
714
  pkgs=[pkg_name],
597
715
  # origins="TODO",
598
716
  )
717
+ widget.setProperty("current_job_id", job_id)
599
718
  if self._warn_dialog:
600
719
  self._warn_dialog.exec_()
601
720
  self.scrollToTop()
@@ -605,6 +724,7 @@ class QPluginList(QListWidget):
605
724
  pkg_name += f"=={item.latest_version}"
606
725
 
607
726
  widget.set_busy(trans._("updating..."), action_name)
727
+ widget.update_btn.setDisabled(True)
608
728
  widget.action_button.setDisabled(True)
609
729
 
610
730
  job_id = self.installer.upgrade(
@@ -612,6 +732,7 @@ class QPluginList(QListWidget):
612
732
  pkgs=[pkg_name],
613
733
  # origins="TODO",
614
734
  )
735
+ widget.setProperty("current_job_id", job_id)
615
736
  if self._warn_dialog:
616
737
  self._warn_dialog.exec_()
617
738
  self.scrollToTop()
@@ -637,10 +758,17 @@ class QPluginList(QListWidget):
637
758
  finally:
638
759
  widget.setProperty("current_job_id", None)
639
760
 
761
+ def set_data(self, data):
762
+ self._data = data
763
+
764
+ def is_running(self):
765
+ return self.count() != len(self._data)
766
+
767
+ def packages(self):
768
+ return [self.item(idx).widget.name for idx in range(self.count())]
769
+
640
770
  @Slot(npe2.PackageMetadata, bool)
641
- def tag_outdated(
642
- self, project_info: npe2.PackageMetadata, is_available: bool
643
- ):
771
+ def tag_outdated(self, metadata: npe2.PackageMetadata, is_available: bool):
644
772
  """Determines if an installed plugin is up to date with the latest version.
645
773
  If it is not, the latest version will be displayed on the update button.
646
774
  """
@@ -648,13 +776,21 @@ class QPluginList(QListWidget):
648
776
  return
649
777
 
650
778
  for item in self.findItems(
651
- project_info.name, Qt.MatchFlag.MatchStartsWith
779
+ metadata.name, Qt.MatchFlag.MatchStartsWith
652
780
  ):
653
781
  current = item.version
654
- latest = project_info.version
782
+ latest = metadata.version
783
+ is_marked_outdated = getattr(item, 'outdated', False)
655
784
  if parse_version(current) >= parse_version(latest):
785
+ # currently is up to date
786
+ if is_marked_outdated:
787
+ # previously marked as outdated, need to update item
788
+ # `outdated` state and hide item widget `update_btn`
789
+ item.outdated = False
790
+ widg = self.itemWidget(item)
791
+ widg.update_btn.setVisible(False)
656
792
  continue
657
- if hasattr(item, 'outdated'):
793
+ if is_marked_outdated:
658
794
  # already tagged it
659
795
  continue
660
796
 
@@ -666,7 +802,7 @@ class QPluginList(QListWidget):
666
802
  trans._("update (v{latest})", latest=latest)
667
803
  )
668
804
 
669
- def tag_unavailable(self, project_info: npe2.PackageMetadata):
805
+ def tag_unavailable(self, metadata: npe2.PackageMetadata):
670
806
  """
671
807
  Tag list items as unavailable for install with conda-forge.
672
808
 
@@ -674,7 +810,7 @@ class QPluginList(QListWidget):
674
810
  icon with a hover tooltip.
675
811
  """
676
812
  for item in self.findItems(
677
- project_info.name, Qt.MatchFlag.MatchStartsWith
813
+ metadata.name, Qt.MatchFlag.MatchStartsWith
678
814
  ):
679
815
  widget = self.itemWidget(item)
680
816
  widget.show_warning(
@@ -688,15 +824,28 @@ class QPluginList(QListWidget):
688
824
  widget.action_button.setEnabled(False)
689
825
  widget.warning_tooltip.setVisible(True)
690
826
 
691
- def filter(self, text: str):
827
+ def filter(self, text: str, starts_with_chars: int = 1):
692
828
  """Filter items to those containing `text`."""
693
829
  if text:
694
830
  # PySide has some issues, so we compare using id
695
831
  # See: https://bugreports.qt.io/browse/PYSIDE-74
696
- shown = [
832
+ flag = (
833
+ Qt.MatchFlag.MatchStartsWith
834
+ if len(text) <= starts_with_chars
835
+ else Qt.MatchFlag.MatchContains
836
+ )
837
+ if len(text) <= starts_with_chars:
838
+ flag = Qt.MatchFlag.MatchStartsWith
839
+ queries = (text, f'napari-{text}')
840
+ else:
841
+ flag = Qt.MatchFlag.MatchContains
842
+ queries = (text,)
843
+
844
+ shown = {
697
845
  id(it)
698
- for it in self.findItems(text, Qt.MatchFlag.MatchContains)
699
- ]
846
+ for query in queries
847
+ for it in self.findItems(query, flag)
848
+ }
700
849
  for i in range(self.count()):
701
850
  item = self.item(i)
702
851
  item.setHidden(id(item) not in shown)
@@ -706,35 +855,93 @@ class QPluginList(QListWidget):
706
855
  item.setHidden(False)
707
856
 
708
857
 
709
- class RefreshState(Enum):
710
- REFRESHING = auto()
711
- OUTDATED = auto()
712
- DONE = auto()
713
-
714
-
715
858
  class QtPluginDialog(QDialog):
716
- def __init__(self, parent=None) -> None:
859
+ def __init__(self, parent=None, prefix=None) -> None:
717
860
  super().__init__(parent)
718
- self.refresh_state = RefreshState.DONE
861
+
862
+ self._parent = parent
863
+ if (
864
+ parent is not None
865
+ and getattr(parent, '_plugin_dialog', None) is None
866
+ ):
867
+ self._parent._plugin_dialog = self
868
+
719
869
  self.already_installed = set()
720
870
  self.available_set = set()
721
-
722
- self._plugin_data = [] # Store plugin data while populating lists
723
- self.all_plugin_data = [] # Store all plugin data
871
+ self._prefix = prefix
872
+ self._first_open = True
873
+ self._plugin_queue = [] # Store plugin data to be added
874
+ self._plugin_data = [] # Store all plugin data
875
+ self._filter_texts = []
876
+ self._filter_idxs_cache = set()
877
+ self._filter_timer = QTimer(self)
878
+ self.worker = None
879
+
880
+ # timer to avoid triggering a filter for every keystroke
881
+ self._filter_timer.setInterval(140) # ms
882
+ self._filter_timer.timeout.connect(self.filter)
883
+ self._filter_timer.setSingleShot(True)
884
+ self._plugin_data_map = {}
724
885
  self._add_items_timer = QTimer(self)
725
- # Add items in batches to avoid blocking the UI
726
- self._add_items_timer.setInterval(100)
886
+
887
+ # Timer to avoid race conditions and incorrect count of plugins when
888
+ # refreshing multiple times in a row. After click we disable the
889
+ # `Refresh` button and re-enable it after 3 seconds.
890
+ self._refresh_timer = QTimer(self)
891
+ self._refresh_timer.setInterval(3000) # ms
892
+ self._refresh_timer.setSingleShot(True)
893
+ self._refresh_timer.timeout.connect(self._enable_refresh_button)
894
+
895
+ # Add items in batches with a pause to avoid blocking the UI
896
+ self._add_items_timer.setInterval(61) # ms
727
897
  self._add_items_timer.timeout.connect(self._add_items)
728
- self._add_items_timer.timeout.connect(self._update_count_in_label)
729
898
 
730
- self.installer = InstallerQueue()
899
+ self.installer = InstallerQueue(parent=self, prefix=prefix)
731
900
  self.setWindowTitle(trans._('Plugin Manager'))
732
- self.setup_ui()
733
- self.setWindowTitle('Plugin Manager')
901
+ self._setup_ui()
734
902
  self.installer.set_output_widget(self.stdout_text)
735
903
  self.installer.started.connect(self._on_installer_start)
736
- self.installer.finished.connect(self._on_installer_done)
737
- self.refresh()
904
+ self.installer.processFinished.connect(self._on_process_finished)
905
+ self.installer.allFinished.connect(self._on_installer_all_finished)
906
+ self.setAcceptDrops(True)
907
+
908
+ if (
909
+ parent is not None and parent._plugin_dialog is self
910
+ ) or parent is None:
911
+ self.refresh()
912
+ self._setup_shortcuts()
913
+
914
+ # region - Private methods
915
+ # ------------------------------------------------------------------------
916
+ def _enable_refresh_button(self):
917
+ self.refresh_button.setEnabled(True)
918
+
919
+ def _quit(self):
920
+ self.close()
921
+ with contextlib.suppress(AttributeError):
922
+ self._parent.close(quit_app=True, confirm_need=True)
923
+
924
+ def _setup_shortcuts(self):
925
+ self._refresh_styles_action = QAction(trans._('Refresh Styles'), self)
926
+ self._refresh_styles_action.setShortcut('Ctrl+R')
927
+ self._refresh_styles_action.triggered.connect(self._update_theme)
928
+ self.addAction(self._refresh_styles_action)
929
+
930
+ self._quit_action = QAction(trans._('Exit'), self)
931
+ self._quit_action.setShortcut('Ctrl+Q')
932
+ self._quit_action.setMenuRole(QAction.QuitRole)
933
+ self._quit_action.triggered.connect(self._quit)
934
+ self.addAction(self._quit_action)
935
+
936
+ self._close_shortcut = QShortcut(
937
+ QKeySequence(Qt.CTRL | Qt.Key_W), self
938
+ )
939
+ self._close_shortcut.activated.connect(self.close)
940
+ get_settings().appearance.events.theme.connect(self._update_theme)
941
+
942
+ def _update_theme(self, event):
943
+ stylesheet = get_current_stylesheet([STYLES_PATH])
944
+ self.setStyleSheet(stylesheet)
738
945
 
739
946
  def _on_installer_start(self):
740
947
  """Updates dialog buttons and status when installing a plugin."""
@@ -742,73 +949,115 @@ class QtPluginDialog(QDialog):
742
949
  self.working_indicator.show()
743
950
  self.process_success_indicator.hide()
744
951
  self.process_error_indicator.hide()
745
- self.close_btn.setDisabled(True)
952
+ self.refresh_button.setDisabled(True)
953
+
954
+ def _on_process_finished(self, process_finished_data: ProcessFinishedData):
955
+ action = process_finished_data['action']
956
+ exit_code = process_finished_data['exit_code']
957
+ pkg_names = [
958
+ pkg.split('==')[0] for pkg in process_finished_data['pkgs']
959
+ ]
960
+ if action == InstallerActions.INSTALL:
961
+ if exit_code == 0:
962
+ for pkg_name in pkg_names:
963
+ if pkg_name in self.available_set:
964
+ self.available_set.remove(pkg_name)
965
+
966
+ self.available_list.removeItem(pkg_name)
967
+ self._add_installed(pkg_name)
968
+ self._tag_outdated_plugins()
969
+ else:
970
+ for pkg_name in pkg_names:
971
+ self.available_list.refreshItem(pkg_name)
972
+ elif action == InstallerActions.UNINSTALL:
973
+ if exit_code == 0:
974
+ for pkg_name in pkg_names:
975
+ if pkg_name in self.already_installed:
976
+ self.already_installed.remove(pkg_name)
977
+
978
+ self.installed_list.removeItem(pkg_name)
979
+ self._add_to_available(pkg_name)
980
+ else:
981
+ for pkg_name in pkg_names:
982
+ self.installed_list.refreshItem(pkg_name)
983
+ elif action == InstallerActions.UPGRADE:
984
+ pkg_info = [
985
+ (pkg.split('==')[0], pkg.split('==')[1])
986
+ for pkg in process_finished_data['pkgs']
987
+ ]
988
+ for pkg_name, pkg_version in pkg_info:
989
+ self.installed_list.refreshItem(pkg_name, version=pkg_version)
990
+ self._tag_outdated_plugins()
991
+ elif action in [InstallerActions.CANCEL, InstallerActions.CANCEL_ALL]:
992
+ for pkg_name in pkg_names:
993
+ self.installed_list.refreshItem(pkg_name)
994
+ self.available_list.refreshItem(pkg_name)
995
+ self._tag_outdated_plugins()
746
996
 
747
- def _on_installer_done(self, exit_code):
748
- """Updates buttons and status when plugin is done installing."""
749
997
  self.working_indicator.hide()
750
998
  if exit_code:
751
999
  self.process_error_indicator.show()
752
1000
  else:
753
1001
  self.process_success_indicator.show()
1002
+
1003
+ def _on_installer_all_finished(self, exit_codes):
1004
+ self.working_indicator.hide()
754
1005
  self.cancel_all_btn.setVisible(False)
755
1006
  self.close_btn.setDisabled(False)
756
- self.refresh()
1007
+ self.refresh_button.setDisabled(False)
757
1008
 
758
- def closeEvent(self, event):
759
- self._add_items_timer.stop()
760
- if self.close_btn.isEnabled():
761
- super().closeEvent(event)
762
- event.ignore()
1009
+ if not self.isVisible():
1010
+ if sum(exit_codes) > 0:
1011
+ show_warning(
1012
+ trans._('Plugin Manager: process completed with errors\n')
1013
+ )
1014
+ else:
1015
+ show_info(trans._('Plugin Manager: process completed\n'))
763
1016
 
764
- def refresh(self):
765
- if self.refresh_state != RefreshState.DONE:
766
- self.refresh_state = RefreshState.OUTDATED
767
- return
768
- self.refresh_state = RefreshState.REFRESHING
769
- self.installed_list.clear()
770
- self.available_list.clear()
1017
+ def _add_to_available(self, pkg_name):
1018
+ self._add_items_timer.stop()
1019
+ self._plugin_queue.insert(0, self._plugin_data_map[pkg_name])
1020
+ self._add_items_timer.start()
1021
+ self._update_plugin_count()
771
1022
 
772
- self.already_installed = set()
773
- self.available_set = set()
1023
+ def _add_to_installed(self, distname, enabled, npe_version=1):
1024
+ norm_name = normalized_name(distname or '')
1025
+ if distname:
1026
+ try:
1027
+ meta = importlib.metadata.metadata(distname)
774
1028
 
775
- def _add_to_installed(distname, enabled, npe_version=1):
776
- norm_name = normalized_name(distname or '')
777
- if distname:
778
- try:
779
- meta = importlib.metadata.metadata(distname)
780
-
781
- except importlib.metadata.PackageNotFoundError:
782
- self.refresh_state = RefreshState.OUTDATED
783
- return # a race condition has occurred and the package is uninstalled by another thread
784
- if len(meta) == 0:
785
- # will not add builtins.
786
- return
787
- self.already_installed.add(norm_name)
788
- else:
789
- meta = {}
790
-
791
- self.installed_list.addItem(
792
- (
793
- npe2.PackageMetadata(
794
- metadata_version="1.0",
795
- name=norm_name,
796
- version=meta.get('version', ''),
797
- summary=meta.get('summary', ''),
798
- home_page=meta.get('Home-page', ''),
799
- author=meta.get('author', ''),
800
- license=meta.get('license', ''),
801
- ),
802
- [],
803
- [],
1029
+ except importlib.metadata.PackageNotFoundError:
1030
+ return # a race condition has occurred and the package is uninstalled by another thread
1031
+ if len(meta) == 0:
1032
+ # will not add builtins.
1033
+ return
1034
+ self.already_installed.add(norm_name)
1035
+ else:
1036
+ meta = {}
1037
+
1038
+ self.installed_list.addItem(
1039
+ ProjectInfoVersions(
1040
+ npe2.PackageMetadata(
1041
+ metadata_version="1.0",
1042
+ name=norm_name,
1043
+ version=meta.get('version', ''),
1044
+ summary=meta.get('summary', ''),
1045
+ home_page=meta.get('Home-page', ''),
1046
+ author=meta.get('author', ''),
1047
+ license=meta.get('license', ''),
804
1048
  ),
805
- installed=True,
806
- enabled=enabled,
807
- npe_version=npe_version,
808
- )
1049
+ norm_name,
1050
+ [],
1051
+ [],
1052
+ ),
1053
+ installed=True,
1054
+ enabled=enabled,
1055
+ npe_version=npe_version,
1056
+ )
809
1057
 
1058
+ def _add_installed(self, pkg_name=None):
810
1059
  pm2 = npe2.PluginManager.instance()
811
- discovered = pm2.discover()
1060
+ pm2.discover()
812
1061
  for manifest in pm2.iter_manifests():
813
1062
  distname = normalized_name(manifest.name or '')
814
1063
  if distname in self.already_installed or distname == 'napari':
@@ -816,7 +1065,8 @@ class QtPluginDialog(QDialog):
816
1065
  enabled = not pm2.is_disabled(manifest.name)
817
1066
  # if it's an Npe1 adaptor, call it v1
818
1067
  npev = 'shim' if manifest.npe1_shim else 2
819
- _add_to_installed(distname, enabled, npe_version=npev)
1068
+ if distname == pkg_name or pkg_name is None:
1069
+ self._add_to_installed(distname, enabled, npe_version=npev)
820
1070
 
821
1071
  napari.plugins.plugin_manager.discover() # since they might not be loaded yet
822
1072
  for (
@@ -825,50 +1075,49 @@ class QtPluginDialog(QDialog):
825
1075
  distname,
826
1076
  ) in napari.plugins.plugin_manager.iter_available():
827
1077
  # not showing these in the plugin dialog
828
- if plugin_name in ('napari_plugin_engine',):
1078
+ if plugin_name in (
1079
+ 'napari_plugin_engine',
1080
+ 'napari_plugin_manager',
1081
+ ):
829
1082
  continue
830
1083
  if normalized_name(distname or '') in self.already_installed:
831
1084
  continue
832
- _add_to_installed(
833
- distname,
834
- not napari.plugins.plugin_manager.is_blocked(plugin_name),
835
- )
836
-
837
- self.installed_label.setText(
838
- trans._(
839
- "Installed Plugins ({amount})",
840
- amount=len(self.already_installed),
841
- )
842
- )
843
-
844
- # fetch available plugins
1085
+ if normalized_name(distname or '') == pkg_name or pkg_name is None:
1086
+ self._add_to_installed(
1087
+ distname,
1088
+ not napari.plugins.plugin_manager.is_blocked(plugin_name),
1089
+ )
1090
+ self._update_plugin_count()
1091
+
1092
+ for i in range(self.installed_list.count()):
1093
+ item = self.installed_list.item(i)
1094
+ widget = item.widget
1095
+ if widget.name == pkg_name:
1096
+ self.installed_list.scrollToItem(item)
1097
+ self.installed_list.setCurrentItem(item)
1098
+ if widget.npe_version != 1:
1099
+ _show_message(widget)
1100
+ break
1101
+
1102
+ def _fetch_available_plugins(self, clear_cache: bool = False):
845
1103
  get_settings()
846
1104
 
847
- self.worker = create_worker(iter_napari_plugin_info)
1105
+ if clear_cache:
1106
+ cache_clear()
848
1107
 
1108
+ self.worker = create_worker(iter_napari_plugin_info)
849
1109
  self.worker.yielded.connect(self._handle_yield)
1110
+ self.worker.started.connect(self.working_indicator.show)
850
1111
  self.worker.finished.connect(self.working_indicator.hide)
851
- self.worker.finished.connect(self._end_refresh)
1112
+ self.worker.finished.connect(self._add_items_timer.start)
852
1113
  self.worker.start()
853
- self._add_items_timer.start()
854
1114
 
855
- if discovered:
856
- message = trans._(
857
- 'When installing/uninstalling npe2 plugins, '
858
- 'you must restart napari for UI changes to take effect.'
859
- )
860
- self._warn_dialog = WarnPopup(text=message)
861
- global_point = self.process_error_indicator.mapToGlobal(
862
- self.process_error_indicator.rect().topLeft()
863
- )
864
- global_point = QPoint(global_point.x(), global_point.y() - 75)
865
- self._warn_dialog.move(global_point)
866
- self._warn_dialog.exec_()
1115
+ pm2 = npe2.PluginManager.instance()
1116
+ pm2.discover()
867
1117
 
868
- def setup_ui(self):
1118
+ def _setup_ui(self):
869
1119
  """Defines the layout for the PluginDialog."""
870
-
871
- self.resize(950, 640)
1120
+ self.resize(900, 600)
872
1121
  vlay_1 = QVBoxLayout(self)
873
1122
  self.h_splitter = QSplitter(self)
874
1123
  vlay_1.addWidget(self.h_splitter)
@@ -885,13 +1134,28 @@ class QtPluginDialog(QDialog):
885
1134
  self.packages_filter.setPlaceholderText(trans._("filter..."))
886
1135
  self.packages_filter.setMaximumWidth(350)
887
1136
  self.packages_filter.setClearButtonEnabled(True)
1137
+ self.packages_filter.textChanged.connect(self._filter_timer.start)
1138
+
1139
+ self.refresh_button = QPushButton(trans._('Refresh'), self)
1140
+ self.refresh_button.setObjectName("refresh_button")
1141
+ self.refresh_button.setToolTip(
1142
+ trans._(
1143
+ 'This will clear and refresh the available and installed plugins lists.'
1144
+ )
1145
+ )
1146
+ self.refresh_button.clicked.connect(self._refresh_and_clear_cache)
1147
+
888
1148
  mid_layout = QVBoxLayout()
889
- mid_layout.addWidget(self.packages_filter)
1149
+ horizontal_mid_layout = QHBoxLayout()
1150
+ horizontal_mid_layout.addWidget(self.packages_filter)
1151
+ horizontal_mid_layout.addStretch()
1152
+ horizontal_mid_layout.addWidget(self.refresh_button)
1153
+ mid_layout.addLayout(horizontal_mid_layout)
1154
+ # mid_layout.addWidget(self.packages_filter)
890
1155
  mid_layout.addWidget(self.installed_label)
891
1156
  lay.addLayout(mid_layout)
892
1157
 
893
1158
  self.installed_list = QPluginList(installed, self.installer)
894
- self.packages_filter.textChanged.connect(self.installed_list.filter)
895
1159
  lay.addWidget(self.installed_list)
896
1160
 
897
1161
  uninstalled = QWidget(self.v_splitter)
@@ -903,7 +1167,6 @@ class QtPluginDialog(QDialog):
903
1167
  mid_layout.addStretch()
904
1168
  lay.addLayout(mid_layout)
905
1169
  self.available_list = QPluginList(uninstalled, self.installer)
906
- self.packages_filter.textChanged.connect(self.available_list.filter)
907
1170
  lay.addWidget(self.available_list)
908
1171
 
909
1172
  self.stdout_text = QTextEdit(self.v_splitter)
@@ -931,13 +1194,34 @@ class QtPluginDialog(QDialog):
931
1194
  visibility_direct_entry = not running_as_constructor_app()
932
1195
  self.direct_entry_edit = QLineEdit(self)
933
1196
  self.direct_entry_edit.installEventFilter(self)
934
- self.direct_entry_edit.setPlaceholderText(
935
- trans._('install by name/url, or drop file...')
936
- )
1197
+ self.direct_entry_edit.returnPressed.connect(self._install_packages)
937
1198
  self.direct_entry_edit.setVisible(visibility_direct_entry)
938
- self.direct_entry_btn = QPushButton(trans._("Install"), self)
1199
+ self.direct_entry_btn = QToolButton(self)
939
1200
  self.direct_entry_btn.setVisible(visibility_direct_entry)
940
1201
  self.direct_entry_btn.clicked.connect(self._install_packages)
1202
+ self.direct_entry_btn.setText(trans._("Install"))
1203
+
1204
+ self._action_conda = QAction(trans._('Conda'), self)
1205
+ self._action_conda.setCheckable(True)
1206
+ self._action_conda.triggered.connect(self._update_direct_entry_text)
1207
+
1208
+ self._action_pypi = QAction(trans._('pip'), self)
1209
+ self._action_pypi.setCheckable(True)
1210
+ self._action_pypi.triggered.connect(self._update_direct_entry_text)
1211
+
1212
+ self._action_group = QActionGroup(self)
1213
+ self._action_group.addAction(self._action_pypi)
1214
+ self._action_group.addAction(self._action_conda)
1215
+ self._action_group.setExclusive(True)
1216
+
1217
+ self._menu = QMenu(self)
1218
+ self._menu.addAction(self._action_conda)
1219
+ self._menu.addAction(self._action_pypi)
1220
+
1221
+ if IS_NAPARI_CONDA_INSTALLED:
1222
+ self.direct_entry_btn.setPopupMode(QToolButton.MenuButtonPopup)
1223
+ self._action_conda.setChecked(True)
1224
+ self.direct_entry_btn.setMenu(self._menu)
941
1225
 
942
1226
  self.show_status_btn = QPushButton(trans._("Show Status"), self)
943
1227
  self.show_status_btn.setFixedWidth(100)
@@ -945,11 +1229,12 @@ class QtPluginDialog(QDialog):
945
1229
  self.cancel_all_btn = QPushButton(trans._("cancel all actions"), self)
946
1230
  self.cancel_all_btn.setObjectName("remove_button")
947
1231
  self.cancel_all_btn.setVisible(False)
948
- self.cancel_all_btn.clicked.connect(self.installer.cancel)
1232
+ self.cancel_all_btn.clicked.connect(self.installer.cancel_all)
949
1233
 
950
1234
  self.close_btn = QPushButton(trans._("Close"), self)
951
1235
  self.close_btn.clicked.connect(self.accept)
952
1236
  self.close_btn.setObjectName("close_button")
1237
+
953
1238
  buttonBox.addWidget(self.show_status_btn)
954
1239
  buttonBox.addWidget(self.working_indicator)
955
1240
  buttonBox.addWidget(self.direct_entry_edit)
@@ -967,52 +1252,70 @@ class QtPluginDialog(QDialog):
967
1252
 
968
1253
  self.show_status_btn.setCheckable(True)
969
1254
  self.show_status_btn.setChecked(False)
970
- self.show_status_btn.toggled.connect(self._toggle_status)
1255
+ self.show_status_btn.toggled.connect(self.toggle_status)
971
1256
 
972
1257
  self.v_splitter.setStretchFactor(1, 2)
973
1258
  self.h_splitter.setStretchFactor(0, 2)
974
1259
 
975
1260
  self.packages_filter.setFocus()
1261
+ self._update_direct_entry_text()
976
1262
 
977
- def _update_count_in_label(self):
978
- """Counts all available but not installed plugins. Updates value."""
979
-
980
- count = self.available_list.count()
981
- self.avail_label.setText(
982
- trans._("Available Plugins ({count})", count=count)
1263
+ def _update_direct_entry_text(self):
1264
+ tool = (
1265
+ str(InstallerTools.CONDA)
1266
+ if self._action_conda.isChecked()
1267
+ else str(InstallerTools.PIP)
1268
+ )
1269
+ self.direct_entry_edit.setPlaceholderText(
1270
+ trans._(
1271
+ "install with '{tool}' by name/url, or drop file...", tool=tool
1272
+ )
983
1273
  )
984
1274
 
985
- def _end_refresh(self):
986
- refresh_state = self.refresh_state
987
- self.refresh_state = RefreshState.DONE
988
- if refresh_state == RefreshState.OUTDATED:
989
- self.refresh()
1275
+ def _update_plugin_count(self):
1276
+ """Update count labels for both installed and available plugin lists.
1277
+ Displays also amount of visible plugins out of total when filtering.
1278
+ """
1279
+ installed_count = self.installed_list.count()
1280
+ installed_count_visible = self.installed_list.count_visible()
1281
+ if installed_count == installed_count_visible:
1282
+ self.installed_label.setText(
1283
+ trans._(
1284
+ "Installed Plugins ({amount})",
1285
+ amount=installed_count,
1286
+ )
1287
+ )
1288
+ else:
1289
+ self.installed_label.setText(
1290
+ trans._(
1291
+ "Installed Plugins ({count}/{amount})",
1292
+ count=installed_count_visible,
1293
+ amount=installed_count,
1294
+ )
1295
+ )
990
1296
 
991
- def eventFilter(self, watched, event):
992
- if event.type() == QEvent.DragEnter:
993
- # we need to accept this event explicitly to be able
994
- # to receive QDropEvents!
995
- event.accept()
996
- if event.type() == QEvent.Drop:
997
- md = event.mimeData()
998
- if md.hasUrls():
999
- files = [url.toLocalFile() for url in md.urls()]
1000
- self.direct_entry_edit.setText(files[0])
1001
- return True
1002
- return super().eventFilter(watched, event)
1003
-
1004
- def _toggle_status(self, show):
1005
- if show:
1006
- self.show_status_btn.setText(trans._("Hide Status"))
1007
- self.stdout_text.show()
1297
+ available_count = len(self._plugin_data) - self.installed_list.count()
1298
+ available_count = available_count if available_count >= 0 else 0
1299
+ available_count_visible = self.available_list.count_visible()
1300
+ if available_count == available_count_visible:
1301
+ self.avail_label.setText(
1302
+ trans._(
1303
+ "Available Plugins ({amount})",
1304
+ amount=available_count,
1305
+ )
1306
+ )
1008
1307
  else:
1009
- self.show_status_btn.setText(trans._("Show Status"))
1010
- self.stdout_text.hide()
1308
+ self.avail_label.setText(
1309
+ trans._(
1310
+ "Available Plugins ({count}/{amount})",
1311
+ count=available_count_visible,
1312
+ amount=available_count,
1313
+ )
1314
+ )
1011
1315
 
1012
1316
  def _install_packages(
1013
1317
  self,
1014
1318
  packages: Sequence[str] = (),
1015
- versions: Optional[Sequence[str]] = None,
1016
1319
  ):
1017
1320
  if not packages:
1018
1321
  _packages = self.direct_entry_edit.text()
@@ -1020,40 +1323,75 @@ class QtPluginDialog(QDialog):
1020
1323
  [_packages] if os.path.exists(_packages) else _packages.split()
1021
1324
  )
1022
1325
  self.direct_entry_edit.clear()
1326
+
1023
1327
  if packages:
1024
- self.installer.install(
1025
- packages,
1026
- versions=versions,
1328
+ tool = (
1329
+ InstallerTools.CONDA
1330
+ if self._action_conda.isChecked()
1331
+ else InstallerTools.PIP
1027
1332
  )
1333
+ self.installer.install(tool, packages)
1334
+
1335
+ def _tag_outdated_plugins(self):
1336
+ """Tag installed plugins that might be outdated."""
1337
+ for pkg_name in self.installed_list.packages():
1338
+ _data = self._plugin_data_map.get(pkg_name)
1339
+ if _data is not None:
1340
+ metadata, is_available_in_conda, _ = _data
1341
+ self.installed_list.tag_outdated(
1342
+ metadata, is_available_in_conda
1343
+ )
1028
1344
 
1029
1345
  def _add_items(self):
1030
- """Add items to the lists one by one using a timer to prevent freezing the UI."""
1031
- if len(self._plugin_data) == 0:
1346
+ """
1347
+ Add items to the lists by `batch_size` using a timer to add a pause
1348
+ and prevent freezing the UI.
1349
+ """
1350
+ if len(self._plugin_queue) == 0:
1032
1351
  if (
1033
1352
  self.installed_list.count() + self.available_list.count()
1034
- == len(self.all_plugin_data)
1353
+ == len(self._plugin_data)
1354
+ and self.available_list.count() != 0
1035
1355
  ):
1036
1356
  self._add_items_timer.stop()
1357
+ if not self.isVisible():
1358
+ show_info(
1359
+ trans._(
1360
+ 'Plugin Manager: All available plugins loaded\n'
1361
+ )
1362
+ )
1363
+
1037
1364
  return
1038
1365
 
1039
- data = self._plugin_data.pop(0)
1040
- project_info, is_available, extra_info = data
1041
- if project_info.name in self.already_installed:
1042
- self.installed_list.tag_outdated(project_info, is_available)
1043
- else:
1044
- if project_info.name not in self.available_set:
1045
- self.available_set.add(project_info.name)
1046
- self.available_list.addItem(
1047
- (
1048
- project_info,
1049
- extra_info['pypi_versions'],
1050
- extra_info['conda_versions'],
1051
- )
1366
+ batch_size = 2
1367
+ for _ in range(batch_size):
1368
+ data = self._plugin_queue.pop(0)
1369
+ metadata, is_available_in_conda, extra_info = data
1370
+ display_name = extra_info.get('display_name', metadata.name)
1371
+ if metadata.name in self.already_installed:
1372
+ self.installed_list.tag_outdated(
1373
+ metadata, is_available_in_conda
1052
1374
  )
1053
- if not is_available:
1054
- self.available_list.tag_unavailable(project_info)
1375
+ else:
1376
+ if metadata.name not in self.available_set:
1377
+ self.available_set.add(metadata.name)
1378
+ self.available_list.addItem(
1379
+ ProjectInfoVersions(
1380
+ metadata,
1381
+ display_name,
1382
+ extra_info['pypi_versions'],
1383
+ extra_info['conda_versions'],
1384
+ )
1385
+ )
1386
+ if ON_BUNDLE and not is_available_in_conda:
1387
+ self.available_list.tag_unavailable(metadata)
1388
+
1389
+ if len(self._plugin_queue) == 0:
1390
+ self._tag_outdated_plugins()
1391
+ break
1055
1392
 
1056
- self.filter()
1393
+ if not self._filter_timer.isActive():
1394
+ self.filter(None, skip=True)
1057
1395
 
1058
1396
  def _handle_yield(self, data: Tuple[npe2.PackageMetadata, bool, Dict]):
1059
1397
  """Output from a worker process.
@@ -1064,23 +1402,149 @@ class QtPluginDialog(QDialog):
1064
1402
  method to prevent the UI from freezing by adding all items at once.
1065
1403
  """
1066
1404
  self._plugin_data.append(data)
1067
- self.all_plugin_data.append(data)
1405
+ self._plugin_queue.append(data)
1406
+ self._filter_texts = [
1407
+ f"{i[0].name} {i[-1].get('display_name', '')} {i[0].summary}".lower()
1408
+ for i in self._plugin_data
1409
+ ]
1410
+ metadata, _, _ = data
1411
+ self._plugin_data_map[metadata.name] = data
1412
+ self.available_list.set_data(self._plugin_data)
1413
+
1414
+ def _search_in_available(self, text):
1415
+ idxs = []
1416
+ for idx, item in enumerate(self._filter_texts):
1417
+ if text.lower() in item and idx not in self._filter_idxs_cache:
1418
+ idxs.append(idx)
1419
+ self._filter_idxs_cache.add(idx)
1420
+
1421
+ return idxs
1422
+
1423
+ def _refresh_and_clear_cache(self):
1424
+ self.refresh(clear_cache=True)
1425
+
1426
+ # endregion - Private methods
1427
+
1428
+ # region - Qt overrides
1429
+ # ------------------------------------------------------------------------
1430
+ def closeEvent(self, event):
1431
+ if self._parent is not None:
1432
+ plugin_dialog = getattr(self._parent, '_plugin_dialog', self)
1433
+ if self != plugin_dialog:
1434
+ self.destroy(True, True)
1435
+ super().closeEvent(event)
1436
+ else:
1437
+ plugin_dialog.hide()
1438
+ else:
1439
+ super().closeEvent(event)
1440
+
1441
+ def dragEnterEvent(self, event):
1442
+ event.accept()
1443
+
1444
+ def dropEvent(self, event):
1445
+ md = event.mimeData()
1446
+ if md.hasUrls():
1447
+ files = [url.toLocalFile() for url in md.urls()]
1448
+ self.direct_entry_edit.setText(files[0])
1449
+ return True
1068
1450
 
1069
- def filter(self, text: Optional[str] = None) -> None:
1451
+ return super().dropEvent(event)
1452
+
1453
+ def exec_(self):
1454
+ plugin_dialog = getattr(self._parent, '_plugin_dialog', self)
1455
+ if plugin_dialog != self:
1456
+ self.close()
1457
+
1458
+ plugin_dialog.setModal(True)
1459
+ plugin_dialog.show()
1460
+
1461
+ if self._first_open:
1462
+ self._update_theme(None)
1463
+ self._first_open = False
1464
+
1465
+ def hideEvent(self, event):
1466
+ self.packages_filter.clear()
1467
+ self.toggle_status(False)
1468
+ super().hideEvent(event)
1469
+
1470
+ # endregion - Qt overrides
1471
+
1472
+ # region - Public methods
1473
+ # ------------------------------------------------------------------------
1474
+ def filter(self, text: Optional[str] = None, skip=False) -> None:
1070
1475
  """Filter by text or set current text as filter."""
1071
1476
  if text is None:
1072
1477
  text = self.packages_filter.text()
1073
1478
  else:
1074
1479
  self.packages_filter.setText(text)
1075
1480
 
1481
+ if not skip and self.available_list.is_running() and len(text) >= 1:
1482
+ items = [
1483
+ self._plugin_data[idx]
1484
+ for idx in self._search_in_available(text)
1485
+ ]
1486
+ if items:
1487
+ for item in items:
1488
+ if item in self._plugin_queue:
1489
+ self._plugin_queue.remove(item)
1490
+
1491
+ self._plugin_queue = items + self._plugin_queue
1492
+
1076
1493
  self.installed_list.filter(text)
1077
1494
  self.available_list.filter(text)
1495
+ self._update_plugin_count()
1496
+
1497
+ def refresh(self, clear_cache: bool = False):
1498
+ self.refresh_button.setDisabled(True)
1499
+
1500
+ if self.worker is not None:
1501
+ self.worker.quit()
1502
+
1503
+ if self._add_items_timer.isActive():
1504
+ self._add_items_timer.stop()
1505
+
1506
+ self._filter_texts = []
1507
+ self._plugin_queue = []
1508
+ self._plugin_data = []
1509
+ self._plugin_data_map = {}
1510
+
1511
+ self.installed_list.clear()
1512
+ self.available_list.clear()
1513
+ self.already_installed = set()
1514
+ self.available_set = set()
1515
+
1516
+ self._add_installed()
1517
+ self._fetch_available_plugins(clear_cache=clear_cache)
1518
+
1519
+ self._refresh_timer.start()
1520
+
1521
+ def toggle_status(self, show=None):
1522
+ show = not self.stdout_text.isVisible() if show is None else show
1523
+ if show:
1524
+ self.show_status_btn.setText(trans._("Hide Status"))
1525
+ self.stdout_text.show()
1526
+ else:
1527
+ self.show_status_btn.setText(trans._("Show Status"))
1528
+ self.stdout_text.hide()
1529
+
1530
+ def set_prefix(self, prefix):
1531
+ self._prefix = prefix
1532
+ self.installer._prefix = prefix
1533
+ for idx in range(self.available_list.count()):
1534
+ item = self.available_list.item(idx)
1535
+ item.widget.prefix = prefix
1536
+
1537
+ for idx in range(self.installed_list.count()):
1538
+ item = self.installed_list.item(idx)
1539
+ item.widget.prefix = prefix
1540
+
1541
+ # endregion - Public methods
1078
1542
 
1079
1543
 
1080
1544
  if __name__ == "__main__":
1081
1545
  from qtpy.QtWidgets import QApplication
1082
1546
 
1083
1547
  app = QApplication([])
1084
- w = QtPluginDialog()
1085
- w.show()
1086
- app.exec_()
1548
+ widget = QtPluginDialog()
1549
+ widget.exec_()
1550
+ sys.exit(app.exec_())