From 83ccdf8bb2aac346fed61fedce528a0244c31663 Mon Sep 17 00:00:00 2001 From: Kaapra Date: Tue, 18 Aug 2026 17:54:46 -0400 Subject: [PATCH 1/3] Add scrollbar interaction and demo for Wooting keyboards - Introduced `AnalogSliderMixin` for pressure-controlled scrollbar interaction. - Added `run_slider_interaction` function for customizable scrollbar handling. - Created `slider_demo.py` to showcase the new scrollbar functionality. - Updated documentation to include scrollbar interaction details. - Added tests for pressure mapping and slider confirmation behavior. --- README.md | 12 +- docs/api.rst | 6 + docs/examples.rst | 6 - docs/index.rst | 1 + docs/scrollbar.rst | 88 ++++++ docs/text_rendering.rst | 2 +- docs/wooting.rst | 158 +++++++++- setup.py | 1 + src/tachypy/__init__.py | 4 + src/tachypy/feedback/runner.py | 3 + src/tachypy/scrollbar_interaction.py | 367 +++++++++++++++++++++++ src/tachypy/wooting/__init__.py | 10 +- src/tachypy/wooting/demos/__init__.py | 1 + src/tachypy/wooting/demos/slider_demo.py | 83 +++++ tests/test_slider_interaction.py | 89 ++++++ 15 files changed, 803 insertions(+), 28 deletions(-) create mode 100644 docs/scrollbar.rst create mode 100644 src/tachypy/scrollbar_interaction.py create mode 100644 src/tachypy/wooting/demos/slider_demo.py create mode 100644 tests/test_slider_interaction.py diff --git a/README.md b/README.md index 45074ac..381afbb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # TachyPy +[![CI](https://github.com/Charestlab/tachypy/actions/workflows/ci.yml/badge.svg)](https://github.com/Charestlab/tachypy/actions/workflows/ci.yml) +[![PyPI version](https://img.shields.io/pypi/v/tachypy.svg)](https://pypi.org/project/tachypy/) +[![Python versions](https://img.shields.io/pypi/pyversions/tachypy.svg)](https://pypi.org/project/tachypy/) [![Docs Status](https://readthedocs.org/projects/tachypy/badge/?version=latest)](https://tachypy.readthedocs.io/en/latest/?badge=latest) +[![License](https://img.shields.io/github/license/Charestlab/tachypy.svg)](https://github.com/Charestlab/tachypy/blob/main/LICENSE) TachyPy is a psychophysics engine for Python focused on precise visual timing with OpenGL rendering, a GLFW-first display/input backend, and experiment-friendly @@ -53,7 +57,8 @@ software timestamps alone. - Psychophysics helpers (`make_gabor`, gratings, normalization, dithering). - Audio playback utility (`Audio`) backed by `tachyaudio`. - Optional Wooting analog-keyboard integration (`tachypy[wooting]`): on-screen - pressure feedback and `WOOTING_ACQUISITION` straight from `tachypy`. + pressure feedback, analog scrollbar interaction, and + `WOOTING_ACQUISITION` straight from `tachypy`. - Test suite for core logic and regressions. ## Installation @@ -88,7 +93,8 @@ pip install -e ".[wooting]" # Wooting analog-keyboard integration ### Wooting analog-keyboard integration `pip install "tachypy[wooting]"` adds support for Wooting analog keyboards -(pressure acquisition, logging, and on-screen visual feedback): +(pressure acquisition, logging, visual feedback, and analog scrollbar +interaction): ```python from tachypy import Screen, WOOTING_ACQUISITION @@ -183,7 +189,7 @@ TACHYPY_FONT="Avenir Next, Helvetica, Arial" python example_tachypy.py - `GLSystemText` supports system font selection by family name, fallback list (e.g. `"Avenir Next, Helvetica, Arial"`), or direct font file path. - For production instruction text, prefer `Text`. -- The old texture-backed constructor is backbenched as `tachypy.text.LegacyText`. +- The old texture-backed constructor is retained as `tachypy.text.LegacyText`. ## API Naming diff --git a/docs/api.rst b/docs/api.rst index ba83517..88555bb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -35,6 +35,12 @@ Core modules .. automodule:: tachypy.scrollbar :members: +Scrollbar interaction +--------------------- + +.. automodule:: tachypy.scrollbar_interaction + :members: + .. automodule:: tachypy.psychophysics :members: diff --git a/docs/examples.rst b/docs/examples.rst index 6d48bdf..b284743 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -29,12 +29,6 @@ Use ``Esc`` to quit, click ``START``/``STOP``/``RESET``, or use ``Space`` and tachypy-clock-demo --windowed -Run the default demo: - -.. code-block:: bash - - python example_tachypy.py - Notes ----- diff --git a/docs/index.rst b/docs/index.rst index 17f1975..0ce2f67 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,7 @@ abstractions for display/input, and helper utilities for experiment workflows. timing_validation text_rendering audio + scrollbar wooting examples contributing diff --git a/docs/scrollbar.rst b/docs/scrollbar.rst new file mode 100644 index 0000000..a191664 --- /dev/null +++ b/docs/scrollbar.rst @@ -0,0 +1,88 @@ +Scrollbar widget +================ + +TachyPy's :class:`~tachypy.scrollbar.Scrollbar` is a customizable visual +widget for selecting a continuous value, ``0``–``100`` by default. It is +independent of the input device: the same widget can be controlled with the +mouse, an analog keyboard, or another custom interaction loop. + +Normal mouse use +---------------- + +Create the scrollbar with the display dimensions, draw it every frame, and +pass the current mouse position to ``handle_mouse``. The value is selected when +a mouse button is released: + +.. code-block:: python + + from tachypy import ResponseHandler, Screen, Scrollbar + + screen = Screen(fullscreen=False) + responses = ResponseHandler(screen=screen) + scrollbar = Scrollbar( + screen_width=screen.width, + screen_height=screen.height, + position_y=screen.height / 2, + half_bar_length=350, + num_marks=11, + text_left="0", + text_right="100", + content_scale=screen.content_scale, + ) + + value = None + while value is None: + responses.get_events() + if responses.should_quit(): + break + + scrollbar.handle_mouse(*responses.get_mouse_position()) + screen.fill((128, 128, 128)) + scrollbar.draw() + screen.flip() + + for click in responses.get_mouse_clicks(): + if click["type"] == "mouseup": + value = scrollbar.get_value() + + screen.close() + +The widget's value can also be controlled directly: + +.. code-block:: python + + scrollbar.set_value(50) # choose an initial/current value + current = scrollbar.get_value() + +Customization +------------- + +The constructor keeps the appearance and geometry of the scrollbar explicit. +Common options include: + +* ``half_bar_length``, ``bar_thickness`` and ``bar_color`` for the main bar; +* ``num_marks``, ``mark_thickness`` and ``mark_color`` for tick marks; +* ``text_left``, ``text_right``, ``font_name``, ``font_size`` and + ``text_color`` for endpoint labels; +* ``half_end_height``, ``end_thickness`` and ``end_color`` for the endpoints; +* ``limit_mouse`` to require the cursor to stay near the bar's horizontal line; +* ``content_scale=screen.content_scale`` for sharp labels on Retina/HiDPI + displays. + +For the complete constructor reference, see the +:class:`~tachypy.scrollbar.Scrollbar` API documentation. + +Analog keyboard interaction +---------------------------- + +Analog keyboard controls are documented with the Wooting integration because +they include key roles, pressure-to-speed mapping, confirmation safety, Wooting +key validation, and keyboard/mouse modes. The interaction layer keeps this +widget unchanged and simply drives its existing ``set_value``/``draw`` API: + +.. seealso:: + + :doc:`wooting` + +The generic, keyboard-agnostic API is documented in +:mod:`tachypy.scrollbar_interaction`. diff --git a/docs/text_rendering.rst b/docs/text_rendering.rst index 37debc9..851e50e 100644 --- a/docs/text_rendering.rst +++ b/docs/text_rendering.rst @@ -28,7 +28,7 @@ Recommended usage - Use ``Text`` for high-quality instruction screens and overlays. - Use ``GLSystemText`` only when you want the explicit historical class name. - Use ``GLTextSDF`` when scalable text quality matters and shaping is simple. -- The old Pillow texture-backed constructor is backbenched as +- The old Pillow texture-backed constructor is retained as ``tachypy.text.LegacyText`` for compatibility. HiDPI and Retina displays diff --git a/docs/wooting.rst b/docs/wooting.rst index 4063fa0..05e4e73 100644 --- a/docs/wooting.rst +++ b/docs/wooting.rst @@ -5,9 +5,9 @@ TachyPy integrates with **TachyWooting**, a hardware toolbox for Wooting analog keyboards (analog pressure acquisition, hierarchical HDF5 logging, light-press / release readiness checks). The hardware toolbox is usable on its own; this page documents only what becomes available -**inside TachyPy** once the integration is installed — chiefly on-screen visual -pressure feedback. For the full keyboard/logging reference, see TachyWooting's own -documentation. +**inside TachyPy** once the integration is installed — on-screen visual pressure +feedback and analog scrollbar responses. For the full keyboard/logging +reference, see TachyWooting's own documentation. Installation ------------ @@ -24,8 +24,9 @@ the keyboard through the top-level ``tachypy`` namespace: One import surface ------------------ -The enriched ``WOOTING_ACQUISITION`` — the hardware acquisition class plus TachyPy -visual feedback — is available straight from the top-level package: +The enriched ``WOOTING_ACQUISITION`` — the hardware acquisition class plus +TachyPy visual feedback and analog scrollbar interaction — is available straight +from the top-level package: .. code-block:: python @@ -46,24 +47,28 @@ How the enrichment works ------------------------ ``WOOTING_ACQUISITION`` is enriched in ``tachypy/wooting/__init__.py``: it is a -thin subclass that combines TachyWooting's hardware acquisition class with -:class:`~tachypy.feedback.VisualPressureFeedbackMixin`. The mixin is what adds the -``wait_light_press_visual`` method — nothing else changes: +thin subclass that combines TachyWooting's hardware acquisition class with two +TachyPy mixins. ``VisualPressureFeedbackMixin`` adds +``wait_light_press_visual`` and ``AnalogSliderMixin`` adds ``interact_slider``: .. code-block:: python from tachywooting import WOOTING_ACQUISITION as _BaseAcquisition from tachypy.feedback import VisualPressureFeedbackMixin + from tachypy.scrollbar_interaction import AnalogSliderMixin - class WOOTING_ACQUISITION(_BaseAcquisition, VisualPressureFeedbackMixin): - """Hardware acquisition + logging (base) + TachyPy visual feedback (mixin).""" + class WOOTING_ACQUISITION( + _BaseAcquisition, VisualPressureFeedbackMixin, AnalogSliderMixin + ): + """Hardware acquisition plus TachyPy feedback and slider interaction.""" This keeps the hardware package (TachyWooting) completely free of TachyPy — the -visual method is grafted on here, on TachyPy's side. Because the mixin only relies -on the :class:`~tachypy.feedback.PressureSource` contract (reading pressures plus -the light-press thresholds), the very same pattern enriches any future analog -keyboard: subclass its base acquisition class and mix in -``VisualPressureFeedbackMixin``. +TachyPy features are grafted on here, on the integration side. The visual mixin +relies on the :class:`~tachypy.feedback.PressureSource` contract, while the +slider mixin relies on ``read_pressures(keys)`` and optional +``validate_analog_keys(keys)``. The same pattern can enrich another analog +keyboard by subclassing its acquisition class and selecting the applicable +TachyPy mixins. First-time setup ---------------- @@ -160,6 +165,129 @@ fall outside the acceptable interval. :alt: Interactive fixation cross with real-time pressure feedback :width: 100% +Analog scrollbar responses +-------------------------- + +The Wooting acquisition provides pressure-controlled responses for any +configured :class:`~tachypy.scrollbar.Scrollbar` through ``interact_slider``. +For normal mouse-only use and visual customization, see :doc:`scrollbar`. + +Quick start +~~~~~~~~~~~ + +Pass a normal TachyPy scrollbar to the enriched Wooting acquisition. The +defaults are ``Z`` to decrease, ``C`` to increase, and ``X`` to confirm: + +.. code-block:: python + + from tachypy import Screen, Scrollbar, WOOTING_ACQUISITION + + acq = WOOTING_ACQUISITION() + acq.initialize_keyboard() + screen = Screen(fullscreen=False) + scrollbar = Scrollbar(screen_width=screen.width, screen_height=screen.height, + position_y=screen.height / 2, + content_scale=screen.content_scale) + + try: + value, reaction_time = acq.interact_slider( + slider=scrollbar, + screen=screen, + ) + finally: + acq.uninitialize_keyboard() + screen.close() + +The method returns ``(value, reaction_time)`` on confirmation and +``(None, None)`` when the participant presses ``Escape`` or closes the window. +A default :class:`~tachypy.responses.ResponseHandler` is created automatically. + +Controls and pressure mapping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The controls are: + +.. list-table:: Default analog controls + :header-rows: 1 + :widths: 18 28 54 + + * - Key + - Role + - Behaviour + * - ``Z`` + - Decrease + - Moves toward the lower end of the scrollbar. Speed depends on pressure. + * - ``C`` + - Increase + - Moves toward the higher end of the scrollbar. Speed depends on pressure. + * - ``X`` + - Confirm + - Selects the current value when its pressure crosses the confirmation threshold. + +Use any three distinct analog keys by passing ``decrease_key``, ``increase_key`` +and ``confirm_key``. TachyPy validates their Wooting analog mappings before the +loop starts. + +.. code-block:: python + + value, reaction_time = acq.interact_slider( + slider=scrollbar, screen=screen, + decrease_key="a", increase_key="d", confirm_key="space") + +Movement is continuous. For pressure ``p`` above the deadzone ``d``, the +private :func:`~tachypy.scrollbar_interaction._effective_pressure` helper uses: + +.. math:: + + p_{effective} = \left(\frac{p - d}{1 - d}\right)^\gamma + +Pressures at or below ``d`` produce zero movement; the normalized result is +raised to ``pressure_gamma`` and converted to a proportion of +``movement_speed``. ``gamma=1`` is linear after the deadzone, ``gamma>1`` gives +gentler fine control, and ``0 float: + """Map raw pressure to movement strength after a deadzone. + + For ``pressure > deadzone``, the exact mapping is:: + + effective = ((pressure - deadzone) / (1 - deadzone)) ** gamma + + The result is clipped to the ``0.0``–``1.0`` range before exponentiation. + ``gamma=1`` is therefore linear *after* the deadzone. ``gamma > 1`` makes + low pressures disproportionately gentle while keeping full pressure at + full speed. ``gamma=0`` would be a step function (zero below the deadzone, + one above it), not a linear mapping, and is rejected by the public loop. + """ + if pressure <= deadzone: + return 0.0 + return ((min(1.0, float(pressure)) - deadzone) / (1.0 - deadzone)) ** gamma + + +def run_slider_interaction( + *, + slider, + screen, + response_handler=None, + input_mode: Literal["keyboard", "mouse_keyboard"] = "keyboard", + control_reader: Callable[[], SliderControls] | None = None, + validate_input: Callable[[], None] | None = None, + drawables=(), + initial_value: float = 50.0, + movement_speed: float = 100.0, + pressure_deadzone: float = 0.05, + pressure_gamma: float = 2.5, + confirm_threshold: float = 0.6, + release_threshold: float = 0.03, + mouse_quiet_period: float = 0.08, + background_color=(128, 128, 128), + clock: Callable[[], float] = time.perf_counter, + wait_until: Callable[[float], None] | None = None, +): + """Run a customizable scrollbar with analog controls. + + This is the backend-agnostic interaction loop. It never imports Wooting or + another keyboard package: ``control_reader`` supplies the current controls, + one :class:`SliderControls` object per frame. Use + :meth:`AnalogSliderMixin.interact_slider` when the input source already has + a ``read_pressures`` method. + + Parameters + ---------- + slider : object + Scrollbar-like widget exposing ``set_value(value)``, ``get_value()``, + and ``draw()``. For ``mouse_keyboard`` it must also expose + ``handle_mouse(x, y)``. A normal TachyPy ``Scrollbar`` keeps all of its + visual and mouse customization. + screen : TachyPy Screen-like object + Must expose ``flip()``; if it exposes ``fill(color)``, the screen is + cleared before each frame. + response_handler : ResponseHandler-like object, optional + Used for event polling, window-close/Escape detection, and mouse + position in ``mouse_keyboard`` mode. It is optional in keyboard-only + mode, although supplying one enables quit handling. + input_mode : {"keyboard", "mouse_keyboard"}, default="keyboard" + ``"keyboard"`` uses analog decrease/increase pressures for movement and + the confirm pressure for selection. ``"mouse_keyboard"`` uses the mouse + for movement and the confirm pressure for selection; the mouse must be + quiet for ``mouse_quiet_period`` before confirmation. + control_reader : callable + Zero-argument callable returning ``SliderControls`` for the current + frame. Values are expected in ``0.0``–``1.0``. + validate_input : callable, optional + Called once before the loop. Use this in a keyboard-specific adapter to + validate key availability without putting keyboard imports here. + drawables : sequence, optional + Objects with ``draw()`` called after the scrollbar on every frame. + initial_value : float, default=50.0 + Value assigned to the scrollbar at the start of the interaction. + movement_speed : float, default=100.0 + Maximum scrollbar units per second at full effective pressure. + pressure_deadzone : float, default=0.05 + Pressures at or below this value do not move the scrollbar. + pressure_gamma : float, default=2.5 + Exponent used after deadzone normalization in + ``effective = normalized_pressure ** pressure_gamma``. ``1`` gives a + linear mapping after the deadzone; values above ``1`` make light + presses slower and improve fine adjustment. ``0`` is invalid because it + would create an abrupt on/off step rather than a useful speed curve. + confirm_threshold : float, default=0.6 + Pressure that the confirm key must cross to select the value. + release_threshold : float, default=0.03 + All three controls must fall below this level before a trial is armed. + This prevents a key held from the previous trial from immediately + moving or confirming the next one. + mouse_quiet_period : float, default=0.08 + In ``mouse_keyboard`` mode, required seconds without mouse movement + before confirmation is accepted. + background_color : tuple, default=(128, 128, 128) + RGB color used to clear the screen when ``fill`` is available. + clock : callable, default=time.perf_counter + Monotonic clock used for movement integration and reaction time. + wait_until : callable, optional + ``wait_until(deadline)`` used to pace the loop. Defaults to a portable + sleep-based implementation; inject a deterministic function in tests. + + Returns + ------- + (float, float) or (None, None) + The selected scrollbar value and elapsed reaction time in seconds, or + ``(None, None)`` if the response handler requests a quit. + + Raises + ------ + ValueError + If the input mode, control reader, or pressure thresholds are invalid. + + Examples + -------- + A Wooting experiment normally uses the mixin adapter: + + .. code-block:: python + + value, rt = acquisition.interact_slider( + slider=scrollbar, screen=screen, + drawables=(instruction_text,), + pressure_gamma=2.5, + ) + + A different analog keyboard can use the generic loop directly: + + .. code-block:: python + + def read_controls(): + return SliderControls( + decrease=keyboard.pressure("z"), + increase=keyboard.pressure("c"), + confirm=keyboard.pressure("x"), + ) + + value, rt = run_slider_interaction( + slider=scrollbar, screen=screen, control_reader=read_controls, + ) + """ + if input_mode not in ("keyboard", "mouse_keyboard"): + raise ValueError("input_mode must be 'keyboard' or 'mouse_keyboard'") + if control_reader is None: + raise ValueError("control_reader is required") + if input_mode == "mouse_keyboard" and response_handler is None: + raise ValueError("response_handler is required for mouse_keyboard mode") + if not 0 <= pressure_deadzone < 1 or pressure_gamma <= 0: + raise ValueError("Invalid pressure mapping parameters") + if not 0 <= release_threshold < confirm_threshold <= 1: + raise ValueError("Require 0 <= release_threshold < confirm_threshold <= 1") + if validate_input is not None: + validate_input() + if wait_until is None: + wait_until = lambda deadline: time.sleep(max(0.0, deadline - clock())) + if response_handler is not None: + if hasattr(response_handler, "clear_events"): + response_handler.clear_events() + if hasattr(response_handler, "reset_timer"): + response_handler.reset_timer() + + slider.set_value(initial_value) + start = last = clock() + previous_confirm = 0.0 + armed = False + last_mouse_move = float("-inf") + next_tick = start + + def draw(): + if hasattr(screen, "fill"): + screen.fill(background_color) + slider.draw() + for drawable in drawables: + drawable.draw() + screen.flip() + + while True: + if response_handler is not None: + response_handler.get_events() + if response_handler.should_quit(): + return None, None + + now = clock() + dt = min(max(0.0, now - last), 0.1) + last = now + controls = control_reader() + movement_active = input_mode == "keyboard" and max(controls.decrease, controls.increase) > pressure_deadzone + + if not armed: + armed = max(controls.decrease, controls.increase, controls.confirm) < release_threshold + previous_confirm = controls.confirm + if not armed: + draw() + next_tick += 0.001 + wait_until(next_tick) + continue + + if input_mode == "mouse_keyboard": + position = response_handler.get_mouse_position() if response_handler is not None else None + moved = position is not None and slider.handle_mouse(*position) + if moved: + last_mouse_move = now + elif movement_active: + direction = _effective_pressure(controls.increase, pressure_deadzone, pressure_gamma) + direction -= _effective_pressure(controls.decrease, pressure_deadzone, pressure_gamma) + slider.set_value(slider.get_value() + movement_speed * direction * dt) + + if ( + previous_confirm < confirm_threshold <= controls.confirm + and not movement_active + and (input_mode == "keyboard" or now - last_mouse_move >= mouse_quiet_period) + ): + return slider.get_value(), now - start + previous_confirm = controls.confirm + draw() + + next_tick += 0.001 + wait_until(next_tick) diff --git a/src/tachypy/wooting/__init__.py b/src/tachypy/wooting/__init__.py index 228b981..b11e8ee 100644 --- a/src/tachypy/wooting/__init__.py +++ b/src/tachypy/wooting/__init__.py @@ -3,7 +3,8 @@ This module is the single import surface for using a Wooting analog keyboard *inside* TachyPy experiments. It re-exports TachyWooting's public API and adds an enriched :class:`WOOTING_ACQUISITION` that gains TachyPy visual feedback -(``wait_light_press_visual``) on top of the hardware acquisition class. +(``wait_light_press_visual``) and analog scrollbar interaction +(``interact_slider``) on top of the hardware acquisition class. TachyPy core never imports this module, so ``pip install tachypy`` stays usable without a keyboard. Importing this module without TachyWooting installed raises a @@ -20,6 +21,7 @@ ) from exc from tachypy.feedback import VisualPressureFeedbackMixin +from tachypy.scrollbar_interaction import AnalogSliderMixin # Re-export the keyboard's public API so experiments need only one import. from tachywooting import ( # noqa: F401 @@ -34,12 +36,14 @@ from tachywooting.visualize import visualize, visualize_all_keys # noqa: F401 # TachyPy-enriched acquisition class that combines Wooting's hardware acquisition and TachyPy's visual feedback. -class WOOTING_ACQUISITION(_tachywooting.WOOTING_ACQUISITION, VisualPressureFeedbackMixin): +class WOOTING_ACQUISITION(_tachywooting.WOOTING_ACQUISITION, VisualPressureFeedbackMixin, AnalogSliderMixin): """Wooting acquisition enriched with TachyPy visual feedback. Identical to :class:`tachywooting.WOOTING_ACQUISITION` (acquisition, logging, readiness checks) plus :meth:`~tachypy.feedback.VisualPressureFeedbackMixin.wait_light_press_visual` - for on-screen pressure feedback. + for on-screen pressure feedback and + :meth:`~tachypy.scrollbar_interaction.AnalogSliderMixin.interact_slider` + for pressure-controlled scrollbar responses. """ diff --git a/src/tachypy/wooting/demos/__init__.py b/src/tachypy/wooting/demos/__init__.py index ccb4412..a6d3603 100644 --- a/src/tachypy/wooting/demos/__init__.py +++ b/src/tachypy/wooting/demos/__init__.py @@ -5,4 +5,5 @@ - ``tachypy-wooting-fixation-demo`` → :func:`visual_fixation_demo.main` - ``tachypy-wooting-mini-bw`` → :func:`mini_bw_experiment.main` +- ``tachypy-wooting-slider-demo`` → :func:`slider_demo.main` """ diff --git a/src/tachypy/wooting/demos/slider_demo.py b/src/tachypy/wooting/demos/slider_demo.py new file mode 100644 index 0000000..931a137 --- /dev/null +++ b/src/tachypy/wooting/demos/slider_demo.py @@ -0,0 +1,83 @@ +"""Minimal three-trial Wooting analog scrollbar demo. + +Run with ``python -m tachypy.wooting.demos.slider_demo`` or the installed +``tachypy-wooting-slider-demo`` command. + +The demo uses the TachyPy ``Scrollbar`` widget unchanged and adds interaction +through ``WOOTING_ACQUISITION.interact_slider``. ``Z`` decreases the value, +``C`` increases it, and ``X`` confirms it. The first two keys are pressure +sensitive: a light press makes a fine adjustment, while a full press moves +quickly. Press ``Escape`` to quit. +""" +from __future__ import annotations + +try: + from tachypy import Screen, Scrollbar, Text, WOOTING_ACQUISITION +except ImportError as exc: # pragma: no cover + raise SystemExit("Install the Wooting extra first: pip install 'tachypy[wooting]'") from exc + + +N_TRIALS = 3 +BACKGROUND = (128, 128, 128) + + +def main() -> int: + acquisition = WOOTING_ACQUISITION() + screen = None + try: + acquisition.initialize_keyboard() + screen = Screen(width=1100, height=650, fullscreen=False, grab_input=False) + screen.hide_mouse() + decrease_key, increase_key, confirm_key = "z", "c", "x" + instruction = ( + f"{decrease_key.upper()} : DECREASE " + f"{increase_key.upper()} : INCREASE " + f"{confirm_key.upper()} : CONFIRM" + ) + + slider = Scrollbar( + screen_width=screen.width, + screen_height=screen.height, + position_y=screen.height / 2, + half_bar_length=350, + num_marks=11, + text_left="0", + text_right="100", + content_scale=screen.content_scale, + ) + message = Text( + "", + dest_rect=(60, 40, screen.width - 60, 180), + font_size=32, + color=(0, 0, 0), + align="center", + vertical_align="center", + content_scale=screen.content_scale, + ) + + print(f"Keyboard controls: {instruction}. Press Escape to quit.") + for trial in range(1, N_TRIALS + 1): + message.set_text(f"TRIAL {trial}/{N_TRIALS}\n{instruction}\nESCAPE : QUIT") + value, reaction_time = acquisition.interact_slider( + slider=slider, + screen=screen, + drawables=(message,), + decrease_key=decrease_key, + increase_key=increase_key, + confirm_key=confirm_key, + ) + if value is None: + print("Demo cancelled.") + return 0 + print(f"Trial {trial}: value={value:.2f}, reaction_time={reaction_time:.3f}s") + + print("Demo complete.") + return 0 + finally: + acquisition.uninitialize_keyboard() + if screen is not None and hasattr(screen, "close"): + screen.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_slider_interaction.py b/tests/test_slider_interaction.py new file mode 100644 index 0000000..3bb3339 --- /dev/null +++ b/tests/test_slider_interaction.py @@ -0,0 +1,89 @@ +from tachypy.scrollbar_interaction import ( + SliderControls, + _effective_pressure, + run_slider_interaction, +) + + +class FakeSlider: + def __init__(self): + self.value = 50.0 + + def set_value(self, value): + self.value = max(0.0, min(100.0, value)) + + def get_value(self): + return self.value + + def draw(self): + pass + + +class FakeInput: + def __init__(self, values): + self.values = iter(values) + + def __call__(self): + return next(self.values) + + +class FakeResponse: + def get_events(self): + pass + + def should_quit(self): + return False + + +class FakeScreen: + def fill(self, color): + pass + + def flip(self): + pass + + +def test_pressure_mapping_is_precise_at_low_pressure(): + assert _effective_pressure(0.05, 0.05, 2.5) == 0.0 + assert _effective_pressure(0.2, 0.05, 2.5) < _effective_pressure(0.8, 0.05, 2.5) + + +def test_keyboard_slider_moves_and_confirms(): + slider = FakeSlider() + clock_value = iter([0.0, 0.001, 0.002, 0.003, 0.004]) + result = run_slider_interaction( + slider=slider, + screen=FakeScreen(), + response_handler=FakeResponse(), + control_reader=FakeInput([ + SliderControls(), + SliderControls(decrease=0.8), + SliderControls(), + SliderControls(confirm=0.8), + ]), + clock=lambda: next(clock_value), + wait_until=lambda _: None, + ) + assert result[0] < 50.0 + assert result[1] > 0.0 + + +def test_confirmation_is_blocked_during_movement_until_x_is_repressed(): + slider = FakeSlider() + clock_value = iter(i / 1000 for i in range(8)) + result = run_slider_interaction( + slider=slider, + screen=FakeScreen(), + response_handler=FakeResponse(), + control_reader=FakeInput([ + SliderControls(), + SliderControls(increase=0.8), + SliderControls(increase=0.8, confirm=0.8), + SliderControls(confirm=0.8), + SliderControls(), + SliderControls(confirm=0.8), + ]), + clock=lambda: next(clock_value), + wait_until=lambda _: None, + ) + assert result[0] > 50.0 From d79653454de51a72e50da6a84a1bb5657d1fbff2 Mon Sep 17 00:00:00 2001 From: Kaapra Date: Tue, 18 Aug 2026 18:05:08 -0400 Subject: [PATCH 2/3] Update Wooting dependency to version 0.2.4 and update the key validation error handling --- docs/wooting.rst | 2 +- setup.cfg | 2 +- setup.py | 2 +- src/tachypy/scrollbar_interaction.py | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/wooting.rst b/docs/wooting.rst index 05e4e73..58deb38 100644 --- a/docs/wooting.rst +++ b/docs/wooting.rst @@ -226,7 +226,7 @@ The controls are: Use any three distinct analog keys by passing ``decrease_key``, ``increase_key`` and ``confirm_key``. TachyPy validates their Wooting analog mappings before the -loop starts. +loop starts and raises if a configured key is unavailable. .. code-block:: python diff --git a/setup.cfg b/setup.cfg index 57a0034..af2266f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,7 +42,7 @@ test = pytest-cov>=5.0 ruff>=0.6 wooting = - tachywooting>=0.2.2 + tachywooting>=0.2.4 [options.entry_points] console_scripts = diff --git a/setup.py b/setup.py index bc6ea1d..9a2fa52 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ 'system_text': ['freetype-py>=2.4', 'uharfbuzz>=0.39'], 'glfw': ['glfw>=2.7'], 'audio': [], - 'wooting': ['tachywooting>=0.2.2'], + 'wooting': ['tachywooting>=0.2.4'], }, python_requires='>=3.10', author='Ian Charest, Mathias Salvas-Hebert and Frederic Gosselin', diff --git a/src/tachypy/scrollbar_interaction.py b/src/tachypy/scrollbar_interaction.py index c158c30..76c42f7 100644 --- a/src/tachypy/scrollbar_interaction.py +++ b/src/tachypy/scrollbar_interaction.py @@ -62,9 +62,9 @@ class AnalogSliderMixin: The host object must implement ``read_pressures(keys)`` and return a mapping from each requested key to a normalized pressure. If it implements - ``validate_analog_keys(keys)``, that method is called before the loop starts; - ``WOOTING_ACQUISITION`` uses it to verify that all three keys are available - as analog keys on the connected Wooting keyboard. + ``validate_analog_keys(keys)``, that method is called before the loop starts. + The Wooting implementation raises for an unmapped key, before any response + is collected. The mixin does not draw or replace the scrollbar. Pass any configured :class:`tachypy.scrollbar.Scrollbar` instance to :meth:`interact_slider`. @@ -111,8 +111,8 @@ def interact_slider( window close and ``Escape`` can abort the interaction. decrease_key, increase_key, confirm_key : str or int, optional The three analog keys. Defaults to ``Z``, ``C``, and ``X``. They - must be distinct, non-empty, and valid analog keys when the host - provides ``validate_analog_keys``. + must be distinct, non-empty, and valid analog keys. A Wooting host + raises before the loop if a key is not mapped as analog. **kwargs Options forwarded to :func:`run_slider_interaction`, including ``input_mode``, ``drawables``, ``initial_value``, From 8d43349853f9928272c332b67e2b344d863e25d8 Mon Sep 17 00:00:00 2001 From: Kaapra Date: Wed, 19 Aug 2026 10:08:31 -0400 Subject: [PATCH 3/3] Enhance mouse_keyboard interaction for scrollbar: add move_by method, update demo, and improve tests --- docs/wooting.rst | 10 +- src/tachypy/scrollbar.py | 7 ++ src/tachypy/scrollbar_interaction.py | 95 +++++++++++++---- src/tachypy/wooting/demos/slider_demo.py | 28 ++--- tests/test_slider_interaction.py | 128 +++++++++++++++++++++++ 5 files changed, 221 insertions(+), 47 deletions(-) diff --git a/docs/wooting.rst b/docs/wooting.rst index 58deb38..ea4cb17 100644 --- a/docs/wooting.rst +++ b/docs/wooting.rst @@ -255,8 +255,8 @@ Input modes and customization ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The default ``input_mode="keyboard"`` uses analog ``Z``/``C`` movement and -``X`` confirmation. Use ``input_mode="mouse_keyboard"`` to move with the mouse -and confirm with an analog key: +``X`` confirmation. Use ``input_mode="mouse_keyboard"`` to allow both mouse +and analog-key movement, with either a mouse click or ``X`` for confirmation: .. code-block:: python @@ -267,8 +267,10 @@ and confirm with an analog key: confirm_key="x", ) -In this mode, the cursor must remain still for ``mouse_quiet_period`` seconds -before confirmation. ``Z`` and ``C`` are not used for movement. +The cursor is hidden and recentered at screen edges. A left click or ``X`` +confirms after ``mouse_quiet_period`` seconds without mouse movement. Pressure +on any analog key temporarily gives the keyboard exclusive control. ``X`` +must be released and pressed again if it was held during mouse movement. The interaction loop does not recreate the scrollbar, so all of its visual customization remains available. Pass ``drawables`` for instruction text or diff --git a/src/tachypy/scrollbar.py b/src/tachypy/scrollbar.py index 22ea12c..833a1f4 100644 --- a/src/tachypy/scrollbar.py +++ b/src/tachypy/scrollbar.py @@ -226,6 +226,13 @@ def handle_mouse(self, mouse_x: float, mouse_y: float) -> bool: self._update_mobile_line_geometry() return True + def move_by(self, delta_x: float, mouse_y: float | None = None) -> bool: + """Move the marker by a relative x-distance in screen pixels.""" + return self.handle_mouse( + self.mobile_line_x + delta_x, + self.position_y if mouse_y is None else mouse_y, + ) + def get_normalized_value(self) -> float: """Return current position in [0, 1].""" return (self.mobile_line_x - self.min_x) / (self.max_x - self.min_x) diff --git a/src/tachypy/scrollbar_interaction.py b/src/tachypy/scrollbar_interaction.py index 76c42f7..1febb93 100644 --- a/src/tachypy/scrollbar_interaction.py +++ b/src/tachypy/scrollbar_interaction.py @@ -103,7 +103,7 @@ def interact_slider( slider : object TachyPy ``Scrollbar``-like object. It must expose ``set_value``, ``get_value``, and ``draw``. In ``mouse_keyboard`` mode it must also - expose ``handle_mouse``. + expose ``move_by(delta_x, mouse_y)``. screen : TachyPy Screen-like object Display surface exposing ``flip()`` and optionally ``fill(color)``. response_handler : ResponseHandler, optional @@ -127,8 +127,8 @@ def interact_slider( Notes ----- In ``keyboard`` mode, movement pressure is sampled continuously. In - ``mouse_keyboard`` mode, the mouse controls the value and the confirm - key selects it; the keyboard movement keys are not used for movement. + ``mouse_keyboard`` mode, the mouse or the analog movement keys control + the value, and the confirm key or a mouse click selects it. """ keys = tuple(str(key).strip().lower() for key in (decrease_key, increase_key, confirm_key)) if not all(keys) or len(set(keys)) != 3: @@ -199,20 +199,26 @@ def run_slider_interaction( slider : object Scrollbar-like widget exposing ``set_value(value)``, ``get_value()``, and ``draw()``. For ``mouse_keyboard`` it must also expose - ``handle_mouse(x, y)``. A normal TachyPy ``Scrollbar`` keeps all of its - visual and mouse customization. + ``move_by(delta_x, mouse_y)``. A normal TachyPy ``Scrollbar`` keeps all + of its visual and mouse customization. screen : TachyPy Screen-like object Must expose ``flip()``; if it exposes ``fill(color)``, the screen is cleared before each frame. response_handler : ResponseHandler-like object, optional Used for event polling, window-close/Escape detection, and mouse position in ``mouse_keyboard`` mode. It is optional in keyboard-only - mode, although supplying one enables quit handling. + mode, although supplying one enables quit handling. Mouse mode also + requires ``set_position`` for automatic edge recentering. input_mode : {"keyboard", "mouse_keyboard"}, default="keyboard" ``"keyboard"`` uses analog decrease/increase pressures for movement and - the confirm pressure for selection. ``"mouse_keyboard"`` uses the mouse - for movement and the confirm pressure for selection; the mouse must be - quiet for ``mouse_quiet_period`` before confirmation. + the confirm pressure for selection. ``"mouse_keyboard"`` accepts both + mouse or analog-key movement and either a left mouse click or the confirm + pressure for selection; the mouse must be quiet for + ``mouse_quiet_period`` before confirmation. + Any analog-key pressure above the deadzone temporarily gives the + keyboard exclusive control. + The hidden cursor is recentered horizontally when it reaches a screen + edge, so relative movement remains available in both directions. control_reader : callable Zero-argument callable returning ``SliderControls`` for the current frame. Values are expected in ``0.0``–``1.0``. @@ -240,8 +246,9 @@ def run_slider_interaction( This prevents a key held from the previous trial from immediately moving or confirming the next one. mouse_quiet_period : float, default=0.08 - In ``mouse_keyboard`` mode, required seconds without mouse movement - before confirmation is accepted. + Required seconds without mouse movement before mouse confirmation is + accepted. It is also used by ``mouse_keyboard`` mode before keyboard + confirmation. background_color : tuple, default=(128, 128, 128) RGB color used to clear the screen when ``fill`` is available. clock : callable, default=time.perf_counter @@ -293,7 +300,14 @@ def read_controls(): if control_reader is None: raise ValueError("control_reader is required") if input_mode == "mouse_keyboard" and response_handler is None: - raise ValueError("response_handler is required for mouse_keyboard mode") + raise ValueError("response_handler is required for mouse input") + if input_mode == "mouse_keyboard" and not hasattr(response_handler, "set_position"): + raise ValueError("mouse_keyboard mode requires response_handler.set_position") + if input_mode == "mouse_keyboard" and not hasattr(slider, "move_by"): + raise ValueError("mouse_keyboard mode requires slider.move_by(delta_x, mouse_y)") + mouse_was_visible = getattr(screen, "mouse_visible", None) + if input_mode == "mouse_keyboard" and hasattr(screen, "hide_mouse"): + screen.hide_mouse() if not 0 <= pressure_deadzone < 1 or pressure_gamma <= 0: raise ValueError("Invalid pressure mapping parameters") if not 0 <= release_threshold < confirm_threshold <= 1: @@ -309,11 +323,10 @@ def read_controls(): response_handler.reset_timer() slider.set_value(initial_value) - start = last = clock() previous_confirm = 0.0 armed = False last_mouse_move = float("-inf") - next_tick = start + last_mouse_position = None def draw(): if hasattr(screen, "fill"): @@ -323,17 +336,41 @@ def draw(): drawable.draw() screen.flip() + def finish(result): + if input_mode == "mouse_keyboard" and mouse_was_visible is not None: + (screen.show_mouse if mouse_was_visible else screen.hide_mouse)() + return result + + draw() + start = last = clock() + next_tick = start + while True: if response_handler is not None: response_handler.get_events() if response_handler.should_quit(): - return None, None + return finish((None, None)) now = clock() dt = min(max(0.0, now - last), 0.1) last = now controls = control_reader() - movement_active = input_mode == "keyboard" and max(controls.decrease, controls.increase) > pressure_deadzone + keyboard_active = max(controls.decrease, controls.increase, controls.confirm) > pressure_deadzone + movement_active = ( + input_mode in ("keyboard", "mouse_keyboard") + and max(controls.decrease, controls.increase) > pressure_deadzone + ) + + mouse_position = None + mouse_moved = False + previous_mouse_position = None + if input_mode == "mouse_keyboard": + mouse_position = response_handler.get_mouse_position() + previous_mouse_position = last_mouse_position + mouse_moved = previous_mouse_position is not None and mouse_position != previous_mouse_position + if mouse_moved: + last_mouse_move = now + last_mouse_position = mouse_position if not armed: armed = max(controls.decrease, controls.increase, controls.confirm) < release_threshold @@ -344,22 +381,36 @@ def draw(): wait_until(next_tick) continue - if input_mode == "mouse_keyboard": - position = response_handler.get_mouse_position() if response_handler is not None else None - moved = position is not None and slider.handle_mouse(*position) - if moved: + if input_mode == "mouse_keyboard" and mouse_moved: + if not keyboard_active: + delta_x = mouse_position[0] - previous_mouse_position[0] + slider.move_by(delta_x, mouse_position[1]) + if hasattr(screen, "width") and ( + mouse_position[0] <= 1 or mouse_position[0] >= screen.width - 1): + center = (screen.width / 2, mouse_position[1]) + response_handler.set_position(*center) + last_mouse_position = center last_mouse_move = now - elif movement_active: + + if movement_active: direction = _effective_pressure(controls.increase, pressure_deadzone, pressure_gamma) direction -= _effective_pressure(controls.decrease, pressure_deadzone, pressure_gamma) slider.set_value(slider.get_value() + movement_speed * direction * dt) + if input_mode == "mouse_keyboard" and not keyboard_active: + for click in response_handler.get_mouse_clicks(): + if (click["type"] == "mouseup" and click.get("button", 0) == 0 + and now - last_mouse_move >= mouse_quiet_period): + return finish((slider.get_value(), now - start)) + if ( + # A held X pressed during mouse movement must be released/repressed + # before confirming, preventing simultaneous input sources. previous_confirm < confirm_threshold <= controls.confirm and not movement_active and (input_mode == "keyboard" or now - last_mouse_move >= mouse_quiet_period) ): - return slider.get_value(), now - start + return finish((slider.get_value(), now - start)) previous_confirm = controls.confirm draw() diff --git a/src/tachypy/wooting/demos/slider_demo.py b/src/tachypy/wooting/demos/slider_demo.py index 931a137..7d4c0d4 100644 --- a/src/tachypy/wooting/demos/slider_demo.py +++ b/src/tachypy/wooting/demos/slider_demo.py @@ -4,10 +4,9 @@ ``tachypy-wooting-slider-demo`` command. The demo uses the TachyPy ``Scrollbar`` widget unchanged and adds interaction -through ``WOOTING_ACQUISITION.interact_slider``. ``Z`` decreases the value, -``C`` increases it, and ``X`` confirms it. The first two keys are pressure -sensitive: a light press makes a fine adjustment, while a full press moves -quickly. Press ``Escape`` to quit. +through ``WOOTING_ACQUISITION.interact_slider`` in ``mouse_keyboard`` mode: +the mouse or analog ``Z``/``C`` keys move the scrollbar, while a mouse click +or ``X`` confirms it. Press ``Escape`` to quit. """ from __future__ import annotations @@ -18,7 +17,6 @@ N_TRIALS = 3 -BACKGROUND = (128, 128, 128) def main() -> int: @@ -27,13 +25,7 @@ def main() -> int: try: acquisition.initialize_keyboard() screen = Screen(width=1100, height=650, fullscreen=False, grab_input=False) - screen.hide_mouse() - decrease_key, increase_key, confirm_key = "z", "c", "x" - instruction = ( - f"{decrease_key.upper()} : DECREASE " - f"{increase_key.upper()} : INCREASE " - f"{confirm_key.upper()} : CONFIRM" - ) + instruction = "MOUSE or Z/C : MOVE CLICK or X : CONFIRM" slider = Scrollbar( screen_width=screen.width, @@ -41,30 +33,24 @@ def main() -> int: position_y=screen.height / 2, half_bar_length=350, num_marks=11, - text_left="0", - text_right="100", content_scale=screen.content_scale, ) message = Text( "", dest_rect=(60, 40, screen.width - 60, 180), - font_size=32, color=(0, 0, 0), - align="center", - vertical_align="center", content_scale=screen.content_scale, ) - print(f"Keyboard controls: {instruction}. Press Escape to quit.") + print(f"Controls: {instruction}. Press Escape to quit.") for trial in range(1, N_TRIALS + 1): message.set_text(f"TRIAL {trial}/{N_TRIALS}\n{instruction}\nESCAPE : QUIT") value, reaction_time = acquisition.interact_slider( slider=slider, screen=screen, drawables=(message,), - decrease_key=decrease_key, - increase_key=increase_key, - confirm_key=confirm_key, + input_mode="mouse_keyboard", + mouse_quiet_period=0.04, ) if value is None: print("Demo cancelled.") diff --git a/tests/test_slider_interaction.py b/tests/test_slider_interaction.py index 3bb3339..526c360 100644 --- a/tests/test_slider_interaction.py +++ b/tests/test_slider_interaction.py @@ -18,6 +18,16 @@ def get_value(self): def draw(self): pass + def handle_mouse(self, x, _y): + changed = self.value != x + self.set_value(x) + return changed + + def move_by(self, delta_x, _mouse_y=None): + changed = delta_x != 0 + self.set_value(self.value + delta_x) + return changed + class FakeInput: def __init__(self, values): @@ -34,8 +44,44 @@ def get_events(self): def should_quit(self): return False + def set_position(self, _x, _y): + pass + + +class FakeMouseResponse(FakeResponse): + def __init__(self, positions, clicks=True, click_after=0): + self.positions = iter(positions) + self.clicks = clicks + self.frame = 0 + self.click_after = click_after + + def get_mouse_position(self): + return next(self.positions) + + def get_mouse_clicks(self): + self.frame += 1 + return ([{"type": "mouseup", "pos": (80.0, 0.0)}] + if self.clicks and self.frame > self.click_after else []) + + +class EdgeMouseResponse(FakeResponse): + def __init__(self): + self.positions = iter([(500.0, 300.0), (999.0, 300.0), (500.0, 300.0)]) + self.recenters = [] + + def get_mouse_position(self): + return next(self.positions) + + def get_mouse_clicks(self): + return [] + + def set_position(self, x, y): + self.recenters.append((x, y)) + class FakeScreen: + width = 1000 + def fill(self, color): pass @@ -87,3 +133,85 @@ def test_confirmation_is_blocked_during_movement_until_x_is_repressed(): wait_until=lambda _: None, ) assert result[0] > 50.0 + + +def test_mouse_click_confirms_after_quiet_period(): + slider = FakeSlider() + clock_value = iter([0.0, 0.0, 0.01, 0.06]) + result = run_slider_interaction( + slider=slider, + screen=FakeScreen(), + response_handler=FakeMouseResponse( + [(10.0, 0.0), (50.0, 0.0), (50.0, 0.0)], click_after=2 + ), + control_reader=FakeInput([SliderControls(), SliderControls(), SliderControls()]), + input_mode="mouse_keyboard", + mouse_quiet_period=0.04, + clock=lambda: next(clock_value), + wait_until=lambda _: None, + ) + assert result == (90.0, 0.06) + + +def test_mouse_keyboard_keeps_analog_key_movement(): + slider = FakeSlider() + clock_value = iter([0.0, 0.0, 0.01, 0.02, 0.10]) + result = run_slider_interaction( + slider=slider, + screen=FakeScreen(), + response_handler=FakeMouseResponse([(50.0, 0.0)] * 4, clicks=False), + control_reader=FakeInput([ + SliderControls(), + SliderControls(increase=0.8), + SliderControls(), + SliderControls(confirm=0.8), + ]), + input_mode="mouse_keyboard", + wait_until=lambda _: None, + clock=lambda: next(clock_value), + ) + assert result[0] > 50.0 + + +def test_mouse_is_locked_while_any_keyboard_key_has_pressure(): + slider = FakeSlider() + clock_value = iter([0.0, 0.0, 0.01, 0.02, 0.10]) + result = run_slider_interaction( + slider=slider, + screen=FakeScreen(), + response_handler=FakeMouseResponse( + [(500.0, 0.0), (900.0, 0.0), (900.0, 0.0), (900.0, 0.0)], + clicks=False, + ), + control_reader=FakeInput([ + SliderControls(), + SliderControls(increase=0.8), + SliderControls(), + SliderControls(confirm=0.8), + ]), + input_mode="mouse_keyboard", + wait_until=lambda _: None, + clock=lambda: next(clock_value), + ) + assert 50.0 < result[0] < 60.0 + + +def test_mouse_keyboard_recenters_at_screen_edge(): + slider = FakeSlider() + response = EdgeMouseResponse() + clock_value = iter([0.0, 0.0, 0.01, 0.10]) + result = run_slider_interaction( + slider=slider, + screen=FakeScreen(), + response_handler=response, + control_reader=FakeInput([ + SliderControls(), + SliderControls(), + SliderControls(confirm=0.8), + ]), + input_mode="mouse_keyboard", + wait_until=lambda _: None, + clock=lambda: next(clock_value), + ) + assert result[0] == 100.0 + assert response.recenters == [(500.0, 300.0)]