Skip to content
Open
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6ca2b2a
Simplify job handling
FireChickenProductivity Oct 8, 2025
e3cada0
Refactor setting speed
FireChickenProductivity Oct 8, 2025
8739906
Simplify handling direction and speed
FireChickenProductivity Oct 8, 2025
186a296
Refactor acceleration
FireChickenProductivity Oct 8, 2025
5a8ed38
Stop using text description as state
FireChickenProductivity Oct 8, 2025
0cfb9e0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 8, 2025
2a90afb
Extract managing description
FireChickenProductivity Oct 14, 2025
d5e1eca
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 14, 2025
bf870e5
Remove unused variable
FireChickenProductivity Oct 14, 2025
9847fc8
Encapsulate control mouse state
FireChickenProductivity Oct 14, 2025
9183d30
Encapsulate direction
FireChickenProductivity Oct 14, 2025
bf4d7a1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 14, 2025
03aa721
Make simplifications, fix gaze scroll
FireChickenProductivity Oct 14, 2025
770b132
Simplify further
FireChickenProductivity Oct 14, 2025
00cbaa2
Make helper a method
FireChickenProductivity Oct 14, 2025
bcb6b64
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 14, 2025
125dc21
Use enumerated type
FireChickenProductivity Oct 21, 2025
1735a52
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 21, 2025
abd72bf
Remove unnecessary match statement
FireChickenProductivity Oct 21, 2025
82e05c1
Use match statement
FireChickenProductivity Oct 25, 2025
aae9501
Stop relying on accessor methods to communicate information about var…
FireChickenProductivity Oct 25, 2025
9abb0de
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2025
9db91a6
Use slots
FireChickenProductivity Oct 25, 2025
20ae320
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2025
2db260d
Refactor direction
FireChickenProductivity Oct 25, 2025
8f8c056
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2025
4c0e386
Merge branch 'main' into simplify-scrolling
FireChickenProductivity Oct 25, 2025
5b06bc8
Remove debug statement
FireChickenProductivity Oct 25, 2025
e07361d
Initialize using method
FireChickenProductivity Oct 25, 2025
d7fa64c
Improve naming
FireChickenProductivity Oct 25, 2025
65ce2a2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2025
e28d5e6
Further improve naming
FireChickenProductivity Oct 25, 2025
f61cdcb
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2025
630c314
Reset scrolling acceleration when changing speed
FireChickenProductivity Nov 8, 2025
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
298 changes: 186 additions & 112 deletions plugin/mouse/mouse_scroll.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,164 @@
import time
from enum import Enum, auto
from typing import Literal, Optional

from talon import Context, Module, actions, app, cron, ctrl, imgui, settings, ui

continuous_scroll_mode = ""
scroll_job = None
gaze_job = None
scroll_dir: Literal[-1, 1] = 1
scroll_start_ts: float = 0
hiss_scroll_up = False
control_mouse_forced = False
continuous_scrolling_speed_factor: float = 1.0
is_continuous_scrolling_vertical: bool = True


class ScrollingDirection(Enum):
UP = auto()
DOWN = auto()
LEFT = auto()
RIGHT = auto()


class Scroller:
"""Understands how to scroll in a specific direction"""

__slots__ = ("_scroll_dir", "_is_vertical", "direction")

def __init__(self):
self._set_down()

def set_direction(self, direction: ScrollingDirection):
self.direction = direction
match direction:
case ScrollingDirection.UP:
self._set_up()
case ScrollingDirection.DOWN:
self._set_down()
case ScrollingDirection.LEFT:
self._set_left()
case ScrollingDirection.RIGHT:
self._set_right()

def _set_up(self):
self._is_vertical: bool = True
self._scroll_dir = -1

def _set_down(self):
self._is_vertical: bool = True
self._scroll_dir = 1

def _set_left(self):
self._is_vertical: bool = False
self._scroll_dir = -1

def _set_right(self):
self._is_vertical: bool = False
self._scroll_dir = 1

