Terminal.Gui icon indicating copy to clipboard operation
Terminal.Gui copied to clipboard

Duplicate Mouse Click Events on Double-Click

Open tig opened this issue 2 weeks ago • 6 comments

When a user double-clicks the mouse, v2_devellop raises duplicate click and double-click events. The drivers send 6 events instead of the expected 3-4:

Current (Incorrect) Behavior:

Button1Pressed
Button1Released
Button1Clicked        ← First duplicate
Button1Pressed
Button1Released
Button1DoubleClicked  ← Second duplicate

Expected Behavior:

Button1Pressed
Button1Released
Button1Released
Button1DoubleClicked

Root Cause

The mouse event processing pipeline has two layers that both attempt to synthesize click events from press/release events:

  1. Platform Drivers (WindowsDriver, CursesDriver, NetDriver) - Some drivers (especially WindowsDriver) synthesize their own Clicked and DoubleClicked events
  2. MouseInterpreter - Always synthesizes Clicked and DoubleClicked events from Pressed/Released pairs

This causes duplication because both layers are doing the same work.

Architecture

Current Flow

┌──────────────────┐
│  Platform Driver │
│  (Windows/Unix)  │
└────────┬─────────┘
         │ Sends: Pressed, Released, [Clicked], [DoubleClicked]
         ↓
┌──────────────────┐
│ MouseInterpreter │ ← Process(MouseEventArgs e)
│                  │   - yield return e;  (passes through original)
│                  │   - Tracks button state
│                  │   - Synthesizes Clicked/DoubleClicked from Pressed/Released
└────────┬─────────┘
         │ Result: DUPLICATE events
         ↓
┌──────────────────┐
│   Application    │
│   (receives 6    │
│    events)       │
└──────────────────┘

Code Location

The duplication logic is split between:

1. MouseInterpreter (Terminal.Gui/Drivers/MouseInterpreter.cs)

public IEnumerable<MouseEventArgs> Process (MouseEventArgs e)
{
    yield return e;  // ← Passes through driver events (including any Clicked/DoubleClicked)

    // For each mouse button
    for (var i = 0; i < 4; i++)
    {
        _buttonStates [i].UpdateState (e, out int? numClicks);

        if (numClicks.HasValue)
        {
            yield return RaiseClick (i, numClicks.Value, e);  // ← ALSO synthesizes Clicked/DoubleClicked
        }
    }
}

2. MouseButtonStateEx (Terminal.Gui/Drivers/MouseButtonStateEx.cs)

public void UpdateState (MouseEventArgs e, out int? numClicks)
{
    // ...
    if (Pressed)
    {
        // Click released
        numClicks = ++_consecutiveClicks;  // ← Synthesizes click on release
    }
    // ...
}

3. Platform Drivers

The drivers may also synthesize these events:

  • WindowsDriver: Receives DOUBLE_CLICK flag from Windows API and translates to Button1DoubleClicked
  • CursesDriver: Only sends Pressed/Released (no native double-click detection)
  • NetDriver: Only sends Pressed/Released via ANSI sequences (no native double-click detection)

Platform Behavior

Windows Console API

Native events from OS:

  1. MOUSE_BUTTON_PRESSEDButton1Pressed
  2. MOUSE_BUTTON_RELEASEDButton1Released
  3. DOUBLE_CLICK flag → Button1DoubleClicked
  4. MOUSE_BUTTON_RELEASEDButton1Released

Unix/Linux (ncurses)

Native events from ncurses:

  1. BUTTON1_PRESSEDButton1Pressed
  2. BUTTON1_RELEASEDButton1Released
  3. BUTTON1_PRESSEDButton1Pressed (second press)
  4. BUTTON1_RELEASEDButton1Released

Note: ncurses does NOT detect double-clicks natively.

.NET Console (ANSI/VT)

Native events from ANSI escape sequences:

  1. CSI < 0;x;y MButton1Pressed
  2. CSI < 0;x;y mButton1Released
  3. CSI < 0;x;y MButton1Pressed (second press)
  4. CSI < 0;x;y mButton1Released

Note: ANSI sequences do NOT include click/double-click information.

Impact

This affects any code that handles mouse clicks:

view.MouseEvent += (s, e) =>
{
    if (e.Flags.HasFlag(MouseFlags.Button1Clicked))
    {
        // This fires TWICE on a double-click!
        DoSomething();
    }
    
    if (e.Flags.HasFlag(MouseFlags.Button1DoubleClicked))
    {
        // This fires TWICE on a double-click!
        DoSomethingElse();
    }
};

Or when using mouse bindings:

MouseBindings.Add(MouseFlags.Button1Clicked, Command.Activate);
// Command.Activate gets invoked twice on double-click

Proposed Solution

Option 1: Drivers Send Only Pressed/Released (Recommended)

Have all drivers send ONLY Pressed and Released events, and let MouseInterpreter handle click synthesis uniformly:

  1. Strip out any Clicked/DoubleClicked/TripleClicked event generation from all drivers
  2. Drivers only send: Pressed, Released, Moved, Wheel* events
  3. MouseInterpreter synthesizes all Clicked/DoubleClicked/TripleClicked events

Pros:

  • Consistent behavior across all platforms
  • Single source of truth for click detection
  • Easier to maintain and test

Cons:

  • Loses native platform double-click detection on Windows (but we can use platform timing)

Option 2: Skip Processing of Pre-Synthesized Events

Modify MouseInterpreter.Process() to skip processing if the driver already sent click events:

public IEnumerable<MouseEventArgs> Process (MouseEventArgs e)
{
    yield return e;

    // Don't re-process if driver already sent a click/double-click event
    if (e.IsSingleDoubleOrTripleClicked)
    {
        yield break;
    }

    // For each mouse button
    for (var i = 0; i < 4; i++)
    {
        _buttonStates [i].UpdateState (e, out int? numClicks);
        // ...
    }
}

Pros:

  • Preserves native platform double-click detection on Windows
  • Less invasive change

Cons:

  • Inconsistent behavior across platforms
  • More complex logic
  • Drivers and MouseInterpreter stay coupled

Recommendation

Option 1 is recommended because:

  1. It provides consistent behavior across all platforms
  2. It simplifies the codebase (single source of truth)
  3. It's easier to test and maintain
  4. The MouseInterpreter already has the infrastructure for accurate click detection (timing, position tracking)

Future Consideration: Windows Driver ANSI Mouse Support

The Windows driver currently uses native Win32 API (ReadConsoleInput()) for mouse input regardless of the IsLegacyConsole setting, while Unix/Linux drivers use ANSI escape sequences. This architectural difference complicates cross-platform testing and maintenance.

Potential Benefits of ANSI Mouse Input on Windows:

  • Unified codebase: Same mouse handling logic across all platforms
  • Simplified testing: Mock ANSI sequences instead of Win32 INPUT_RECORD structs
  • Consistency: Already using ANSI for keyboard input when IsLegacyConsole = false
  • Standards-based: VT100/xterm mouse protocol is well-documented

Trade-offs:

  • ANSI parsing overhead (string/regex vs. binary structs) - likely negligible for typical mouse event rates (<100/sec)
  • Loss of native Windows double-click timing - MouseInterpreter already handles this uniformly
  • Requires Windows 10+ with VT support enabled (already a requirement for non-legacy mode)

This change would complement Option 1 by ensuring all drivers provide identical mouse event streams (Pressed/Released only), with MouseInterpreter as the sole authority for click synthesis. Worth investigating after resolving the current duplication issue.

tig avatar Dec 10 '25 05:12 tig

@tznind I'd really appreciate your input on this topic. I uncovered all of this when I was working on

  • #4472

which is broken because I failed to re-wire up MouseHeldDown. As I was working on this I noticed confusing behavior so I decided to document and investigate. So here we are....

tig avatar Dec 10 '25 05:12 tig

Button1Pressed Button1Released Button1Released Button1DoubleClicked

Do you mean pressed,released,pressed,released

Then double clicked?

You cannot release twice without pressing twice.

‐-----

Is the 'duplicate' purely 1 click and 1 double?

This was intentional, if you 'hold' the first click while you wait to see if theres another then you run into delay and complicstion

--‐----

Ah i see you get repeat events for windows input. Yes we should go with option 1

tznind avatar Dec 10 '25 05:12 tznind

Only WindowsDriver send their own events. FYI UnixDriver isn't CursesDriver and doesn't have anything of ncurses library and the mouse event is pure native escape sequences as the DotnetDriver.

BDisp avatar Dec 10 '25 06:12 BDisp

Without a doubt, option 1 should be considered, allowing MouseInterpreter to perform the necessary procedure for all drivers.

BDisp avatar Dec 10 '25 11:12 BDisp

Only WindowsDriver send their own events. FYI UnixDriver isn't CursesDriver and doesn't have anything of ncurses library and the mouse event is pure native escape sequences as the DotnetDriver.

Yeah. I included the ncurses into just to ensure I documented the native behavior.

tig avatar Dec 10 '25 13:12 tig

Button1Pressed Button1Released Button1Released Button1DoubleClicked

Do you mean pressed,released,pressed,released

Then double clicked?

You cannot release twice without pressing twice.

‐-----

Is the 'duplicate' purely 1 click and 1 double?

This was intentional, if you 'hold' the first click while you wait to see if theres another then you run into delay and complicstion

--‐----

Ah i see you get repeat events for windows input. Yes we should go with option 1

For clarity, at the end of the day, what we want is for View.NewMouseEvent to receive the same flow that the ANSI model has:

User single-clicks:

  1. CSI < 0;x;y MButton1Pressed (first press)
  2. CSI < 0;x;y mButton1Released (first release)
    • Button1SingleClicked (synthetic single click)

and double-clicks:

  1. CSI < 0;x;y MButton1Pressed (first press)
  2. CSI < 0;x;y mButton1Released (first release)
  3. CSI < 0;x;y MButton1Pressed (second press)
  4. CSI < 0;x;y mButton1Released (second release)
    • Button1DoubleClicked (synthetic double click)

and triple-clicks:

  1. CSI < 0;x;y MButton1Pressed (first press)
  2. CSI < 0;x;y mButton1Released (first release)
  3. CSI < 0;x;y MButton1Pressed (second press)
  4. CSI < 0;x;y mButton1Released (second release
  5. CSI < 0;x;y MButton1Pressed (third press)
  6. CSI < 0;x;y mButton1Released (third release)
    • Button1TripleClicked (synthetic triple click)

The definition of a "single click", "double click", and "triple click" is the time between "first press" and the last "Button1Release" is less than doubleClickThreshold (misnamed; should be multiClickThreshold).

tig avatar Dec 10 '25 21:12 tig