diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f405d19..65877065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [0.2.2] +## [0.3.0] +### Features +- Allows app uninstall by right clicking it. + ### Improvements - Adding tooltips to toolbar buttons - "Update ?" column renamed to "Upgrade ?" diff --git a/fpakman/__init__.py b/fpakman/__init__.py index 020ed73d..0404d810 100644 --- a/fpakman/__init__.py +++ b/fpakman/__init__.py @@ -1 +1 @@ -__version__ = '0.2.2' +__version__ = '0.3.0' diff --git a/fpakman/core/controller.py b/fpakman/core/controller.py index 84ed8233..4a05e3a2 100755 --- a/fpakman/core/controller.py +++ b/fpakman/core/controller.py @@ -20,3 +20,6 @@ class FlatpakController: def get_version(self) -> str: return self.model.get_version() + + def uninstall(self, app_ref: str): + return self.model.remove_app(app_ref) diff --git a/fpakman/core/flatpak.py b/fpakman/core/flatpak.py index 82ea400d..b42cdd8c 100755 --- a/fpakman/core/flatpak.py +++ b/fpakman/core/flatpak.py @@ -81,5 +81,14 @@ def update_and_stream(app_ref: str): return system.stream_cmd(['flatpak', 'update', '-y', app_ref]) +def uninstall_and_stream(app_ref: str): + """ + Removes the app by its reference + :param app_ref: + :return: + """ + return system.stream_cmd(['flatpak', 'uninstall', app_ref, '-y']) + + def list_updates_as_str(): return system.run_cmd('flatpak update', ignore_return_code=True) diff --git a/fpakman/core/model.py b/fpakman/core/model.py index 8346a4ad..904b0116 100755 --- a/fpakman/core/model.py +++ b/fpakman/core/model.py @@ -91,6 +91,12 @@ class FlatpakManager: return [] + def _find_by_ref(self, ref: str): + package_found = [app for app in self.apps if app['ref'] == ref] + + if package_found: + return package_found[0] + def update_app(self, ref: str): """ @@ -100,9 +106,19 @@ class FlatpakManager: if self.apps: - package_found = [app for app in self.apps if app['ref'] == ref] + app_found = self._find_by_ref(ref) - if package_found: + if app_found: return flatpak.update_and_stream(ref) - return None + def remove_app(self, ref: str): + """ + :param ref: + :return: the uninstall command stream + """ + if self.apps: + + app_found = self._find_by_ref(ref) + + if app_found: + return flatpak.uninstall_and_stream(ref) diff --git a/fpakman/resources/img/uninstall.svg b/fpakman/resources/img/uninstall.svg new file mode 100644 index 00000000..b09818d1 --- /dev/null +++ b/fpakman/resources/img/uninstall.svg @@ -0,0 +1,76 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/fpakman/resources/locale/en b/fpakman/resources/locale/en index 9b424929..877f1790 100644 --- a/fpakman/resources/locale/en +++ b/fpakman/resources/locale/en @@ -7,10 +7,14 @@ manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origin manage_window.columns.update=Upgrade ? +manage_window.apps_table.row.actions.uninstall=Uninstall +manage_window.apps_table.row.actions.uninstall.popup.title=Uninstall +manage_window.apps_table.row.actions.uninstall.popup.body=Remove {} ? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Updates manage_window.status.refreshing=Refreshing manage_window.status.updating=Updating +manage_window.status.uninstalling=Uninstalling manage_window.bt.refresh.tooltip=Reload the data about installed applications manage_window.bt.upgrade.tooltip=Upgrade all selected applications popup.flatpak_not_installed.title=Error diff --git a/fpakman/resources/locale/es b/fpakman/resources/locale/es index a05f65ac..4a3a0f37 100644 --- a/fpakman/resources/locale/es +++ b/fpakman/resources/locale/es @@ -7,10 +7,14 @@ manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origen manage_window.columns.update=¿Actualizar? +manage_window.apps_table.row.actions.uninstall=Desinstalar +manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalación +manage_window.apps_table.row.actions.uninstall.popup.body=¿Eliminar {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Actualizaciones manage_window.status.refreshing=Recargando manage_window.status.updating=Actualizando +manage_window.status.uninstalling=Desinstalando manage_window.bt.refresh.tooltip=Recarga los datos sobre los aplicativos instalados manage_window.bt.upgrade.tooltip=Actualiza todos los aplicativos seleccionados popup.flatpak_not_installed.title=Error diff --git a/fpakman/resources/locale/pt b/fpakman/resources/locale/pt index 36cbf078..d4a005b7 100644 --- a/fpakman/resources/locale/pt +++ b/fpakman/resources/locale/pt @@ -7,10 +7,14 @@ manage_window.columns.arch=Arch manage_window.columns.ref=Ref manage_window.columns.origin=Origem manage_window.columns.update=Atualizar ? +manage_window.apps_table.row.actions.uninstall=Desinstalar +manage_window.apps_table.row.actions.uninstall.popup.title=Desinstalação +manage_window.apps_table.row.actions.uninstall.popup.body=Rmover {}? manage_window.checkbox.only_apps=Apps manage_window.label.updates=Atualizações manage_window.status.refreshing=Recarregando manage_window.status.updating=Atualizando +manage_window.status.uninstalling=Desinstalando manage_window.bt.refresh.tooltip=Recarrega os dados sobre os aplicativos instalados manage_window.bt.upgrade.tooltip=Atualiza todos os aplicativos selecionados popup.flatpak_not_installed.title=Erro diff --git a/fpakman/view/qt/apps_table.py b/fpakman/view/qt/apps_table.py new file mode 100644 index 00000000..4671b847 --- /dev/null +++ b/fpakman/view/qt/apps_table.py @@ -0,0 +1,154 @@ +from typing import List + +from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtGui import QPixmap, QIcon, QColor, QCursor +from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager +from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ + QMessageBox, QHeaderView + +from fpakman.core import resource +from fpakman.core.controller import FlatpakController + + +class UpdateToggleButton(QToolButton): + + def __init__(self, model: dict, root: QWidget, checked: bool = True): + super(UpdateToggleButton, self).__init__() + self.model = model + self.root = root + self.setCheckable(True) + self.clicked.connect(self.change_state) + self.icon_on = QIcon(resource.get_path('img/toggle_on.svg')) + self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) + self.setIcon(self.icon_on) + self.setStyleSheet('border: 0px;') + + if not checked: + self.click() + + def change_state(self, not_checked: bool): + self.model['update_checked'] = not not_checked + self.setIcon(self.icon_on if not not_checked else self.icon_off) + self.root.change_update_state() + + +class AppsTable(QTableWidget): + + def __init__(self, parent: QWidget, controller: FlatpakController, columns: List[str]): + super(AppsTable, self).__init__() + self.parent = parent + self.controller = controller + self.columns = columns + self.setColumnCount(len(columns)) + self.setFocusPolicy(Qt.NoFocus) + self.setShowGrid(False) + self.verticalHeader().setVisible(False) + self.setSelectionBehavior(QTableView.SelectRows) + self.setHorizontalHeaderLabels(columns) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) + self.icon_uninstall = QIcon(resource.get_path('img/uninstall.svg')) + + self.network_man = QNetworkAccessManager() + self.network_man.finished.connect(self._load_icon) + + self.icon_cache = {} + + def contextMenuEvent(self, QContextMenuEvent): # selected row right click event + menu_row = QMenu() + + action_uninstall = QAction(self.parent.locale_keys["manage_window.apps_table.row.actions.uninstall"]) + action_uninstall.setIcon(self.icon_uninstall) + action_uninstall.triggered.connect(self._uninstall_app) + + menu_row.addAction(action_uninstall) + menu_row.adjustSize() + menu_row.popup(QCursor.pos()) + menu_row.exec_() + + def _uninstall_app(self): + selected_app = self.parent.apps[self.currentRow()] + + confirmation = QMessageBox.question(self, self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.title'], + self.parent.locale_keys['manage_window.apps_table.row.actions.uninstall.popup.body'].format(selected_app['model']['name']), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if confirmation == QMessageBox.Yes: + self.parent.uninstall_app(selected_app['model']['ref']) + + def _load_icon(self, http_response): + icon_url = http_response.url().toString() + pixmap = QPixmap() + pixmap.loadFromData(http_response.readAll()) + icon = QIcon(pixmap) + self.icon_cache[icon_url] = icon + + for idx, app in enumerate(self.parent.apps): + if app['model']['icon'] == icon_url: + self.item(idx, 0).setIcon(icon) + self.parent.resize_and_center() + break + + def update_apps(self, apps: List[dict]): + self.setEnabled(True) + self.setRowCount(len(apps) if apps else 0) + + if apps: + for idx, app in enumerate(apps): + + col_name = QTableWidgetItem() + col_name.setText(app['model']['name']) + col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + if not app['model']['icon']: + col_name.setIcon(self.icon_flathub) + else: + cached_icon = self.icon_cache.get(app['model']['icon']) + + if cached_icon: + col_name.setIcon(cached_icon) + else: + col_name.setIcon(self.icon_flathub) + self.network_man.get(QNetworkRequest(QUrl(app['model']['icon']))) + + self.setItem(idx, 0, col_name) + + col_version = QTableWidgetItem() + col_version.setText(app['model']['version']) + col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 1, col_version) + + col_release = QTableWidgetItem() + col_release.setText(app['model']['latest_version']) + col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 2, col_release) + + if app['model']['update']: + col_release.setForeground(QColor('orange')) + + col_branch = QTableWidgetItem() + col_branch.setText(app['model']['branch']) + col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 3, col_branch) + + col_arch = QTableWidgetItem() + col_arch.setText(app['model']['arch']) + col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 4, col_arch) + + col_package = QTableWidgetItem() + col_package.setText(app['model']['ref']) + col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 5, col_package) + + col_origin = QTableWidgetItem() + col_origin.setText(app['model']['origin']) + col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + self.setItem(idx, 6, col_origin) + + col_update = UpdateToggleButton(app['model'], self.parent, app['model']['update']) if app['model']['update'] else None + self.setCellWidget(idx, 7, col_update) + + def change_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): + header_horizontal = self.horizontalHeader() + for i in range(0, len(self.columns)): + header_horizontal.setSectionResizeMode(i, policy) diff --git a/fpakman/view/qt/window.py b/fpakman/view/qt/window.py index 0bd38872..630ab340 100755 --- a/fpakman/view/qt/window.py +++ b/fpakman/view/qt/window.py @@ -3,38 +3,15 @@ from functools import reduce from threading import Lock from typing import List -from PyQt5.QtCore import Qt, QThread, pyqtSignal, QUrl, QEvent -from PyQt5.QtGui import QIcon, QColor, QPixmap, QWindowStateChangeEvent -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QTableWidget, \ - QTableWidgetItem, QTableView, QCheckBox, QHeaderView, QToolButton, QToolBar, \ +from PyQt5.QtCore import QThread, pyqtSignal, QEvent +from PyQt5.QtGui import QIcon, QWindowStateChangeEvent +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QSizePolicy, QLabel, QMessageBox, QPlainTextEdit from fpakman import __version__ from fpakman.core import resource from fpakman.core.controller import FlatpakController - - -class UpdateToggleButton(QToolButton): - - def __init__(self, model: dict, root: QWidget, checked: bool = True): - super(UpdateToggleButton, self).__init__() - self.model = model - self.root = root - self.setCheckable(True) - self.clicked.connect(self.change_state) - self.icon_on = QIcon(resource.get_path('img/toggle_on.svg')) - self.icon_off = QIcon(resource.get_path('img/toggle_off.svg')) - self.setIcon(self.icon_on) - self.setStyleSheet('border: 0px;') - - if not checked: - self.click() - - def change_state(self, not_checked: bool): - self.model['update_checked'] = not not_checked - self.setIcon(self.icon_on if not not_checked else self.icon_off) - self.root.change_update_state() +from fpakman.view.qt.apps_table import AppsTable class ManageWindow(QWidget): @@ -53,16 +30,12 @@ class ManageWindow(QWidget): locale_keys['manage_window.columns.origin'], locale_keys['manage_window.columns.update']] self.controller = controller - self.icon_cache = {} self.tray_icon = tray_icon self.thread_lock = Lock() self.working = False # restrict the number of threaded actions self.apps = [] self.label_flatpak = None - self.network_man = QNetworkAccessManager() - self.network_man.finished.connect(self._load_icon) - self.icon_flathub = QIcon(resource.get_path('img/flathub_45.svg')) self._check_flatpak_installed() self.resize(ManageWindow.__BASE_HEIGHT__, ManageWindow.__BASE_HEIGHT__) @@ -108,16 +81,8 @@ class ManageWindow(QWidget): self.layout.addWidget(toolbar) - self.table_apps = QTableWidget() - self.table_apps.setColumnCount(len(self.column_names)) - self.table_apps.setFocusPolicy(Qt.NoFocus) - self.table_apps.setShowGrid(False) - self.table_apps.verticalHeader().setVisible(False) - self.table_apps.setSelectionBehavior(QTableView.SelectRows) - self.table_apps.setHorizontalHeaderLabels(self.column_names) - self.table_apps.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._change_table_headers_policy() + self.table_apps = AppsTable(self, self.controller, self.column_names) + self.table_apps.change_headers_policy() self.layout.addWidget(self.table_apps) @@ -135,6 +100,10 @@ class ManageWindow(QWidget): self.thread_refresh = RefreshApps(self.controller) self.thread_refresh.signal.connect(self._finish_refresh) + self.thread_uninstall = UninstallApp(self.controller) + self.thread_uninstall.signal_output.connect(self._update_action_output) + self.thread_uninstall.signal_finished.connect(self._finish_uninstall) + self.toolbar_bottom = QToolBar() self.label_updates = QLabel('') self.toolbar_bottom.addWidget(self.label_updates) @@ -148,15 +117,11 @@ class ManageWindow(QWidget): self.centralize() - def _change_table_headers_policy(self, policy: QHeaderView = QHeaderView.ResizeToContents): - header_horizontal = self.table_apps.horizontalHeader() - for i in range(0, len(self.column_names)): - header_horizontal.setSectionResizeMode(i, policy) - def changeEvent(self, e: QEvent): if isinstance(e, QWindowStateChangeEvent): - self._change_table_headers_policy(QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents) + policy = QHeaderView.Stretch if self.isMaximized() else QHeaderView.ResizeToContents + self.table_apps.adjust_header_policy(policy) def closeEvent(self, event): @@ -164,19 +129,6 @@ class ManageWindow(QWidget): event.ignore() self.hide() - def _load_icon(self, http_response): - icon_url = http_response.url().toString() - pixmap = QPixmap() - pixmap.loadFromData(http_response.readAll()) - icon = QIcon(pixmap) - self.icon_cache[icon_url] = icon - - for idx, app in enumerate(self.apps): - if app['model']['icon'] == icon_url: - self.table_apps.item(idx, 0).setIcon(icon) - self.resize_and_center() - break - def _check_flatpak_installed(self): if not self.controller.check_installed(): @@ -231,6 +183,22 @@ class ManageWindow(QWidget): self.finish_action() self._release_lock() + def uninstall_app(self, app_ref: str): + self._check_flatpak_installed() + + if self._acquire_lock(): + self.textarea_output.clear() + self.textarea_output.setVisible(True) + self._begin_action(self.locale_keys['manage_window.status.uninstalling'] + '...') + + self.thread_uninstall.app_ref = app_ref + self.thread_uninstall.start() + + def _finish_uninstall(self): + self.finish_action() + self._release_lock() + self.refresh(clear_output=False) + def filter_only_apps(self, only_apps: int): if self.apps: @@ -242,8 +210,8 @@ class ManageWindow(QWidget): app['visible'] = not hidden self.change_update_state() - self._change_table_headers_policy(QHeaderView.Stretch) - self._change_table_headers_policy() + self.table_apps.change_headers_policy(QHeaderView.Stretch) + self.table_apps.change_headers_policy() self.resize_and_center() def change_update_state(self): @@ -284,73 +252,16 @@ class ManageWindow(QWidget): def update_apps(self, apps: List[dict]): self._check_flatpak_installed() - self.table_apps.setEnabled(True) self.apps = [] - - self.table_apps.setRowCount(len(apps) if apps else 0) - if apps: - for idx, app in enumerate(apps): - - col_name = QTableWidgetItem() - col_name.setText(app['name']) - col_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - - if not app['icon']: - col_name.setIcon(self.icon_flathub) - else: - cached_icon = self.icon_cache.get(app['icon']) - - if cached_icon: - col_name.setIcon(cached_icon) - else: - col_name.setIcon(self.icon_flathub) - self.network_man.get(QNetworkRequest(QUrl(app['icon']))) - - self.table_apps.setItem(idx, 0, col_name) - - col_version = QTableWidgetItem() - col_version.setText(app['version']) - col_version.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 1, col_version) - - col_release = QTableWidgetItem() - col_release.setText(app['latest_version']) - col_release.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 2, col_release) - - if app['update']: - col_release.setForeground(QColor('orange')) - - col_branch = QTableWidgetItem() - col_branch.setText(app['branch']) - col_branch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 3, col_branch) - - col_arch = QTableWidgetItem() - col_arch.setText(app['arch']) - col_arch.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 4, col_arch) - - col_package = QTableWidgetItem() - col_package.setText(app['ref']) - col_package.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 5, col_package) - - col_origin = QTableWidgetItem() - col_origin.setText(app['origin']) - col_origin.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) - self.table_apps.setItem(idx, 6, col_origin) - + for app in apps: app_model = {'model': app, 'update_checked': app['update'], 'visible': not app['runtime'] or not self.checkbox_only_apps.isChecked()} - - col_update = UpdateToggleButton(app_model, self, app['update']) if app['update'] else None - self.table_apps.setCellWidget(idx, 7, col_update) - self.apps.append(app_model) + self.table_apps.update_apps(self.apps) + self.change_update_state() self.filter_only_apps(2 if self.checkbox_only_apps.isChecked() else 0) self.resize_and_center() @@ -431,3 +342,22 @@ class RefreshApps(QThread): def run(self): self.apps = self.controller.refresh() self.signal.emit() + + +class UninstallApp(QThread): + signal_finished = pyqtSignal() + signal_output = pyqtSignal(str) + + def __init__(self, controller: FlatpakController): + super(UninstallApp, self).__init__() + self.controller = controller + self.app_ref = None + + def run(self): + if self.app_ref: + for output in self.controller.uninstall(self.app_ref): + line = output.decode().strip() + if line: + self.signal_output.emit(line) + + self.signal_finished.emit()