import time 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 mod = Module() ctx = Context() mod.list( "continuous_scrolling_direction", desc="Defines names for directions used with continuous scrolling", ) mod.setting( "mouse_wheel_down_amount", type=int, default=120, desc="The amount to scroll up/down (equivalent to mouse wheel on Windows by default)", ) mod.setting( "mouse_wheel_horizontal_amount", type=int, default=40, desc="The amount to scroll left/right", ) mod.setting( "mouse_continuous_scroll_amount", type=int, default=8, desc="The default amount used when scrolling continuously", ) mod.setting( "mouse_continuous_scroll_acceleration", type=float, default=1, desc="The maximum (linear) acceleration factor when scrolling continuously. 1=constant speed/no acceleration", ) mod.setting( "mouse_enable_hiss_scroll", type=bool, default=False, desc="Hiss noise scrolls down when enabled", ) mod.setting( "mouse_hide_mouse_gui", type=bool, default=False, desc="When enabled, the 'Scroll Mouse' GUI will not be shown.", ) mod.setting( "mouse_continuous_scroll_speed_quotient", type=float, default=10.0, desc="When adjusting the continuous scrolling speed through voice commands, the result is that the speed is multiplied by the dictated number divided by this number.", ) mod.setting( "mouse_gaze_scroll_speed_multiplier", type=float, default=1.0, desc="This multiplies the gaze scroll speed", ) mod.tag( "continuous_scrolling", desc="Allows commands for adjusting continuous scrolling behavior", ) @imgui.open(x=700, y=0) def gui_wheel(gui: imgui.GUI): gui.text(f"Scroll mode: {continuous_scroll_mode}") gui.text(f"say a number between 0 and 99 to set scrolling speed") gui.line() if gui.button("[Wheel] Stop"): actions.user.mouse_scroll_stop() @mod.action_class class Actions: def mouse_scroll_up(amount: float = 1): """Scrolls up""" y = amount * settings.get("user.mouse_wheel_down_amount") actions.mouse_scroll(-y) def mouse_scroll_down(amount: float = 1): """Scrolls down""" y = amount * settings.get("user.mouse_wheel_down_amount") actions.mouse_scroll(y) def mouse_scroll_left(amount: float = 1): """Scrolls left""" x = amount * settings.get("user.mouse_wheel_horizontal_amount") actions.mouse_scroll(0, -x) def mouse_scroll_right(amount: float = 1): """Scrolls right""" x = amount * settings.get("user.mouse_wheel_horizontal_amount") actions.mouse_scroll(0, x) 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}") def mouse_scroll_up_continuous(speed_factor: Optional[int] = None): """Scrolls up continuously""" mouse_scroll_continuous(-1, speed_factor) def mouse_scroll_down_continuous(speed_factor: Optional[int] = None): """Scrolls down continuously""" mouse_scroll_continuous(1, speed_factor) def mouse_scroll_right_continuous(speed_factor: Optional[int] = None): """Scrolls right continuously""" mouse_scroll_continuous(1, speed_factor, is_vertical=False) def mouse_scroll_left_continuous(speed_factor: Optional[int] = None): """Scrolls left continuously""" mouse_scroll_continuous(-1, speed_factor, is_vertical=False) 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) 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: actions.user.mouse_scroll_stop() 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 return_value = True if control_mouse_forced: actions.tracking.control_toggle(False) control_mouse_forced = False 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() if speed is None: continuous_scrolling_speed_factor = 1.0 else: continuous_scrolling_speed_factor = speed / settings.get( "user.mouse_continuous_scroll_speed_quotient" ) def mouse_is_continuous_scrolling(): """Returns whether continuous scroll is in progress""" return len(continuous_scroll_mode) > 0 def hiss_scroll_up(): """Change mouse hiss scroll direction to up""" global hiss_scroll_up hiss_scroll_up = True def hiss_scroll_down(): """Change mouse hiss scroll direction to down""" global hiss_scroll_up hiss_scroll_up = False @ctx.action_class("user") class UserActions: def noise_trigger_hiss(active: bool): if settings.get("user.mouse_enable_hiss_scroll"): if active: if hiss_scroll_up: actions.user.mouse_scroll_up_continuous() else: actions.user.mouse_scroll_down_continuous() else: actions.user.mouse_scroll_stop() def mouse_scroll_continuous( new_scroll_dir: Literal[-1, 1], 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) if scroll_job: # 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() else: scroll_dir = new_scroll_dir scroll_start_ts = time.perf_counter() scroll_continuous_helper() scroll_job = cron.interval("16ms", scroll_continuous_helper) 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() # The window containing the mouse window = get_window_containing(x, y) if window is None: return rect = window.rect midpoint = rect.center.y factor = continuous_scrolling_speed_factor * settings.get( "user.mouse_gaze_scroll_speed_multiplier" ) amount = factor * (((y - midpoint) / (rect.height / 10)) ** 3) actions.mouse_scroll(amount) def get_window_containing(x: float, y: float): # on windows, check the active_window first since ui.windows() is not z-ordered if app.platform == "windows" and ui.active_window().rect.contains(x, y): return ui.active_window() for window in ui.windows(): if window.rect.contains(x, y): return window return None