Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions native_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions native_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions native_app/resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions native_app/resources/lang/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"max_tokens": "最大 Tokens",
"stream": "流式输出",
"memory_mode": "记忆模式",
"wheel_adjust": "滚轮调整数值",
"send_mode": "发送方式",
"send_mode_enter": "回车发送",
"send_mode_ctrl_enter": "Ctrl+Enter 发送",
Expand Down Expand Up @@ -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}} 会被替换为历史内容",
Expand Down
1 change: 1 addition & 0 deletions native_app/storage/_config_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
_SETTING_FIELDS_BY_SCOPE = {
CONFIG_SCOPE_APPEARANCE: {
"language",
"wheel_adjust_enabled",
"ui_scale_percent",
"body_font_point_size",
"font_profile",
Expand Down
93 changes: 93 additions & 0 deletions native_app/wheel_guard.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 10 additions & 3 deletions native_app/widgets/settings_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
SETTINGS_WIDTH,
_dp,
)
from ..wheel_guard import set_wheel_adjust_enabled
from .common import ToggleSwitch


Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'))
Expand Down