From 766948284d99b7d24ab96d2654fed86d6c5c0d64 Mon Sep 17 00:00:00 2001 From: Miint-Sunny <3478345733@qq.com> Date: Sun, 19 Jul 2026 23:51:30 +0800 Subject: [PATCH] feat: optional mouse-wheel value adjustment, off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrolling a list with the pointer over a spinbox/slider/combo silently bumped its value (upstream issue #3, no undo either). Add an app-wide wheel guard: when the new settings toggle (off by default, as requested) is disabled, the wheel is swallowed before the control sees it and the delta is applied straight to the nearest scroll area's scrollbar — the list scrolls, the value stays put. Direct scrollbar driving (not synthetic event forwarding) keeps macOS trackpad pixel deltas smooth and avoids phase-tracking breakage; combo popup lists still scroll natively. The per-control wheelEvent=ignore hacks in the settings panel are superseded. Setting lives in AppSettings (appearance config scope) with zh/en strings and takes effect live from the toggle. Closes upstream issue #3. Co-Authored-By: Claude Opus 4.8 --- native_app/main.py | 3 + native_app/models.py | 5 ++ native_app/resources/lang/en.json | 2 + native_app/resources/lang/zh-CN.json | 2 + native_app/storage/_config_bundle.py | 1 + native_app/wheel_guard.py | 93 ++++++++++++++++++++++++++++ native_app/widgets/settings_panel.py | 13 +++- 7 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 native_app/wheel_guard.py 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'))