1313
1414namespace osu . Android . Input
1515{
16+ /// <summary>
17+ /// Handles Samsung S Pen / stylus input as a true tablet device with area mapping.
18+ /// Provides the same coordinate transformation as desktop Wacom tablets:
19+ /// raw digitizer coordinates → area selection → output area on screen.
20+ /// </summary>
1621 public class AndroidStylusHandler : InputHandler , ITabletHandler
1722 {
18- public override string Description => "S Pen / Stylus (Low Latency) " ;
23+ public override string Description => "S Pen / Stylus" ;
1924 public override bool IsActive => Enabled . Value ;
2025
2126 public Bindable < Vector2 > AreaOffset { get ; } = new Bindable < Vector2 > ( ) ;
@@ -28,6 +33,7 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
2833 {
2934 MinValue = 0.01f ,
3035 MaxValue = 0.9f ,
36+ Precision = 0.01f ,
3137 } ;
3238
3339 private readonly Bindable < TabletInfo ? > tablet = new Bindable < TabletInfo ? > ( ) ;
@@ -36,6 +42,11 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
3642 private bool lastRightDown ;
3743 private bool lastEraserDown ;
3844
45+ // Cached area values for hot path (avoids bindable access per event).
46+ private float areaLeft , areaTop , areaWidth , areaHeight ;
47+ private float outLeft , outTop , outWidth , outHeight ;
48+ private float rotSin , rotCos ;
49+
3950 public AndroidStylusHandler ( )
4051 {
4152 Enabled . Default = true ;
@@ -44,10 +55,70 @@ public AndroidStylusHandler()
4455
4556 public override bool Initialize ( GameHost host )
4657 {
47- tablet . Value = new TabletInfo ( "S Pen" , new Vector2 ( 2000 , 1000 ) ) ;
58+ // Default size will be updated by SetDisplaySize once the display metrics are known.
59+ tablet . Value = new TabletInfo ( "S Pen" , new Vector2 ( 1920 , 1080 ) ) ;
60+
61+ AreaSize . BindValueChanged ( _ => updateCachedTransform ( ) ) ;
62+ AreaOffset . BindValueChanged ( _ => updateCachedTransform ( ) ) ;
63+ OutputAreaSize . BindValueChanged ( _ => updateCachedTransform ( ) ) ;
64+ OutputAreaOffset . BindValueChanged ( _ => updateCachedTransform ( ) ) ;
65+ Rotation . BindValueChanged ( _ => updateCachedTransform ( ) ) ;
66+
4867 return base . Initialize ( host ) ;
4968 }
5069
70+ /// <summary>
71+ /// Sets the digitizer/display dimensions. Must be called after the display is known.
72+ /// This sets the full tablet area and default output area.
73+ /// </summary>
74+ public void SetDisplaySize ( int width , int height )
75+ {
76+ var size = new Vector2 ( width , height ) ;
77+ tablet . Value = new TabletInfo ( "S Pen" , size ) ;
78+
79+ // Default: full digitizer area mapped to full screen (1:1 passthrough).
80+ AreaSize . Default = size ;
81+ AreaOffset . Default = size / 2 ;
82+ OutputAreaSize . Default = size ;
83+ OutputAreaOffset . Default = size / 2 ;
84+
85+ // Only set current values if they haven't been configured by the user yet.
86+ if ( AreaSize . Value == default || AreaSize . Value == new Vector2 ( 1920 , 1080 ) )
87+ {
88+ AreaSize . Value = size ;
89+ AreaOffset . Value = size / 2 ;
90+ }
91+
92+ if ( OutputAreaSize . Value == default || OutputAreaSize . Value == new Vector2 ( 1920 , 1080 ) )
93+ {
94+ OutputAreaSize . Value = size ;
95+ OutputAreaOffset . Value = size / 2 ;
96+ }
97+
98+ updateCachedTransform ( ) ;
99+ }
100+
101+ private void updateCachedTransform ( )
102+ {
103+ var aSize = AreaSize . Value ;
104+ var aOff = AreaOffset . Value ;
105+ areaLeft = aOff . X - aSize . X / 2 ;
106+ areaTop = aOff . Y - aSize . Y / 2 ;
107+ areaWidth = aSize . X ;
108+ areaHeight = aSize . Y ;
109+
110+ var oSize = OutputAreaSize . Value ;
111+ var oOff = OutputAreaOffset . Value ;
112+ outLeft = oOff . X - oSize . X / 2 ;
113+ outTop = oOff . Y - oSize . Y / 2 ;
114+ outWidth = oSize . X ;
115+ outHeight = oSize . Y ;
116+
117+ float radians = MathF . PI / 180f * Rotation . Value ;
118+ rotSin = MathF . Sin ( radians ) ;
119+ rotCos = MathF . Cos ( radians ) ;
120+ }
121+
51122 public bool HandleMotionEvent ( MotionEvent e )
52123 {
53124 if ( ! Enabled . Value ) return false ;
@@ -61,10 +132,10 @@ public bool HandleMotionEvent(MotionEvent e)
61132 return true ;
62133 }
63134
135+ // Process all batched historical events for maximum accuracy.
64136 for ( int i = 0 ; i < e . HistorySize ; i ++ )
65- {
66137 handlePointer ( e , i ) ;
67- }
138+
68139 handlePointer ( e , - 1 ) ;
69140
70141 return true ;
@@ -75,19 +146,50 @@ private void handlePointer(MotionEvent e, int historyIndex)
75146 const int pointer_index = 0 ;
76147 if ( e . PointerCount <= pointer_index ) return ;
77148
78- float x = historyIndex < 0 ? e . GetX ( pointer_index ) : e . GetHistoricalX ( pointer_index , historyIndex ) ;
79- float y = historyIndex < 0 ? e . GetY ( pointer_index ) : e . GetHistoricalY ( pointer_index , historyIndex ) ;
149+ float rawX = historyIndex < 0 ? e . GetX ( pointer_index ) : e . GetHistoricalX ( pointer_index , historyIndex ) ;
150+ float rawY = historyIndex < 0 ? e . GetY ( pointer_index ) : e . GetHistoricalY ( pointer_index , historyIndex ) ;
80151 float pressure = historyIndex < 0 ? e . GetPressure ( pointer_index ) : e . GetHistoricalPressure ( pointer_index , historyIndex ) ;
81152
82- if ( tablet . Value == null || x > tablet . Value . Size . X || y > tablet . Value . Size . Y )
153+ // Auto-expand tablet size if the digitizer reports coordinates beyond current bounds.
154+ if ( tablet . Value == null || rawX > tablet . Value . Size . X || rawY > tablet . Value . Size . Y )
83155 {
84156 var currentSize = tablet . Value ? . Size ?? Vector2 . Zero ;
85- var newSize = new Vector2 ( Math . Max ( x , currentSize . X ) , Math . Max ( y , currentSize . Y ) ) ;
157+ var newSize = new Vector2 ( Math . Max ( rawX + 1 , currentSize . X ) , Math . Max ( rawY + 1 , currentSize . Y ) ) ;
86158 tablet . Value = new TabletInfo ( "S Pen" , newSize ) ;
87159 }
88160
89- PendingInputs . Enqueue ( new MousePositionAbsoluteInput { Position = new Vector2 ( x , y ) } ) ;
161+ // Apply tablet area → output area coordinate mapping.
162+ float mappedX , mappedY ;
163+
164+ if ( areaWidth > 0 && areaHeight > 0 )
165+ {
166+ // Normalize to [0, 1] within the configured tablet area.
167+ float normX = ( rawX - areaLeft ) / areaWidth ;
168+ float normY = ( rawY - areaTop ) / areaHeight ;
169+
170+ // Apply rotation around center of normalized space.
171+ if ( Rotation . Value != 0 )
172+ {
173+ float cx = normX - 0.5f ;
174+ float cy = normY - 0.5f ;
175+ normX = cx * rotCos - cy * rotSin + 0.5f ;
176+ normY = cx * rotSin + cy * rotCos + 0.5f ;
177+ }
178+
179+ // Map to output area.
180+ mappedX = outLeft + normX * outWidth ;
181+ mappedY = outTop + normY * outHeight ;
182+ }
183+ else
184+ {
185+ // Fallback: raw passthrough if area is invalid.
186+ mappedX = rawX ;
187+ mappedY = rawY ;
188+ }
189+
190+ PendingInputs . Enqueue ( new MousePositionAbsoluteInput { Position = new Vector2 ( mappedX , mappedY ) } ) ;
90191
192+ // Button state: pressure-based click (primary) with action overrides.
91193 bool isLeftDown = pressure >= PressureThreshold . Value ;
92194 if ( e . ActionMasked == MotionEventActions . Down || e . ActionMasked == MotionEventActions . ButtonPress ) isLeftDown = true ;
93195 else if ( e . ActionMasked == MotionEventActions . Up || e . ActionMasked == MotionEventActions . ButtonRelease || e . ActionMasked == MotionEventActions . Cancel ) isLeftDown = false ;
@@ -99,13 +201,15 @@ private void handlePointer(MotionEvent e, int historyIndex)
99201 lastLeftDown = isLeftDown ;
100202 }
101203
204+ // S Pen button → right click.
102205 bool isRightDown = ( e . ButtonState & MotionEventButtonState . StylusPrimary ) != 0 ;
103206 if ( isRightDown != lastRightDown )
104207 {
105208 PendingInputs . Enqueue ( new MouseButtonInput ( MouseButton . Right , isRightDown ) ) ;
106209 lastRightDown = isRightDown ;
107210 }
108211
212+ // Eraser → middle click.
109213 bool isEraserDown = ( e . ButtonState & MotionEventButtonState . StylusSecondary ) != 0 || e . GetToolType ( pointer_index ) == MotionEventToolType . Eraser ;
110214 if ( isEraserDown != lastEraserDown )
111215 {
0 commit comments