diff --git a/native_app/main.py b/native_app/main.py index 5cc1fce..3423461 100644 --- a/native_app/main.py +++ b/native_app/main.py @@ -11,6 +11,7 @@ from .i18n import Translator from .storage import AppStorage from .theme import generate_qss, scale_qss +from .wheel_guard import install as install_wheel_guard, set_wheel_adjust_enabled from .window import MainWindow @@ -66,6 +67,8 @@ def main() -> int: load_app_fonts(resources_dir) storage.load_imported_fonts() settings = initial_state.settings + set_wheel_adjust_enabled(settings.wheel_adjust_enabled) + install_wheel_guard(app) custom_family = storage.font_family_by_id(settings.custom_font_id) if settings.custom_font_id else '' app.setFont(build_body_font(settings.font_profile, settings.body_font_point_size, custom_family)) custom_palette = None diff --git a/native_app/models.py b/native_app/models.py index 5520762..d53c8ab 100644 --- a/native_app/models.py +++ b/native_app/models.py @@ -407,6 +407,10 @@ class AppSettings: max_tokens: int = 64000 stream: bool = True memory_mode: bool = True + # Mouse-wheel value adjustment on spinboxes/sliders/combos. Off by default: + # wheeling over a control while scrolling a list should scroll, not nudge + # the value (upstream issue #3). + wheel_adjust_enabled: bool = False send_mode: str = SEND_MODE_ENTER summary_prompt: str = DEFAULT_SUMMARY_PROMPT language: str = "zh-CN" @@ -495,6 +499,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AppSettings": max_tokens=clamp_int(data.get("max_tokens", data.get("maxTokens", 2048)), 2048, 1, 200000), stream=bool(data.get("stream", True)), memory_mode=bool(data.get("memory_mode", True)), + wheel_adjust_enabled=bool(data.get("wheel_adjust_enabled", False)), send_mode=str(data.get("send_mode", SEND_MODE_ENTER) or SEND_MODE_ENTER), summary_prompt=str(data.get("summary_prompt", DEFAULT_SUMMARY_PROMPT)), language=str(data.get("language", "zh-CN") or "zh-CN"), diff --git a/native_app/resources/lang/en.json b/native_app/resources/lang/en.json index 4631d0f..611d5e8 100644 --- a/native_app/resources/lang/en.json +++ b/native_app/resources/lang/en.json @@ -151,6 +151,7 @@ "max_tokens": "Max Tokens", "stream": "Stream", "memory_mode": "Memory Mode", + "wheel_adjust": "Wheel Adjusts Values", "send_mode": "Send Mode", "send_mode_enter": "Enter to send", "send_mode_ctrl_enter": "Ctrl+Enter to send", @@ -220,6 +221,7 @@ "tip_max_tokens": "Maximum number of tokens per response", "tip_stream": "Display output incrementally instead of waiting for full response", "tip_memory": "Auto-summarize chat history, retaining key preferences for future generation", + "tip_wheel_adjust": "Let the mouse wheel change spinbox/slider/combo values. When off, the wheel only scrolls the list, so scrolling never nudges a value by accident (off by default)", "tip_send_mode": "Choose how send is confirmed; the other combo stays available for line breaks", "tip_history_retention": "Automatically clean old generations so the sidebar stays easier to scan", "tip_summary_prompt": "Prompt used for summarizing history; {{content}} is replaced with history", diff --git a/native_app/resources/lang/zh-CN.json b/native_app/resources/lang/zh-CN.json index b32fd37..1982a91 100644 --- a/native_app/resources/lang/zh-CN.json +++ b/native_app/resources/lang/zh-CN.json @@ -151,6 +151,7 @@ "max_tokens": "最大 Tokens", "stream": "流式输出", "memory_mode": "记忆模式", + "wheel_adjust": "滚轮调整数值", "send_mode": "发送方式", "send_mode_enter": "回车发送", "send_mode_ctrl_enter": "Ctrl+Enter 发送", @@ -220,6 +221,7 @@ "tip_max_tokens": "单次回复的最大 token 数量", "tip_stream": "逐步显示生成内容,而非等待完整回复", "tip_memory": "自动总结历史对话,保留关键偏好用于后续生成", + "tip_wheel_adjust": "允许鼠标滚轮直接改变数值框/滑块/下拉的值。关闭后滚轮只滚动列表,避免翻页时误改(默认关闭)", "tip_send_mode": "切换发送确认方式;另一种组合保留给换行", "tip_history_retention": "自动清理过旧的生成历史,保持侧栏更好找", "tip_summary_prompt": "总结历史时使用的提示词,{{content}} 会被替换为历史内容", diff --git a/native_app/storage/_config_bundle.py b/native_app/storage/_config_bundle.py index eb41f43..bd34a31 100644 --- a/native_app/storage/_config_bundle.py +++ b/native_app/storage/_config_bundle.py @@ -47,6 +47,7 @@ _SETTING_FIELDS_BY_SCOPE = { CONFIG_SCOPE_APPEARANCE: { "language", + "wheel_adjust_enabled", "ui_scale_percent", "body_font_point_size", "font_profile", diff --git a/native_app/wheel_guard.py b/native_app/wheel_guard.py new file mode 100644 index 0000000..f392133 --- /dev/null +++ b/native_app/wheel_guard.py @@ -0,0 +1,93 @@ +"""Application-wide guard against accidental mouse-wheel value changes. + +Spinboxes, sliders and combo boxes adjust their value on a mouse wheel, so +scrolling a settings/prompt list with the pointer over one of them silently +bumps the value — the complaint in upstream issue #3. When wheel-adjust is +disabled (the default), this filter swallows the wheel before the control +sees it and scrolls the nearest scroll area DIRECTLY via its scrollbar — +no synthetic event forwarding (re-dispatching wheel events breaks macOS +trackpad phase/momentum tracking). Enable the settings toggle to restore +native wheel-adjust behaviour. + +A single filter on the QApplication covers every current and future control +without touching creation sites. +""" +from __future__ import annotations + +from PyQt6.QtCore import QEvent, QObject +from PyQt6.QtGui import QWheelEvent +from PyQt6.QtWidgets import ( + QAbstractItemView, + QAbstractScrollArea, + QAbstractSlider, + QAbstractSpinBox, + QApplication, + QComboBox, + QWidget, +) + +_enabled = False +_GUARDED = (QAbstractSpinBox, QComboBox, QAbstractSlider) + + +def set_wheel_adjust_enabled(enabled: bool) -> None: + """True = native wheel-adjust; False = wheel scrolls the list instead.""" + global _enabled + _enabled = bool(enabled) + + +def wheel_adjust_enabled() -> bool: + return _enabled + + +def _scroll_ancestor(widget: QWidget) -> QAbstractScrollArea | None: + parent = widget.parentWidget() + while parent is not None: + if isinstance(parent, QAbstractScrollArea): + return parent + parent = parent.parentWidget() + return None + + +def _scroll_directly(area: QAbstractScrollArea, event: QWheelEvent) -> None: + """Apply the wheel delta straight to the area's scrollbars.""" + for bar, pixels, angle in ( + (area.verticalScrollBar(), event.pixelDelta().y(), event.angleDelta().y()), + (area.horizontalScrollBar(), event.pixelDelta().x(), event.angleDelta().x()), + ): + if bar is None: + continue + if pixels: # trackpads: pixel-precise deltas + bar.setValue(bar.value() - pixels) + elif angle: # wheel notches: 120 units == 3 lines + bar.setValue(bar.value() - round(angle / 120.0 * bar.singleStep() * 3)) + + +class _WheelGuard(QObject): + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if _enabled or event.type() != QEvent.Type.Wheel: + return False + widget = obj + depth = 0 + # Walk up a couple of levels: the wheel may land on a spinbox's inner + # line edit rather than the spinbox itself. + while isinstance(widget, QWidget) and depth < 3: + if isinstance(widget, QAbstractItemView): + return False # a real list/table (e.g. an open combo popup) scrolls itself + if isinstance(widget, _GUARDED): + area = _scroll_ancestor(widget) + if area is not None: + _scroll_directly(area, event) + return True # swallow it so the control's value doesn't change + widget = widget.parentWidget() + depth += 1 + return False + + +_guard = _WheelGuard() + + +def install(app: QApplication) -> None: + """Install the guard on the application (idempotent).""" + app.removeEventFilter(_guard) + app.installEventFilter(_guard) diff --git a/native_app/widgets/settings_panel.py b/native_app/widgets/settings_panel.py index 2a6c88d..b90dc68 100644 --- a/native_app/widgets/settings_panel.py +++ b/native_app/widgets/settings_panel.py @@ -46,6 +46,7 @@ SETTINGS_WIDTH, _dp, ) +from ..wheel_guard import set_wheel_adjust_enabled from .common import ToggleSwitch @@ -159,7 +160,6 @@ def __init__(self, translator: Translator, parent=None) -> None: self.top_k_spin.setRange(-1, 2000) self.top_k_spin.setSpecialValueText('--') self.top_k_spin.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.top_k_spin.wheelEvent = lambda e: e.ignore() self.top_k_spin.valueChanged.connect(self.settings_changed) self.body_layout.addWidget(self.top_k_spin) @@ -172,12 +172,12 @@ def __init__(self, translator: Translator, parent=None) -> None: self.max_tokens_spin.setRange(1, 200000) self.max_tokens_spin.setValue(2048) self.max_tokens_spin.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.max_tokens_spin.wheelEvent = lambda e: e.ignore() self.max_tokens_spin.valueChanged.connect(self.settings_changed) self.body_layout.addWidget(self.max_tokens_spin) self.stream_label, self.stream_toggle = self._add_toggle_row(body) self.memory_label, self.memory_toggle = self._add_toggle_row(body) + self.wheel_label, self.wheel_toggle = self._add_toggle_row(body) self.history_retention_label = self._add_label(body) self.history_retention_combo = QComboBox(body) @@ -301,6 +301,9 @@ def _marker_row(label_start, label_end, default_start, default_end): self.stream_toggle.toggled.connect(self.settings_changed) self.memory_toggle.toggled.connect(self.settings_changed) + # Wheel-adjust takes effect live (the app-wide guard reads the flag). + self.wheel_toggle.toggled.connect(self.settings_changed) + self.wheel_toggle.toggled.connect(set_wheel_adjust_enabled) self._tag_dictionary = None self.retranslate_ui() @@ -339,7 +342,6 @@ def _add_slider(self, parent: QWidget, minimum: int, maximum: int, default: int) slider.setRange(minimum, maximum) slider.setValue(default) slider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - slider.wheelEvent = lambda e: e.ignore() self.body_layout.addWidget(slider) return slider, value_label, label @@ -546,6 +548,7 @@ def settings(self) -> AppSettings: max_tokens=int(self.max_tokens_spin.value()), stream=self.stream_toggle.isChecked(), memory_mode=self.memory_toggle.isChecked(), + wheel_adjust_enabled=self.wheel_toggle.isChecked(), send_mode=str(self.send_mode_combo.currentData() or SEND_MODE_ENTER), summary_prompt=self.summary_prompt_edit.toPlainText(), language=str(self.language_combo.currentData()), @@ -576,6 +579,8 @@ def apply_settings(self, settings: AppSettings) -> None: self.max_tokens_spin.setValue(settings.max_tokens) self.stream_toggle.setChecked(settings.stream) self.memory_toggle.setChecked(settings.memory_mode) + self.wheel_toggle.setChecked(settings.wheel_adjust_enabled) + set_wheel_adjust_enabled(settings.wheel_adjust_enabled) send_mode_index = max(0, self.send_mode_combo.findData(settings.send_mode)) self.send_mode_combo.setCurrentIndex(send_mode_index) self.summary_prompt_edit.setPlainText(settings.summary_prompt) @@ -653,6 +658,8 @@ def retranslate_ui(self) -> None: self.stream_label.setToolTip(self._translator.t('tip_stream')) self.memory_label.setText(self._translator.t('memory_mode')) self.memory_label.setToolTip(self._translator.t('tip_memory')) + self.wheel_label.setText(self._translator.t('wheel_adjust')) + self.wheel_label.setToolTip(self._translator.t('tip_wheel_adjust')) current_history_retention = self.history_retention_combo.currentData() self.history_retention_label.setText(self._translator.t('history_retention')) self.history_retention_label.setToolTip(self._translator.t('tip_history_retention'))