class MovableSlider(DoubleSlider, MovableWidget): def __init__(self, name, minSlider, maxSlider, stepSlider, label, shortcutPlusKey, shortcutMinusKey, startValue, **kwargs): MovableWidget.__init__(self, name, label) DoubleSlider.__init__(self, Qt.Horizontal, name=name) self.parameter = kwargs.get('parameter', None) self.module = kwargs.get('module', None) self.minSlider = minSlider self.maxSlider = maxSlider self.stepSlider = stepSlider self.label = label self.shortcutPlus = QShortcut(self) self.shortcutPlus.setKey(shortcutPlusKey) self.shortcutPlus.activated.connect( lambda: self.setValue(self.value + float(self.stepSlider))) self.shortcutMinus = QShortcut(self) self.shortcutMinus.setKey(shortcutMinusKey) self.shortcutMinus.activated.connect( lambda: self.setValue(self.value - float(self.stepSlider))) self.startValue = startValue self.setTracking(False) self.updateData() def getData(self): data = dict() data['widgetType'] = 'Slider' data['name'] = self.widgetName data['minSlider'] = self.minSlider data['maxSlider'] = self.maxSlider data['stepSlider'] = self.stepSlider data['module'] = self.module data['parameter'] = self.parameter data['shortcutPlus'] = self.shortcutPlus.key().toString() data['shortcutMinus'] = self.shortcutMinus.key().toString() return data def updateData(self): self.setMinimum(float(self.minSlider)) self.setMaximum(float(self.maxSlider)) self.setTickInterval(float(self.stepSlider)) self.setPageStep(1) self.setSingleStep(1) self.setValue(float(self.startValue)) self.label.setText(self.widgetName + ': ' + str(self.startValue))
class Shortcut(QObject): def __init__(self, id, name, shortcut_key, callback, widget): self._id = id self._name = name self._shortcut = QShortcut(QKeySequence(shortcut_key), widget) self._shortcut.activated.connect(callback) self._callback = callback def id(self): return self._id def name(self): return self._name def setName(self, name): self._name = name def shortcut(self): return self._shortcut def key(self): return self._shortcut.key().toString() def setKey(self, key): self._shortcut.setKey(QKeySequence(key)) def setShortcut(self, shortcut): self._shortcut = shortcut def callback(self): return self._callback
def defineShortcuts(self): self.shortcuts = [] for x in EnumCamModes: hotkey = QShortcut(QKeySequence(Qt.Key_F1 + int(x)), self) self.shortcuts.append(hotkey) key = (hotkey.key().toString()) if key == 'F1': self.shortcuts[0].activated.connect(lambda: self.setCamera( int(EnumCamModes.CAM_MODE_MATCH_UP))) elif key == 'F2': self.shortcuts[1].activated.connect(lambda: self.setCamera( int(EnumCamModes.CAM_MODE_MATCH_SIDE))) elif key == 'F3': self.shortcuts[2].activated.connect(lambda: self.setCamera( int(EnumCamModes.CAM_MODE_BROADCASTER))) elif key == 'F4': self.shortcuts[3].activated.connect( lambda: self.setCamera(int(EnumCamModes.CAM_MODE_CROWD))) elif key == 'F5': self.shortcuts[4].activated.connect( lambda: self.setCamera(int(EnumCamModes.CAM_MODE_FREE))) elif key == 'F6': self.shortcuts[5].activated.connect( lambda: self.setCamera(int(EnumCamModes.CAM_MODE_LOGO)))
class MovablePushButton(QPushButton, MovableWidget): def __init__(self, name, valueOn, shortcutKey, **kwargs): MovableWidget.__init__(self, name) QPushButton.__init__(self, name=name) self.valueOn = valueOn self.parameter = kwargs.get('parameter', None) self.module = kwargs.get('module', None) self.shortcut = QShortcut(self) self.shortcut.setKey(shortcutKey) self.shortcut.setAutoRepeat(False) self.shortcut.activated.connect(lambda: self.animateClick()) self.updateData() def getData(self): data = dict() data['widgetType'] = 'PushButton' data['name'] = self.widgetName data['valueOn'] = self.valueOn data['module'] = self.module data['parameter'] = self.parameter data['shortcut'] = self.shortcut.key().toString() return data def updateData(self): self.setText(self.widgetName + '\n' + self.valueOn)
class MainWindow(QMainWindow, Ui_MainWindow): foreground_needed = pyqtSignal() new_instance_needed = pyqtSignal(object) systray_notification = pyqtSignal(str, str, int) TAB_NOTIFICATION_COLOR = QColor(46, 146, 208) TAB_NOT_SELECTED_COLOR = QColor(123, 132, 163) TAB_SELECTED_COLOR = QColor(12, 65, 159) def __init__(self, jobs_ctx, event_bus, config, minimize_on_close: bool = False, **kwargs): super().__init__(**kwargs) self.setupUi(self) self.setMenuBar(None) self.jobs_ctx = jobs_ctx self.event_bus = event_bus self.config = config self.minimize_on_close = minimize_on_close # Explain only once that the app stays in background self.minimize_on_close_notif_already_send = False self.force_close = False self.need_close = False self.event_bus.connect(CoreEvent.GUI_CONFIG_CHANGED, self.on_config_updated) self.setWindowTitle( _("TEXT_PARSEC_WINDOW_TITLE_version").format( version=PARSEC_VERSION)) self.foreground_needed.connect(self._on_foreground_needed) self.new_instance_needed.connect(self._on_new_instance_needed) self.tab_center.tabCloseRequested.connect(self.close_tab) self.menu_button = Button() self.menu_button.setCursor(Qt.PointingHandCursor) self.menu_button.setIcon(QIcon(":/icons/images/material/menu.svg")) self.menu_button.setIconSize(QSize(24, 24)) self.menu_button.setText(_("ACTION_MAIN_MENU_SHOW")) self.menu_button.setObjectName("MenuButton") self.menu_button.setProperty("color", QColor(0x00, 0x92, 0xFF)) self.menu_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD)) self.menu_button.setStyleSheet( "#MenuButton {background: none; border: none; color: #0092FF;}" "#MenuButton:hover {color: #0070DD;}") self.menu_button.apply_style() self.menu_button.clicked.connect(self._show_menu) self.tab_center.setCornerWidget(self.menu_button, Qt.TopRightCorner) self.add_tab_button = Button() self.add_tab_button.setCursor(Qt.PointingHandCursor) self.add_tab_button.setIcon(QIcon(":/icons/images/material/add.svg")) self.add_tab_button.setIconSize(QSize(24, 24)) self.add_tab_button.setProperty("color", QColor(0x00, 0x92, 0xFF)) self.add_tab_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD)) self.add_tab_button.setStyleSheet("background: none; border: none;") self.add_tab_button.apply_style() self.add_tab_button.clicked.connect(self._on_add_instance_clicked) self.tab_center.setCornerWidget(self.add_tab_button, Qt.TopLeftCorner) self.tab_center.currentChanged.connect(self.on_current_tab_changed) self._define_shortcuts() self.ensurePolished() def _define_shortcuts(self): self.shortcut_close = QShortcut(QKeySequence(QKeySequence.Close), self) self.shortcut_close.activated.connect( self._shortcut_proxy(self.close_current_tab)) self.shortcut_new_tab = QShortcut(QKeySequence(QKeySequence.AddTab), self) self.shortcut_new_tab.activated.connect( self._shortcut_proxy(self._on_add_instance_clicked)) self.shortcut_settings = QShortcut(QKeySequence(_("Ctrl+K")), self) self.shortcut_settings.activated.connect( self._shortcut_proxy(self._show_settings)) self.shortcut_menu = QShortcut(QKeySequence(_("Alt+E")), self) self.shortcut_menu.activated.connect( self._shortcut_proxy(self._show_menu)) self.shortcut_help = QShortcut(QKeySequence(QKeySequence.HelpContents), self) self.shortcut_help.activated.connect( self._shortcut_proxy(self._on_show_doc_clicked)) self.shortcut_quit = QShortcut(QKeySequence(QKeySequence.Quit), self) self.shortcut_quit.activated.connect( self._shortcut_proxy(self.close_app)) self.shortcut_create_org = QShortcut(QKeySequence(QKeySequence.New), self) self.shortcut_create_org.activated.connect( self._shortcut_proxy(self._on_create_org_clicked)) self.shortcut_join_org = QShortcut(QKeySequence(QKeySequence.Open), self) self.shortcut_join_org.activated.connect( self._shortcut_proxy(self._on_join_org_clicked)) shortcut = QShortcut(QKeySequence(QKeySequence.NextChild), self) shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(1))) shortcut = QShortcut(QKeySequence(QKeySequence.PreviousChild), self) shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(-1))) def _shortcut_proxy(self, funct): def _inner_proxy(): if ParsecApp.has_active_modal(): return funct() return _inner_proxy def _cycle_tabs(self, offset): def _inner_cycle_tabs(): idx = self.tab_center.currentIndex() idx += offset if idx >= self.tab_center.count(): idx = 0 if idx < 0: idx = self.tab_center.count() - 1 self.switch_to_tab(idx) return _inner_cycle_tabs def _toggle_add_tab_button(self): if self._get_login_tab_index() == -1: self.add_tab_button.setDisabled(False) else: self.add_tab_button.setDisabled(True) def resizeEvent(self, event): super().resizeEvent(event) for win in self.children(): if win.objectName() == "GreyedDialog": win.resize(event.size()) win.move(0, 0) def _show_menu(self): menu = QMenu(self) menu.setObjectName("MainMenu") action = None idx = self._get_login_tab_index() action = menu.addAction(_("ACTION_MAIN_MENU_ADD_INSTANCE")) action.triggered.connect(self._on_add_instance_clicked) action.setShortcut(self.shortcut_new_tab.key()) action.setShortcutVisibleInContextMenu(True) if idx != -1: action.setDisabled(True) action = menu.addAction(_("ACTION_MAIN_MENU_CREATE_ORGANIZATION")) action.triggered.connect(self._on_create_org_clicked) action.setShortcut(self.shortcut_create_org.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_JOIN_ORGANIZATION")) action.triggered.connect(self._on_join_org_clicked) action.setShortcut(self.shortcut_join_org.key()) action.setShortcutVisibleInContextMenu(True) menu.addSeparator() action = menu.addAction(_("ACTION_MAIN_MENU_SETTINGS")) action.triggered.connect(self._show_settings) action.setShortcut(self.shortcut_settings.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION")) action.triggered.connect(self._on_show_doc_clicked) action.setShortcut(self.shortcut_help.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_ABOUT")) action.triggered.connect(self._show_about) action = menu.addAction(_("ACTION_MAIN_MENU_CHANGELOG")) action.triggered.connect(self._show_changelog) action = menu.addAction(_("ACTION_MAIN_MENU_LICENSE")) action.triggered.connect(self._show_license) action = menu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND")) action.triggered.connect(self._on_send_feedback_clicked) menu.addSeparator() action = menu.addAction(_("ACTION_MAIN_MENU_QUIT_PARSEC")) action.triggered.connect(self.close_app) action.setShortcut(self.shortcut_quit.key()) action.setShortcutVisibleInContextMenu(True) pos = self.menu_button.pos() pos.setY(pos.y() + self.menu_button.size().height()) pos = self.mapToGlobal(pos) menu.exec_(pos) menu.setParent(None) def _show_about(self): w = AboutWidget() d = GreyedDialog(w, title="", parent=self, width=1000) d.exec_() def _show_license(self): w = LicenseWidget() d = GreyedDialog(w, title=_("TEXT_LICENSE_TITLE"), parent=self, width=1000) d.exec_() def _show_changelog(self): w = ChangelogWidget() d = GreyedDialog(w, title=_("TEXT_CHANGELOG_TITLE"), parent=self, width=1000) d.exec_() def _show_settings(self): w = SettingsWidget(self.config, self.jobs_ctx, self.event_bus) d = GreyedDialog(w, title=_("TEXT_SETTINGS_TITLE"), parent=self, width=1000) d.exec_() def _on_show_doc_clicked(self): desktop.open_doc_link() def _on_send_feedback_clicked(self): desktop.open_feedback_link() def _on_add_instance_clicked(self): self.add_instance() def _on_create_org_clicked(self, addr=None): def _on_finished(ret): if ret is None: return self.reload_login_devices() self.try_login(ret[0], ret[1]) CreateOrgWidget.show_modal(self.jobs_ctx, self.config, self, on_finished=_on_finished, start_addr=addr) def _on_join_org_clicked(self): url = get_text_input( parent=self, title=_("TEXT_JOIN_ORG_URL_TITLE"), message=_("TEXT_JOIN_ORG_URL_INSTRUCTIONS"), placeholder=_("TEXT_JOIN_ORG_URL_PLACEHOLDER"), ) if url is None: return elif url == "": show_error(self, _("TEXT_JOIN_ORG_INVALID_URL")) return action_addr = None try: action_addr = BackendActionAddr.from_url(url) except ValueError as exc: show_error(self, _("TEXT_INVALID_URL"), exception=exc) return if isinstance(action_addr, BackendOrganizationBootstrapAddr): self._on_create_org_clicked(action_addr) elif isinstance(action_addr, BackendInvitationAddr): if action_addr.invitation_type == InvitationType.USER: self._on_claim_user_clicked(action_addr) elif action_addr.invitation_type == InvitationType.DEVICE: self._on_claim_device_clicked(action_addr) else: show_error(self, _("TEXT_INVALID_URL")) return else: show_error(self, _("TEXT_INVALID_URL")) return def _on_claim_user_clicked(self, action_addr): widget = None def _on_finished(): nonlocal widget if not widget.status: return login, password = widget.status self.reload_login_devices() self.try_login(login, password) widget = ClaimUserWidget.show_modal( jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self, on_finished=_on_finished, ) def _on_claim_device_clicked(self, action_addr): widget = None def _on_finished(): nonlocal widget if not widget.status: return login, password = widget.status self.reload_login_devices() self.try_login(login, password) widget = ClaimDeviceWidget.show_modal( jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self, on_finished=_on_finished, ) def try_login(self, device, password): idx = self._get_login_tab_index() tab = None if idx == -1: tab = self.add_new_tab() else: tab = self.tab_center.widget(idx) kf = get_key_file(self.config.config_dir, device) tab.login_with_password(kf, password) def reload_login_devices(self): idx = self._get_login_tab_index() if idx == -1: return w = self.tab_center.widget(idx) if not w: return w.show_login_widget() def on_current_tab_changed(self, index): for i in range(self.tab_center.tabBar().count()): if i != index: if self.tab_center.tabBar().tabTextColor( i) != MainWindow.TAB_NOTIFICATION_COLOR: self.tab_center.tabBar().setTabTextColor( i, MainWindow.TAB_NOT_SELECTED_COLOR) else: self.tab_center.tabBar().setTabTextColor( i, MainWindow.TAB_SELECTED_COLOR) def _on_foreground_needed(self): self.show_top() def _on_new_instance_needed(self, start_arg): self.add_instance(start_arg) self.show_top() def on_config_updated(self, event, **kwargs): self.config = self.config.evolve(**kwargs) save_config(self.config) telemetry.init(self.config) def show_window(self, skip_dialogs=False, invitation_link=""): try: if not self.restoreGeometry(self.config.gui_geometry): self.showMaximized() except TypeError: self.showMaximized() QCoreApplication.processEvents() # Used with the --diagnose option if skip_dialogs: return # At the very first launch if self.config.gui_first_launch: r = ask_question( self, _("TEXT_ERROR_REPORTING_TITLE"), _("TEXT_ERROR_REPORTING_INSTRUCTIONS"), [_("ACTION_ERROR_REPORTING_ACCEPT"), _("ACTION_NO")], ) # Acknowledge the changes self.event_bus.send( CoreEvent.GUI_CONFIG_CHANGED, gui_first_launch=False, gui_last_version=PARSEC_VERSION, telemetry_enabled=r == _("ACTION_ERROR_REPORTING_ACCEPT"), ) # For each parsec update if self.config.gui_last_version and self.config.gui_last_version != PARSEC_VERSION: # Update from parsec `<1.14` to `>=1.14` if LooseVersion(self.config.gui_last_version) < "1.14": # Revert the acrobat reader workaround if (platform.system() == "Windows" and win_registry.is_acrobat_reader_dc_present() and not win_registry.get_acrobat_app_container_enabled()): win_registry.del_acrobat_app_container_enabled() # Acknowledge the changes self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED, gui_last_version=PARSEC_VERSION) telemetry.init(self.config) devices = list_available_devices(self.config.config_dir) if not len(devices) and not invitation_link: r = ask_question( self, _("TEXT_KICKSTART_PARSEC_WHAT_TO_DO_TITLE"), _("TEXT_KICKSTART_PARSEC_WHAT_TO_DO_INSTRUCTIONS"), [ _("ACTION_NO_DEVICE_CREATE_ORGANIZATION"), _("ACTION_NO_DEVICE_JOIN_ORGANIZATION"), ], radio_mode=True, ) if r == _("ACTION_NO_DEVICE_JOIN_ORGANIZATION"): self._on_join_org_clicked() elif r == _("ACTION_NO_DEVICE_CREATE_ORGANIZATION"): self._on_create_org_clicked() def show_top(self): self.activateWindow() self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive) self.raise_() self.show() def on_tab_state_changed(self, tab, state): idx = self.tab_center.indexOf(tab) if idx == -1: return if state == "login": if self._get_login_tab_index() != -1: self.tab_center.removeTab(idx) else: self.tab_center.setTabToolTip( idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN")) self.tab_center.setTabText(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN")) elif state == "logout": self.tab_center.removeTab(idx) idx = self._get_login_tab_index() if idx == -1: self.add_instance() else: tab_widget = self.tab_center.widget(idx) log_widget = None if not tab_widget else tab_widget.get_login_widget( ) if log_widget: log_widget.reload_devices() elif state == "connected": device = tab.current_device tab_name = ( f"{device.organization_id} - {device.short_user_display} - {device.device_display}" ) self.tab_center.setTabToolTip(idx, tab_name) self.tab_center.setTabText( idx, ensure_string_size(tab_name, 150, self.tab_center.tabBar().font())) if self.tab_center.count() == 1: self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() def on_tab_notification(self, widget, event): idx = self.tab_center.indexOf(widget) if idx == -1 or idx == self.tab_center.currentIndex(): return if event == CoreEvent.SHARING_UPDATED: self.tab_center.tabBar().setTabTextColor( idx, MainWindow.TAB_NOTIFICATION_COLOR) def _get_login_tab_index(self): for idx in range(self.tab_center.count()): if self.tab_center.tabText(idx) == _( "TEXT_TAB_TITLE_LOG_IN_SCREEN"): return idx return -1 def add_new_tab(self): tab = InstanceWidget(self.jobs_ctx, self.event_bus, self.config, self.systray_notification) tab.join_organization_clicked.connect(self._on_join_org_clicked) tab.create_organization_clicked.connect(self._on_create_org_clicked) idx = self.tab_center.addTab(tab, "") tab.state_changed.connect(self.on_tab_state_changed) self.tab_center.setCurrentIndex(idx) if self.tab_center.count() > 1: self.tab_center.setTabsClosable(True) else: self.tab_center.setTabsClosable(False) return tab def switch_to_tab(self, idx): if not ParsecApp.has_active_modal(): self.tab_center.setCurrentIndex(idx) def _find_device_from_addr(self, action_addr, display_error=False): device = None for available_device in list_available_devices(self.config.config_dir): if available_device.organization_id == action_addr.organization_id: device = available_device break if device is None: show_error( self, _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format( organization=action_addr.organization_id), ) return device def switch_to_login_tab(self, action_addr=None): idx = self._get_login_tab_index() if idx != -1: self.switch_to_tab(idx) else: tab = self.add_new_tab() tab.show_login_widget() self.on_tab_state_changed(tab, "login") idx = self.tab_center.count() - 1 self.switch_to_tab(idx) if action_addr is not None: device = self._find_device_from_addr(action_addr, display_error=True) instance_widget = self.tab_center.widget(idx) instance_widget.set_workspace_path(action_addr) login_w = self.tab_center.widget(idx).get_login_widget() login_w._on_account_clicked(device) def go_to_file_link(self, action_addr): found_org = self._find_device_from_addr(action_addr, display_error=True) is not None if not found_org: self.switch_to_login_tab() return for idx in range(self.tab_center.count()): if self.tab_center.tabText(idx) == _( "TEXT_TAB_TITLE_LOG_IN_SCREEN"): continue w = self.tab_center.widget(idx) if (not w or not w.core or w.core.device.organization_addr.organization_id != action_addr.organization_id): continue user_manifest = w.core.user_fs.get_user_manifest() found_workspace = False for wk in user_manifest.workspaces: if not wk.role: continue if wk.id == action_addr.workspace_id: found_workspace = True central_widget = w.get_central_widget() try: central_widget.go_to_file_link(wk.id, action_addr.path) self.switch_to_tab(idx) except AttributeError: logger.exception("Central widget is not available") return if not found_workspace: show_error( self, _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization"). format(organization=action_addr.organization_id), ) return show_info( self, _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format( organization=action_addr.organization_id), ) self.switch_to_login_tab(action_addr) def show_create_org_widget(self, action_addr): self.switch_to_login_tab() self._on_create_org_clicked(action_addr) def show_claim_user_widget(self, action_addr): self.switch_to_login_tab() self._on_claim_user_clicked(action_addr) def show_claim_device_widget(self, action_addr): self.switch_to_login_tab() self._on_claim_device_clicked(action_addr) def add_instance(self, start_arg: Optional[str] = None): action_addr = None if start_arg: try: action_addr = BackendActionAddr.from_url(start_arg) except ValueError as exc: show_error(self, _("TEXT_INVALID_URL"), exception=exc) self.show_top() if not action_addr: self.switch_to_login_tab() elif isinstance(action_addr, BackendOrganizationFileLinkAddr): self.go_to_file_link(action_addr) elif isinstance(action_addr, BackendOrganizationBootstrapAddr): self.show_create_org_widget(action_addr) elif (isinstance(action_addr, BackendInvitationAddr) and action_addr.invitation_type == InvitationType.USER): self.show_claim_user_widget(action_addr) elif (isinstance(action_addr, BackendInvitationAddr) and action_addr.invitation_type == InvitationType.DEVICE): self.show_claim_device_widget(action_addr) else: show_error(self, _("TEXT_INVALID_URL")) def close_current_tab(self, force=False): if self.tab_center.count() == 1: self.close_app() else: idx = self.tab_center.currentIndex() self.close_tab(idx, force=force) def close_app(self, force=False): self.show_top() self.need_close = True self.force_close = force self.close() def close_all_tabs(self): for idx in range(self.tab_center.count()): self.close_tab(idx, force=True) def close_tab(self, index, force=False): tab = self.tab_center.widget(index) if not force: r = _("ACTION_TAB_CLOSE_CONFIRM") if tab and tab.is_logged_in: r = ask_question( self, _("TEXT_TAB_CLOSE_TITLE"), _("TEXT_TAB_CLOSE_INSTRUCTIONS_device").format( device= f"{tab.core.device.short_user_display} - {tab.core.device.device_display}" ), [_("ACTION_TAB_CLOSE_CONFIRM"), _("ACTION_CANCEL")], ) if r != _("ACTION_TAB_CLOSE_CONFIRM"): return self.tab_center.removeTab(index) if not tab: return tab.logout() self.reload_login_devices() if self.tab_center.count() == 1: self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() def closeEvent(self, event): if self.minimize_on_close and not self.need_close: self.hide() event.ignore() if not self.minimize_on_close_notif_already_send: self.minimize_on_close_notif_already_send = True self.systray_notification.emit( "Parsec", _("TEXT_TRAY_PARSEC_STILL_RUNNING_MESSAGE"), 2000) else: if self.config.gui_confirmation_before_close and not self.force_close: result = ask_question( self if self.isVisible() else None, _("TEXT_PARSEC_QUIT_TITLE"), _("TEXT_PARSEC_QUIT_INSTRUCTIONS"), [_("ACTION_PARSEC_QUIT_CONFIRM"), _("ACTION_CANCEL")], ) if result != _("ACTION_PARSEC_QUIT_CONFIRM"): event.ignore() self.force_close = False self.need_close = False return state = self.saveGeometry() self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED, gui_geometry=state) self.close_all_tabs() event.accept() QApplication.quit()
class ConfigTreeWidget(QTreeView): def __init__(self): QTreeView.__init__(self) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.open_menu) self.setSelectionMode(self.SingleSelection) # self.setSelectionBehavior(self.SelectItems) self.setDragDropMode(QAbstractItemView.DragDrop) self.setDefaultDropAction(Qt.MoveAction) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setAnimated(True) self.duplicate_shortcut = QShortcut(QKeySequence('Shift+D'), self) self.duplicate_shortcut.activated.connect( self.with_selected(self.duplicate)) self.exclude_shortcut = QShortcut(QKeySequence('Alt+Del'), self) self.exclude_shortcut.activated.connect( self.with_selected(self.exclude)) self.remove_shortcut = QShortcut(QKeySequence('Del'), self) self.remove_shortcut.activated.connect(self.with_selected(self.remove)) self.clear_shortcut = QShortcut(QKeySequence('Shift+R'), self) self.clear_shortcut.activated.connect( self.with_selected(self.reset_item, 'clear_value')) self.default_shortcut = QShortcut(QKeySequence('Ctrl+R'), self) self.default_shortcut.activated.connect( self.with_selected(self.reset_item, 'default')) self.reset_shortcut = QShortcut(QKeySequence('Alt+R'), self) self.reset_shortcut.activated.connect( self.with_selected(self.reset_item, 'all')) self.item_shortcut = QShortcut(QKeySequence('Shift+A'), self) self.item_shortcut.activated.connect( self.with_selected(self.add_item, False)) self.section_shortcut = QShortcut(QKeySequence('Ctrl+A'), self) self.section_shortcut.activated.connect( self.with_selected(self.add_item, True)) def with_selected(self, f, *args, **kwargs): def decorated(): index = self.selectedIndexes()[0] return f(index, *args, **kwargs) return decorated def open_menu(self, point): index = self.indexAt(point) item = index.internalPointer() menu = QMenu() duplicate = QAction("Duplicate") duplicate.setShortcut(self.duplicate_shortcut.key()) duplicate.triggered.connect(partial(self.duplicate, index)) menu.addAction(duplicate) exclude = QAction("Toggle exclude") exclude.setShortcut(self.exclude_shortcut.key()) exclude.triggered.connect(partial(self.exclude, index)) menu.addAction(exclude) remove = QAction("Remove from config") remove.setShortcut(self.remove_shortcut.key()) remove.triggered.connect(partial(self.remove, index)) menu.addAction(remove) menu.addSeparator() clear = QAction("Clear item value") clear.setShortcut(self.clear_shortcut.key()) clear.triggered.connect(partial(self.reset_item, index, 'clear_value')) menu.addAction(clear) reset_default = QAction("Reset value to default") reset_default.setShortcut(self.default_shortcut.key()) reset_default.triggered.connect( partial(self.reset_item, index, 'default')) menu.addAction(reset_default) reset_all = QAction("Reset all changes") reset_all.setShortcut(self.reset_shortcut.key()) reset_all.triggered.connect(partial(self.reset_item, index, 'all')) menu.addAction(reset_all) menu.addSeparator() add_option = QAction("Add option") add_option.setShortcut(self.item_shortcut.key()) add_option.triggered.connect(partial(self.add_item, index, False)) menu.addAction(add_option) add_section = QAction("Add section") add_section.setShortcut(self.section_shortcut.key()) add_section.triggered.connect(partial(self.add_item, index, True)) menu.addAction(add_section) if item is None: clear.setDisabled(True) reset_all.setDisabled(True) reset_default.setDisabled(True) duplicate.setDisabled(True) remove.setDisabled(True) exclude.setDisabled(True) else: if item.type in ('list', 'list_item'): add_section.setDisabled(True) if item.type == 'list': clear.setDisabled(True) # Temporary, cuz buggg # if item.type == 'section': # clear.setDisabled(True) menu.exec_(QCursor.pos()) def duplicate(self, index): item = deepcopy(index.internalPointer()) item.set_state('added') ensure_unique_names(item) self.model().insertItems(index.row() + 1, [item], index.parent()) self.expandAll() # fixes not expanded duplicated section def remove(self, index): self.model().removeRow(index) def exclude(self, index): item = self.model().nodeFromIndex(index) if item.state == 'deleted': self.model().setData(index, item.previous_state, StateRole) else: self.model().setData(index, 'deleted', StateRole) def add_item(self, index, is_section): parentItem = self.model().nodeFromIndex(index) if parentItem.type in ('list', 'list_item'): if is_section: return item_type = 'list_item' else: item_type = 'section' if is_section else 'option' prompt = 'Enter {} name'.format(item_type.replace('_', ' ')) text, ok = QInputDialog.getText(self, prompt, prompt) if not ok: return if parentItem.type in ( 'list', 'section'): # to append at first index in section or list row = 0 parent = index else: row = index.row() parent = index.parent() if row == -1: # to append at last position e.g. at root row = parentItem.childCount() else: row += 1 # to append under current position item = ConfigModelItem((text, None, '', ''), item_type=item_type, state='added') self.model().insertItems(row, [item], parent) ensure_unique_names(item, include_self=False) # parent.internalPointer().set_state('edited') self.expandAll() def reset_item(self, index, reset_type): item = index.internalPointer() model = self.model() itemdataindex = model.modifyCol(index, 1) if reset_type == 'all': for i, default in enumerate(item.default_values): model.setData(model.modifyCol(index, i), default) model.setData(index, item.default_state, role=StateRole) elif reset_type == 'default': # if item.type == 'list' and \ # not isinstance(item.spec_default, (list, tuple)): # self.reset_item(item, 'clear_value') model.setData(itemdataindex, item.spec_default) if item.default_state == 'unchanged': model.setData(index, 'unchanged', role=StateRole) elif reset_type == 'clear_value': item_type = model.data(itemdataindex, TypeRole) if item_type == 'list': return if item_type != 'section': model.setData(itemdataindex, None) # if model.data(itemdataindex, TypeRole) == 'list': # TODO # model.removeRows(0, item.childCount(), index) # model.setData(index, 'option', role=TypeRole) # return for child in model.childrenIndexes(index): self.reset_item(child, reset_type)
class MainWindow(QMainWindow, Ui_MainWindow): # type: ignore[misc] foreground_needed = pyqtSignal() new_instance_needed = pyqtSignal(object) systray_notification = pyqtSignal(str, str, int) TAB_NOTIFICATION_COLOR = QColor(46, 146, 208) TAB_NOT_SELECTED_COLOR = QColor(123, 132, 163) TAB_SELECTED_COLOR = QColor(12, 65, 159) def __init__( self, jobs_ctx: QtToTrioJobScheduler, quit_callback: Callable[[], None], event_bus: EventBus, config: CoreConfig, minimize_on_close: bool = False, parent: Optional[QWidget] = None, ): super().__init__(parent=parent) self.setupUi(self) self.setMenuBar(None) self.jobs_ctx = jobs_ctx self.quit_callback = quit_callback self.event_bus = event_bus self.config = config self.minimize_on_close = minimize_on_close # Explain only once that the app stays in background self.minimize_on_close_notif_already_send = False self.force_close = False self.need_close = False self.event_bus.connect( CoreEvent.GUI_CONFIG_CHANGED, cast(EventCallback, self.on_config_updated) ) self.setWindowTitle(_("TEXT_PARSEC_WINDOW_TITLE_version").format(version=PARSEC_VERSION)) self.foreground_needed.connect(self._on_foreground_needed) self.new_instance_needed.connect(self._on_new_instance_needed) self.tab_center.tabCloseRequested.connect(self.close_tab) self.menu_button = Button() self.menu_button.setCursor(Qt.CursorShape.PointingHandCursor) self.menu_button.setIcon(QIcon(":/icons/images/material/menu.svg")) self.menu_button.setIconSize(QSize(24, 24)) self.menu_button.setText(_("ACTION_MAIN_MENU_SHOW")) self.menu_button.setObjectName("MenuButton") self.menu_button.setProperty("color", QColor(0x00, 0x92, 0xFF)) self.menu_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD)) self.menu_button.setStyleSheet( "#MenuButton {background: none; border: none; color: #0092FF;}" "#MenuButton:hover {color: #0070DD;}" ) self.menu_button.apply_style() self.menu_button.clicked.connect(self._show_menu) self.tab_center.setCornerWidget(self.menu_button, Qt.Corner.TopRightCorner) self.add_tab_button = Button() self.add_tab_button.setCursor(Qt.CursorShape.PointingHandCursor) self.add_tab_button.setIcon(QIcon(":/icons/images/material/add.svg")) self.add_tab_button.setIconSize(QSize(24, 24)) self.add_tab_button.setProperty("color", QColor(0x00, 0x92, 0xFF)) self.add_tab_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD)) self.add_tab_button.setStyleSheet("background: none; border: none;") self.add_tab_button.apply_style() self.add_tab_button.clicked.connect(self._on_add_instance_clicked) self.tab_center.setCornerWidget(self.add_tab_button, Qt.Corner.TopLeftCorner) self.tab_center.currentChanged.connect(self.on_current_tab_changed) self._define_shortcuts() self.ensurePolished() if sys.platform == "darwin": # Native menu bar on MacOS self._create_mac_menu_bar() def _define_shortcuts(self) -> None: self.shortcut_close = QShortcut(QKeySequence(QKeySequence.Close), self) self.shortcut_close.activated.connect(self._shortcut_proxy(self.close_current_tab)) self.shortcut_new_tab = QShortcut(QKeySequence(QKeySequence.AddTab), self) self.shortcut_new_tab.activated.connect(self._shortcut_proxy(self._on_add_instance_clicked)) self.shortcut_settings = QShortcut(QKeySequence(_("Ctrl+K")), self) self.shortcut_settings.activated.connect(self._shortcut_proxy(self._show_settings)) self.shortcut_recovery = QShortcut(QKeySequence(_("Ctrl+I")), self) self.shortcut_recovery.activated.connect(self._shortcut_proxy(self._on_manage_keys)) self.shortcut_menu = QShortcut(QKeySequence(_("Alt+E")), self) self.shortcut_menu.activated.connect(self._shortcut_proxy(self._show_menu)) self.shortcut_help = QShortcut(QKeySequence(QKeySequence.HelpContents), self) self.shortcut_help.activated.connect(self._shortcut_proxy(self._on_show_doc_clicked)) self.shortcut_quit = QShortcut(QKeySequence(QKeySequence.Quit), self) self.shortcut_quit.activated.connect(self._shortcut_proxy(self.close_app)) self.shortcut_create_org = QShortcut(QKeySequence(QKeySequence.New), self) self.shortcut_create_org.activated.connect( self._shortcut_proxy(self._on_create_org_clicked) ) self.shortcut_join_org = QShortcut(QKeySequence(QKeySequence.Open), self) self.shortcut_join_org.activated.connect(self._shortcut_proxy(self._on_join_org_clicked)) shortcut = QShortcut(QKeySequence(QKeySequence.NextChild), self) shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(1))) shortcut = QShortcut(QKeySequence(QKeySequence.PreviousChild), self) shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(-1))) def _shortcut_proxy(self, funct: Callable[[], None]) -> Callable[[], None]: def _inner_proxy() -> None: if ParsecApp.has_active_modal(): return funct() return _inner_proxy def _cycle_tabs(self, offset: int) -> Callable[[], None]: def _inner_cycle_tabs() -> None: idx = self.tab_center.currentIndex() idx += offset if idx >= self.tab_center.count(): idx = 0 if idx < 0: idx = self.tab_center.count() - 1 self.switch_to_tab(idx) return _inner_cycle_tabs def _toggle_add_tab_button(self) -> None: if self._get_login_tab_index() == -1: self.add_tab_button.setDisabled(False) else: self.add_tab_button.setDisabled(True) def resizeEvent(self, event: QResizeEvent) -> None: super().resizeEvent(event) for win in self.children(): if isinstance(win, GreyedDialog): win.resize(event.size()) win.move(0, 0) def _create_mac_menu_bar(self) -> None: menuBar = QMenuBar() fileMenu = QMenu(_("TEXT_MENU_FILE"), self) menuBar.addMenu(fileMenu) # 'settings' and 'about' are key words processed by Qt to make standard # MacOS submenus associated with standard key bindings. 'quit' links # cmd+Q to the close confirmation widget, and leaves the red X to its # standard behaviour depending on the `minimize_on_close` option. action = fileMenu.addAction("about") action.triggered.connect(self._show_about) action = fileMenu.addAction("settings") action.triggered.connect(self._show_settings) action = fileMenu.addAction("quit") action.triggered.connect(self.close_app) action = fileMenu.addAction(_("ACTION_MAIN_MENU_CREATE_ORGANIZATION")) action.triggered.connect(self._on_create_org_clicked) action.setShortcut(self.shortcut_create_org.key()) action = fileMenu.addAction(_("ACTION_MAIN_MENU_JOIN_ORGANIZATION")) action.triggered.connect(self._on_join_org_clicked) action.setShortcut(self.shortcut_join_org.key()) deviceMenu = QMenu(_("TEXT_MENU_DEVICE"), self) menuBar.addMenu(deviceMenu) action = deviceMenu.addAction(_("ACTION_MAIN_MENU_MANAGE_KEYS")) action.triggered.connect(self._on_manage_keys) action.setShortcut(self.shortcut_recovery.key()) helpMenu = QMenu(_("TEXT_MENU_HELP"), self) menuBar.addMenu(helpMenu) action = helpMenu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION")) action.triggered.connect(self._on_show_doc_clicked) action = helpMenu.addAction(_("ACTION_MAIN_MENU_CHANGELOG")) action.triggered.connect(self._show_changelog) helpMenu.addSeparator() action = helpMenu.addAction(_("ACTION_MAIN_MENU_LICENSE")) action.triggered.connect(self._show_license) helpMenu.addSeparator() action = helpMenu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND")) action.triggered.connect(self._on_send_feedback_clicked) self.setMenuBar(menuBar) def _show_menu(self) -> None: menu = QMenu(self) menu.setObjectName("MainMenu") action = None idx = self._get_login_tab_index() action = menu.addAction(_("ACTION_MAIN_MENU_ADD_INSTANCE")) action.triggered.connect(self._on_add_instance_clicked) action.setShortcut(self.shortcut_new_tab.key()) action.setShortcutVisibleInContextMenu(True) if idx != -1: action.setDisabled(True) action = menu.addAction(_("ACTION_MAIN_MENU_CREATE_ORGANIZATION")) action.triggered.connect(self._on_create_org_clicked) action.setShortcut(self.shortcut_create_org.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_JOIN_ORGANIZATION")) action.triggered.connect(self._on_join_org_clicked) action.setShortcut(self.shortcut_join_org.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_MANAGE_KEYS")) action.triggered.connect(self._on_manage_keys) action.setShortcut(self.shortcut_recovery.key()) action.setShortcutVisibleInContextMenu(True) menu.addSeparator() action = menu.addAction(_("ACTION_MAIN_MENU_SETTINGS")) action.triggered.connect(self._show_settings) action.setShortcut(self.shortcut_settings.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION")) action.triggered.connect(self._on_show_doc_clicked) action.setShortcut(self.shortcut_help.key()) action.setShortcutVisibleInContextMenu(True) action = menu.addAction(_("ACTION_MAIN_MENU_ABOUT")) action.triggered.connect(self._show_about) action = menu.addAction(_("ACTION_MAIN_MENU_CHANGELOG")) action.triggered.connect(self._show_changelog) action = menu.addAction(_("ACTION_MAIN_MENU_LICENSE")) action.triggered.connect(self._show_license) action = menu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND")) action.triggered.connect(self._on_send_feedback_clicked) menu.addSeparator() action = menu.addAction(_("ACTION_MAIN_MENU_QUIT_PARSEC")) action.triggered.connect(self.close_app) action.setShortcut(self.shortcut_quit.key()) action.setShortcutVisibleInContextMenu(True) pos = self.menu_button.pos() pos.setY(pos.y() + self.menu_button.size().height()) pos = self.mapToGlobal(pos) menu.exec_(pos) menu.setParent(None) def _show_about(self) -> None: w = AboutWidget() d = GreyedDialog(w, title="", parent=self, width=1000) d.exec_() def _show_license(self) -> None: w = LicenseWidget() d = GreyedDialog(w, title=_("TEXT_LICENSE_TITLE"), parent=self, width=1000) d.exec_() def _show_changelog(self) -> None: w = ChangelogWidget() d = GreyedDialog(w, title=_("TEXT_CHANGELOG_TITLE"), parent=self, width=1000) d.exec_() def _show_settings(self) -> None: w = SettingsWidget(self.config, self.jobs_ctx, self.event_bus) d = GreyedDialog(w, title=_("TEXT_SETTINGS_TITLE"), parent=self, width=1000) d.exec_() def _on_manage_keys(self) -> None: devices = [device for device in list_available_devices(self.config.config_dir)] options = [_("ACTION_CANCEL"), _("ACTION_RECOVER_DEVICE")] if len(devices): options.append(_("ACTION_CREATE_RECOVERY_DEVICE")) result = ask_question( self, _("TEXT_DEVICE_RECOVERY_TITLE"), _("TEXT_DEVICE_RECOVERY_QUESTION"), options ) if result == _("ACTION_RECOVER_DEVICE"): DeviceRecoveryImportWidget.show_modal( self.config, self.jobs_ctx, parent=self, on_finished=self.reload_login_devices ) elif result == _("ACTION_CREATE_RECOVERY_DEVICE"): DeviceRecoveryExportWidget.show_modal(self.config, self.jobs_ctx, devices, parent=self) def _on_show_doc_clicked(self) -> None: desktop.open_doc_link() def _on_send_feedback_clicked(self) -> None: desktop.open_feedback_link() def _on_add_instance_clicked(self) -> None: self.add_instance() def _bind_async_callback( self, callback: Callable[[], Awaitable[None]] ) -> Callable[[], Awaitable[None]]: """Async callbacks need to be bound to the MainWindow instance in order to be able to be scheduled in the job context. """ async def wrapper(instance: "MainWindow") -> None: return await callback() return wrapper.__get__(self) # type: ignore[attr-defined] def _on_create_org_clicked( self, addr: Optional[BackendOrganizationBootstrapAddr] = None ) -> None: widget: CreateOrgWidget @self._bind_async_callback async def _on_finished() -> None: nonlocal widget # It's safe to access the widget status here since this does not perform a Qt call. # But the underlying C++ widget might already be deleted so we should make sure not # not do anything Qt related with this widget. if widget.status is None: return self.reload_login_devices() device, auth_method, password = widget.status await self.try_login(device, auth_method, password) answer = ask_question( self, _("TEXT_BOOTSTRAP_ORG_SUCCESS_TITLE"), _("TEXT_BOOTSTRAP_ORG_SUCCESS_organization").format( organization=device.organization_id ), [_("ACTION_CREATE_RECOVERY_DEVICE"), _("ACTION_NO")], oriented_question=True, ) if answer == _("ACTION_CREATE_RECOVERY_DEVICE"): DeviceRecoveryExportWidget.show_modal( self.config, self.jobs_ctx, [device], parent=self ) widget = CreateOrgWidget.show_modal( self.jobs_ctx, self.config, self, on_finished=_on_finished, start_addr=addr ) def _on_join_org_clicked(self) -> None: url = get_text_input( parent=self, title=_("TEXT_JOIN_ORG_URL_TITLE"), message=_("TEXT_JOIN_ORG_URL_INSTRUCTIONS"), placeholder=_("TEXT_JOIN_ORG_URL_PLACEHOLDER"), validator=validators.BackendActionAddrValidator(), ) if url is None: return elif url == "": show_error(self, _("TEXT_JOIN_ORG_INVALID_URL")) return action_addr = None try: action_addr = BackendActionAddr.from_url(url, allow_http_redirection=True) except ValueError as exc: show_error(self, _("TEXT_INVALID_URL"), exception=exc) return if isinstance(action_addr, BackendOrganizationBootstrapAddr): self._on_create_org_clicked(action_addr) elif isinstance(action_addr, BackendInvitationAddr): if action_addr.invitation_type == InvitationType.USER: self._on_claim_user_clicked(action_addr) elif action_addr.invitation_type == InvitationType.DEVICE: self._on_claim_device_clicked(action_addr) elif isinstance(action_addr, BackendPkiEnrollmentAddr): if not is_pki_enrollment_available(): show_error(self, _("TEXT_PKI_ENROLLMENT_NOT_AVAILABLE")) return self._on_claim_pki_clicked(action_addr) else: show_error(self, _("TEXT_INVALID_URL")) return def _on_recover_device_clicked(self) -> None: DeviceRecoveryImportWidget.show_modal( self.config, self.jobs_ctx, parent=self, on_finished=self.reload_login_devices ) def _on_claim_pki_clicked(self, action_addr: BackendPkiEnrollmentAddr) -> None: widget: EnrollmentQueryWidget def _on_finished() -> None: nonlocal widget # It's safe to access the widget status here since this does not perform a Qt call. # But the underlying C++ widget might already be deleted so we should make sure not # not do anything Qt related with this widget. if not widget.status: return show_info(self, _("TEXT_ENROLLMENT_QUERY_SUCCEEDED")) self.reload_login_devices() widget = EnrollmentQueryWidget.show_modal( jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self, on_finished=_on_finished, ) def _on_claim_user_clicked(self, action_addr: BackendInvitationAddr) -> None: widget: ClaimUserWidget @self._bind_async_callback async def _on_finished() -> None: nonlocal widget # It's safe to access the widget status here since this does not perform a Qt call. # But the underlying C++ widget might already be deleted so we should make sure not # not do anything Qt related with this widget. if not widget.status: return device, auth_method, password = widget.status self.reload_login_devices() await self.try_login(device, auth_method, password) answer = ask_question( self, _("TEXT_CLAIM_USER_SUCCESSFUL_TITLE"), _("TEXT_CLAIM_USER_SUCCESSFUL"), [_("ACTION_CREATE_RECOVERY_DEVICE"), _("ACTION_NO")], oriented_question=True, ) if answer == _("ACTION_CREATE_RECOVERY_DEVICE"): DeviceRecoveryExportWidget.show_modal(self.config, self.jobs_ctx, [device], self) widget = ClaimUserWidget.show_modal( jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self, on_finished=_on_finished, ) def _on_claim_device_clicked(self, action_addr: BackendInvitationAddr) -> None: widget: ClaimDeviceWidget @self._bind_async_callback async def _on_finished() -> None: nonlocal widget # It's safe to access the widget status here since this does not perform a Qt call. # But the underlying C++ widget might already be deleted so we should make sure not # not do anything Qt related with this widget. if not widget.status: return device, auth_method, password = widget.status self.reload_login_devices() await self.try_login(device, auth_method, password) widget = ClaimDeviceWidget.show_modal( jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self, on_finished=_on_finished, ) async def try_login( self, device: LocalDevice, auth_method: DeviceFileType, password: str ) -> None: idx = self._get_login_tab_index() if idx == -1: tab = self.add_new_tab() else: tab = self.tab_center.widget(idx) kf = get_key_file(self.config.config_dir, device) if auth_method == DeviceFileType.PASSWORD: tab.login_with_password(kf, password) elif auth_method == DeviceFileType.SMARTCARD: await tab.login_with_smartcard(kf) def reload_login_devices(self) -> None: idx = self._get_login_tab_index() if idx == -1: return w = self.tab_center.widget(idx) if not w: return w.show_login_widget() def on_current_tab_changed(self, index: int) -> None: for i in range(self.tab_center.tabBar().count()): if i != index: if self.tab_center.tabBar().tabTextColor(i) != MainWindow.TAB_NOTIFICATION_COLOR: self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_NOT_SELECTED_COLOR) else: self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_SELECTED_COLOR) def _on_foreground_needed(self) -> None: self.show_top() def _on_new_instance_needed(self, start_arg: Optional[str]) -> None: self.add_instance(start_arg) self.show_top() def on_config_updated(self, event: CoreEvent, **kwargs: object) -> None: self.config = self.config.evolve(**kwargs) save_config(self.config) telemetry.init(self.config) def show_window(self, skip_dialogs: bool = False) -> None: try: if not self.restoreGeometry(self.config.gui_geometry): self.showMaximized() except TypeError: self.showMaximized() QCoreApplication.processEvents() # Used with the --diagnose option if skip_dialogs: return # At the very first launch if self.config.gui_first_launch: r = ask_question( self, _("TEXT_ENABLE_TELEMETRY_TITLE"), _("TEXT_ENABLE_TELEMETRY_INSTRUCTIONS"), [_("ACTION_ENABLE_TELEMETRY_ACCEPT"), _("ACTION_ENABLE_TELEMETRY_REFUSE")], oriented_question=True, ) # Acknowledge the changes self.event_bus.send( CoreEvent.GUI_CONFIG_CHANGED, gui_first_launch=False, gui_last_version=PARSEC_VERSION, telemetry_enabled=r == _("ACTION_ENABLE_TELEMETRY_ACCEPT"), ) # For each parsec update if self.config.gui_last_version and self.config.gui_last_version != PARSEC_VERSION: # Update from parsec `<1.14` to `>=1.14` if LooseVersion(self.config.gui_last_version) < "1.14": # Revert the acrobat reader workaround if ( sys.platform == "win32" and win_registry.is_acrobat_reader_dc_present() and not win_registry.get_acrobat_app_container_enabled() ): win_registry.del_acrobat_app_container_enabled() # Acknowledge the changes self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED, gui_last_version=PARSEC_VERSION) telemetry.init(self.config) def show_top(self) -> None: self.activateWindow() state: Qt.WindowState = ( self.windowState() & ~Qt.WindowState.WindowMinimized ) | Qt.WindowState.WindowActive self.setWindowState(state) self.raise_() self.show() def on_tab_state_changed(self, tab: InstanceWidget, state: str) -> None: idx = self.tab_center.indexOf(tab) if idx == -1: if state == "logout": self.reload_login_devices() return if state == "login": if self._get_login_tab_index() != -1: self.tab_center.removeTab(idx) else: self.tab_center.setTabToolTip(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN")) self.tab_center.setTabText(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN")) elif state == "logout": self.tab_center.removeTab(idx) idx = self._get_login_tab_index() if idx == -1: self.add_instance() else: tab_widget = self.tab_center.widget(idx) log_widget = None if not tab_widget else tab_widget.get_login_widget() if log_widget: log_widget.reload_devices() elif state == "connected": device = tab.current_device tab_name = ( f"{device.organization_id} - {device.short_user_display} - {device.device_display}" ) self.tab_center.setTabToolTip(idx, tab_name) self.tab_center.setTabText( idx, ensure_string_size(tab_name, 150, self.tab_center.tabBar().font()) ) if self.tab_center.count() == 1: self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() def on_tab_notification(self, tab: InstanceWidget, event: CoreEvent) -> None: idx = self.tab_center.indexOf(tab) if idx == -1 or idx == self.tab_center.currentIndex(): return if event == CoreEvent.SHARING_UPDATED: self.tab_center.tabBar().setTabTextColor(idx, MainWindow.TAB_NOTIFICATION_COLOR) def _get_login_tab_index(self) -> int: for idx in range(self.tab_center.count()): if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"): return idx return -1 def add_new_tab(self) -> InstanceWidget: tab = InstanceWidget(self.jobs_ctx, self.event_bus, self.config, self.systray_notification) tab.join_organization_clicked.connect(self._on_join_org_clicked) tab.create_organization_clicked.connect(self._on_create_org_clicked) tab.recover_device_clicked.connect(self._on_recover_device_clicked) idx = self.tab_center.addTab(tab, "") tab.state_changed.connect(self.on_tab_state_changed) self.tab_center.setCurrentIndex(idx) if self.tab_center.count() > 1: self.tab_center.setTabsClosable(True) else: self.tab_center.setTabsClosable(False) return tab def switch_to_tab(self, idx: int) -> None: if not ParsecApp.has_active_modal(): self.tab_center.setCurrentIndex(idx) def switch_to_login_tab( self, file_link_addr: Optional[BackendOrganizationFileLinkAddr] = None ) -> None: # Retrieve the login tab idx = self._get_login_tab_index() if idx != -1: self.switch_to_tab(idx) else: # No loging tab, create one tab = self.add_new_tab() tab.show_login_widget() self.on_tab_state_changed(tab, "login") idx = self.tab_center.count() - 1 self.switch_to_tab(idx) if not file_link_addr: # We're done here return # Find the device corresponding to the organization in the link for available_device in list_available_devices(self.config.config_dir): if available_device.organization_id == file_link_addr.organization_id: break else: # Cannot reach this organization with our available devices show_error( self, _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format( organization=file_link_addr.organization_id ), ) return # Pre-select the corresponding device login_w = self.tab_center.widget(idx).get_login_widget() login_w._on_account_clicked(available_device) # Set the path instance_widget = self.tab_center.widget(idx) instance_widget.set_workspace_path(file_link_addr) # Prompt the user for the need to log in first SnackbarManager.inform( _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format( organization=file_link_addr.organization_id ) ) def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None: # Try to use the file link on the already logged in cores for idx in range(self.tab_center.count()): if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"): continue w = self.tab_center.widget(idx) if ( not w or not w.core or w.core.device.organization_addr.organization_id != addr.organization_id ): continue central_widget = w.get_central_widget() if not central_widget: continue try: central_widget.go_to_file_link(addr) except GoToFileLinkBadOrganizationIDError: continue except GoToFileLinkBadWorkspaceIDError: # Switch tab so user understand where the error comes from self.switch_to_tab(idx) show_error( self, _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization").format( organization=addr.organization_id ), ) return except GoToFileLinkPathDecryptionError: # Switch tab so user understand where the error comes from self.switch_to_tab(idx) show_error(self, _("TEXT_INVALID_URL")) return else: self.switch_to_tab(idx) return # The file link is from an organization we'r not currently logged in # or we don't have any device related to self.switch_to_login_tab(addr) def show_create_org_widget(self, action_addr: BackendOrganizationBootstrapAddr) -> None: self.switch_to_login_tab() self._on_create_org_clicked(action_addr) def show_claim_user_widget(self, action_addr: BackendInvitationAddr) -> None: self.switch_to_login_tab() self._on_claim_user_clicked(action_addr) def show_claim_device_widget(self, action_addr: BackendInvitationAddr) -> None: self.switch_to_login_tab() self._on_claim_device_clicked(action_addr) def show_claim_pki_widget(self, action_addr: BackendPkiEnrollmentAddr) -> None: self.switch_to_login_tab() self._on_claim_pki_clicked(action_addr) def add_instance(self, start_arg: Optional[str] = None) -> None: action_addr = None if start_arg: try: action_addr = BackendActionAddr.from_url(start_arg, allow_http_redirection=True) except ValueError as exc: show_error(self, _("TEXT_INVALID_URL"), exception=exc) self.show_top() if not action_addr: self.switch_to_login_tab() elif isinstance(action_addr, BackendOrganizationFileLinkAddr): self.go_to_file_link(action_addr) elif isinstance(action_addr, BackendOrganizationBootstrapAddr): self.show_create_org_widget(action_addr) elif ( isinstance(action_addr, BackendInvitationAddr) and action_addr.invitation_type == InvitationType.USER ): self.show_claim_user_widget(action_addr) elif ( isinstance(action_addr, BackendInvitationAddr) and action_addr.invitation_type == InvitationType.DEVICE ): self.show_claim_device_widget(action_addr) elif isinstance(action_addr, BackendPkiEnrollmentAddr): self.show_claim_pki_widget(action_addr) else: show_error(self, _("TEXT_INVALID_URL")) def close_current_tab(self, force: bool = False) -> None: if self.tab_center.count() == 1: self.close_app() else: idx = self.tab_center.currentIndex() self.close_tab(idx, force=force) def close_app(self, force: bool = False) -> None: self.show_top() self.need_close = True self.force_close = force self.close() def close_all_tabs(self) -> None: for idx in range(self.tab_center.count()): self.close_tab(idx, force=True) def close_tab(self, index: int, force: bool = False) -> None: tab = self.tab_center.widget(index) if not force: r = _("ACTION_TAB_CLOSE_CONFIRM") if tab and tab.is_logged_in: r = ask_question( self, _("TEXT_TAB_CLOSE_TITLE"), _("TEXT_TAB_CLOSE_INSTRUCTIONS_device").format( device=f"{tab.core.device.short_user_display} - {tab.core.device.device_display}" ), [_("ACTION_TAB_CLOSE_CONFIRM"), _("ACTION_CANCEL")], ) if r != _("ACTION_TAB_CLOSE_CONFIRM"): return self.tab_center.removeTab(index) if not tab: return tab.logout() self.reload_login_devices() if self.tab_center.count() == 1: self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() def closeEvent(self, event: QCloseEvent) -> None: if self.minimize_on_close and not self.need_close: self.hide() event.ignore() # This notification is disabled on Mac since minimizing apps on # close is the standard behaviour on this OS. if not self.minimize_on_close_notif_already_send and sys.platform != "darwin": self.minimize_on_close_notif_already_send = True self.systray_notification.emit( "Parsec", _("TEXT_TRAY_PARSEC_STILL_RUNNING_MESSAGE"), 2000 ) else: if self.config.gui_confirmation_before_close and not self.force_close: result = ask_question( self if self.isVisible() else None, _("TEXT_PARSEC_QUIT_TITLE"), _("TEXT_PARSEC_QUIT_INSTRUCTIONS"), [_("ACTION_PARSEC_QUIT_CONFIRM"), _("ACTION_CANCEL")], ) if result != _("ACTION_PARSEC_QUIT_CONFIRM"): event.ignore() self.force_close = False self.need_close = False return state = self.saveGeometry() self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED, gui_geometry=state) self.close_all_tabs() self.quit_callback() event.ignore()
class PlaylistView(QTableView): def __init__(self, listplayer, status_bar: QStatusBar, play_ctrls, parent=None): super().__init__(parent=parent) self.player = listplayer self.status_bar = status_bar self.play_ctrls = play_ctrls self.setSelectionBehavior(self.SelectRows) self.setDragDropMode(self.InternalMove) self.setDragDropOverwriteMode(False) self.setEditTriggers(QTableView.NoEditTriggers) self.setAlternatingRowColors(True) self.setDropIndicatorShown(True) self.setHorizontalHeader(PlaylistViewHeader(parent=self)) self.setContextMenuPolicy(Qt.CustomContextMenu) # Create shortcuts self.rem_selected_items_shortcut = QShortcut(self) self.rem_selected_items_shortcut.setKey(QKeySequence.Delete) self.rem_selected_items_shortcut.setContext(Qt.WidgetWithChildrenShortcut) self.rem_selected_items_shortcut.activated.connect(self.remove_selected_items) self.play_selected_item_shortcut = QShortcut(self) self.play_selected_item_shortcut.setKey(Qt.Key_Return) self.play_selected_item_shortcut.setContext(Qt.WidgetWithChildrenShortcut) self.play_selected_item_shortcut.activated.connect(self.play_selected_item) self.setModel(PlaylistModel()) # Setup signals self.customContextMenuRequested.connect(self.show_context_menu) self.doubleClicked.connect(self.on_doubleClicked) self.selectionModel().selectionChanged.connect(self.on_selectionChanged) def showEvent(self, e): if self.model().rowCount(): if not self.selectionModel().hasSelection(): self.setCurrentIndex(self.model().item(0).index()) self.setFocus() @pyqtSlot() def play_selected_item(self): self.player.load_media(index=self.currentIndex()) self.player.mp.play() def mousePressEvent(self, e): """Clear both row and current index selections when clicking away from items.""" clicked_index = self.indexAt(e.pos()) if clicked_index.isValid(): item = self.model().item(clicked_index.row()) status_tip = item.data(role=Qt.DisplayRole) self.status_bar.showMessage(status_tip) else: self.selectionModel().clear() return super().mousePressEvent(e) def on_selectionChanged(self, selected, deselected): if not selected: self.selectionModel().clear() @pyqtSlot(QModelIndex) def on_doubleClicked(self, index): self.player.load_media(index=index) self.player.mp.play() def dropEvent(self, e): dragged_index = self.currentIndex() dropped_index = self.indexAt(e.pos()) log.debug( f"dragged_index={dragged_index.row(), dragged_index.column()} dropped_index={dropped_index.row(), dropped_index.column()} action={e.dropAction()} source={e.source()}" ) if dropped_index.row() == -1: return None model = self.model() item = model.takeRow(dragged_index.row()) model.insertRow(dropped_index.row(), item) self.setCurrentIndex(dropped_index) e.ignore() @pyqtSlot(int) def on_model_rowCountChanged(self, count): """Enable/disable GUI elements when media is added or removed""" if count: self.play_ctrls.setEnabled(True) else: self.play_ctrls.setEnabled(False) def setModel(self, model): # Disconnect previous model if self.model(): self.model().rowCountChanged.disconnect() # Connect this model model.rowCountChanged.connect(self.on_model_rowCountChanged) model.rowCountChanged.connect(self.player.on_playlist_rowCountChanged) super().setModel(model) def selected_items(self): items = [] for i in self.selectionModel().selectedRows(): items.append(self.model().itemFromIndex(i)) return items def show_context_menu(self, pos: QPoint): selected_items = self.selected_items() if len(selected_items) <= 1: rem_selected_text = f"Remove '{selected_items[0].data(Qt.DisplayRole)}'" else: rem_selected_text = f"Remove {len(selected_items)} items" menu = QMenu(self) menu.addAction( icons.get("file_remove"), rem_selected_text, self.remove_selected_items, self.rem_selected_items_shortcut.key(), ) menu.exec_(self.mapToGlobal(pos)) @pyqtSlot() def remove_selected_items(self): indexes = self.selectionModel().selectedRows() items = [self.model().itemFromIndex(i) for i in indexes] self.remove_items(items) def remove_items(self, items): # Create a status message if len(items) == 1: status_msg = f"Removed '{items[0].data(Qt.DisplayRole)}'" else: status_msg = f"Removed {len(items)} items" # Unload from player self.player.unload_media(items=items) # Remove from model start_row = self.model().indexFromItem(items[0]).row() num_rows = len(items) self.model().removeRows(start_row, num_rows) # Push status message self.status_bar.showMessage(status_msg)