diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 51328de..64063aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,7 +10,7 @@ checks:pylint: stage: checks before_script: - sudo dnf install -y python3-gobject gtk3 xorg-x11-server-Xvfb - python3-pip python3-mypy python3-pyxdg gtk-layer-shell + python3-pip python3-mypy python3-pyxdg gtk-layer-shell cairo-devel - pip3 install --quiet -r ci/requirements.txt - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client script: @@ -25,7 +25,7 @@ checks:tests: - "PATH=$PATH:$HOME/.local/bin" - sudo dnf install -y python3-gobject gtk3 python3-pytest python3-pytest-asyncio python3-coverage xorg-x11-server-Xvfb python3-inotify sequoia-sqv - python3-pip python3-pyxdg gtk-layer-shell + python3-pip python3-pyxdg gtk-layer-shell cairo-devel - pip3 install --quiet -r ci/requirements.txt - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client - git clone https://github.com/QubesOS/qubes-desktop-linux-manager ~/desktop-linux-manager diff --git a/.pylintrc b/.pylintrc index 3fccc23..f229cb4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -80,7 +80,7 @@ notes=FIXME,FIX,XXX,TODO [FORMAT] # Maximum number of characters on a single line. -max-line-length=80 +max-line-length=88 # Maximum number of lines in a module max-module-lines=3000 diff --git a/Makefile b/Makefile index f21b446..22d9e95 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ install-icons: cp icons/qappmenu-top-right.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-top-right.svg cp icons/qappmenu-bottom-left.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-bottom-left.svg cp icons/qappmenu-bottom-right.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-bottom-right.svg + cp icons/qappmenu-az.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-az.svg + cp icons/qappmenu-za.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-za.svg + cp icons/qappmenu-qube-az.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-qube-az.svg + cp icons/qappmenu-qube-za.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qappmenu-qube-za.svg cp icons/settings-*.svg $(DESTDIR)/usr/share/icons/hicolor/scalable/apps/ install-autostart: diff --git a/icons/qappmenu-az.svg b/icons/qappmenu-az.svg new file mode 100644 index 0000000..ab46560 --- /dev/null +++ b/icons/qappmenu-az.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/qappmenu-qube-az.svg b/icons/qappmenu-qube-az.svg new file mode 100644 index 0000000..96d7a0b --- /dev/null +++ b/icons/qappmenu-qube-az.svg @@ -0,0 +1,33 @@ + + + + diff --git a/icons/qappmenu-qube-za.svg b/icons/qappmenu-qube-za.svg new file mode 100644 index 0000000..15ac0a3 --- /dev/null +++ b/icons/qappmenu-qube-za.svg @@ -0,0 +1,33 @@ + + + + diff --git a/icons/qappmenu-za.svg b/icons/qappmenu-za.svg new file mode 100644 index 0000000..960075f --- /dev/null +++ b/icons/qappmenu-za.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qubes_menu/app_widgets.py b/qubes_menu/app_widgets.py index 540a7e0..0580d72 100644 --- a/qubes_menu/app_widgets.py +++ b/qubes_menu/app_widgets.py @@ -26,21 +26,26 @@ from typing import Optional, List from functools import reduce -from .custom_widgets import (LimitedWidthLabel, SelfAwareMenu, HoverEventBox, - FavoritesMenu) +from .custom_widgets import ( + LimitedWidthLabel, + SelfAwareMenu, + HoverEventBox, + FavoritesMenu, +) from .desktop_file_manager import ApplicationInfo from .vm_manager import VMManager, VMEntry from .utils import load_icon, text_search, highlight_words, remove_from_feature from . import constants import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk -logger = logging.getLogger('qubes-appmenu') +logger = logging.getLogger("qubes-appmenu") -DISP_TEXT = 'new Disposable Qube from ' +DISP_TEXT = "new Disposable Qube from " class AppEntry(Gtk.ListBoxRow): @@ -53,6 +58,7 @@ class AppEntry(Gtk.ListBoxRow): - supports running an application on click; after click signals to the complete menu it might need hiding """ + def __init__(self, app_info: ApplicationInfo, **properties): """ :param app_info: ApplicationInfo obj with data about related app file @@ -61,23 +67,21 @@ def __init__(self, app_info: ApplicationInfo, **properties): super().__init__(**properties) self.app_info = app_info self.app_info.entries.append(self) - self.vm_name = app_info.vm.name if app_info.vm else 'dom0' + self.vm_name = app_info.vm.name if app_info.vm else "dom0" self.menu = SelfAwareMenu() self.event_box = HoverEventBox(focus_widget=self) self.add(self.event_box) self.event_box.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) - self.event_box.connect('button-press-event', self.show_menu) + self.event_box.connect("button-press-event", self.show_menu) - self.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, [], Gdk.DragAction.COPY) + self.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, [], Gdk.DragAction.COPY) self.drag_source_add_uri_targets() self.connect("drag-data-get", self._on_drag_data_get) def _on_drag_data_get(self, _widget, _drag_context, data, _info, _time): - data.set_uris(['file://' + - urllib.parse.quote(str(self.app_info.file_path))]) + data.set_uris(["file://" + urllib.parse.quote(str(self.app_info.file_path))]) def show_menu(self, _widget, event): """ @@ -100,6 +104,9 @@ def run_app(self, vm): # pylint: disable=consider-using-with command = self.app_info.get_command_for_vm(vm) subprocess.Popen(command, stdin=subprocess.DEVNULL) + self.get_toplevel().get_application().emit( + "app-started", self.app_info.file_path.name + ) self.get_toplevel().get_application().hide_menu() @@ -107,6 +114,7 @@ class BaseAppEntry(AppEntry): """ A 'normal' Application row, used by main applications menu and system tools. """ + def __init__(self, app_info: ApplicationInfo, **properties): """ :param app_info: ApplicationInfo obj with data about related app file @@ -115,7 +123,7 @@ def __init__(self, app_info: ApplicationInfo, **properties): super().__init__(app_info, **properties) self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.event_box.add(self.box) - self.get_style_context().add_class('app_entry') + self.get_style_context().add_class("app_entry") self.menu = FavoritesMenu(lambda: self.app_info) self.icon = Gtk.Image() @@ -137,13 +145,15 @@ def show_menu(self, widget, event): def update_contents(self): """Update icon and app name.""" self.icon.set_from_pixbuf( - load_icon(self.app_info.app_icon, Gtk.IconSize.LARGE_TOOLBAR)) + load_icon(self.app_info.app_icon, Gtk.IconSize.LARGE_TOOLBAR) + ) self.label.set_label(self.app_info.app_name) self.show_all() class VMIcon(Gtk.Image): """Helper class for displaying and auto-updating""" + def __init__(self, vm_entry: Optional[VMEntry]): super().__init__() self.vm_entry = vm_entry @@ -151,11 +161,13 @@ def __init__(self, vm_entry: Optional[VMEntry]): self.vm_entry.entries.append(self) self.update_contents(update_label=True) - def update_contents(self, - update_power_state=False, - update_label=False, - update_has_network=False, - update_type=False): + def update_contents( + self, + update_power_state=False, + update_label=False, + update_has_network=False, + update_type=False, + ): # pylint: disable=unused-argument """ Update own contents (or related widgets, if applicable) based on state @@ -168,8 +180,7 @@ def update_contents(self, :return: """ if update_label and self.vm_entry: - vm_icon = load_icon(self.vm_entry.vm_icon_name, - Gtk.IconSize.LARGE_TOOLBAR) + vm_icon = load_icon(self.vm_entry.vm_icon_name, Gtk.IconSize.LARGE_TOOLBAR) self.set_from_pixbuf(vm_icon) self.show_all() @@ -177,10 +188,10 @@ def update_contents(self, class AppEntryWithVM(AppEntry): """Application Gtk.ListBoxRow with VM description underneath; to be used in Search and Favorites.""" - def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, - **properties): + + def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, **properties): super().__init__(app_info, **properties) - self.get_style_context().add_class('favorite_entry') + self.get_style_context().add_class("favorite_entry") self.grid = Gtk.Grid() self.event_box.add(self.grid) @@ -192,8 +203,8 @@ def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box.pack_start(self.vm_icon, False, False, 5) box.pack_start(self.vm_label, False, False, 5) - self.vm_label.get_style_context().add_class('favorite_vm_name') - self.app_label.get_style_context().add_class('favorite_app_name') + self.vm_label.get_style_context().add_class("favorite_vm_name") + self.app_label.get_style_context().add_class("favorite_app_name") self.app_label.set_halign(Gtk.Align.START) self.grid.attach(self.app_icon, 0, 0, 1, 2) @@ -210,8 +221,7 @@ def update_contents(self): self.app_icon.set_from_pixbuf(app_icon) if self.app_info.disposable: - self.vm_label.set_text( - DISP_TEXT + str(self.app_info.vm)) + self.vm_label.set_text(DISP_TEXT + str(self.app_info.vm)) elif self.app_info.vm: self.vm_label.set_text(str(self.app_info.vm)) else: @@ -228,11 +238,11 @@ class FavoritesAppEntry(AppEntryWithVM): constants.py, as a space-separated list containing a subset of menu-items feature. """ - def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, - **properties): + + def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, **properties): super().__init__(app_info, vm_manager, **properties) - self.remove_item = Gtk.MenuItem(label='Remove from favorites') - self.remove_item.connect('activate', self._remove_from_favorites) + self.remove_item = Gtk.MenuItem(label="Remove from favorites") + self.remove_item.connect("activate", self._remove_from_favorites) self.menu.add(self.remove_item) self.menu.show_all() @@ -241,16 +251,17 @@ def _remove_from_favorites(self, *_args, **_kwargs): feature""" if not self.app_info.entry_name: return # there is nothing to remove - vm = self.app_info.vm or self.app_info.qapp.domains[ - self.app_info.qapp.local_name] - remove_from_feature(vm, constants.FAVORITES_FEATURE, - self.app_info.entry_name) + vm = ( + self.app_info.vm + or self.app_info.qapp.domains[self.app_info.qapp.local_name] + ) + remove_from_feature(vm, constants.FAVORITES_FEATURE, self.app_info.entry_name) class SearchAppEntry(AppEntryWithVM): """Entry for apps listed on the Search tab.""" - def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, - **properties): + + def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, **properties): super().__init__(app_info, vm_manager, **properties) self.menu = FavoritesMenu(lambda: self.app_info) @@ -269,17 +280,21 @@ def __init__(self, app_info: ApplicationInfo, vm_manager: VMManager, if self.app_info.vm: self.search_words.extend( - self.app_info.vm.name.lower().replace('_', '-').split('-')) + self.app_info.vm.name.lower().replace("_", "-").split("-") + ) else: - self.search_words.append('dom0') + self.search_words.append("dom0") if self.app_info.disposable: self.search_words.extend(DISP_TEXT.lower().split()) if self.app_info.app_name: self.search_words.extend( - self.app_info.app_name.lower().replace( - '_', ' ').replace('-', ' ').split()) + self.app_info.app_name.lower() + .replace("_", " ") + .replace("-", " ") + .split() + ) if self.app_info.keywords: self.search_words.extend(k.lower() for k in self.app_info.keywords) @@ -293,9 +308,10 @@ def find_text(self, search_words: List[str]): return self.last_search_result if search_words: - result = reduce(lambda x, y: x*y, - [text_search(word, self.search_words) - for word in search_words]) + result = reduce( + lambda x, y: x * y, + [text_search(word, self.search_words) for word in search_words], + ) else: result = 0 diff --git a/qubes_menu/application_page.py b/qubes_menu/application_page.py index 6c9ba5b..4799f43 100644 --- a/qubes_menu/application_page.py +++ b/qubes_menu/application_page.py @@ -23,15 +23,15 @@ from typing import Optional from .desktop_file_manager import DesktopFileManager -from .custom_widgets import NetworkIndicator, \ - VMRow, ControlList, KeynavController +from .custom_widgets import NetworkIndicator, VMRow, ControlList, KeynavController from .app_widgets import AppEntry, BaseAppEntry from .vm_manager import VMEntry, VMManager from .page_handler import MenuPage from .utils import get_visible_child import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk @@ -40,20 +40,18 @@ class VMTypeToggle: A class controlling a set of radio buttons for toggling which VMs are shown. """ + def __init__(self, builder: Gtk.Builder): """ :param builder: Gtk.Builder, containing loaded glade data """ - self.apps_toggle: Gtk.RadioButton = builder.get_object('apps_toggle') - self.templates_toggle: Gtk.RadioButton = \ - builder.get_object('templates_toggle') - self.system_toggle: Gtk.RadioButton = \ - builder.get_object('system_toggle') - self.vm_list: Gtk.ListBox = builder.get_object('vm_list') - self.app_list: Gtk.ListBox = builder.get_object('app_list') + self.apps_toggle: Gtk.RadioButton = builder.get_object("apps_toggle") + self.templates_toggle: Gtk.RadioButton = builder.get_object("templates_toggle") + self.system_toggle: Gtk.RadioButton = builder.get_object("system_toggle") + self.vm_list: Gtk.ListBox = builder.get_object("vm_list") + self.app_list: Gtk.ListBox = builder.get_object("app_list") - self.buttons = [self.apps_toggle, self.templates_toggle, - self.system_toggle] + self.buttons = [self.apps_toggle, self.templates_toggle, self.system_toggle] for button in self.buttons: button.set_relief(Gtk.ReliefStyle.NONE) @@ -61,7 +59,7 @@ def __init__(self, builder: Gtk.Builder): button.set_can_focus(True) # the below is necessary to make sure keyboard navigation # behaves correctly - button.connect('focus', self._activate_button) + button.connect("focus", self._activate_button) def initialize_state(self): """ @@ -76,7 +74,7 @@ def initialize_state(self): for button in self.buttons: if button.get_size_request() == (-1, -1): - button.set_size_request(button.get_allocated_width()*1.2, -1) + button.set_size_request(button.get_allocated_width() * 1.2, -1) def grab_focus(self): """Simulates other grab_focus type functions: grabs keyboard focus @@ -95,7 +93,7 @@ def _activate_button(widget, _event): def connect_to_toggle(self, func): """Connect a function to toggling of all buttons""" for button in self.buttons: - button.connect('toggled', func) + button.connect("toggled", func) def filter_function(self, row): """Filter function calculated based on currently selected VM toggle @@ -124,7 +122,7 @@ def _filter_templatevms(vm_entry: VMEntry): Filter function for template VMEntries. Returns VMs that are a templateVM or a template for DispVMs. """ - if vm_entry.vm_klass == 'TemplateVM': + if vm_entry.vm_klass == "TemplateVM": return True return vm_entry.is_dispvm_template @@ -141,8 +139,13 @@ class AppPage(MenuPage): """ Helper class for managing the entirety of Applications menu page. """ - def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, - desktop_file_manager: DesktopFileManager): + + def __init__( + self, + vm_manager: VMManager, + builder: Gtk.Builder, + desktop_file_manager: DesktopFileManager, + ): """ :param vm_manager: VM Manager object :param builder: Gtk.Builder with loaded glade object @@ -154,10 +157,10 @@ def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, self.page_widget: Gtk.Box = builder.get_object("app_page") - self.vm_list: Gtk.ListBox = builder.get_object('vm_list') - self.app_list: Gtk.ListBox = builder.get_object('app_list') - self.vm_right_pane: Gtk.Box = builder.get_object('vm_right_pane') - self.separator_bottom = builder.get_object('separator_bottom') + self.vm_list: Gtk.ListBox = builder.get_object("vm_list") + self.app_list: Gtk.ListBox = builder.get_object("app_list") + self.vm_right_pane: Gtk.Box = builder.get_object("vm_right_pane") + self.separator_bottom = builder.get_object("separator_bottom") self.network_indicator = NetworkIndicator() self.vm_right_pane.pack_start(self.network_indicator, False, False, 0) @@ -168,20 +171,20 @@ def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, self.toggle_buttons.connect_to_toggle(self._button_toggled) self.app_list.set_filter_func(self._is_app_fitting) - self.app_list.connect('row-activated', self._app_clicked) + self.app_list.connect("row-activated", self._app_clicked) self.app_list.set_sort_func( - lambda x, y: - x.app_info.sort_name > y.app_info.sort_name) + lambda x, y: x.app_info.sort_name > y.app_info.sort_name + ) self.app_list.invalidate_sort() vm_manager.register_new_vm_callback(self._vm_callback) self.vm_list.set_sort_func(self._sort_vms) self.vm_list.set_filter_func(self.toggle_buttons.filter_function) - self.vm_list.connect('row-selected', self._selection_changed) + self.vm_list.connect("row-selected", self._selection_changed) self.control_list = ControlList(self) - self.control_list.connect('row-activated', self._app_clicked) + self.control_list.connect("row-activated", self._app_clicked) self.vm_right_pane.pack_end(self.control_list, False, False, 0) self.setup_keynav() @@ -190,15 +193,15 @@ def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, self.control_list.set_selection_mode(Gtk.SelectionMode.NONE) self.keynav_manager = KeynavController( - widgets_in_order=[self.app_list, self.control_list]) + widgets_in_order=[self.app_list, self.control_list] + ) - self.widget_order = [self.app_list, - self.control_list] + self.widget_order = [self.app_list, self.control_list] self.vm_list.select_row(None) self._selection_changed(None, None) - self.vm_list.connect('map', self._on_map_vm_list) + self.vm_list.connect("map", self._on_map_vm_list) def _on_map_vm_list(self, *_args): # workaround for https://gitlab.gnome.org/GNOME/gtk/-/issues/4977 @@ -216,11 +219,16 @@ def _sort_vms(self, vmentry: VMRow, other_entry: VMRow): my_sort_name = vmentry.sort_order other_sort_name = other_entry.sort_order if self.sort_running: - my_sort_name = "0" if (vmentry.vm_entry.power_state == "Running") \ + my_sort_name = ( + "0" + if (vmentry.vm_entry.power_state == "Running") else "1" + my_sort_name - other_sort_name = "0" if \ - (other_entry.vm_entry.power_state == "Running") \ + ) + other_sort_name = ( + "0" + if (other_entry.vm_entry.power_state == "Running") else "1" + other_sort_name + ) return my_sort_name > other_sort_name def set_sorting_order(self, sort_running: bool = False): @@ -234,12 +242,12 @@ def set_sorting_order(self, sort_running: bool = False): def setup_keynav(self): """Do all the required faffing about to convince Gtk to have reasonable keyboard nav""" - self.vm_list.connect('keynav-failed', self._vm_keynav_failed) + self.vm_list.connect("keynav-failed", self._vm_keynav_failed) - self.app_list.connect('key-press-event', self._focus_vm_list) - self.control_list.connect('key-press-event', self._focus_vm_list) + self.app_list.connect("key-press-event", self._focus_vm_list) + self.control_list.connect("key-press-event", self._focus_vm_list) - self.vm_list.connect('key-press-event', self._vm_key_pressed) + self.vm_list.connect("key-press-event", self._vm_key_pressed) def _vm_key_pressed(self, _widget, event): if event.keyval == Gdk.KEY_Right: @@ -262,8 +270,7 @@ def _vm_callback(self, vm_entry: VMEntry): Callback to be performed on all newly loaded VMEntry instances. """ if vm_entry: - vm_row = VMRow(vm_entry, - show_dispvm_inheritance=not self.sort_running) + vm_row = VMRow(vm_entry, show_dispvm_inheritance=not self.sort_running) vm_row.show_all() vm_entry.entries.append(vm_row) self.vm_list.add(vm_row) @@ -279,15 +286,19 @@ def _is_app_fitting(self, appentry: BaseAppEntry): """ if not self.selected_vm_entry: return False - if appentry.app_info.vm and \ - appentry.app_info.vm.name != \ - self.selected_vm_entry.vm_entry.vm_name: - return self.selected_vm_entry.vm_entry.parent_vm == \ - appentry.app_info.vm.name and \ - not appentry.app_info.disposable + if ( + appentry.app_info.vm + and appentry.app_info.vm.name != self.selected_vm_entry.vm_entry.vm_name + ): + return ( + self.selected_vm_entry.vm_entry.parent_vm == appentry.app_info.vm.name + and not appentry.app_info.disposable + ) if self.selected_vm_entry.vm_entry.is_dispvm_template: - return appentry.app_info.disposable == \ - self.toggle_buttons.apps_toggle.get_active() + return ( + appentry.app_info.disposable + == self.toggle_buttons.apps_toggle.get_active() + ) return True def _vm_keynav_failed(self, _widget, direction: Gtk.DirectionType): @@ -333,8 +344,7 @@ def _selection_changed(self, _widget, row: Optional[VMRow]): self.network_indicator.set_network_state(row.vm_entry.has_network) self.control_list.update_visibility(row.vm_entry.power_state) self.control_list.unselect_all() - self.app_list.ephemeral_vm = bool( - self.selected_vm_entry.vm_entry.parent_vm) + self.app_list.ephemeral_vm = bool(self.selected_vm_entry.vm_entry.parent_vm) self.app_list.invalidate_filter() def _set_right_visibility(self, visibility: bool): diff --git a/qubes_menu/appmenu.py b/qubes_menu/appmenu.py index 58cbc4b..31be84d 100644 --- a/qubes_menu/appmenu.py +++ b/qubes_menu/appmenu.py @@ -23,35 +23,46 @@ from .custom_widgets import SelfAwareMenu from .vm_manager import VMManager from .page_handler import MenuPage -from .constants import INITIAL_PAGE_FEATURE, SORT_RUNNING_FEATURE, \ - POSITION_FEATURE +from .constants import ( + INITIAL_PAGE_FEATURE, + SORT_RUNNING_FEATURE, + POSITION_FEATURE, + DISABLE_RECENT_FEATURE, +) import gi -gi.require_version('Gtk', '3.0') -gi.require_version('GtkLayerShell', '0.1') -from gi.repository import Gtk, Gdk, GLib, Gio, GtkLayerShell + +gi.require_version("Gtk", "3.0") +gi.require_version("GtkLayerShell", "0.1") +from gi.repository import Gtk, Gdk, GLib, Gio, GtkLayerShell, GObject try: from gi.events import GLibEventLoopPolicy + asyncio.set_event_loop_policy(GLibEventLoopPolicy()) HAS_GBULB = False except ImportError: import gbulb + gbulb.install() HAS_GBULB = True -PAGE_LIST = [ - "search_page", "app_page", "favorites_page", "settings_page" -] +PAGE_LIST = ["search_page", "app_page", "favorites_page", "settings_page"] + +POSITION_LIST = ["mouse", "top-left", "top-right", "bottom-left", "bottom-right"] -POSITION_LIST = [ - "mouse", "top-left", "top-right", "bottom-left", "bottom-right" -] +logger = logging.getLogger("qubes-appmenu") -logger = logging.getLogger('qubes-appmenu') +GObject.signal_new( + "app-started", + Gtk.Application, + GObject.SignalFlags.RUN_LAST, + None, + (str,), +) -def load_theme(widget: Gtk.Widget, light_theme_path: str, - dark_theme_path: str): + +def load_theme(widget: Gtk.Widget, light_theme_path: str, dark_theme_path: str): """ Load a dark or light theme to current screen, based on widget's current (system) defaults. @@ -65,18 +76,20 @@ def load_theme(widget: Gtk.Widget, light_theme_path: str, provider = Gtk.CssProvider() provider.load_from_path(path) Gtk.StyleContext.add_provider_for_screen( - screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) def is_theme_light(widget): """Check if current theme is light or dark""" style_context: Gtk.StyleContext = widget.get_style_context() background_color: Gdk.RGBA = style_context.get_background_color( - Gtk.StateType.NORMAL) - text_color: Gdk.RGBA = style_context.get_color( - Gtk.StateType.NORMAL) - background_intensity = background_color.red + \ - background_color.blue + background_color.green + Gtk.StateType.NORMAL + ) + text_color: Gdk.RGBA = style_context.get_color(Gtk.StateType.NORMAL) + background_intensity = ( + background_color.red + background_color.blue + background_color.green + ) text_intensity = text_color.red + text_color.blue + text_color.green return text_intensity < background_intensity @@ -86,19 +99,23 @@ class AppMenu(Gtk.Application): """ Main Gtk.Application for appmenu. """ + def __init__(self, qapp, dispatcher): """ :param qapp: qubesadmin.Qubes object :param dispatcher: qubesadmin.vm.EventsDispatcher """ - super().__init__(application_id='org.qubesos.appmenu', - flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,) + super().__init__( + application_id="org.qubesos.appmenu", + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, + ) self.qapp = qapp self.dispatcher = dispatcher self.primary = False self.keep_visible = False self.initial_page = "app_page" self.sort_running = False + self.disable_recent = False self.start_in_background = False self.kde = "KDE" in os.getenv("XDG_CURRENT_DESKTOP", "").split(":") @@ -122,7 +139,7 @@ def __init__(self, qapp, dispatcher): self.highlight_tag: Optional[str] = None self.tasks = [] - self.appmenu_position: str = 'mouse' + self.appmenu_position: str = "mouse" def _add_cli_options(self): self.add_main_option( @@ -135,12 +152,12 @@ def _add_cli_options(self): ) self.add_main_option( - 'page', - ord('p'), + "page", + ord("p"), GLib.OptionFlags.NONE, GLib.OptionArg.INT, "Open menu at selected page; 1 is the apps page 1 is the favorites " - "page and 2 is the system tools page" + "page and 2 is the system tools page", ) self.add_main_option( @@ -187,20 +204,21 @@ def _do_power_button(self, _widget): dbus = Gio.bus_get_sync(Gio.BusType.SESSION, None) proxy = Gio.DBusProxy.new_sync( dbus, # dbus - Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS | - Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES, # flags + Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS + | Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES, # flags None, # info "org.kde.LogoutPrompt", # bus name "/LogoutPrompt", # object_path - "org.kde.LogoutPrompt") # interface + "org.kde.LogoutPrompt", + ) # interface proxy.call( - 'promptAll', # method name + "promptAll", # method name None, # parameters 0, # flags - 0 # timeout_msec + 0, # timeout_msec ) else: - subprocess.Popen('xfce4-session-logout', stdin=subprocess.DEVNULL) + subprocess.Popen("xfce4-session-logout", stdin=subprocess.DEVNULL) def reposition(self): """ @@ -208,46 +226,59 @@ def reposition(self): """ assert self.main_window match self.appmenu_position: - case 'top-left': + case "top-left": if self.layer_shell: - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.LEFT, True) - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.TOP, True) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.LEFT, True + ) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.TOP, True + ) else: self.main_window.move(0, 0) - case 'top-right': + case "top-right": if self.layer_shell: - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.RIGHT, True) - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.TOP, True) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.RIGHT, True + ) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.TOP, True + ) else: self.main_window.move( - self.main_window.get_screen().get_width() - - self.main_window.get_size().width, 0) - case 'bottom-left': + self.main_window.get_screen().get_width() + - self.main_window.get_size().width, + 0, + ) + case "bottom-left": if self.layer_shell: - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.LEFT, True) - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.BOTTOM, True) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.LEFT, True + ) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.BOTTOM, True + ) else: - self.main_window.move(0, - self.main_window.get_screen().get_height() - - self.main_window.get_size().height) - case 'bottom-right': + self.main_window.move( + 0, + self.main_window.get_screen().get_height() + - self.main_window.get_size().height, + ) + case "bottom-right": if self.layer_shell: - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.RIGHT, True) - GtkLayerShell.set_anchor(self.main_window, - GtkLayerShell.Edge.BOTTOM, True) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.RIGHT, True + ) + GtkLayerShell.set_anchor( + self.main_window, GtkLayerShell.Edge.BOTTOM, True + ) else: self.main_window.move( - self.main_window.get_screen().get_width() - - self.main_window.get_size().width, - self.main_window.get_screen().get_height() - - self.main_window.get_size().height) + self.main_window.get_screen().get_width() + - self.main_window.get_size().width, + self.main_window.get_screen().get_height() + - self.main_window.get_size().height, + ) def __present(self) -> None: assert self.main_window is not None @@ -264,12 +295,14 @@ def __present(self) -> None: assert max_height > 0 # The default for layer shell is no keyboard input. # Explicitly request exclusive access to the keyboard. - GtkLayerShell.set_keyboard_mode(self.main_window, - GtkLayerShell.KeyboardMode.EXCLUSIVE) + GtkLayerShell.set_keyboard_mode( + self.main_window, GtkLayerShell.KeyboardMode.EXCLUSIVE + ) # Work around https://github.com/wmww/gtk-layer-shell/issues/167 # by explicitly setting the window size. - self.main_window.set_size_request(current_width, - min(current_height, max_height)) + self.main_window.set_size_request( + current_width, min(current_height, max_height) + ) def do_activate(self, *args, **kwargs): """ @@ -295,13 +328,14 @@ def do_activate(self, *args, **kwargs): if not self.start_in_background: # The default for layer shell is no keyboard input. # Explicitly request exclusive access to the keyboard. - GtkLayerShell.set_keyboard_mode(self.main_window, - GtkLayerShell.KeyboardMode.EXCLUSIVE) + GtkLayerShell.set_keyboard_mode( + self.main_window, GtkLayerShell.KeyboardMode.EXCLUSIVE + ) # Work around https://github.com/wmww/gtk-layer-shell/issues/167 # by explicitly setting the window size. self.main_window.set_size_request( - current_width, - min(current_height, max_height)) + current_width, min(current_height, max_height) + ) elif current_height > max_height: self.main_window.resize(current_height, max_height) @@ -314,8 +348,7 @@ def do_activate(self, *args, **kwargs): ] else: if self.main_notebook: - self.main_notebook.set_current_page( - PAGE_LIST.index(self.initial_page)) + self.main_notebook.set_current_page(PAGE_LIST.index(self.initial_page)) if self.main_window: self.main_window.set_keep_above(True) if self.main_window.is_visible() and not self.keep_visible: @@ -330,7 +363,7 @@ def hide_menu(self): app or clicking outside the menu. """ # reset search tab - self.handlers['search_page'].initialize_page() + self.handlers["search_page"].initialize_page() if not self.keep_visible and self.main_window: self.main_window.hide() @@ -344,7 +377,7 @@ def _key_press(self, _widget, event): if event.keyval == Gdk.KEY_Escape: self.hide_menu() if event.keyval == Gdk.KEY_space: - search_page = self.handlers.get('search_page') + search_page = self.handlers.get("search_page") if isinstance(search_page, SearchPage): p = search_page.search_entry.get_position() search_page.search_entry.insert_text(" ", p) @@ -369,8 +402,7 @@ def initialize_state(self): for page in self.handlers.values(): page.initialize_page() if self.main_notebook: - self.main_notebook.set_current_page( - PAGE_LIST.index(self.initial_page)) + self.main_notebook.set_current_page(PAGE_LIST.index(self.initial_page)) def perform_setup(self): """ @@ -380,59 +412,68 @@ def perform_setup(self): # build the frontend self.builder = Gtk.Builder() - self.fav_app_list = self.builder.get_object('fav_app_list') - self.sys_tools_list = self.builder.get_object('sys_tools_list') + self.fav_app_list = self.builder.get_object("fav_app_list") + self.sys_tools_list = self.builder.get_object("sys_tools_list") - glade_path = (importlib.resources.files('qubes_menu') / - 'qubes-menu.glade') + glade_path = importlib.resources.files("qubes_menu") / "qubes-menu.glade" with importlib.resources.as_file(glade_path) as path: self.builder.add_from_file(str(path)) - self.main_window = self.builder.get_object('main_window') + self.main_window = self.builder.get_object("main_window") self.layer_shell = GtkLayerShell.is_supported() - self.main_notebook = self.builder.get_object('main_notebook') + self.main_notebook = self.builder.get_object("main_notebook") self.main_window.set_events(Gdk.EventMask.FOCUS_CHANGE_MASK) - self.main_window.connect('focus-out-event', self._focus_out) - self.main_window.connect('key_press_event', self._key_press) + self.main_window.connect("focus-out-event", self._focus_out) + self.main_window.connect("key_press_event", self._key_press) self.add_window(self.main_window) self.desktop_file_manager = DesktopFileManager(self.qapp) self.vm_manager = VMManager(self.qapp, self.dispatcher) self.handlers = { - 'search_page': SearchPage(self.vm_manager, self.builder, - self.desktop_file_manager), - 'app_page': AppPage(self.vm_manager, self.builder, - self.desktop_file_manager), - 'favorites_page': FavoritesPage(self.qapp, self.builder, - self.desktop_file_manager, - self.dispatcher, self.vm_manager), - 'settings_page': SettingsPage(self.qapp, self.builder, - self.desktop_file_manager, - self.dispatcher)} - self.power_button = self.builder.get_object('power_button') - self.power_button.connect('clicked', self._do_power_button) - self.main_notebook.connect('switch-page', self._handle_page_switch) - self.connect('shutdown', self.do_shutdown) + "search_page": SearchPage( + self.vm_manager, self.builder, self.desktop_file_manager + ), + "app_page": AppPage( + self.vm_manager, self.builder, self.desktop_file_manager + ), + "favorites_page": FavoritesPage( + self.qapp, + self.builder, + self.desktop_file_manager, + self.dispatcher, + self.vm_manager, + ), + "settings_page": SettingsPage( + self.qapp, self.builder, self.desktop_file_manager, self.dispatcher + ), + } + self.power_button = self.builder.get_object("power_button") + self.power_button.connect("clicked", self._do_power_button) + self.main_notebook.connect("switch-page", self._handle_page_switch) + self.connect("shutdown", self.do_shutdown) self.main_window.add_events(Gdk.EventMask.KEY_PRESS_MASK) - self.main_window.connect('key_press_event', self._key_pressed) + self.main_window.connect("key_press_event", self._key_pressed) self.load_style() - Gtk.Settings.get_default().connect('notify::gtk-theme-name', - self.load_style) + Gtk.Settings.get_default().connect("notify::gtk-theme-name", self.load_style) self.load_settings() # monitor for settings changes - for feature in [INITIAL_PAGE_FEATURE, SORT_RUNNING_FEATURE, \ - POSITION_FEATURE]: + for feature in [ + INITIAL_PAGE_FEATURE, + SORT_RUNNING_FEATURE, + POSITION_FEATURE, + DISABLE_RECENT_FEATURE, + ]: self.dispatcher.add_handler( - 'domain-feature-set:' + feature, - self._update_settings) + "domain-feature-set:" + feature, self._update_settings + ) self.dispatcher.add_handler( - 'domain-feature-delete:' + feature, - self._update_settings) + "domain-feature-delete:" + feature, self._update_settings + ) if self.layer_shell: GtkLayerShell.init_for_window(self.main_window) @@ -440,28 +481,31 @@ def perform_setup(self): def load_style(self, *_args): """Load appropriate CSS stylesheet and associated properties.""" - light_ref = (importlib.resources.files('qubes_menu') / - 'qubes-menu-light.css') - dark_ref = (importlib.resources.files('qubes_menu') / - 'qubes-menu-dark.css') - - with importlib.resources.as_file(light_ref) as light_path, \ - importlib.resources.as_file(dark_ref) as dark_path: - load_theme(self.main_window, - light_theme_path=str(light_path), - dark_theme_path=str(dark_path)) + light_ref = importlib.resources.files("qubes_menu") / "qubes-menu-light.css" + dark_ref = importlib.resources.files("qubes_menu") / "qubes-menu-dark.css" + + with ( + importlib.resources.as_file(light_ref) as light_path, + importlib.resources.as_file(dark_ref) as dark_path, + ): + load_theme( + self.main_window, + light_theme_path=str(light_path), + dark_theme_path=str(dark_path), + ) label = Gtk.Label() style_context: Gtk.StyleContext = label.get_style_context() - style_context.add_class('search_highlight') + style_context.add_class("search_highlight") bg_color = style_context.get_background_color(Gtk.StateFlags.NORMAL) fg_color = style_context.get_color(Gtk.StateFlags.NORMAL) # This converts a Gdk.RGBA color to a hex representation liked by span # tags in Pango - self.highlight_tag = \ - f'' + ) def load_settings(self): """Load settings from dom0 features.""" @@ -472,8 +516,8 @@ def load_settings(self): initial_page = "app_page" self.initial_page = initial_page - self.sort_running = \ - bool(local_vm.features.get(SORT_RUNNING_FEATURE, False)) + self.disable_recent = bool(local_vm.features.get(DISABLE_RECENT_FEATURE, False)) + self.sort_running = bool(local_vm.features.get(SORT_RUNNING_FEATURE, False)) position = local_vm.features.get(POSITION_FEATURE, "mouse") if position not in POSITION_LIST: @@ -485,6 +529,8 @@ def load_settings(self): for handler in self.handlers.values(): handler.set_sorting_order(self.sort_running) + if isinstance(handler, SearchPage): + handler.enable_recent(not self.disable_recent) def _update_settings(self, vm, _event, **_kwargs): if not str(vm) == self.qapp.local_name: @@ -494,14 +540,17 @@ def _update_settings(self, vm, _event, **_kwargs): @staticmethod def _rgba_color_to_hex(color: Gdk.RGBA): - return '#' + ''.join([f'{int(c*255):0>2x}' - for c in (color.red, color.green, color.blue)]) + return "#" + "".join( + [f"{int(c*255):0>2x}" for c in (color.red, color.green, color.blue)] + ) def _key_pressed(self, _widget, event_key: Gdk.EventKey): """If user presses a non-control key, move to search.""" - if Gdk.keyval_to_unicode(event_key.keyval) > 32 or \ - event_key.keyval == Gdk.KEY_BackSpace: - search_page = self.handlers.get('search_page') + if ( + Gdk.keyval_to_unicode(event_key.keyval) > 32 + or event_key.keyval == Gdk.KEY_BackSpace + ): + search_page = self.handlers.get("search_page") if not isinstance(search_page, SearchPage): return False @@ -531,7 +580,8 @@ def get_currently_selected_vm(self): """ assert self.main_notebook current_page_handler = self.handlers[ - PAGE_LIST[self.main_notebook.get_current_page()]] + PAGE_LIST[self.main_notebook.get_current_page()] + ] if hasattr(current_page_handler, "get_selected_vm"): return current_page_handler.get_selected_vm() return None @@ -556,5 +606,5 @@ def main(): app.run(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/qubes_menu/constants.py b/qubes_menu/constants.py index d78c601..e95625e 100644 --- a/qubes_menu/constants.py +++ b/qubes_menu/constants.py @@ -23,25 +23,26 @@ """ STATE_DICTIONARY = { - 'domain-pre-start': 'Transient', - 'domain-start': 'Running', - 'domain-start-failed': 'Halted', - 'domain-paused': 'Paused', - 'domain-unpaused': 'Running', - 'domain-shutdown': 'Halted', - 'domain-pre-shutdown': 'Transient', - 'domain-shutdown-failed': 'Running' + "domain-pre-start": "Transient", + "domain-start": "Running", + "domain-start-failed": "Halted", + "domain-paused": "Paused", + "domain-unpaused": "Running", + "domain-shutdown": "Halted", + "domain-pre-shutdown": "Transient", + "domain-shutdown-failed": "Running", } -INITIAL_PAGE_FEATURE = 'menu-initial-page' -SORT_RUNNING_FEATURE = 'menu-sort-running' -POSITION_FEATURE = 'menu-position' +INITIAL_PAGE_FEATURE = "menu-initial-page" +SORT_RUNNING_FEATURE = "menu-sort-running" +POSITION_FEATURE = "menu-position" +DISABLE_RECENT_FEATURE = "menu-disable-recent" -FAVORITES_FEATURE = 'menu-favorites' -DISPOSABLE_PREFIX = '@disp:' +FAVORITES_FEATURE = "menu-favorites" +DISPOSABLE_PREFIX = "@disp:" -RESTART_PARAM_LONG = 'restart' -RESTART_PARAM_SHORT = 'r' +RESTART_PARAM_LONG = "restart" +RESTART_PARAM_SHORT = "r" # Timeout for activation change when hovering over a menu item, in microseconds HOVER_TIMEOUT = 15 diff --git a/qubes_menu/custom_widgets.py b/qubes_menu/custom_widgets.py index 802d0f3..d4bb16c 100644 --- a/qubes_menu/custom_widgets.py +++ b/qubes_menu/custom_widgets.py @@ -29,7 +29,8 @@ from .desktop_file_manager import ApplicationInfo import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib, Pango @@ -38,6 +39,7 @@ class LimitedWidthLabel(Gtk.Label): Gtk.Label, but with ellipsization and capped at 35 characters wide (which is not coincidentally 4 characters more than maximum VM name length) """ + def __init__(self, label_text=None): """ :param label_text: optional text of the newly instantiated label @@ -52,6 +54,7 @@ def __init__(self, label_text=None): class HoverEventBox(Gtk.EventBox): """An EventBox that grabs provided widget on mouse hover.""" + def __init__(self, focus_widget: Gtk.Widget): super().__init__() self.mouse = False @@ -59,8 +62,8 @@ def __init__(self, focus_widget: Gtk.Widget): self.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK) self.add_events(Gdk.EventMask.LEAVE_NOTIFY_MASK) - self.connect('enter-notify-event', self._enter_event) - self.connect('leave-notify-event', self._leave_event) + self.connect("enter-notify-event", self._enter_event) + self.connect("leave-notify-event", self._leave_event) def _enter_event(self, *_args): self.mouse = True @@ -81,6 +84,7 @@ class HoverListBox(Gtk.ListBoxRow): Gtk.ListBoxRow, but selects itself on hover (after a timeout specified in constants.py) """ + def __init__(self): super().__init__() self.mouse = False @@ -90,7 +94,7 @@ def __init__(self): self.event_box.add(self.main_box) self.add(self.event_box) - self.connect('focus-in-event', self._on_focus) + self.connect("focus-in-event", self._on_focus) def _on_focus(self, *_args): if self.get_mapped(): @@ -103,13 +107,14 @@ class SelfAwareMenu(Gtk.Menu): Gtk.Menu, but the class has a counter of number of currently opened menus. There can be only one menu open at a time. """ + OPEN_MENUS = 0 def __init__(self, **kwargs): super().__init__(**kwargs) - self.get_style_context().add_class('right_menu') - self.connect('realize', self._add_to_open) - self.connect('unrealize', self._remove_from_open) + self.get_style_context().add_class("right_menu") + self.connect("realize", self._add_to_open) + self.connect("unrealize", self._remove_from_open) @staticmethod def _add_to_open(*_args): @@ -124,12 +129,13 @@ class FavoritesMenu(SelfAwareMenu): """ Menu for showing add to favorites option. """ + def __init__(self, app_info_getter: Callable[[], ApplicationInfo]): super().__init__() self.app_info_getter = app_info_getter - self.add_menu_item = Gtk.CheckMenuItem(label='Add to favorites') - self.add_menu_item.connect('activate', self._add_to_favorites) + self.add_menu_item = Gtk.CheckMenuItem(label="Add to favorites") + self.add_menu_item.connect("activate", self._add_to_favorites) self.add(self.add_menu_item) self.show_all() @@ -140,7 +146,7 @@ def _has_favorite_sibling(self): """ if self.app_info_getter(): for entry in self.app_info_getter().entries: - if type(entry).__name__ == 'FavoritesAppEntry': + if type(entry).__name__ == "FavoritesAppEntry": return True return False @@ -149,12 +155,18 @@ def _add_to_favorites(self, *_args, **_kwargs): "Add to favorites" action: sets appropriate VM feature """ target_vm = self.app_info_getter().vm + entry_name = self.app_info_getter().entry_name + if not entry_name: + return + if not target_vm: target_vm = self.app_info_getter().qapp.domains[ - self.app_info_getter().qapp.local_name] + self.app_info_getter().qapp.local_name + ] - add_to_feature(target_vm, constants.FAVORITES_FEATURE, - self.app_info_getter().entry_name) # type: ignore + add_to_feature( + target_vm, constants.FAVORITES_FEATURE, entry_name + ) # type: ignore def set_menu_state(self): """ @@ -165,8 +177,10 @@ def set_menu_state(self): :return: """ - if (getattr(self.get_parent(), 'ephemeral_vm', False) or - not self.app_info_getter()): + if ( + getattr(self.get_parent(), "ephemeral_vm", False) + or not self.app_info_getter() + ): self.add_menu_item.set_active(False) self.add_menu_item.set_sensitive(False) else: @@ -180,14 +194,17 @@ class NetworkIndicator(Gtk.Box): Network Indicator Gtk.Box - changes appearance when set_network_state is called. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.icon_size = Gtk.IconSize.LARGE_TOOLBAR self.network_on: Gtk.Image = Gtk.Image.new_from_pixbuf( - load_icon('qappmenu-networking-yes', self.icon_size)) + load_icon("qappmenu-networking-yes", self.icon_size) + ) self.network_off: Gtk.Image = Gtk.Image.new_from_pixbuf( - load_icon('qappmenu-networking-no', self.icon_size)) + load_icon("qappmenu-networking-no", self.icon_size) + ) _, height, _ = Gtk.icon_size_lookup(self.icon_size) self.network_on.set_size_request(-1, height * 1.3) @@ -196,13 +213,13 @@ def __init__(self, *args, **kwargs): self.pack_end(self.network_on, False, True, 10) self.pack_end(self.network_off, False, True, 10) - self.network_on.set_tooltip_text('Qube is networked') - self.network_off.set_tooltip_text('Qube is not networked') + self.network_on.set_tooltip_text("Qube is networked") + self.network_off.set_tooltip_text("Qube is not networked") self.network_on.set_no_show_all(True) self.network_off.set_no_show_all(True) - self.get_style_context().add_class('network_indicator') + self.get_style_context().add_class("network_indicator") def set_network_state(self, state: bool): """ @@ -218,20 +235,20 @@ class SettingsEntry(Gtk.ListBoxRow): """ Gtk.ListBoxRow especially for a (run VM) Settings entry. """ + def __init__(self, desktop_file_manager): super().__init__() self.desktop_file_manager = desktop_file_manager self.event_box = HoverEventBox(focus_widget=self) self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.event_box.add(self.hbox) - self.settings_icon = Gtk.Image.new_from_pixbuf( - load_icon('settings-black')) + self.settings_icon = Gtk.Image.new_from_pixbuf(load_icon("settings-black")) self.hbox.pack_start(self.settings_icon, False, False, 5) self.settings_label = Gtk.Label(label="Settings", xalign=0) self.hbox.pack_start(self.settings_label, False, False, 5) - self.get_style_context().add_class('app_entry') + self.get_style_context().add_class("app_entry") self.add(self.event_box) - self.event_box.connect('button-press-event', self.show_menu) + self.event_box.connect("button-press-event", self.show_menu) self.menu = FavoritesMenu(self.get_appinfo) @@ -240,16 +257,17 @@ def __init__(self, desktop_file_manager): def run_app(self, vm): """Run settings for specified vm.""" # pylint: disable=consider-using-with - subprocess.Popen( - ['qubes-vm-settings', vm.name], stdin=subprocess.DEVNULL) + subprocess.Popen(["qubes-vm-settings", vm.name], stdin=subprocess.DEVNULL) self.get_toplevel().get_application().hide_menu() def get_appinfo(self) -> ApplicationInfo: """Get relevant app_info for currently selected vm""" - vm_entry: VMEntry = (self.get_toplevel().get_application(). - get_currently_selected_vm()) + vm_entry: VMEntry = ( + self.get_toplevel().get_application().get_currently_selected_vm() + ) return self.desktop_file_manager.get_app_info_by_name( - vm_entry.settings_desktop_file_name) + vm_entry.settings_desktop_file_name + ) def update_state(self, state): # pylint: disable=unused-argument """Update state: should be always visible.""" @@ -270,8 +288,10 @@ class VMRow(HoverListBox): """ Helper widget representing a VM row. """ - def __init__(self, vm_entry: VMEntry, - show_dispvm_inheritance: Optional[bool] = True): + + def __init__( + self, vm_entry: VMEntry, show_dispvm_inheritance: Optional[bool] = True + ): """ :param vm_entry: VMEntry object, stored and managed by VMManager :param show_dispvm_inheritance: bool, should dispvm children be shown @@ -281,15 +301,15 @@ def __init__(self, vm_entry: VMEntry, self.vm_entry = vm_entry self.vm_name = vm_entry.vm_name self.show_dispvm_inheritance = show_dispvm_inheritance - self.get_style_context().add_class('vm_entry') + self.get_style_context().add_class("vm_entry") self.icon_img = Gtk.Image() self.dispvm_icon = Gtk.Image() # add the icon for dispvm parent existing if self.vm_entry.parent_vm: - dispvm_icon_img = load_icon('qappmenu-dispvm-child', None, 15) + dispvm_icon_img = load_icon("qappmenu-dispvm-child", None, 15) self.dispvm_icon.set_from_pixbuf(dispvm_icon_img) - self.dispvm_icon.get_style_context().add_class('dispvm_icon') + self.dispvm_icon.get_style_context().add_class("dispvm_icon") self.dispvm_icon.set_valign(Gtk.Align.START) self.main_box.pack_start(self.dispvm_icon, False, False, 2) self.dispvm_icon.set_no_show_all(True) @@ -298,35 +318,41 @@ def __init__(self, vm_entry: VMEntry, self.label = Gtk.Label(label=self.vm_entry.vm_name) self.main_box.pack_start(self.label, False, False, 2) - self.update_contents(update_power_state=True, update_label=True, - update_has_network=True, update_type=True) + self.update_contents( + update_power_state=True, + update_label=True, + update_has_network=True, + update_type=True, + ) def update_style(self, update_power_state: bool = True): """Update own style, based on whether VM is running or not and what type it has.""" style_context: Gtk.StyleContext = self.get_style_context() if self.vm_entry.is_dispvm_template: - style_context.add_class('dvm_template_entry') + style_context.add_class("dvm_template_entry") elif self.vm_entry.parent_vm: # has a parent VM means that it should have arrow etc. - style_context.add_class('dispvm_entry') + style_context.add_class("dispvm_entry") else: - style_context.remove_class('dispvm_entry') - style_context.remove_class('dvm_template_entry') + style_context.remove_class("dispvm_entry") + style_context.remove_class("dvm_template_entry") if update_power_state: - if self.vm_entry.power_state == 'Running': - style_context.add_class('running_vm') + if self.vm_entry.power_state == "Running": + style_context.add_class("running_vm") else: - style_context.remove_class('running_vm') + style_context.remove_class("running_vm") self.dispvm_icon.set_visible(self.show_dispvm_inheritance) - def update_contents(self, - update_power_state=False, - update_label=False, - update_has_network=False, - update_type=False): + def update_contents( + self, + update_power_state=False, + update_label=False, + update_has_network=False, + update_type=False, + ): """ Update own contents (or related widgets, if applicable) based on state change. @@ -362,35 +388,41 @@ def sort_order(self): class SearchVMRow(VMRow): """VM Row used for the Search tab.""" - def update_contents(self, - update_power_state=False, - update_label=False, - update_has_network=False, - update_type=False): + + def update_contents( + self, + update_power_state=False, + update_label=False, + update_has_network=False, + update_type=False, + ): """ Search rows do not show power state. """ - super().update_contents(update_power_state=False, - update_label=update_label, - update_has_network=False, - update_type=update_type) + super().update_contents( + update_power_state=False, + update_label=update_label, + update_has_network=False, + update_type=update_type, + ) class AnyVMRow(HoverListBox): """Generic Any VM row for search purposes.""" + def __init__(self): super().__init__() self.vm_name = None - self.sort_order = '' - self.get_style_context().add_class('vm_entry') + self.sort_order = "" + self.get_style_context().add_class("vm_entry") icon_img = Gtk.Image() - icon_vm = load_icon('qubes-logo-icon') + icon_vm = load_icon("qubes-logo-icon") icon_img.set_from_pixbuf(icon_vm) self.main_box.pack_start(icon_img, False, False, 2) self.label = Gtk.Label() - self.label.set_markup('Any qube') + self.label.set_markup("Any qube") self.main_box.pack_start(self.label, False, False, 2) self.show_all() @@ -400,10 +432,11 @@ class ControlRow(Gtk.ListBoxRow): Gtk.ListBoxRow representing one of the VM control options: start/shutdown/ pause etc. """ + def __init__(self): super().__init__() self.row_label = LimitedWidthLabel() - self.get_style_context().add_class('app_entry') + self.get_style_context().add_class("app_entry") self.event_box = HoverEventBox(focus_widget=self) self.add(self.event_box) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) @@ -437,20 +470,23 @@ class StartControlItem(ControlRow): shutdown if it's running, unpause if it's paused, and kill if it's transient. """ + def __init__(self, desktop_file_manager): super().__init__() self.desktop_file_manager = desktop_file_manager self.state = None - self.event_box.connect('button-press-event', self.show_menu) + self.event_box.connect("button-press-event", self.show_menu) self.menu = FavoritesMenu(self.get_appinfo) def get_appinfo(self) -> ApplicationInfo: """Get relevant app_info for currently selected vm""" - vm_entry: VMEntry = (self.get_toplevel().get_application(). - get_currently_selected_vm()) + vm_entry: VMEntry = ( + self.get_toplevel().get_application().get_currently_selected_vm() + ) return self.desktop_file_manager.get_app_info_by_name( - vm_entry.settings_desktop_file_name) + vm_entry.settings_desktop_file_name + ) def show_menu(self, _widget, event): """ @@ -467,30 +503,34 @@ def update_state(self, state): state. """ self.state = state - if state == 'Running': - self.row_label.set_label('Shutdown qube') - self.command = 'qvm-shutdown' - self.icon.set_from_pixbuf(load_icon("qappmenu-shutdown", size=None, - pixel_size=15)) + if state == "Running": + self.row_label.set_label("Shutdown qube") + self.command = "qvm-shutdown" + self.icon.set_from_pixbuf( + load_icon("qappmenu-shutdown", size=None, pixel_size=15) + ) return - if state == 'Transient': - self.row_label.set_label('Kill qube') - self.command = 'qvm-kill' - self.icon.set_from_pixbuf(load_icon("qappmenu-shutdown", size=None, - pixel_size=15)) + if state == "Transient": + self.row_label.set_label("Kill qube") + self.command = "qvm-kill" + self.icon.set_from_pixbuf( + load_icon("qappmenu-shutdown", size=None, pixel_size=15) + ) return - if state == 'Halted': - self.row_label.set_label('Start qube') - self.command = 'qvm-start' - self.icon.set_from_pixbuf(load_icon("qappmenu-start", size=None, - pixel_size=15)) + if state == "Halted": + self.row_label.set_label("Start qube") + self.command = "qvm-start" + self.icon.set_from_pixbuf( + load_icon("qappmenu-start", size=None, pixel_size=15) + ) return - if state == 'Paused': - self.row_label.set_label('Unpause qube') - self.command = 'qvm-unpause' - self.icon.set_from_pixbuf(load_icon("qappmenu-start", size=None, - pixel_size=15)) + if state == "Paused": + self.row_label.set_label("Unpause qube") + self.command = "qvm-unpause" + self.icon.set_from_pixbuf( + load_icon("qappmenu-start", size=None, pixel_size=15) + ) return @@ -498,10 +538,10 @@ class PauseControlItem(ControlRow): """ Control Row item representing pausing VM: visible only when it's running. """ + def __init__(self): super().__init__() - self.icon.set_from_pixbuf(load_icon("qappmenu-pause", size=None, - pixel_size=15)) + self.icon.set_from_pixbuf(load_icon("qappmenu-pause", size=None, pixel_size=15)) self.state = None def update_state(self, state): @@ -510,13 +550,13 @@ def update_state(self, state): state. """ self.state = state - if state == 'Running': - self.row_label.set_label('Pause qube') + if state == "Running": + self.row_label.set_label("Pause qube") self.set_sensitive(True) - self.command = 'qvm-pause' + self.command = "qvm-pause" self.icon.show() return - self.row_label.set_label(' ') + self.row_label.set_label(" ") self.set_sensitive(False) self.command = None self.icon.hide() @@ -526,11 +566,12 @@ class ControlList(Gtk.ListBox): """ ListBox containing VM state control items. """ + def __init__(self, app_page): super().__init__() self.app_page = app_page - self.get_style_context().add_class('control_panel') + self.get_style_context().add_class("control_panel") self.settings_item = SettingsEntry(self.app_page.desktop_file_manager) @@ -554,11 +595,12 @@ class KeynavController: not enough, namely, when we have a bunch of ListBoxes stacked on top of each other. """ + def __init__(self, widgets_in_order: List[Gtk.ListBox]): self.widgets_in_order = widgets_in_order for widget in self.widgets_in_order: - widget.connect('keynav-failed', self._keynav_failed) + widget.connect("keynav-failed", self._keynav_failed) def get_neighbor(self, widget: Gtk.ListBox, direction: Gtk.DirectionType): """Get next widget in given direction""" @@ -569,8 +611,7 @@ def get_neighbor(self, widget: Gtk.ListBox, direction: Gtk.DirectionType): return self.widgets_in_order[(i + 1) % len(self.widgets_in_order)] return None - def _keynav_failed(self, widget: Gtk.ListBox, - direction: Gtk.DirectionType): + def _keynav_failed(self, widget: Gtk.ListBox, direction: Gtk.DirectionType): """ Callback to be performed when keyboard nav fails. Attempts to find next widget and move keyboard focus to it. @@ -579,7 +620,8 @@ def _keynav_failed(self, widget: Gtk.ListBox, if not next_widget: return next_focus_widget = get_visible_child( - next_widget, reverse=direction == Gtk.DirectionType.UP) + next_widget, reverse=direction == Gtk.DirectionType.UP + ) if next_focus_widget: widget.select_row(None) next_focus_widget.grab_focus() diff --git a/qubes_menu/desktop_file_manager.py b/qubes_menu/desktop_file_manager.py index 6f03161..b8d1ee0 100644 --- a/qubes_menu/desktop_file_manager.py +++ b/qubes_menu/desktop_file_manager.py @@ -36,7 +36,7 @@ from . import constants -logger = logging.getLogger('qubes-appmenu') +logger = logging.getLogger("qubes-appmenu") def exec_parse(desktop_entry: xdg.DesktopEntry.DesktopEntry): @@ -47,13 +47,12 @@ def exec_parse(desktop_entry: xdg.DesktopEntry.DesktopEntry): split_str = shlex.split(desktop_entry.getExec()) result = [] for s in split_str: - if s in ['%f', '%F', '%u', '%U', '%d', '%D', '%n', '%N', '%v', - '%m', '%k']: + if s in ["%f", "%F", "%u", "%U", "%d", "%D", "%n", "%N", "%v", "%m", "%k"]: continue - if s == '%i' and desktop_entry.getIcon(): - result.extend(['--icon', desktop_entry.getIcon()]) + if s == "%i" and desktop_entry.getIcon(): + result.extend(["--icon", desktop_entry.getIcon()]) continue - if s == '%c': + if s == "%c": result.append(desktop_entry.getName()) continue result.append(s) @@ -64,6 +63,7 @@ class ApplicationInfo: """ Class representing data within a single .desktop file. """ + def __init__(self, qapp, file_path): self.qapp: qubesadmin.Qubes = qapp self.file_path: PosixPath = file_path @@ -81,20 +81,20 @@ def __init__(self, qapp, file_path): def load_data(self, entry): """Fill own data with information from xdg.DesktopEntry provided.""" - vm_name = entry.get('X-Qubes-VmName') or None + vm_name = entry.get("X-Qubes-VmName") or None try: self.vm = self.qapp.domains[vm_name] except KeyError: self.vm = None - self.app_name = entry.getName() or '' + self.app_name = entry.getName() or "" if self.vm: self.app_name = self.app_name.split(": ", 1)[-1] self.sort_name = str(self.app_name).lower() self.vm_icon = self.vm.icon if self.vm else None self.app_icon = entry.getIcon() - self.disposable = bool(entry.get('X-Qubes-NonDispvmExec')) - self.entry_name = entry.get('X-Qubes-AppName') or self.file_path.name + self.disposable = bool(entry.get("X-Qubes-NonDispvmExec")) + self.entry_name = entry.get("X-Qubes-AppName") or self.file_path.name if self.disposable: self.entry_name = constants.DISPOSABLE_PREFIX + self.entry_name self.exec = exec_parse(entry) @@ -111,35 +111,40 @@ def get_command_for_vm(self, vm=None): their own .desktop files.""" command = self.exec if vm and not self.vm: - logger.warning('Unexpected command: cannot run local' - ' application for a non-local VM: %s', vm) + logger.warning( + "Unexpected command: cannot run local" + " application for a non-local VM: %s", + vm, + ) return command if vm and str(self.vm) != str(vm): # replace name of the old VM - used for opening apps from DVM # template in their child dispvm if len(command) < 6 or command[5] != str(self.vm): - logger.error( - 'Unexpected command for a disposable VM: %s', command) + logger.error("Unexpected command for a disposable VM: %s", command) return [] return command[:5] + [str(vm)] + command[6:] return command def is_qubes_specific(self): """Check if the current file represents a qubes-generated app.""" - return 'X-Qubes-VM' in self.categories + return "X-Qubes-VM" in self.categories class DesktopFileManager: """ Class that loads, caches and observes changes in .desktop files. """ + desktop_dirs = [ - Path(xdg.BaseDirectory.xdg_data_home) / 'applications', - Path('/usr/share/applications')] + Path(xdg.BaseDirectory.xdg_data_home) / "applications", + Path("/usr/share/applications"), + ] # pylint: disable=invalid-name class EventProcessor(pyinotify.ProcessEvent): """pyinotify helper class""" + def __init__(self, parent): self.parent = parent super().__init__() @@ -183,8 +188,7 @@ def __init__(self, qapp): # directories used by Qubes menu tools, not necessarily all possible # XDG directories - self.current_environments = \ - os.environ.get('XDG_CURRENT_DESKTOP', '').split(':') + self.current_environments = os.environ.get("XDG_CURRENT_DESKTOP", "").split(":") self.app_entries: Dict[Path, ApplicationInfo] = {} @@ -250,14 +254,13 @@ def load_file(self, path: Union[str, Path]): self.remove_file(path) return - if not path.name.endswith('.desktop'): + if not path.name.endswith(".desktop"): return try: entry = xdg.DesktopEntry.DesktopEntry(path) except Exception as ex: # pylint: disable=broad-except - logger.warning( - 'Cannot load desktop entry file %s: %s', path, str(ex)) + logger.warning("Cannot load desktop entry file %s: %s", path, str(ex)) self.remove_file(path) return @@ -286,14 +289,12 @@ def _eligibility_check(self, entry: xdg.DesktopEntry.DesktopEntry): if entry.getNoDisplay(): return False if entry.getOnlyShowIn(): - if not set(entry.getOnlyShowIn()).intersection( - self.current_environments): + if not set(entry.getOnlyShowIn()).intersection(self.current_environments): return False if entry.getNotShowIn(): - if set(entry.getNotShowIn()).intersection( - self.current_environments): + if set(entry.getNotShowIn()).intersection(self.current_environments): return False - if entry.get('X-AppStream-Ignore'): + if entry.get("X-AppStream-Ignore"): return False return True @@ -304,17 +305,23 @@ def _initialize_watchers(self): self.watch_manager = pyinotify.WatchManager() # pylint: disable=no-member - mask = pyinotify.IN_CREATE | pyinotify.IN_DELETE | \ - pyinotify.IN_MODIFY | pyinotify.IN_MOVED_FROM | \ - pyinotify.IN_MOVED_TO + mask = ( + pyinotify.IN_CREATE + | pyinotify.IN_DELETE + | pyinotify.IN_MODIFY + | pyinotify.IN_MOVED_FROM + | pyinotify.IN_MOVED_TO + ) loop = asyncio.get_event_loop() self.notifier = pyinotify.AsyncioNotifier( - self.watch_manager, loop, - default_proc_fun=DesktopFileManager.EventProcessor(self)) + self.watch_manager, + loop, + default_proc_fun=DesktopFileManager.EventProcessor(self), + ) for path in self.desktop_dirs: self.watches.append( - self.watch_manager.add_watch( - str(path), mask, rec=True, auto_add=True)) + self.watch_manager.add_watch(str(path), mask, rec=True, auto_add=True) + ) diff --git a/qubes_menu/favorites_page.py b/qubes_menu/favorites_page.py index b31e1ab..89a294e 100644 --- a/qubes_menu/favorites_page.py +++ b/qubes_menu/favorites_page.py @@ -21,6 +21,7 @@ Qubes App Menu favorites page and related widgets. """ import logging +from functools import partial import qubesadmin.events from .desktop_file_manager import DesktopFileManager @@ -30,45 +31,89 @@ from . import constants import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk -logger = logging.getLogger('qubes-appmenu') +logger = logging.getLogger("qubes-appmenu") class FavoritesPage(MenuPage): """ Helper class for managing the entirety of Favorites menu page. """ - def __init__(self, qapp: qubesadmin.Qubes, builder: Gtk.Builder, - desktop_file_manager: DesktopFileManager, - dispatcher: qubesadmin.events.EventsDispatcher, - vm_manager: VMManager): + + def __init__( + self, + qapp: qubesadmin.Qubes, + builder: Gtk.Builder, + desktop_file_manager: DesktopFileManager, + dispatcher: qubesadmin.events.EventsDispatcher, + vm_manager: VMManager, + ): self.qapp = qapp self.desktop_file_manager = desktop_file_manager self.dispatcher = dispatcher self.vm_manager = vm_manager - self.page_widget: Gtk.Box = builder.get_object("favorites_page") + self.sort_names: bool | None = None # sort by app name, AZ (True) or ZA (False) + self.sort_qubes: bool | None = ( + None # sort by qube name, AZ (True) or ZA (False) + ) - self.app_list: Gtk.ListBox = builder.get_object('fav_app_list') - self.app_list.connect('row-activated', self._app_clicked) + self.page_widget: Gtk.Box = builder.get_object("favorites_page") - self.app_list.set_sort_func( - lambda x, y: x.app_info.sort_name > y.app_info.sort_name) + self.sort_qube_az_button: Gtk.ToggleButton = builder.get_object( + "favorites_qube_az_toggle" + ) + self.sort_qube_za_button: Gtk.ToggleButton = builder.get_object( + "favorites_qube_za_toggle" + ) + self.sort_appname_az_button: Gtk.ToggleButton = builder.get_object( + "favorites_appname_az_toggle" + ) + self.sort_appname_za_button: Gtk.ToggleButton = builder.get_object( + "favorites_appname_za_toggle" + ) + self.sort_buttons = [ + self.sort_appname_za_button, + self.sort_appname_az_button, + self.sort_qube_az_button, + self.sort_qube_za_button, + ] + self.sort_qube_az_button.connect( + "toggled", partial(self._button_toggled, "sort_qubes", True) + ) + self.sort_qube_za_button.connect( + "toggled", partial(self._button_toggled, "sort_qubes", False) + ) + self.sort_appname_az_button.connect( + "toggled", partial(self._button_toggled, "sort_names", True) + ) + self.sort_appname_za_button.connect( + "toggled", partial(self._button_toggled, "sort_names", False) + ) + + self.app_list: Gtk.ListBox = builder.get_object("fav_app_list") + self.app_list.connect("row-activated", self._app_clicked) + + self.app_list.set_sort_func(self._favorites_sort) self.desktop_file_manager.register_callback(self._app_info_callback) self.app_list.show_all() self.app_list.invalidate_sort() self.app_list.set_selection_mode(Gtk.SelectionMode.NONE) self.dispatcher.add_handler( - f'domain-feature-delete:{constants.FAVORITES_FEATURE}', - self._feature_deleted) + f"domain-feature-delete:{constants.FAVORITES_FEATURE}", + self._feature_deleted, + ) self.dispatcher.add_handler( - f'domain-feature-set:{constants.FAVORITES_FEATURE}', - self._feature_set) - self.dispatcher.add_handler('domain-add', self._domain_added) - self.dispatcher.add_handler('domain-delete', self._domain_deleted) + f"domain-feature-set:{constants.FAVORITES_FEATURE}", self._feature_set + ) + self.dispatcher.add_handler("domain-add", self._domain_added) + self.dispatcher.add_handler("domain-delete", self._domain_deleted) + + self.sort_appname_az_button.toggled() def _load_vms_favorites(self, vm): """ @@ -80,14 +125,15 @@ def _load_vms_favorites(self, vm): vm = self.qapp.domains[vm] except KeyError: return - favorites = vm.features.get(constants.FAVORITES_FEATURE, '') - favorites = favorites.split(' ') + favorites = vm.features.get(constants.FAVORITES_FEATURE, "") + favorites = favorites.split(" ") is_local_vm = vm.name == self.qapp.local_name for app_info in self.desktop_file_manager.get_app_infos(): - if (not is_local_vm and app_info.vm == vm)\ - or (is_local_vm and not app_info.vm): + if (not is_local_vm and app_info.vm == vm) or ( + is_local_vm and not app_info.vm + ): if app_info.entry_name in favorites: self._add_from_app_info(app_info) self.app_list.invalidate_sort() @@ -100,7 +146,7 @@ def _app_info_callback(self, app_info): else: vm = app_info.qapp.domains[app_info.qapp.local_name] - feature = vm.features.get(constants.FAVORITES_FEATURE, '').split(' ') + feature = vm.features.get(constants.FAVORITES_FEATURE, "").split(" ") if app_info.entry_name in feature: self._add_from_app_info(app_info) @@ -124,8 +170,7 @@ def _feature_deleted(self, vm, _event, _feature, *_args, **_kwargs): self.app_list.remove(child) self.app_list.invalidate_sort() except Exception as ex: # pylint: disable=broad-except - logger.warning( - 'Encountered problem removing favorite entry: %s', repr(ex)) + logger.warning("Encountered problem removing favorite entry: %s", repr(ex)) def _feature_set(self, vm, event, feature, *_args, **_kwargs): """When VM feature specified in constants.py is changed, all existing @@ -135,8 +180,7 @@ def _feature_set(self, vm, event, feature, *_args, **_kwargs): self._feature_deleted(vm, event, feature) self._load_vms_favorites(vm) except Exception as ex: # pylint: disable=broad-except - logger.warning( - 'Encountered problem adding favorite entry: %s', repr(ex)) + logger.warning("Encountered problem adding favorite entry: %s", repr(ex)) def _domain_added(self, _submitter, _event, vm, **_kwargs): """On a newly created domain, load all favorites from features @@ -150,3 +194,34 @@ def _domain_deleted(self, _submitter, event, vm, **_kwargs): def initialize_page(self): """Favorites page does not require additional post-init setup""" + + def _button_toggled(self, var_name, state, widget, *_args): + if not widget.get_active(): + return + self.sort_names = None + self.sort_qubes = None + setattr(self, var_name, state) + for button in self.sort_buttons: + if button == widget: + continue + button.set_active(False) + self.app_list.invalidate_sort() + + def _favorites_sort(self, x: FavoritesAppEntry, y: FavoritesAppEntry): + sort_name_x = x.app_info.app_name or "" + sort_name_y = y.app_info.app_name or "" + + if self.sort_qubes is not None: + sort_name_x = ( + (x.app_info.vm.name if x.app_info.vm else "") + " | " + sort_name_x + ) + sort_name_y = ( + (y.app_info.vm.name if y.app_info.vm else "") + " | " + sort_name_y + ) + if self.sort_qubes: + return sort_name_x > sort_name_y + return sort_name_x < sort_name_y + + if self.sort_names: + return sort_name_x > sort_name_y + return sort_name_x < sort_name_y diff --git a/qubes_menu/page_handler.py b/qubes_menu/page_handler.py index eca31c2..e2d1882 100644 --- a/qubes_menu/page_handler.py +++ b/qubes_menu/page_handler.py @@ -21,12 +21,14 @@ import abc import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk class MenuPage(abc.ABC): """Abstract Menu Page.""" + page_widget: Gtk.Widget @abc.abstractmethod diff --git a/qubes_menu/qubes-menu-base.css b/qubes_menu/qubes-menu-base.css index e96cb52..ff5d0df 100644 --- a/qubes_menu/qubes-menu-base.css +++ b/qubes_menu/qubes-menu-base.css @@ -78,6 +78,24 @@ list :not(:selected) { font-weight: 900; } +.sort_buttons { + background: @left-background; + opacity: 0.9; + border-width: 0px 0px 0px 2px; + border-color: @left-background; + border-radius: 0px; + margin: 4px 10px 4px 2px; + padding: 5px; +} + +.sort_buttons:checked { + border-color: @text-color; +} + +.sort_buttons:hover, .sort_buttons:focus { + transition: none; +} + .vm_entry { padding: 10px; } diff --git a/qubes_menu/qubes-menu.glade b/qubes_menu/qubes-menu.glade index 91d1001..c2acb62 100644 --- a/qubes_menu/qubes-menu.glade +++ b/qubes_menu/qubes-menu.glade @@ -1,7 +1,7 @@ - + False @@ -18,7 +18,7 @@ True left - + True False @@ -68,8 +68,7 @@ 0 - 3 - 2 + 2 @@ -114,63 +113,145 @@ 1 - 3 + 2 - + True - True + False True - True - never - in + vertical - + True False + start + Recent applications + + + + False + True + 0 + + + + + True + True + True + never + in + True - + True False - - + True + + True False - start start - No recent searches + True + + + True + False + start + start + True + No recent applications + + + - + + + + False + True + 1 + + + + + True + False + start + Recently searched + + + False + True + 2 + + + + + True + True + True + True + never + in + + + True + False + True + + + True + False + start + True + + + True + False + start + start + True + No recent searches + + + + + + + + + + + + False + True + 3 + - - - - 0 - 2 - 2 - - - - - True - False - start - Recently searched - 0 @@ -178,9 +259,6 @@ 2 - - - @@ -483,8 +561,117 @@ To add an application to favorites, right-click on it in the application list. - + + True + False + start + vertical + + + True + True + True + Sort by application name (A-Z) + + + True + False + qappmenu-az + + + + + + False + True + 0 + + + + + True + True + True + Sort by application name (Z-A) + + + True + False + qappmenu-za + + + + + + False + True + 1 + + + + + True + True + True + Sort by qube and application name (A-Z) + + + True + False + qappmenu-qube-az + + + + + + False + True + 2 + + + + + True + True + True + Sort by qube and application name (Z-A) + + + True + False + qappmenu-qube-za + + + + + + False + True + 3 + + + + + False + True + 1 + + 2 diff --git a/qubes_menu/search_page.py b/qubes_menu/search_page.py index 2a085a2..15ee7a2 100644 --- a/qubes_menu/search_page.py +++ b/qubes_menu/search_page.py @@ -28,7 +28,8 @@ from .utils import load_icon, parse_search import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk @@ -36,32 +37,71 @@ class RecentSearchRow(Gtk.ListBoxRow): """ Gtk.ListBoxRow with a recently searched text. """ + def __init__(self, search_text: str): super().__init__() self.search_text = search_text self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.recent_icon = Gtk.Image.new_from_pixbuf( - load_icon('qappmenu-search')) + self.recent_icon = Gtk.Image.new_from_pixbuf(load_icon("qappmenu-search")) self.hbox.pack_start(self.recent_icon, False, False, 5) self.search_label = Gtk.Label(label=search_text, xalign=0) self.hbox.pack_start(self.search_label, False, False, 5) - self.get_style_context().add_class('app_entry') + self.get_style_context().add_class("app_entry") self.add(self.hbox) self.show_all() class RecentSearchManager: """Class for managing the list of recent searches.""" + SEARCH_VALUES_TO_KEEP = 10 - def __init__(self, recent_list: Gtk.ListBox, search_box: Gtk.SearchEntry): + + def __init__( + self, + recent_list: Gtk.ListBox, + search_box: Gtk.SearchEntry, + enabled: bool, + other_widgets: list[Gtk.ListBox], + ): + self.recent_enabled = enabled self.recent_list_box = recent_list self.search_box = search_box self.recent_searches: Dict[str, RecentSearchRow] = {} - self.recent_list_box.connect('row-activated', self._row_clicked) + self.other_widgets = other_widgets + self.recent_list_box.connect("row-activated", self._row_clicked) + self.recent_list_box.connect("row-selected", self._deselect_others) + + def _deselect_others(self, *_args): + for widget in self.other_widgets: + widget.select_row(None) + + def set_recent_enabled(self, state): + """Set whether recent searches should be stored or not.""" + self.recent_enabled = state + self.recent_searches.clear() + for child in self.recent_list_box.get_children(): + self.recent_list_box.remove(child) + + label = Gtk.Label() + label.get_style_context().add_class("placeholder") + label.set_visible(True) + label.set_halign(Gtk.Align.START) + label.set_valign(Gtk.Align.START) + + if state: + label.set_text("No recent searches") + else: + label.set_text( + "Recent searches saving disabled.\nUse Menu Settings to enable." + ) + + self.recent_list_box.set_placeholder(label) def add_new_recent_search(self, text: str): """Add new recent search entry""" + if not self.recent_enabled: + return if not text: return @@ -72,7 +112,7 @@ def add_new_recent_search(self, text: str): self.recent_list_box.insert(old_row, 0) return - if len(self.recent_searches) == self.SEARCH_VALUES_TO_KEEP: + if len(self.recent_searches) == self.SEARCH_VALUES_TO_KEEP + 1: last_row: RecentSearchRow = self.recent_list_box.get_children()[-1] del self.recent_searches[last_row.search_text] self.recent_list_box.remove(last_row) @@ -82,15 +122,104 @@ def add_new_recent_search(self, text: str): self.recent_searches[text] = row def _row_clicked(self, _widget, row: RecentSearchRow): + self._deselect_others() self.search_box.set_text(row.search_text) +class RecentAppsManager: + """Class for managing recently run apps""" + + APPS_TO_KEEP = 10 + + def __init__( + self, + recent_list: Gtk.ListBox, + desktop_file_manager: DesktopFileManager, + vm_manager: VMManager, + enabled: bool, + other_widgets: list[Gtk.ListBox], + ): + self.recent_enabled = enabled + self.recent_list_box = recent_list + self.desktop_file_manager = desktop_file_manager + self.vm_manager = vm_manager + self.recent_apps: list[SearchAppEntry] = [] + self.recent_list_box.connect("row-activated", self._row_clicked) + application = self.recent_list_box.get_toplevel().get_application() + if application: + # this is a workaround for tests: without Gtk.Application.run, + # this object does not exist + application.connect("app-started", self.add_new_recent_app) + self.other_widgets = other_widgets + self.recent_list_box.connect("row-selected", self._deselect_others) + + def _deselect_others(self, *_args): + for widget in self.other_widgets: + widget.select_row(None) + + def set_recent_enabled(self, state): + """Set whether recent apps should be stored or not.""" + self.recent_enabled = state + self.recent_apps.clear() + for child in self.recent_list_box.get_children(): + self.recent_list_box.remove(child) + + label = Gtk.Label() + label.get_style_context().add_class("placeholder") + label.set_visible(True) + label.set_halign(Gtk.Align.START) + label.set_valign(Gtk.Align.START) + + if state: + label.set_text("No recent applications") + else: + label.set_text( + "Recent application saving disabled.\nUse Menu Settings to enable." + ) + + self.recent_list_box.set_placeholder(label) + + def add_new_recent_app(self, _widget, app_path: str): + """Add new "recent" record, based on .desktop file path given as string""" + if not self.recent_enabled: + return + + # only add if not exists, if exists: bump to top and return + for app_entry in self.recent_apps: + if app_entry.app_info.file_path.name == app_path: + self.recent_list_box.remove(app_entry) + self.recent_list_box.insert(app_entry, 0) + return + + app_info = self.desktop_file_manager.get_app_info_by_name(app_path) + if not app_info: + return + new_entry = SearchAppEntry(app_info, self.vm_manager) + self.recent_apps.append(new_entry) + self.recent_list_box.insert(new_entry, 0) + + if len(self.recent_apps) == self.APPS_TO_KEEP + 1: + last_row: SearchAppEntry = self.recent_list_box.get_children()[-1] + del self.recent_apps[last_row] + self.recent_list_box.remove(last_row) + + def _row_clicked(self, _widget, row: SearchAppEntry): + self._deselect_others() + if hasattr(row, "app_info"): + row.run_app(row.app_info.vm) + + class SearchPage(MenuPage): """ Helper class for managing the Search menu page. """ - def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, - desktop_file_manager: DesktopFileManager): + + def __init__( + self, + vm_manager: VMManager, + builder: Gtk.Builder, + desktop_file_manager: DesktopFileManager, + ): """ :param vm_manager: VM Manager object :param builder: Gtk.Builder with loaded glade object @@ -102,23 +231,24 @@ def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, self.page_widget: Gtk.Grid = builder.get_object("search_page") self.sort_running = False # sort running vms to top + self.recent_enabled = True - self.vm_list: Gtk.ListBox = builder.get_object('search_vm_list') - self.app_list: Gtk.ListBox = builder.get_object('search_app_list') - self.search_entry: Gtk.SearchEntry = builder.get_object('search_entry') + self.vm_list: Gtk.ListBox = builder.get_object("search_vm_list") + self.app_list: Gtk.ListBox = builder.get_object("search_app_list") + self.search_entry: Gtk.SearchEntry = builder.get_object("search_entry") self.selected_vm_row: Optional[SearchVMRow] = None self.filtered_vms: Set[str] = set() - self.main_notebook = builder.get_object('main_notebook') + self.main_notebook = builder.get_object("main_notebook") - self.search_entry.connect('search-changed', self._do_search) - self.search_entry.connect('key-press-event', self._search_key_press) + self.search_entry.connect("search-changed", self._do_search) + self.search_entry.connect("key-press-event", self._search_key_press) desktop_file_manager.register_callback(self._app_info_callback) self.app_list.set_filter_func(self._is_app_fitting) - self.app_list.connect('row-activated', self._app_clicked) + self.app_list.connect("row-activated", self._app_clicked) self.vm_list.add(AnyVMRow()) vm_manager.register_new_vm_callback(self._vm_callback) @@ -129,37 +259,47 @@ def __init__(self, vm_manager: VMManager, builder: Gtk.Builder, self.app_list.invalidate_sort() self.vm_list.invalidate_sort() - self.recent_list: Gtk.ListBox = builder.get_object('search_recent_list') + self.recent_list: Gtk.ListBox = builder.get_object("search_recent_list") + self.recent_app_list: Gtk.ListBox = builder.get_object( + "search_recent_apps_list" + ) - self.app_view: Gtk.ScrolledWindow = \ - builder.get_object("search_app_view") - self.app_placeholder: Gtk.Label = \ - builder.get_object('search_app_placeholder') + self.app_view: Gtk.ScrolledWindow = builder.get_object("search_app_view") + self.app_placeholder: Gtk.Label = builder.get_object("search_app_placeholder") self.vm_view: Gtk.ScrolledWindow = builder.get_object("search_vm_view") - self.recent_view: Gtk.ScrolledWindow = \ - builder.get_object("search_recent_view") - self.recent_title: Gtk.Label = builder.get_object('search_recent_title') + self.recent_box: Gtk.Box = builder.get_object("search_no_box") self.recent_search_manager = RecentSearchManager( - self.recent_list, self.search_entry) - - self.vm_list.connect('row-selected', self._selection_changed) - self.search_entry.connect('activate', self._move_to_first) + self.recent_list, + self.search_entry, + self.recent_enabled, + [self.recent_app_list], + ) + self.recent_apps_manager = RecentAppsManager( + self.recent_app_list, + self.desktop_file_manager, + self.vm_manager, + self.recent_enabled, + [self.recent_list], + ) + + self.vm_list.connect("row-selected", self._selection_changed) + self.search_entry.connect("activate", self._move_to_first) self.control_list = ControlList(self) self.page_widget.attach(self.control_list, 1, 4, 1, 1) - self.control_list.connect('row-activated', self._app_clicked) + self.control_list.connect("row-activated", self._app_clicked) self.control_list.set_selection_mode(Gtk.SelectionMode.NONE) self.keynav_manager = KeynavController( - widgets_in_order=[self.app_list, self.control_list]) + widgets_in_order=[self.app_list, self.control_list] + ) def _app_clicked(self, _widget, row): - self.recent_search_manager.add_new_recent_search( - self.search_entry.get_text()) + self.recent_search_manager.add_new_recent_search(self.search_entry.get_text()) if self.selected_vm_row: row.run_app(self.selected_vm_row.vm_entry.vm) - elif hasattr(row, 'app_info'): + elif hasattr(row, "app_info"): row.run_app(row.app_info.vm) def _app_info_callback(self, app_info): @@ -184,13 +324,11 @@ def _vm_callback(self, vm_entry: VMEntry): def _do_search(self, *_args): has_search = bool(self.search_entry.get_text()) self.app_view.set_visible(has_search) - self.recent_view.set_visible(not has_search) - self.recent_title.set_visible(not has_search) + self.recent_box.set_visible(not has_search) self._filter_lists() - self.vm_view.set_visible(has_search and - not self.app_placeholder.get_mapped()) + self.vm_view.set_visible(has_search and not self.app_placeholder.get_mapped()) if not self.app_placeholder.get_mapped(): for row in self.app_list.get_children(): @@ -297,17 +435,49 @@ def initialize_page(self): """ Initialize own state. """ - self.search_entry.set_text('') + self.search_entry.set_text("") self.app_list.select_row(None) self.vm_list.select_row(None) self.app_view.set_visible(False) self.vm_view.set_visible(False) - self.recent_view.set_visible(True) + self.recent_box.set_visible(True) self._filter_lists() self.search_entry.grab_focus_without_selecting() + def enable_recent(self, state: bool): + """Enable/disable storing recent apps/searches""" + self.recent_enabled = state + self.recent_search_manager.set_recent_enabled(state) + self.recent_apps_manager.set_recent_enabled(state) + + app_label = Gtk.Label() + app_label.get_style_context().add_class("placeholder") + search_label = Gtk.Label() + search_label.get_style_context().add_class("placeholder") + app_label.set_visible(True) + search_label.set_visible(True) + + if state: + app_label.set_text( + "No recent applications. \nUse Menu Settings to " + "disable recent applications." + ) + search_label.set_text( + "No recent searches. \nUse Menu Settings to disable recent searches." + ) + else: + app_label.set_text( + "Recent application saving disabled.\nUse Menu Settings to enable." + ) + search_label.set_text( + "Recent searches saving disabled.\nUse Menu Settings to enable." + ) + + self.recent_app_list.set_placeholder(app_label) + self.recent_list.set_placeholder(search_label) + def reset_page(self): """Reset page after hiding the menu.""" self.initialize_page() diff --git a/qubes_menu/settings_page.py b/qubes_menu/settings_page.py index cd69428..ffc6351 100644 --- a/qubes_menu/settings_page.py +++ b/qubes_menu/settings_page.py @@ -28,7 +28,8 @@ from .page_handler import MenuPage import gi -gi.require_version('Gtk', '3.0') + +gi.require_version("Gtk", "3.0") from gi.repository import Gtk @@ -37,13 +38,14 @@ class SettingsCategoryRow(custom_widgets.HoverListBox): A custom widget representing a category of Settings; selects itself on hover. """ + def __init__(self, name, filter_func): super().__init__() self.name = name self.label = custom_widgets.LimitedWidthLabel(self.name) self.main_box.add(self.label) self.filter_func = filter_func - self.get_style_context().add_class('settings_category_row') + self.get_style_context().add_class("settings_category_row") self.show_all() @@ -51,31 +53,37 @@ class SettingsPage(MenuPage): """ Helper class for managing the entirety of Settings menu page. """ - def __init__(self, qapp, builder: Gtk.Builder, - desktop_file_manager: DesktopFileManager, - dispatcher: qubesadmin.events.EventsDispatcher): + + def __init__( + self, + qapp, + builder: Gtk.Builder, + desktop_file_manager: DesktopFileManager, + dispatcher: qubesadmin.events.EventsDispatcher, + ): self.qapp = qapp self.desktop_file_manager = desktop_file_manager self.dispatcher = dispatcher self.page_widget: Gtk.Box = builder.get_object("settings_page") - self.app_list: Gtk.ListBox = builder.get_object('sys_tools_list') - self.app_list.connect('row-activated', self._app_clicked) + self.app_list: Gtk.ListBox = builder.get_object("sys_tools_list") + self.app_list.connect("row-activated", self._app_clicked) self.app_list.set_sort_func( - lambda x, y: x.app_info.sort_name > y.app_info.sort_name) + lambda x, y: x.app_info.sort_name > y.app_info.sort_name + ) self.app_list.set_filter_func(self._filter_apps) - self.category_list: Gtk.ListBox = builder.get_object( - 'settings_categories') + self.category_list: Gtk.ListBox = builder.get_object("settings_categories") - self.category_list.connect('row-selected', self._category_clicked) - self.category_list.add(SettingsCategoryRow('Qubes Tools', - self._filter_qubes_tools)) + self.category_list.connect("row-selected", self._category_clicked) + self.category_list.add( + SettingsCategoryRow("Qubes Tools", self._filter_qubes_tools) + ) self.category_list.add( - SettingsCategoryRow('System Settings', - self._filter_system_settings)) - self.category_list.add(SettingsCategoryRow('Other', self._filter_other)) + SettingsCategoryRow("System Settings", self._filter_system_settings) + ) + self.category_list.add(SettingsCategoryRow("Other", self._filter_other)) self.desktop_file_manager.register_callback(self._app_info_callback) @@ -88,30 +96,32 @@ def initialize_page(self): self.category_list.select_row(None) def _filter_apps(self, row): - filter_func = getattr(self.category_list.get_selected_row(), - 'filter_func', None) + filter_func = getattr( + self.category_list.get_selected_row(), "filter_func", None + ) if not filter_func: return False return filter_func(row) @staticmethod def _filter_qubes_tools(row): - if 'X-XFCE-SettingsDialog' not in row.app_info.categories: + if "X-XFCE-SettingsDialog" not in row.app_info.categories: return False - return 'qubes' in row.app_info.entry_name + return "qubes" in row.app_info.entry_name @staticmethod def _filter_system_settings(row): - if 'X-XFCE-SettingsDialog' in row.app_info.categories: - return 'qubes' not in row.app_info.entry_name - if 'Settings' in row.app_info.categories: + if "X-XFCE-SettingsDialog" in row.app_info.categories: + return "qubes" not in row.app_info.entry_name + if "Settings" in row.app_info.categories: return True return False @staticmethod def _filter_other(row): - return not SettingsPage._filter_qubes_tools(row) and \ - not SettingsPage._filter_system_settings(row) + return not SettingsPage._filter_qubes_tools( + row + ) and not SettingsPage._filter_system_settings(row) def _category_clicked(self, *_args): self.app_list.invalidate_filter() diff --git a/qubes_menu/tests/test_appmenu.py b/qubes_menu/tests/test_appmenu.py index 766582e..b51ccf7 100644 --- a/qubes_menu/tests/test_appmenu.py +++ b/qubes_menu/tests/test_appmenu.py @@ -29,6 +29,7 @@ def test_app_menu_conffeatures(): qapp._qubes['dom0'].features['menu-initial-page'] = 'favorites_page' qapp._qubes['dom0'].features['menu-sort-running'] = '1' qapp._qubes['dom0'].features['menu-position'] = '' + qapp._qubes['dom0'].features['menu-disable-recent'] = '1' qapp.update_vm_calls() dispatcher = MockDispatcher(qapp) @@ -40,6 +41,7 @@ def test_app_menu_conffeatures(): assert app_menu.initial_page == "favorites_page" assert app_menu.sort_running assert app_menu.appmenu_position == "mouse" + assert app_menu.disable_recent == True def test_app_menu_conffeatures_default(): @@ -51,7 +53,9 @@ def test_app_menu_conffeatures_default(): features={'menu-favorites': '', 'menu-initial-page': 'fake', 'menu-sort-running': 'fake', - 'menu-position': 'fake'}) + 'menu-position': 'fake', + 'menu-disable-recent': '' + }) qapp.update_vm_calls() dispatcher = MockDispatcher(qapp) @@ -63,6 +67,7 @@ def test_app_menu_conffeatures_default(): assert app_menu.initial_page == "app_page" assert not app_menu.sort_running assert app_menu.appmenu_position == "mouse" + assert not app_menu.disable_recent def test_appmenu_options(): @@ -73,6 +78,7 @@ def test_appmenu_options(): qapp._qubes['dom0'].features['menu-initial-page'] = 'app_page' qapp._qubes['dom0'].features['menu-sort-running'] = '1' qapp._qubes['dom0'].features['menu-position'] = 'top-left' + qapp._qubes['dom0'].features['menu-disable-recent'] = '' qapp.update_vm_calls() dispatcher = MockDispatcher(qapp) @@ -101,6 +107,7 @@ def test_appmenu_positioning(): qapp._qubes['dom0'].features['menu-initial-page'] = 'app_page' qapp._qubes['dom0'].features['menu-sort-running'] = '1' qapp._qubes['dom0'].features['menu-position'] = '' + qapp._qubes['dom0'].features['menu-disable-recent'] = '' qapp.update_vm_calls() dispatcher = MockDispatcher(qapp) diff --git a/qubes_menu/tests/test_search.py b/qubes_menu/tests/test_search.py index 308d61f..1dd0d42 100644 --- a/qubes_menu/tests/test_search.py +++ b/qubes_menu/tests/test_search.py @@ -24,7 +24,6 @@ from qubesadmin.tests.mock_app import MockDispatcher from ..search_page import SearchPage - def test_search(test_desktop_file_path, test_qapp, test_builder): dispatcher = MockDispatcher(test_qapp) vm_manager = VMManager(test_qapp, dispatcher) @@ -90,3 +89,71 @@ def test_search(test_desktop_file_path, test_qapp, test_builder): if search_page._is_app_fitting(row)] assert len(found_entries) == 1 assert found_entries[0].app_info.app_name == 'Xfce Appearance Settings' + +@mock.patch('gi.repository.Gtk.Application') +def test_recent_searches(mock_application, test_desktop_file_path, test_qapp, + test_builder): + dispatcher = MockDispatcher(test_qapp) + vm_manager = VMManager(test_qapp, dispatcher) + + with mock.patch.object(DesktopFileManager, 'desktop_dirs', + [test_desktop_file_path]): + desktop_file_manager = DesktopFileManager(test_qapp) + + search_page = SearchPage(vm_manager, test_builder, desktop_file_manager) + + assert search_page.search_entry.get_sensitive() + + search_page.search_entry.set_text('dragons') + + # find a dom0 app + search_page.search_entry.set_text('dom0') + + for row in search_page.app_list.get_children(): + if search_page._is_app_fitting(row): + with mock.patch('subprocess.Popen') as mock_run, mock.patch.object( + row.get_toplevel(), 'get_application', side_effect=mock_application): + row.activate() + assert mock_run.call_count == 1 + assert mock.call().emit('app-started', 'test3.desktop') in mock_application.mock_calls + + # we are faking signals here + search_page.recent_apps_manager.add_new_recent_app(None, 'test3.desktop') + + texts = [row.search_text for row in search_page.recent_list.get_children()] + assert texts == ['dom0'] + apps = [row.app_info.entry_name for row in + search_page.recent_app_list.get_children()] + assert apps == ['test3.desktop'] + + # do two more searches, but one should be the same as an existing search + search_page.search_entry.set_text('') + search_page.search_entry.set_text('xTeRm') + + for row in search_page.app_list.get_children(): + if search_page._is_app_fitting(row): + with mock.patch('subprocess.Popen') as mock_run, mock.patch.object( + row.get_toplevel(), 'get_application', side_effect=mock_application): + row.activate() + assert mock_run.call_count == 1 + assert mock.call().emit('app-started', 'test1.desktop') in mock_application.mock_calls + + search_page.recent_apps_manager.add_new_recent_app(None, 'test1.desktop') + search_page.search_entry.set_text('') + search_page.search_entry.set_text('dom0') + + for row in search_page.app_list.get_children(): + if search_page._is_app_fitting(row): + with mock.patch('subprocess.Popen') as mock_run, mock.patch.object( + row.get_toplevel(), 'get_application', side_effect=mock_application): + row.activate() + assert mock_run.call_count == 1 + assert mock.call().emit('app-started', 'test3.desktop') in mock_application.mock_calls + + search_page.recent_apps_manager.add_new_recent_app(None, 'test3.desktop') + + texts = [row.search_text for row in search_page.recent_list.get_children()] + assert texts == ['dom0', 'xTeRm'] + apps = [row.app_info.entry_name for row in + search_page.recent_app_list.get_children()] + assert apps == ['test3.desktop', 'test1.desktop'] diff --git a/qubes_menu/utils.py b/qubes_menu/utils.py index 885e0c0..5fed8a4 100644 --- a/qubes_menu/utils.py +++ b/qubes_menu/utils.py @@ -26,13 +26,15 @@ import qubesadmin.vm -gi.require_version('Gtk', '3.0') +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GdkPixbuf, GLib -def load_icon(icon_name, - size: Optional[Gtk.IconSize] = Gtk.IconSize.LARGE_TOOLBAR, - pixel_size: Optional[int] = None): +def load_icon( + icon_name, + size: Optional[Gtk.IconSize] = Gtk.IconSize.LARGE_TOOLBAR, + pixel_size: Optional[int] = None, +): """Load icon from provided name, if available. If not, attempt to treat provided name as a path. If icon not found in any of the above ways, load a blank icon of specified size. @@ -49,30 +51,32 @@ def load_icon(icon_name, try: # icon name is a path image: GdkPixbuf.Pixbuf = Gtk.IconTheme.get_default().load_icon( - icon_name, width, Gtk.IconLookupFlags.FORCE_SIZE) + icon_name, width, Gtk.IconLookupFlags.FORCE_SIZE + ) return image except (TypeError, GLib.Error): # icon not found in any way pixbuf: GdkPixbuf.Pixbuf = GdkPixbuf.Pixbuf.new( - GdkPixbuf.Colorspace.RGB, True, 8, width, height) + GdkPixbuf.Colorspace.RGB, True, 8, width, height + ) pixbuf.fill(0x000) return pixbuf + def show_error(title, text): """ Helper function to display error messages. """ - dialog = Gtk.MessageDialog( - None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK) + dialog = Gtk.MessageDialog(None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK) dialog.set_title(title) dialog.set_markup(GLib.markup_escape_text(text)) dialog.connect("response", lambda *x: dialog.destroy()) dialog.show() + def parse_search(search_text: str) -> List[str]: """Parse search text into separate words""" - search_words = search_text.lower().replace( - '-', ' ').replace('_', ' ').split(' ') + search_words = search_text.lower().replace("-", " ").replace("_", " ").split(" ") return [w for w in search_words if w] @@ -92,8 +96,9 @@ def text_search(search_word: str, text_words: List[str]): return 0 -def highlight_words(labels: List[Gtk.Label], search_words: List[str], - hl_tag: Optional[str] = None) -> None: +def highlight_words( + labels: List[Gtk.Label], search_words: List[str], hl_tag: Optional[str] = None +) -> None: """Highlight provided search_words in the provided labels.""" if not labels: return @@ -123,13 +128,14 @@ def highlight_words(labels: List[Gtk.Label], search_words: List[str], if not found_intervals: continue - found_intervals.sort(key= lambda x: x[0]) + found_intervals.sort(key=lambda x: x[0]) result_intervals = [found_intervals[0]] for interval in found_intervals[1:]: if interval[0] <= result_intervals[-1][1]: - result_intervals[-1] = \ - (result_intervals[-1][0], - max(result_intervals[-1][1], interval[1])) + result_intervals[-1] = ( + result_intervals[-1][0], + max(result_intervals[-1][1], interval[1]), + ) else: result_intervals.append(interval) @@ -139,7 +145,7 @@ def highlight_words(labels: List[Gtk.Label], search_words: List[str], markup_list.append(GLib.markup_escape_text(text[last_start:start])) markup_list.append(hl_tag) markup_list.append(GLib.markup_escape_text(text[start:end])) - markup_list.append('') + markup_list.append("") last_start = end markup_list.append(GLib.markup_escape_text(text[last_start:])) @@ -166,24 +172,23 @@ def add_to_feature(vm: qubesadmin.vm.QubesVM, feature_name: str, text: str): """ current_feature = vm.features.get(feature_name) if current_feature: - feature_list = current_feature.split(' ') + feature_list = current_feature.split(" ") else: feature_list = [] if text in feature_list: return feature_list.append(text) - vm.features[feature_name] = ' '.join(feature_list) + vm.features[feature_name] = " ".join(feature_list) -def remove_from_feature(vm: qubesadmin.vm.QubesVM, - feature_name: str, text: str): +def remove_from_feature(vm: qubesadmin.vm.QubesVM, feature_name: str, text: str): """ Remove a given string to a feature containing a list of space-separated strings.Can raise ValueError if ext was not found in the feature. """ - current_feature = vm.features.get(feature_name, '').split(' ') + current_feature = vm.features.get(feature_name, "").split(" ") current_feature.remove(text) - vm.features[feature_name] = ' '.join(current_feature) + vm.features[feature_name] = " ".join(current_feature) diff --git a/qubes_menu/vm_manager.py b/qubes_menu/vm_manager.py index 98953bd..7cb25d2 100644 --- a/qubes_menu/vm_manager.py +++ b/qubes_menu/vm_manager.py @@ -57,12 +57,8 @@ def __init__(self, vm: QubesVM): except qubesadmin.exc.QubesDaemonAccessError: self._internal = False self._servicevm = bool(self.vm.features.get("servicevm", False)) - self._is_dispvm_template = getattr( - self.vm, "template_for_dispvms", False - ) - self._has_network = ( - self.vm.is_networked() if vm.klass != "AdminVM" else False - ) + self._is_dispvm_template = getattr(self.vm, "template_for_dispvms", False) + self._has_network = self.vm.is_networked() if vm.klass != "AdminVM" else False self._vm_icon_name = getattr( self.vm, "icon", getattr(self.vm.label, "icon", None) ) @@ -179,20 +175,14 @@ def show_in_apps(self): def _escaped_name(self) -> str: """Name escaped according to rules from desktop-linux-common package""" - return ( - self.vm_name.replace("_", "_u") - .replace("-", "_d") - .replace(".", "_p") - ) + return self.vm_name.replace("_", "_u").replace("-", "_d").replace(".", "_p") @property def settings_desktop_file_name(self) -> str: """ Name of relevant .desktop vm settings file. """ - return ( - "org.qubes-os.qubes-vm-settings._" + self._escaped_name + ".desktop" - ) + return "org.qubes-os.qubes-vm-settings._" + self._escaped_name + ".desktop" @property def start_vm_desktop_file_name(self) -> str: @@ -275,9 +265,7 @@ def _update_domain_state(self, vm_name, event, **_kwargs): state = constants.STATE_DICTIONARY[event] vm_entry.power_state = state - def _update_domain_property( - self, vm_name, event, newvalue, *_args, **_kwargs - ): + def _update_domain_property(self, vm_name, event, newvalue, *_args, **_kwargs): vm_entry = self.load_vm_from_name(vm_name) if not vm_entry: @@ -298,9 +286,7 @@ def _update_domain_property( # it will disable any future event handling pass - def _update_domain_feature( - self, vm, _event, feature=None, value=None, **_kwargs - ): + def _update_domain_feature(self, vm, _event, feature=None, value=None, **_kwargs): vm_entry = self.load_vm_from_name(vm) if not vm_entry: @@ -339,36 +325,20 @@ def _update_domain_feature( def register_events(self): """Register handlers for all relevant VM events.""" - self.dispatcher.add_handler( - "domain-pre-start", self._update_domain_state - ) + self.dispatcher.add_handler("domain-pre-start", self._update_domain_state) self.dispatcher.add_handler("domain-start", self._update_domain_state) - self.dispatcher.add_handler( - "domain-start-failed", self._update_domain_state - ) + self.dispatcher.add_handler("domain-start-failed", self._update_domain_state) self.dispatcher.add_handler("domain-paused", self._update_domain_state) - self.dispatcher.add_handler( - "domain-unpaused", self._update_domain_state - ) - self.dispatcher.add_handler( - "domain-shutdown", self._update_domain_state - ) - self.dispatcher.add_handler( - "domain-pre-shutdown", self._update_domain_state - ) - self.dispatcher.add_handler( - "domain-shutdown-failed", self._update_domain_state - ) + self.dispatcher.add_handler("domain-unpaused", self._update_domain_state) + self.dispatcher.add_handler("domain-shutdown", self._update_domain_state) + self.dispatcher.add_handler("domain-pre-shutdown", self._update_domain_state) + self.dispatcher.add_handler("domain-shutdown-failed", self._update_domain_state) self.dispatcher.add_handler("domain-add", self._add_domain) self.dispatcher.add_handler("domain-delete", self._remove_domain) - self.dispatcher.add_handler( - "property-set:netvm", self._update_domain_property - ) - self.dispatcher.add_handler( - "property-set:label", self._update_domain_property - ) + self.dispatcher.add_handler("property-set:netvm", self._update_domain_property) + self.dispatcher.add_handler("property-set:label", self._update_domain_property) self.dispatcher.add_handler( "property-set:template_for_dispvms", self._update_domain_property ) diff --git a/qubes_menu_settings/menu_settings.glade b/qubes_menu_settings/menu_settings.glade index 80268ae..3bd0fb8 100644 --- a/qubes_menu_settings/menu_settings.glade +++ b/qubes_menu_settings/menu_settings.glade @@ -9,7 +9,7 @@ appmenu-settings-program-icon center - + True False 10 @@ -32,7 +32,7 @@ - + True False 3 @@ -86,7 +86,7 @@ - + True False 3 @@ -141,7 +141,7 @@ - + True False @@ -187,7 +187,21 @@ - + + Show recent applications/search queries + True + True + False + True + + + False + True + 4 + + + + True False end @@ -250,7 +264,7 @@ False True - 4 + 5 diff --git a/qubes_menu_settings/menu_settings.py b/qubes_menu_settings/menu_settings.py index cfd07bf..2094da3 100644 --- a/qubes_menu_settings/menu_settings.py +++ b/qubes_menu_settings/menu_settings.py @@ -29,30 +29,35 @@ from qubes_config.widgets.gtk_widgets import ImageListModeler -gi.require_version('Gtk', '3.0') +gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk -from qubes_menu.constants import INITIAL_PAGE_FEATURE, SORT_RUNNING_FEATURE, \ - POSITION_FEATURE +from qubes_menu.constants import ( + INITIAL_PAGE_FEATURE, + SORT_RUNNING_FEATURE, + POSITION_FEATURE, + DISABLE_RECENT_FEATURE, +) MENU_PAGES = { "search_page": "Search", "app_page": "Applications", - "favorites_page": "Favorites" + "favorites_page": "Favorites", } MENU_PAGES_DICT = { "Search": {"icon": "qappmenu-search", "object": "search_page"}, "Applications": {"icon": "qappmenu-qube", "object": "app_page"}, - "Favorites": {"icon": "qappmenu-favorites", "object": "favorites_page"}} + "Favorites": {"icon": "qappmenu-favorites", "object": "favorites_page"}, +} MENU_POSITIONS = { "top-left": "Top Left", "top-right": "Top Right", "bottom-left": "Bottom Left", "bottom-right": "Bottom Right", - "mouse": "Mouse" + "mouse": "Mouse", } MENU_POSITIONS_DICT = { @@ -60,17 +65,20 @@ "Top Right": {"icon": "qappmenu-top-right", "object": "top-right"}, "Bottom Left": {"icon": "qappmenu-bottom-left", "object": "bottom-left"}, "Bottom Right": {"icon": "qappmenu-bottom-right", "object": "bottom-right"}, - "Mouse": {"icon": "input-mouse-symbolic", "object": "mouse"}} + "Mouse": {"icon": "input-mouse-symbolic", "object": "mouse"}, +} + class AppMenuSettings(Gtk.Application): """ Qubes Menu Settings app. """ + def __init__(self, qapp: qubesadmin.Qubes): """ :param qapp: qubesadmin.Qubes object """ - super().__init__(application_id='org.qubesos.appmenusettings') + super().__init__(application_id="org.qubesos.appmenusettings") self.qapp = qapp self.vm = self.qapp.domains[self.qapp.local_name] @@ -92,49 +100,57 @@ def perform_setup(self): """ self.builder = Gtk.Builder() - glade_path = (importlib.resources.files('qubes_menu_settings') / - 'menu_settings.glade') + glade_path = ( + importlib.resources.files("qubes_menu_settings") / "menu_settings.glade" + ) with importlib.resources.as_file(glade_path) as path: self.builder.add_from_file(str(path)) - self.main_window : Gtk.ApplicationWindow = \ - self.builder.get_object('main_window') + self.main_window: Gtk.ApplicationWindow = self.builder.get_object("main_window") - self.confirm_button: Gtk.Button = \ - self.builder.get_object('button_confirm') - self.apply_button: Gtk.Button = self.builder.get_object('button_apply') - self.cancel_button: Gtk.Button = \ - self.builder.get_object('button_cancel') + self.confirm_button: Gtk.Button = self.builder.get_object("button_confirm") + self.apply_button: Gtk.Button = self.builder.get_object("button_apply") + self.cancel_button: Gtk.Button = self.builder.get_object("button_cancel") - self.starting_page_combo: Gtk.ComboBox = \ - self.builder.get_object("starting_page_combo") + self.starting_page_combo: Gtk.ComboBox = self.builder.get_object( + "starting_page_combo" + ) - self.menu_position_combo: Gtk.ComboBox = \ - self.builder.get_object("menu_position_combo") + self.menu_position_combo: Gtk.ComboBox = self.builder.get_object( + "menu_position_combo" + ) - self.sort_running_check: Gtk.CheckButton = \ - self.builder.get_object("sort_running_to_top_check") + self.sort_running_check: Gtk.CheckButton = self.builder.get_object( + "sort_running_to_top_check" + ) + self.show_recent_check: Gtk.CheckButton = self.builder.get_object( + "show_recent_apps_check" + ) screen = Gdk.Screen.get_default() provider = Gtk.CssProvider() - css_path = (importlib.resources.files('qubes_menu_settings') / - 'menu_settings.css') + css_path = ( + importlib.resources.files("qubes_menu_settings") / "menu_settings.css" + ) with importlib.resources.as_file(css_path) as path: provider.load_from_path(str(path)) Gtk.StyleContext.add_provider_for_screen( - screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) self.confirm_button.connect("clicked", self._save_exit) self.apply_button.connect("clicked", self._save) self.cancel_button.connect("clicked", self._quit) self.initial_page_model = ImageListModeler( - self.starting_page_combo, MENU_PAGES_DICT) + self.starting_page_combo, MENU_PAGES_DICT + ) self.menu_position_model = ImageListModeler( - self.menu_position_combo, MENU_POSITIONS_DICT) + self.menu_position_combo, MENU_POSITIONS_DICT + ) self.load_state() @@ -156,11 +172,12 @@ def load_state(self): self.menu_position_model.select_name(MENU_POSITIONS[menu_position]) self.menu_position_model.update_initial() - # this can sometimes be None, thus, the "or False) - sort_running = \ - bool(self.vm.features.get(SORT_RUNNING_FEATURE, False)) + sort_running = bool(self.vm.features.get(SORT_RUNNING_FEATURE, False)) self.sort_running_check.set_active(sort_running) + disable_recent = bool(self.vm.features.get(DISABLE_RECENT_FEATURE, False)) + self.show_recent_check.set_active(not disable_recent) + def _quit(self, *_args): self.quit() @@ -175,19 +192,26 @@ def _save(self, *_args): if old_sort_running: del self.vm.features[SORT_RUNNING_FEATURE] - old_initial_page = self.vm.features.get(INITIAL_PAGE_FEATURE, - "app_page") + old_disable_recent = self.vm.features.get(DISABLE_RECENT_FEATURE, None) + + if self.show_recent_check.get_active(): + if old_disable_recent: + del self.vm.features[DISABLE_RECENT_FEATURE] + else: + if not old_sort_running: + self.vm.features[DISABLE_RECENT_FEATURE] = "1" + + old_initial_page = self.vm.features.get(INITIAL_PAGE_FEATURE, "app_page") if self.initial_page_model.get_selected() != old_initial_page: - self.vm.features[INITIAL_PAGE_FEATURE] = \ + self.vm.features[INITIAL_PAGE_FEATURE] = ( self.initial_page_model.get_selected() + ) - old_menu_position = self.vm.features.get(POSITION_FEATURE, - "mouse") + old_menu_position = self.vm.features.get(POSITION_FEATURE, "mouse") if self.menu_position_model.get_selected() != old_menu_position: - self.vm.features[POSITION_FEATURE] = \ - self.menu_position_model.get_selected() + self.vm.features[POSITION_FEATURE] = self.menu_position_model.get_selected() def _save_exit(self, *_args): self._save() @@ -203,5 +227,5 @@ def main(): app.run() -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/qubes_menu_settings/test_menu_settings.py b/qubes_menu_settings/test_menu_settings.py index daf5bc0..1d381b3 100644 --- a/qubes_menu_settings/test_menu_settings.py +++ b/qubes_menu_settings/test_menu_settings.py @@ -24,10 +24,11 @@ def test_menu_settings_load(): qapp = MockQubesComplete() - qapp._qubes['dom0'].features['menu-initial-page'] = 'favorites_page' - qapp._qubes['dom0'].features['menu-sort-running'] = '1' - qapp._qubes['dom0'].features['menu-favorites'] = '' - qapp._qubes['dom0'].features['menu-position'] = '' + qapp._qubes["dom0"].features["menu-initial-page"] = "favorites_page" + qapp._qubes["dom0"].features["menu-sort-running"] = "1" + qapp._qubes["dom0"].features["menu-favorites"] = "" + qapp._qubes["dom0"].features["menu-position"] = "" + qapp._qubes["dom0"].features["menu-disable-recent"] = "1" qapp.update_vm_calls() @@ -38,14 +39,16 @@ def test_menu_settings_load(): assert app.initial_page_model.get_selected() == "favorites_page" assert app.menu_position_model.get_selected() == "mouse" assert app.sort_running_check.get_active() + assert not app.show_recent_check.get_active() def test_menu_settings_change(): qapp = MockQubesComplete() - qapp._qubes['dom0'].features['menu-initial-page'] = 'app_page' - qapp._qubes['dom0'].features['menu-sort-running'] = '' - qapp._qubes['dom0'].features['menu-favorites'] = '' - qapp._qubes['dom0'].features['menu-position'] = 'mouse' + qapp._qubes["dom0"].features["menu-initial-page"] = "app_page" + qapp._qubes["dom0"].features["menu-sort-running"] = "" + qapp._qubes["dom0"].features["menu-favorites"] = "" + qapp._qubes["dom0"].features["menu-position"] = "mouse" + qapp._qubes["dom0"].features["menu-disable-recent"] = "1" qapp.update_vm_calls() @@ -56,24 +59,36 @@ def test_menu_settings_change(): assert app.initial_page_model.get_selected() == "app_page" assert app.menu_position_model.get_selected() == "mouse" assert not app.sort_running_check.get_active() + assert not app.show_recent_check.get_active() app.starting_page_combo.set_active_id("Search") # the first option is search app.menu_position_combo.set_active_id("Top Left") # the first option is Top Left app.sort_running_check.set_active(True) - - qapp.expected_calls[('dom0', 'admin.vm.feature.Set', 'menu-sort-running', b'1')] = b'0\0' - qapp.expected_calls[('dom0', 'admin.vm.feature.Set', 'menu-initial-page', b'search_page')] = b'0\0' - qapp.expected_calls[('dom0', 'admin.vm.feature.Set', 'menu-position', b'top-left')] = b'0\0' + app.show_recent_check.set_active(True) + + qapp.expected_calls[("dom0", "admin.vm.feature.Set", "menu-sort-running", b"1")] = ( + b"0\0" + ) + qapp.expected_calls[ + ("dom0", "admin.vm.feature.Set", "menu-initial-page", b"search_page") + ] = b"0\0" + qapp.expected_calls[ + ("dom0", "admin.vm.feature.Set", "menu-position", b"top-left") + ] = b"0\0" + qapp.expected_calls[ + ("dom0", "admin.vm.feature.Remove", "menu-disable-recent", None) + ] = b"0\0" app._save() def test_menu_settings_change2(): qapp = MockQubesComplete() - qapp._qubes['dom0'].features['menu-initial-page'] = 'app_page' - qapp._qubes['dom0'].features['menu-sort-running'] = '' - qapp._qubes['dom0'].features['menu-favorites'] = '' - qapp._qubes['dom0'].features['menu-position'] = 'mouse' + qapp._qubes["dom0"].features["menu-initial-page"] = "app_page" + qapp._qubes["dom0"].features["menu-sort-running"] = "" + qapp._qubes["dom0"].features["menu-favorites"] = "" + qapp._qubes["dom0"].features["menu-position"] = "mouse" + qapp._qubes["dom0"].features["menu-disable-recent"] = "" qapp.update_vm_calls() @@ -83,9 +98,16 @@ def test_menu_settings_change2(): assert app.initial_page_model.get_selected() == "app_page" assert not app.sort_running_check.get_active() + assert app.show_recent_check.get_active() app.starting_page_combo.set_active_id("Favorites") - - qapp.expected_calls[('dom0', 'admin.vm.feature.Set', 'menu-initial-page', b'favorites_page')] = b'0\0' + app.show_recent_check.set_active(False) + + qapp.expected_calls[ + ("dom0", "admin.vm.feature.Set", "menu-initial-page", b"favorites_page") + ] = b"0\0" + qapp.expected_calls[ + ("dom0", "admin.vm.feature.Set", "menu-disable-recent", b"1") + ] = b"0\0" app._save() diff --git a/rpm_spec/qubes-desktop-linux-menu.spec.in b/rpm_spec/qubes-desktop-linux-menu.spec.in index 8e7d896..66e878a 100644 --- a/rpm_spec/qubes-desktop-linux-menu.spec.in +++ b/rpm_spec/qubes-desktop-linux-menu.spec.in @@ -143,6 +143,10 @@ gtk-update-icon-cache %{_datadir}/icons/hicolor &>/dev/null || : /usr/share/icons/hicolor/scalable/apps/qappmenu-top-right.svg /usr/share/icons/hicolor/scalable/apps/qappmenu-bottom-left.svg /usr/share/icons/hicolor/scalable/apps/qappmenu-bottom-right.svg +/usr/share/icons/hicolor/scalable/apps/qappmenu-az.svg +/usr/share/icons/hicolor/scalable/apps/qappmenu-za.svg +/usr/share/icons/hicolor/scalable/apps/qappmenu-qube-az.svg +/usr/share/icons/hicolor/scalable/apps/qappmenu-qube-za.svg /usr/share/icons/hicolor/scalable/apps/appmenu-settings-program-icon.svg /usr/share/icons/hicolor/scalable/apps/settings-black.svg /usr/share/icons/hicolor/scalable/apps/settings-blue.svg