def scroll_in_direction(self, amount: int):
scroll_delta = self._scroll_dir * amount
if self._is_vertical:
actions.mouse_scroll(scroll_delta)
else:
actions.mouse_scroll(0, scroll_delta)

def is_direction_equal_to(self, direction: ScrollingDirection) -> bool:
return self.direction == direction

def get_direction_name(self) -> str:
return self.direction.name.lower()


class ScrollingState:
__slots__ = (
"_scroll_job",
"_scroll_start_ts",
"_is_control_mouse_forced",
"continuous_scrolling_speed_factor",
"scroller",
"is_continuously_scrolling",
)

def __init__(self):
self._scroll_job = None
# The time stamp at which continuous scrolling started
# used for acceleration
self._scroll_start_ts: float = 0
# True if eye tracking mouse control was forced on for gaze scroll
self._is_control_mouse_forced = False
self.continuous_scrolling_speed_factor: float = 1.0
self.scroller = Scroller()
self.is_continuously_scrolling: bool = False

def start_continuous_scrolling_job(self):
self.reset_scrolling_start_time()
self.scroll_continuous_helper()
scroll_job = cron.interval("16ms", self.scroll_continuous_helper)
self.set_scrolling_job(scroll_job)
self.is_continuously_scrolling = True

def scroll_continuous_helper(self):
speed = self.compute_scrolling_speed()
self.scroller.scroll_in_direction(speed)

def start_gaze_scrolling_job(self):
self.continuous_scrolling_speed_factor = 1
gaze_job = cron.interval("16ms", scroll_gaze_helper)
self.set_scrolling_job(gaze_job)
# enable 'control mouse' if eye tracker is present and not enabled already
if not actions.tracking.control_enabled():
actions.tracking.control_toggle(True)
self._is_control_mouse_forced = True

def set_scrolling_job(self, job):
self.stop_scrolling_job()
self._scroll_job = job

def stop_scrolling_job(self):
if self._scroll_job:
cron.cancel(self._scroll_job)
self._scroll_job = None
if self._is_control_mouse_forced:
actions.tracking.control_toggle(False)
self._is_control_mouse_forced = False
self.is_continuously_scrolling = False

def has_scrolling_job(self) -> bool:
return self._scroll_job is not None

def compute_scrolling_speed(self) -> int:
scroll_amount = (
settings.get("user.mouse_continuous_scroll_amount")
* self.continuous_scrolling_speed_factor
)
acceleration_setting = settings.get("user.mouse_continuous_scroll_acceleration")
acceleration_speed = (
1
+ min(
(time.perf_counter() - self._scroll_start_ts) / 0.5,
acceleration_setting - 1,
)
if acceleration_setting > 1
else 1
)

accelerated_scroll_amount = round(scroll_amount * acceleration_speed)
if accelerated_scroll_amount == 0:
accelerated_scroll_amount = 1
return accelerated_scroll_amount

def compute_gaze_scrolling_factor(self) -> float:
return self.continuous_scrolling_speed_factor * settings.get(
"user.mouse_gaze_scroll_speed_multiplier"
)

def reset_scrolling_start_time(self):
self._scroll_start_ts = time.perf_counter()

def get_scrolling_mode_description(self):
if not self.has_scrolling_job():
return ""
if self.is_continuously_scrolling:
return f"scroll {self.scroller.get_direction_name()} continuous"
return "gaze scroll"


scrolling_state = ScrollingState()

mod = Module()
ctx = Context()
Expand Down Expand Up @@ -80,7 +227,7 @@

@imgui.open(x=700, y=0)
def gui_wheel(gui: imgui.GUI):
gui.text(f"Scroll mode: {continuous_scroll_mode}")
gui.text(f"Scroll mode: {scrolling_state.get_scrolling_mode_description()}")
gui.text(f"say a number between 0 and 99 to set scrolling speed")
gui.line()
if gui.button("[Wheel] Stop"):
Expand Down Expand Up @@ -111,100 +258,73 @@ def mouse_scroll_right(amount: float = 1):

def mouse_scroll_continuous(direction: str, speed_factor: Optional[int] = None):
"""Scrolls continuously in the given direction"""
match direction:
case "UP":
actions.user.mouse_scroll_up_continuous(speed_factor)
case "DOWN":
actions.user.mouse_scroll_down_continuous(speed_factor)
case "LEFT":
actions.user.mouse_scroll_left_continuous(speed_factor)
case "RIGHT":
actions.user.mouse_scroll_right_continuous(speed_factor)
case _:
raise ValueError(f"Invalid continuous scrolling direction: {direction}")
try:
enumerated_direction = ScrollingDirection[direction]
except KeyError:
raise ValueError(f"Invalid continuous scrolling direction: {direction}")
mouse_scroll_continuous(enumerated_direction, speed_factor)

def mouse_scroll_up_continuous(speed_factor: Optional[int] = None):
"""Scrolls up continuously"""
mouse_scroll_continuous(-1, speed_factor)
mouse_scroll_continuous(ScrollingDirection.UP, speed_factor)

def mouse_scroll_down_continuous(speed_factor: Optional[int] = None):
"""Scrolls down continuously"""
mouse_scroll_continuous(1, speed_factor)
mouse_scroll_continuous(ScrollingDirection.DOWN, speed_factor)

def mouse_scroll_right_continuous(speed_factor: Optional[int] = None):
"""Scrolls right continuously"""
mouse_scroll_continuous(1, speed_factor, is_vertical=False)
mouse_scroll_continuous(ScrollingDirection.RIGHT, speed_factor)

def mouse_scroll_left_continuous(speed_factor: Optional[int] = None):
"""Scrolls left continuously"""
mouse_scroll_continuous(-1, speed_factor, is_vertical=False)
mouse_scroll_continuous(ScrollingDirection.LEFT, speed_factor)

def mouse_gaze_scroll():
"""Starts gaze scroll"""
global gaze_job, continuous_scroll_mode, control_mouse_forced

ctx.tags = ["user.continuous_scrolling"]

continuous_scroll_mode = "gaze scroll"
gaze_job = cron.interval("16ms", scroll_gaze_helper)
scrolling_state.start_gaze_scrolling_job()

if not settings.get("user.mouse_hide_mouse_gui"):
gui_wheel.show()

# enable 'control mouse' if eye tracker is present and not enabled already
if not actions.tracking.control_enabled():
actions.tracking.control_toggle(True)
control_mouse_forced = True

def mouse_gaze_scroll_toggle():
"""If not scrolling, start gaze scroll, else stop scrolling."""
if continuous_scroll_mode == "":
actions.user.mouse_gaze_scroll()
else:
if scrolling_state.has_scrolling_job():
actions.user.mouse_scroll_stop()
else:
actions.user.mouse_gaze_scroll()

def mouse_scroll_stop() -> bool:
"""Stops scrolling"""
global scroll_job, gaze_job, continuous_scroll_mode, control_mouse_forced, continuous_scrolling_speed_factor

continuous_scroll_mode = ""
continuous_scrolling_speed_factor = 1.0
return_value = False
ctx.tags = []

if scroll_job:
cron.cancel(scroll_job)
scroll_job = None
return_value = True

if gaze_job:
cron.cancel(gaze_job)
gaze_job = None
if scrolling_state.has_scrolling_job():
return_value = True

if control_mouse_forced:
actions.tracking.control_toggle(False)
control_mouse_forced = False
scrolling_state.stop_scrolling_job()

gui_wheel.hide()

return return_value

def mouse_scroll_set_speed(speed: Optional[int]):
"""Sets the continuous scrolling speed for the current scrolling"""
global continuous_scrolling_speed_factor, scroll_start_ts
if scroll_start_ts:
scroll_start_ts = time.perf_counter()
scrolling_state.reset_scrolling_start_time()
if speed is None:
continuous_scrolling_speed_factor = 1.0
else:
continuous_scrolling_speed_factor = speed / settings.get(
"user.mouse_continuous_scroll_speed_quotient"
)
scrolling_state.continuous_scrolling_speed_factor = (
continuous_scrolling_speed_factor
)

def mouse_is_continuous_scrolling():
"""Returns whether continuous scroll is in progress"""
return len(continuous_scroll_mode) > 0
return scrolling_state.has_scrolling_job()

def hiss_scroll_up():
"""Change mouse hiss scroll direction to up"""
Expand All @@ -231,71 +351,27 @@ def noise_trigger_hiss(active: bool):


def mouse_scroll_continuous(
new_scroll_dir: Literal[-1, 1],
new_scroll_dir: ScrollingDirection,
speed_factor: Optional[int] = None,
is_vertical: bool = True,
):
global scroll_job, scroll_dir, scroll_start_ts, is_continuous_scrolling_vertical
actions.user.mouse_scroll_set_speed(speed_factor)
was_vertical = is_continuous_scrolling_vertical
is_continuous_scrolling_vertical = is_vertical

update_continuous_scrolling_mode(new_scroll_dir, is_vertical)
current_direction = scrolling_state.scroller

if scroll_job:
if (
scrolling_state.is_continuously_scrolling
and current_direction.is_direction_equal_to(new_scroll_dir)
):
# Issuing a scroll in the same direction aborts scrolling
if scroll_dir == new_scroll_dir and was_vertical == is_vertical:
actions.user.mouse_scroll_stop()
# Issuing a scroll in the reverse direction resets acceleration
else:
scroll_dir = new_scroll_dir
scroll_start_ts = time.perf_counter()
actions.user.mouse_scroll_stop()
else:
scroll_dir = new_scroll_dir
scroll_start_ts = time.perf_counter()
scroll_continuous_helper()
scroll_job = cron.interval("16ms", scroll_continuous_helper)
current_direction.set_direction(new_scroll_dir)
scrolling_state.start_continuous_scrolling_job()
ctx.tags = ["user.continuous_scrolling"]

if not settings.get("user.mouse_hide_mouse_gui"):
gui_wheel.show()


def update_continuous_scrolling_mode(new_scroll_dir: Literal[-1, 1], is_vertical: bool):
global continuous_scroll_mode
if new_scroll_dir == -1:
if is_vertical:
continuous_scroll_mode = "scroll up continuous"
else:
continuous_scroll_mode = "scroll left continuous"
else:
if is_vertical:
continuous_scroll_mode = "scroll down continuous"
else:
continuous_scroll_mode = "scroll right continuous"


def scroll_continuous_helper():
scroll_amount = (
settings.get("user.mouse_continuous_scroll_amount")
* continuous_scrolling_speed_factor
)
acceleration_setting = settings.get("user.mouse_continuous_scroll_acceleration")
acceleration_speed = (
1 + min((time.perf_counter() - scroll_start_ts) / 0.5, acceleration_setting - 1)
if acceleration_setting > 1
else 1
)

scroll_delta = round(scroll_amount * acceleration_speed * scroll_dir)
if scroll_delta == 0:
scroll_delta = scroll_dir
if is_continuous_scrolling_vertical:
actions.mouse_scroll(scroll_delta)
else:
actions.mouse_scroll(0, scroll_delta)


def scroll_gaze_helper():
x, y = ctrl.mouse_pos()

Expand All @@ -307,9 +383,7 @@ def scroll_gaze_helper():

rect = window.rect
midpoint = rect.center.y
factor = continuous_scrolling_speed_factor * settings.get(
"user.mouse_gaze_scroll_speed_multiplier"
)
factor = scrolling_state.compute_gaze_scrolling_factor()
amount = factor * (((y - midpoint) / (rect.height / 10)) ** 3)
actions.mouse_scroll(amount)

Expand Down