diff --git a/settings-gui/core/recommendations.py b/settings-gui/core/recommendations.py new file mode 100644 index 0000000..993162a --- /dev/null +++ b/settings-gui/core/recommendations.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2026 Nguyen Hoang Ky +# +# SPDX-License-Identifier: GPL-3.0-or-later +""" +Recommendation engine for per-application input modes. +This module helps users choose the best input mode for specific applications. +""" + +import re + +# Mode constants (source of truth for GUI) +MODE_OFF = 0 +MODE_SMOOTH = 1 +MODE_SLOW = 2 +MODE_HARDCORE = 3 +MODE_SURROUNDING = 4 +MODE_PREEDIT = 5 +MODE_EMOJI = 6 +MODE_DEFAULT = -1 + +# USER: Group recommendations by category for better readability. +# Status values: "good" (Recommended), "bad" (Poor compatibility) +APP_RECOMMENDATIONS = { + "Firefox-based": { + "pattern": re.compile(r"firefox|librewolf|waterfox|floorp|zen", re.IGNORECASE), + "recommendations": { + MODE_SMOOTH: "good", + MODE_SLOW: "good", + MODE_HARDCORE: "good", + MODE_SURROUNDING: "good", + MODE_PREEDIT: "good", + } + }, + "Chromium-based": { + "pattern": re.compile(r"chrome|chromium|brave|vivaldi|opera|edge", re.IGNORECASE), + "recommendations": { + MODE_SMOOTH: "good", + MODE_SLOW: "good", + MODE_HARDCORE: "good", + MODE_SURROUNDING: "bad", + MODE_PREEDIT: "good", + } + } +} + +def get_recommendation(app_name: str, mode: int) -> str | None: + """ + Looks up a recommendation status for a given app and mode. + Returns: "good", "bad", or None + """ + if not app_name: + return None + + app_lower = app_name.lower() + + for _, data in APP_RECOMMENDATIONS.items(): + if data["pattern"].search(app_lower): + return data["recommendations"].get(mode) + + return None diff --git a/settings-gui/ui/pages/base_editor.py b/settings-gui/ui/pages/base_editor.py index bf278fc..a871b8f 100644 --- a/settings-gui/ui/pages/base_editor.py +++ b/settings-gui/ui/pages/base_editor.py @@ -145,3 +145,13 @@ def on_remove(self): self.update_button_states() self._on_item_changed() + + def _find_row_by_key(self, key: str) -> int | None: + """Finds row index for a given key in the first column. Returns None if not found.""" + if not self.table: + return None + for r in range(self.table.rowCount()): + item = self.table.item(r, 0) + if item and item.text() == key: + return r + return None diff --git a/settings-gui/ui/pages/keymap_editor.py b/settings-gui/ui/pages/keymap_editor.py index 512fda2..f1481f4 100644 --- a/settings-gui/ui/pages/keymap_editor.py +++ b/settings-gui/ui/pages/keymap_editor.py @@ -449,13 +449,6 @@ def upsert_row(self, key: str, action_code: str): self.update_button_states() self._on_item_changed() - def _find_row_by_key(self, key: str) -> int | None: - """Finds row index for a given key. Returns None if not found.""" - for r in range(self.table.rowCount()): - item = self.table.item(r, 0) - if item and item.text() == key: - return r - return None def _update_add_button_icon(self, *_args): """Changes the Add button icon to Update if key exists.""" diff --git a/settings-gui/ui/pages/macro_editor.py b/settings-gui/ui/pages/macro_editor.py index dedceb4..1762ae1 100644 --- a/settings-gui/ui/pages/macro_editor.py +++ b/settings-gui/ui/pages/macro_editor.py @@ -323,13 +323,6 @@ def save_data(self): self.dbus.set_sub_config_list("lotus-macro", "Macro", data) self.initial_state = self._get_current_state() - def _find_row_by_key(self, key: str) -> int | None: - """Finds row index for a given key. Returns None if not found.""" - for r in range(self.table.rowCount()): - item = self.table.item(r, 0) - if item and item.text() == key: - return r - return None def upsert_row(self, key: str, value: str, sort: bool = True): # Update existing @@ -349,7 +342,6 @@ def upsert_row(self, key: str, value: str, sort: bool = True): self.table.setItem(row, 0, QTableWidgetItem(key)) self.table.setItem(row, 1, QTableWidgetItem(value)) self._apply_row_highlight(row, key) - self.on_search_changed() if sort: self.on_search_changed() # Re-apply filter self.update_button_states() diff --git a/settings-gui/ui/pages/mode_manager.py b/settings-gui/ui/pages/mode_manager.py index 21423e1..a9d0106 100644 --- a/settings-gui/ui/pages/mode_manager.py +++ b/settings-gui/ui/pages/mode_manager.py @@ -30,16 +30,17 @@ from i18n import _ from ui.pages.dynamic_settings import CardWidget from core.dbus_handler import LotusDBusHandler - -# Mode constants as defined in C++ LotusEngine -MODE_OFF = 0 -MODE_SMOOTH = 1 -MODE_SLOW = 2 -MODE_HARDCORE = 3 -MODE_SURROUNDING = 4 -MODE_PREEDIT = 5 -MODE_EMOJI = 6 -MODE_DEFAULT = -1 # UI special value for "Use Global Default" +from core.recommendations import ( + get_recommendation, + MODE_OFF, + MODE_SMOOTH, + MODE_SLOW, + MODE_HARDCORE, + MODE_SURROUNDING, + MODE_PREEDIT, + MODE_EMOJI, + MODE_DEFAULT, +) MODE_INFO = { MODE_DEFAULT: {"title": "Default", "icon": "preferences-system"}, @@ -63,11 +64,23 @@ def __init__(self, mode: int, selected: bool = False, parent=None): super().__init__(parent) self.mode = mode self.selected = selected + self.rec_status = None self.setObjectName("ModeCard") self.setCursor(Qt.PointingHandCursor) self._setup_ui() self.update_style() + def set_recommendation(self, status): + """Sets the recommendation status for this card.""" + self.rec_status = status + self.update_style() + if status == "good": + self.setToolTip(_("Recommended for this application")) + elif status == "bad": + self.setToolTip(_("Poor compatibility with this application")) + else: + self.setToolTip("") + def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(10, 15, 10, 15) @@ -83,25 +96,59 @@ def _setup_ui(self): layout.addWidget(title_label) def update_style(self): + status = self.rec_status + + # Color constants + COLOR_GOOD = "#2ecc71" # Green + COLOR_BAD = "#e74c3c" # Red + if self.selected: + # Selected cards have a thicker border (2.5px) + border_color = "palette(highlight)" + if status == "good": border_color = COLOR_GOOD + elif status == "bad": border_color = COLOR_BAD + self.setStyleSheet( - """ - QFrame#ModeCard { - border: 1.5px solid palette(highlight); + f""" + QFrame#ModeCard {{ + border: 2px solid {border_color}; background: palette(highlight); - border-radius: 8px; - } - QLabel { color: palette(highlighted-text); } + border-radius: 10px; + padding: 0px; + }} + QLabel {{ color: palette(highlighted-text); font-weight: bold; }} """ ) else: + # Unselected cards have a thinner border (1px) + # We add 1px padding to keep the total size consistent with selected cards + border_color = "palette(mid)" + border_width = "1px" + padding = "1px" + + if status == "good": + border_color = COLOR_GOOD + border_width = "2px" + padding = "0px" + elif status == "bad": + border_color = COLOR_BAD + border_width = "2px" + padding = "0px" + self.setStyleSheet( - """ - QFrame#ModeCard { - border: 1.5px solid palette(mid); + f""" + QFrame#ModeCard {{ + border: {border_width} solid {border_color}; background: palette(alternate-base); - border-radius: 8px; - } + border-radius: 10px; + padding: {padding}; + }} + QFrame#ModeCard:hover {{ + border: 2px solid palette(highlight); + padding: 0px; + background: palette(base); + }} + QLabel#ModeCardTitle {{ color: {border_color if status else "palette(window-text)"}; }} """ ) @@ -403,8 +450,9 @@ def _setup_ui(self): global_layout.addWidget(QLabel(_("Global Default Mode:"))) self.combo_global_mode = QComboBox() global_modes = [ - MODE_OFF, MODE_SMOOTH, MODE_SLOW, MODE_HARDCORE, - MODE_SURROUNDING, MODE_PREEDIT, MODE_EMOJI + MODE_SMOOTH, MODE_SLOW, MODE_HARDCORE, + MODE_SURROUNDING, MODE_PREEDIT, + MODE_OFF, MODE_EMOJI ] for m in global_modes: self.combo_global_mode.addItem(_(MODE_INFO[m]["title"]), MODE_INFO[m]["title"]) @@ -436,10 +484,10 @@ def _setup_ui(self): self.mode_cards = {} grid_modes = [ - MODE_DEFAULT, MODE_OFF, MODE_SMOOTH, MODE_SLOW, MODE_HARDCORE, MODE_SURROUNDING, - MODE_PREEDIT, MODE_EMOJI + MODE_PREEDIT, MODE_DEFAULT, + MODE_OFF, MODE_EMOJI ] for i, m in enumerate(grid_modes): card = ModeCard(m) @@ -633,9 +681,27 @@ def _on_app_mode_changed(self, mode): self._notify_changed() def _update_mode_cards(self): + # Resolve global default mode if needed for recommendation + global_mode = MODE_SMOOTH + if self.combo_global_mode.currentIndex() >= 0: + global_mode_str = self.combo_global_mode.currentData() + # Map title back to mode constant + for m, info in MODE_INFO.items(): + if info["title"] == global_mode_str: + global_mode = m + break + for m, card in self.mode_cards.items(): card.selected = (m == self.current_app_mode) - card.update_style() + + # Show recommendations only for the currently selected app + if self.selected_app: + # If it's the "Default" card, show recommendation for the global default mode + lookup_mode = global_mode if m == MODE_DEFAULT else m + recommendation_status = get_recommendation(self.selected_app, lookup_mode) + card.set_recommendation(recommendation_status) + else: + card.set_recommendation(None) def _on_add_app(self): dialog = AddAppDialog(self._icon_cache, list(self.app_rules.keys()), self)