Skip to content

Conversation

@flabbet
Copy link
Contributor

@flabbet flabbet commented Dec 28, 2025

What does the pull request do?

Added Wintab tablet support API for Windows.

Currently, Avalonia only handles tablet support via Windows Ink. Unfortunately, Windows Ink comes with a lot of unwanted features and weird design decisions. A lot of tablet users still prefer to use Wintab for painting and navigation purposes.

Additionally, Wintab is a much older, more mature API and has broader hardware support than Windows Ink.

What is the current behavior?

Users with disabled Windows Ink cannot use tablet features like pressure and tilt.

What is the updated/expected behavior with this PR?

With this PR, users who have disabled Windows Ink will fall back to Wintab. Windows Ink is still the default and will be used when possible.

How was the solution implemented (if it's not obvious)?

Wintab is hooked to AppWndProc with 2 messages: WT_PROXIMITY and WT_PACKET.

Logic to pick correct api works like this:

  • Wintab context is opened on startup to listen for any tablet event
  • First WT_PACKET or WT_PROXIMITY tells the app, that tablet is present, Wintab context is closed and Windows Ink Test flag is enabled
  • Next MOUSE or POINTER event decides whether Ink or Wintab should be used. If the next event is POINTER, it means user has Windows Ink enabled and it will be used (Wintab context remains closed).
    If the next event is MOUSE without Pen flag, it means that Ink is not enabled, Wintab context is reopened.

Unfortunately, as far as I know and tested, this is the best way to determine if tablet settings have Ink enabled.

Huion tablet does not report WM_POINTER events if Wintab context is opened, so there's no way to know if tablet drivers have "Windows Ink" checkbox enabled. So we must enable wintab context first.

If we don't open Wintab context and Windows Ink is disabled in tablet settings, the only event avalonia will get is MOUSE without any pen flags. So (as far as I know) it's impossible to tell if pen is used.

There might be a theoretical possibility, where the next event after first WT_PROXIMITY or WT_PACKET will be a mouse event with Ink enabled and app could falsely assume, that it is disabled. Although it is really unlikely

Checklist

Breaking changes

I don't think this can break existing solutions. Maybe only for apps, that rely heavily on Ink and scenario I described above with false positive Ink disabled happens.

Obsoletions / Deprecations

Fixed issues

Comment on lines +1468 to +1487
public (float xTilt, float yTilt) ConvertWintabToStandardTilt(double rawAzimuth, double rawAltitude)
{
float azimuthDeg = (float)rawAzimuth / 10f;
float altitudeDeg = (float)rawAltitude / 10f;

float azimuthRad = azimuthDeg * MathF.PI / 180f;

float tiltFromVertical = 90f - altitudeDeg;

float tiltX, tiltY;

tiltX = tiltFromVertical * MathF.Sin(azimuthRad);
tiltY = tiltFromVertical * MathF.Cos(azimuthRad);

tiltY = -tiltY;

tiltX = Math.Clamp(tiltX, -90f, 90f);
tiltY = Math.Clamp(tiltY, -90f, 90f);

return (tiltX, tiltY);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: Windows Ink tilt values are rounded, this returns decimal values

Comment on lines 1269 to 1290
private void FlushIntermediatePackets(uint count)
{
uint actualSize = 0;
var packets = _wnData.GetDataPackets(count, true, ref actualSize);
foreach (var packet in packets)
{
s_lastWintabPackets[packet.pkSerialNumber] = packet;
}

if (s_lastWintabPackets.Count > MaxWintabPacketHistorySize)
{
var keysToRemove = s_lastWintabPackets.Keys
.OrderBy(k => k)
.Take(s_lastWintabPackets.Count - MaxWintabPacketHistorySize)
.ToList();

foreach (var key in keysToRemove)
{
s_lastWintabPackets.Remove(key);
}
}
}
Copy link
Contributor Author

@flabbet flabbet Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is not ideal, but Wintab API does not have anything like Ink to return packets with starting index argument. Buffering it into local dictionary on each movement also prevents buffer overflows within Wintab itself.

Comment on lines +92 to +503
/*/// <summary>
/// Returns a string containing device name.
/// </summary>
/// <returns></returns>
public static string GetDeviceInfo()
{
string devInfo = null;
IntPtr buf = CMemUtils.AllocUnmanagedBuf(MAX_STRING_SIZE);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)EWTICategoryIndex.WTI_DEVICES,
(uint)EWTIDevicesIndex.DVC_NAME, buf);
if (size < 1)
{
throw new Exception("GetDeviceInfo returned empty string.");
}
// Strip off final null character before marshalling.
devInfo = CMemUtils.MarshalUnmanagedString(buf, size - 1);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetDeviceInfo: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return devInfo;
}
/// <summary>
/// Returns the default digitizing context, with useful context overrides.
/// </summary>
/// <param name="options_I">caller's options; OR'd into context options</param>
/// <returns>A valid context object or null on error.</returns>
public static CWintabContext GetDefaultDigitizingContext(ECTXOptionValues options_I = 0)
{
// Send all possible data bits (not including extended data).
// This is redundant with CWintabContext initialization, which
// also inits with PK_PKTBITS_ALL.
uint PACKETDATA = (uint)EWintabPacketBit.PK_PKTBITS_ALL; // The Full Monty
uint PACKETMODE = (uint)EWintabPacketBit.PK_BUTTONS;
CWintabContext context = GetDefaultContext(EWTICategoryIndex.WTI_DEFCONTEXT);
if (context != null)
{
// Add digitizer-specific context tweaks.
context.PktMode = 0; // all data in absolute mode (set EWintabPacketBit bit(s) for relative mode)
context.SysMode = false; // system cursor tracks in absolute mode (zero)
// Add caller's options.
context.Options |= (uint)options_I;
// Set the context data bits.
context.PktData = PACKETDATA;
context.PktMode = PACKETMODE;
context.MoveMask = PACKETDATA;
context.BtnUpMask = context.BtnDnMask;
}
return context;
}
/// <summary>
/// Returns the default system context, with useful context overrides.
/// </summary>
/// <param name="options_I">caller's options; OR'd into context options</param>
/// <returns>A valid context object or null on error.</returns>
public static CWintabContext GetDefaultSystemContext(ECTXOptionValues options_I = 0)
{
// Send all possible data bits (not including extended data).
// This is redundant with CWintabContext initialization, which
// also inits with PK_PKTBITS_ALL.
uint PACKETDATA = (uint)EWintabPacketBit.PK_PKTBITS_ALL; // The Full Monty
uint PACKETMODE = (uint)EWintabPacketBit.PK_BUTTONS;
CWintabContext context = GetDefaultContext(EWTICategoryIndex.WTI_DEFSYSCTX);
if (context != null)
{
// TODO: Add system-specific context tweaks.
// Add caller's options.
context.Options |= (uint)options_I;
// Make sure we get data packet messages.
context.Options |= (uint)ECTXOptionValues.CXO_MESSAGES;
// Set the context data bits.
context.PktData = PACKETDATA;
context.PktMode = PACKETMODE;
context.MoveMask = PACKETDATA;
context.BtnUpMask = context.BtnDnMask;
context.Name = "WintabDN Event Data Context";
}
return context;
}
/// <summary>
/// Helper function to get digitizing or system default context.
/// </summary>
/// <param name="contextType_I">Use WTI_DEFCONTEXT for digital context or WTI_DEFSYSCTX for system context</param>
/// <returns>Returns the default context or null on error.</returns>
private static CWintabContext GetDefaultContext(EWTICategoryIndex contextIndex_I)
{
CWintabContext context = new CWintabContext();
IntPtr buf = CMemUtils.AllocUnmanagedBuf(context.LogContext);
try
{
int size = (int)CWintabFuncs.WTInfoA((uint)contextIndex_I, 0, buf);
context.LogContext = CMemUtils.MarshalUnmanagedBuf<WintabLogContext>(buf, size);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetDefaultContext: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return context;
}
/// <summary>
/// Returns the default device. If this value is -1, then it also known as a "virtual device".
/// </summary>
/// <returns></returns>
public static Int32 GetDefaultDeviceIndex()
{
Int32 devIndex = 0;
IntPtr buf = CMemUtils.AllocUnmanagedBuf(devIndex);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)EWTICategoryIndex.WTI_DEFCONTEXT,
(uint)EWTIContextIndex.CTX_DEVICE, buf);
devIndex = CMemUtils.MarshalUnmanagedBuf<Int32>(buf, size);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetDefaultDeviceIndex: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return devIndex;
}
/// <summary>
/// Returns the WintabAxis object for specified device and dimension.
/// </summary>
/// <param name="devIndex_I">Device index (-1 = virtual device)</param>
/// <param name="dim_I">Dimension: AXIS_X, AXIS_Y or AXIS_Z</param>
/// <returns></returns>
public static WintabAxis GetDeviceAxis(Int32 devIndex_I, EAxisDimension dim_I)
{
WintabAxis axis = new WintabAxis();
IntPtr buf = CMemUtils.AllocUnmanagedBuf(axis);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)(EWTICategoryIndex.WTI_DEVICES + devIndex_I),
(uint)dim_I, buf);
// If size == 0, then returns a zeroed struct.
axis = CMemUtils.MarshalUnmanagedBuf<WintabAxis>(buf, size);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetDeviceAxis: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return axis;
}
/// <summary>
/// Returns a 3-element array describing the tablet's rotation range and resolution capabilities
/// </summary>
/// <returns></returns>
public static WintabAxisArray GetDeviceRotation(out bool rotationSupported_O)
{
WintabAxisArray axisArray = new WintabAxisArray();
rotationSupported_O = false;
IntPtr buf = CMemUtils.AllocUnmanagedBuf(axisArray);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)EWTICategoryIndex.WTI_DEVICES,
(uint)EWTIDevicesIndex.DVC_ROTATION, buf);
// If size == 0, then returns a zeroed struct.
axisArray = CMemUtils.MarshalUnmanagedBuf<WintabAxisArray>(buf, size);
rotationSupported_O = (axisArray.array[0].axResolution != 0 && axisArray.array[1].axResolution != 0);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetDeviceRotation: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return axisArray;
}
/// <summary>
/// Returns the number of devices connected (attached).
/// </summary>
/// <returns>tablet count</returns>
public static UInt32 GetNumberOfDevices()
{
UInt32 numDevices = 0;
IntPtr buf = CMemUtils.AllocUnmanagedBuf(numDevices);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)EWTICategoryIndex.WTI_INTERFACE,
(uint)EWTIInterfaceIndex.IFC_NDEVICES, buf);
numDevices = CMemUtils.MarshalUnmanagedBuf<UInt32>(buf, size);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetNumberOfDevices: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return numDevices;
}
/// <summary>
/// Returns whether a stylus is currently connected to the active cursor.
/// </summary>
/// <returns></returns>
public static bool IsStylusActive()
{
bool isStylusActive = false;
IntPtr buf = CMemUtils.AllocUnmanagedBuf(isStylusActive);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)EWTICategoryIndex.WTI_INTERFACE,
(uint)EWTIInterfaceIndex.IFC_NDEVICES, buf);
isStylusActive = CMemUtils.MarshalUnmanagedBuf<bool>(buf, size);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetNumberOfDevices: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return isStylusActive;
}
/// <summary>
/// Returns a string containing the name of the selected stylus.
/// </summary>
/// <param name="index_I">indicates stylus type</param>
/// <returns></returns>
public static string GetStylusName(EWTICursorNameIndex index_I)
{
string stylusName = null;
IntPtr buf = CMemUtils.AllocUnmanagedBuf(MAX_STRING_SIZE);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)index_I,
(uint)EWTICursorsIndex.CSR_NAME, buf);
if (size < 1)
{
throw new Exception("GetStylusName returned empty string.");
}
// Strip off final null character before marshalling.
stylusName = CMemUtils.MarshalUnmanagedString(buf, size - 1);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetDeviceInfo: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return stylusName;
}
/// <summary>
/// Return the WintabAxis object for the specified dimension.
/// </summary>
/// <param name="dimension_I">Dimension to fetch (eg: x, y)</param>
/// <returns></returns>
public static WintabAxis GetTabletAxis(EAxisDimension dimension_I)
{
WintabAxis axis = new WintabAxis();
IntPtr buf = CMemUtils.AllocUnmanagedBuf(axis);
try
{
int size = (int)CWintabFuncs.WTInfoA(
(uint)EWTICategoryIndex.WTI_DEVICES,
(uint)dimension_I, buf);
axis = CMemUtils.MarshalUnmanagedBuf<WintabAxis>(buf, size);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetMaxPressure: " + ex.ToString());
}
CMemUtils.FreeUnmanagedBuf(buf);
return axis;
}
/// <summary>
/// Return the number of tablets that have at some time been attached.
/// A record of these devices is in the tablet settings. Since there
/// is no direct query for this value, we have to enumerate all of
/// the tablet settings.
/// </summary>
/// <returns>tablet count</returns>
public static UInt32 GetNumberOfConfiguredDevices()
{
UInt32 numConfiguredTablets = 0;
try
{
WintabLogContext ctx = new WintabLogContext();
IntPtr buf = CMemUtils.AllocUnmanagedBuf(ctx);
for (Int32 idx = 0; idx < MAX_NUM_ATTACHED_TABLETS; idx++)
{
int size = (int)CWintabFuncs.WTInfoA(
(UInt32)(EWTICategoryIndex.WTI_DDCTXS + idx), 0, buf);
if (size == 0)
{
break;
}
else
{
numConfiguredTablets++;
}
}
CMemUtils.FreeUnmanagedBuf(buf);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetNumberOfConfiguredDevices: " + ex.ToString());
}
return numConfiguredTablets;
}
/// <summary>
/// Returns a list of indecies of previous or currently attached devices.
/// It is up to the caller to use the list to determine which devices are
/// actually physically device by responding to data events for those devices.
/// Devices that are not physically attached will, of course, never send
/// a data event.
/// </summary>
/// <returns></returns>
public static List<Byte> GetFoundDevicesIndexList()
{
List<Byte> list = new List<Byte>();
try
{
WintabLogContext ctx = new WintabLogContext();
IntPtr buf = CMemUtils.AllocUnmanagedBuf(ctx);
for (Int32 idx = 0; idx < MAX_NUM_ATTACHED_TABLETS; idx++)
{
int size = (int)CWintabFuncs.WTInfoA(
(UInt32)(EWTICategoryIndex.WTI_DDCTXS + idx), 0, buf);
if (size == 0)
{
break;
}
else
{
list.Add((Byte)idx);
}
}
CMemUtils.FreeUnmanagedBuf(buf);
}
catch (Exception ex)
{
MessageBox.Show("FAILED GetNumberOfConfiguredDevices: " + ex.ToString());
}
return list;
}
}*/
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this from original Wacom sample implementation, in case it is needed later. I can remove it if needed

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0061143-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0061159-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0061173-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0061177-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-bot
Copy link

You can test this PR using the following package version. 12.0.999-cibuild0061191-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants