From eb147eff305847c722e23478e293aa2bf67bfd92 Mon Sep 17 00:00:00 2001 From: hthienloc Date: Mon, 30 Mar 2026 19:42:52 +0700 Subject: [PATCH 1/4] feat: implement application-specific mode recommendations with visual UI feedback in ModeCard --- settings-gui/core/recommendations.py | 60 +++++++++++++++++ settings-gui/ui/pages/mode_manager.py | 93 ++++++++++++++++++++++----- 2 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 settings-gui/core/recommendations.py diff --git a/settings-gui/core/recommendations.py b/settings-gui/core/recommendations.py new file mode 100644 index 0000000..207a30c --- /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 (should match mode_manager.py and 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 + +# USER: Group recommendations by category for better readability. +# Status values: "good" (Recommended), "bad" (Poor compatibility) +APP_RECOMMENDATIONS = { + "Firefox-based": { + "pattern": r"firefox|librewolf|waterfox|floorp|zen", + "recommendations": { + MODE_SMOOTH: "good", + MODE_SLOW: "good", + MODE_HARDCORE: "good", + MODE_SURROUNDING: "good", + MODE_PREEDIT: "good", + } + }, + "Chromium-based": { + "pattern": r"chrome|chromium|brave|vivaldi|opera|edge", + "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: + """ + 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 category, data in APP_RECOMMENDATIONS.items(): + if re.search(data["pattern"], app_lower): + return data["recommendations"].get(mode) + + return None diff --git a/settings-gui/ui/pages/mode_manager.py b/settings-gui/ui/pages/mode_manager.py index 21423e1..cff29f6 100644 --- a/settings-gui/ui/pages/mode_manager.py +++ b/settings-gui/ui/pages/mode_manager.py @@ -30,6 +30,7 @@ from i18n import _ from ui.pages.dynamic_settings import CardWidget from core.dbus_handler import LotusDBusHandler +from core.recommendations import get_recommendation, MODE_DEFAULT as REC_MODE_DEFAULT # Mode constants as defined in C++ LotusEngine MODE_OFF = 0 @@ -68,6 +69,17 @@ def __init__(self, mode: int, selected: bool = False, parent=None): 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 +95,59 @@ def _setup_ui(self): layout.addWidget(title_label) def update_style(self): + status = getattr(self, "rec_status", None) + + # 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 +449,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 +483,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,8 +680,24 @@ 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_val = MODE_SMOOTH + if self.combo_global_mode.currentIndex() >= 0: + global_mode_str = self.combo_global_mode.currentData() + # Crude string to int conversion if needed, but get_recommendation handles int + # Actually, global_mode_val is just for when the app uses MODE_DEFAULT + pass + for m, card in self.mode_cards.items(): card.selected = (m == self.current_app_mode) + + # Show recommendations only for the currently selected app + if self.selected_app: + emoji = get_recommendation(self.selected_app, m) + card.set_recommendation(emoji) + else: + card.set_recommendation(None) + card.update_style() def _on_add_app(self): From be27575cc0e954a51059e4ace1b5941f9a0f3d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= <63589389+loccun@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:43:42 +0700 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- settings-gui/core/recommendations.py | 4 ++-- settings-gui/ui/pages/mode_manager.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/settings-gui/core/recommendations.py b/settings-gui/core/recommendations.py index 207a30c..faad399 100644 --- a/settings-gui/core/recommendations.py +++ b/settings-gui/core/recommendations.py @@ -43,7 +43,7 @@ } } -def get_recommendation(app_name: str, mode: int) -> str: +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 @@ -54,7 +54,7 @@ def get_recommendation(app_name: str, mode: int) -> str: app_lower = app_name.lower() for category, data in APP_RECOMMENDATIONS.items(): - if re.search(data["pattern"], app_lower): + if data["pattern"].search(app_lower): return data["recommendations"].get(mode) return None diff --git a/settings-gui/ui/pages/mode_manager.py b/settings-gui/ui/pages/mode_manager.py index cff29f6..568b3f0 100644 --- a/settings-gui/ui/pages/mode_manager.py +++ b/settings-gui/ui/pages/mode_manager.py @@ -693,8 +693,8 @@ def _update_mode_cards(self): # Show recommendations only for the currently selected app if self.selected_app: - emoji = get_recommendation(self.selected_app, m) - card.set_recommendation(emoji) + recommendation_status = get_recommendation(self.selected_app, m) + card.set_recommendation(recommendation_status) else: card.set_recommendation(None) From d9fd004d6368f6a5a2bd1db12c83e71c96fe6192 Mon Sep 17 00:00:00 2001 From: loccun Date: Tue, 31 Mar 2026 05:53:14 +0700 Subject: [PATCH 3/4] refactor: centralize mode constants, optimize recommendation lookups, and fix UI update logic in mode manager --- settings-gui/core/recommendations.py | 8 +++--- settings-gui/ui/pages/macro_editor.py | 1 - settings-gui/ui/pages/mode_manager.py | 41 ++++++++++++++------------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/settings-gui/core/recommendations.py b/settings-gui/core/recommendations.py index faad399..993162a 100644 --- a/settings-gui/core/recommendations.py +++ b/settings-gui/core/recommendations.py @@ -8,7 +8,7 @@ import re -# Mode constants (should match mode_manager.py and C++ LotusEngine) +# Mode constants (source of truth for GUI) MODE_OFF = 0 MODE_SMOOTH = 1 MODE_SLOW = 2 @@ -22,7 +22,7 @@ # Status values: "good" (Recommended), "bad" (Poor compatibility) APP_RECOMMENDATIONS = { "Firefox-based": { - "pattern": r"firefox|librewolf|waterfox|floorp|zen", + "pattern": re.compile(r"firefox|librewolf|waterfox|floorp|zen", re.IGNORECASE), "recommendations": { MODE_SMOOTH: "good", MODE_SLOW: "good", @@ -32,7 +32,7 @@ } }, "Chromium-based": { - "pattern": r"chrome|chromium|brave|vivaldi|opera|edge", + "pattern": re.compile(r"chrome|chromium|brave|vivaldi|opera|edge", re.IGNORECASE), "recommendations": { MODE_SMOOTH: "good", MODE_SLOW: "good", @@ -53,7 +53,7 @@ def get_recommendation(app_name: str, mode: int) -> str | None: app_lower = app_name.lower() - for category, data in APP_RECOMMENDATIONS.items(): + for _, data in APP_RECOMMENDATIONS.items(): if data["pattern"].search(app_lower): return data["recommendations"].get(mode) diff --git a/settings-gui/ui/pages/macro_editor.py b/settings-gui/ui/pages/macro_editor.py index dedceb4..503efe1 100644 --- a/settings-gui/ui/pages/macro_editor.py +++ b/settings-gui/ui/pages/macro_editor.py @@ -349,7 +349,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 568b3f0..a9d0106 100644 --- a/settings-gui/ui/pages/mode_manager.py +++ b/settings-gui/ui/pages/mode_manager.py @@ -30,17 +30,17 @@ from i18n import _ from ui.pages.dynamic_settings import CardWidget from core.dbus_handler import LotusDBusHandler -from core.recommendations import get_recommendation, MODE_DEFAULT as REC_MODE_DEFAULT - -# 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"}, @@ -64,6 +64,7 @@ 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() @@ -95,7 +96,7 @@ def _setup_ui(self): layout.addWidget(title_label) def update_style(self): - status = getattr(self, "rec_status", None) + status = self.rec_status # Color constants COLOR_GOOD = "#2ecc71" # Green @@ -681,24 +682,26 @@ def _on_app_mode_changed(self, mode): def _update_mode_cards(self): # Resolve global default mode if needed for recommendation - global_mode_val = MODE_SMOOTH + global_mode = MODE_SMOOTH if self.combo_global_mode.currentIndex() >= 0: global_mode_str = self.combo_global_mode.currentData() - # Crude string to int conversion if needed, but get_recommendation handles int - # Actually, global_mode_val is just for when the app uses MODE_DEFAULT - pass + # 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) # Show recommendations only for the currently selected app if self.selected_app: - recommendation_status = get_recommendation(self.selected_app, m) + # 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) - - card.update_style() def _on_add_app(self): dialog = AddAppDialog(self._icon_cache, list(self.app_rules.keys()), self) From f2ec9f82166f0d771d8399289fdf18609bdbb0a7 Mon Sep 17 00:00:00 2001 From: loccun Date: Tue, 31 Mar 2026 05:55:49 +0700 Subject: [PATCH 4/4] refactor: move _find_row_by_key to base_editor to centralize table lookup logic --- settings-gui/ui/pages/base_editor.py | 10 ++++++++++ settings-gui/ui/pages/keymap_editor.py | 7 ------- settings-gui/ui/pages/macro_editor.py | 7 ------- 3 files changed, 10 insertions(+), 14 deletions(-) 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 503efe1..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