diff --git a/WineFix/Patches/ColorPickerWaylandPatch.cs b/WineFix/Patches/ColorPickerWaylandPatch.cs index 7757838..d6cee6f 100644 --- a/WineFix/Patches/ColorPickerWaylandPatch.cs +++ b/WineFix/Patches/ColorPickerWaylandPatch.cs @@ -31,12 +31,24 @@ public static class ColorPickerWaylandPatch private static PropertyInfo _currentViewProperty; private static PropertyInfo _renderControlProperty; // lazily cached (needs runtime type) private static ConstructorInfo _colourRGBConstructor; + private static FieldInfo _screenBmpField; + private static FieldInfo _dragdropWindowField; + private static MethodInfo _showColourPickerMethod; + private static MethodInfo _onFinishDraggingMethod; // State private static IntPtr _hwnd = IntPtr.Zero; private static bool _pickerActive = false; private static bool _useExactPixelColor = false; private static double _monitorScale = 1.0; + private static int _dragStartTicks; + private static int _recaptureIndex; + private static object _pickerService; // the UserInterfaceService showing the picker, if any + private static object _activeMagnifier; // the ColourPickerMagnifier between StartDragging and FinishDragging + private static bool _appInactive; + + // When to take the canvas capture again, in ms after StartDragging; see UpdateWindowLocation_Prefix. + private static readonly int[] RecaptureAfterMs = { 50, 150, 300, 600 }; private const int SRCCOPY = 0x00CC0020; @@ -130,11 +142,43 @@ public static void ApplyPatches(Harmony harmony) if (startDragging != null) harmony.Patch(startDragging, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(StartDragging_Prefix))); + _onFinishDraggingMethod = magnifierType.GetMethod("OnFinishDragging", BindingFlags.Public | BindingFlags.Instance); + var finishDragging = magnifierType.GetMethod("FinishDragging", BindingFlags.Public | BindingFlags.Instance); if (finishDragging != null) harmony.Patch(finishDragging, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(FinishDragging_Prefix))); Logger.Info("Patched ColourPickerMagnifier StartDragging/FinishDragging"); + + // Stop a timer that outlives its window instead of letting it throw + var timerTick = magnifierType.GetMethod("timer_Tick", BindingFlags.NonPublic | BindingFlags.Instance); + _dragdropWindowField = magnifierType.GetField("_dragdropWindow", BindingFlags.NonPublic | BindingFlags.Instance); + if (timerTick != null && _dragdropWindowField != null) + { + harmony.Patch(timerTick, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(TimerTick_Prefix))); + Logger.Info("Patched ColourPickerMagnifier timer_Tick (stop a timer whose window is gone)"); + } + + var updateWindowLocation = magnifierType.GetMethod("UpdateWindowLocation", BindingFlags.NonPublic | BindingFlags.Instance); + _screenBmpField = magnifierType.GetField("_screenBmp", BindingFlags.NonPublic | BindingFlags.Instance); + if (updateWindowLocation != null && _screenBmpField != null) + { + harmony.Patch(updateWindowLocation, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(UpdateWindowLocation_Prefix))); + Logger.Info("Patched ColourPickerMagnifier UpdateWindowLocation (recapture after the overlay is hidden)"); + } + } + + // End the pick when Affinity loses focus + var uiServiceType = serifAssembly.GetType("Serif.Affinity.Services.UserInterfaceService"); + _showColourPickerMethod = uiServiceType?.GetMethod("ShowColourPicker", BindingFlags.Public | BindingFlags.Instance); + var application = System.Windows.Application.Current; + if (_showColourPickerMethod != null && application != null) + { + harmony.Patch(_showColourPickerMethod, + prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(ShowColourPicker_Prefix))); + application.Deactivated += OnApplicationDeactivated; + application.Activated += OnApplicationActivated; + Logger.Info("Patched UserInterfaceService.ShowColourPicker (end the pick when Affinity loses focus)"); } Logger.Info("ColorPickerWayland patch applied successfully"); @@ -142,10 +186,13 @@ public static void ApplyPatches(Harmony harmony) // ── Picker lifecycle ── - public static void StartDragging_Prefix() + public static void StartDragging_Prefix(object __instance) { _pickerActive = true; + _activeMagnifier = __instance; _hwnd = IntPtr.Zero; + _dragStartTicks = Environment.TickCount; + _recaptureIndex = 0; try { @@ -192,6 +239,110 @@ public static void StartDragging_Prefix() public static void FinishDragging_Prefix() { _pickerActive = false; + _activeMagnifier = null; + } + + // ── Ending the pick when Affinity loses focus ── + + /// + /// The magnifier is a topmost window, and under Wine switching to another application + /// does not end the pick, so it stayed above that application's windows. End it as a + /// canceled pick when Affinity is deactivated. The tool ends a pick by calling + /// ShowColourPicker(false); do the same. While Affinity is inactive the tool still sees the + /// mouse over the canvas and would show the magnifier again, so requests to show it are + /// ignored until Affinity is active again; the tool shows a new one when it is next used. + /// A drag from a picker button (the Colors panel) ends on mouse up or lost capture, and + /// both happen elsewhere; end it through the magnifier's OnFinishDragging, which tells the + /// button to release its capture. + /// + /// The end is deferred: WM_ACTIVATEAPP is a sent message and can arrive inside the + /// magnifier's own timer tick (its SetWindowPos call), and ending the pick there destroys + /// the window the rest of that tick still uses. + /// + public static bool ShowColourPicker_Prefix(object __instance, bool isShowing, ref bool __result) + { + if (isShowing && _appInactive) + { + __result = true; + return false; + } + _pickerService = isShowing ? __instance : null; + return true; + } + + private static void OnApplicationDeactivated(object sender, EventArgs e) + { + _appInactive = true; + var service = _pickerService; + var magnifier = _activeMagnifier; + if (service == null && magnifier == null) + return; + System.Windows.Application.Current?.Dispatcher.BeginInvoke( + System.Windows.Threading.DispatcherPriority.Normal, new Action(() => EndPick(service, magnifier))); + } + + private static void EndPick(object service, object magnifier) + { + if (!_appInactive) + return; // Affinity is active again + try + { + if (service != null && service == _pickerService) + _showColourPickerMethod.Invoke(service, new object[] { false, false, false, 0, true, false }); + else if (magnifier != null && magnifier == _activeMagnifier && _onFinishDraggingMethod != null) + _onFinishDraggingMethod.Invoke(magnifier, new object[] { true }); + else + return; + Logger.Info("Color picker ended: Affinity lost focus"); + } + catch (Exception ex) + { + Logger.Debug($"Failed to end the color picker: {ex.Message}"); + } + } + + private static void OnApplicationActivated(object sender, EventArgs e) + { + _appInactive = false; + } + + /// + /// timer_Tick moves the magnifier window and then reads it; once the pick has ended and the + /// window is gone, a tick still delivered throws a NullReferenceException that takes + /// Affinity down. Stop such a timer and skip the tick. + /// + public static bool TimerTick_Prefix(object __instance, object sender) + { + if (_dragdropWindowField.GetValue(__instance) != null) + return true; + (sender as System.Windows.Threading.DispatcherTimer)?.Stop(); + Logger.Debug("Stopped a color picker timer whose window is gone"); + return false; + } + + /// + /// StartDragging hides the tool overlay (selection handles, outlines) and the first tick + /// captures the canvas, which is then kept for the whole drag. Under Wine the canvas is + /// usually not yet redrawn without the overlay at that point, so the zoom preview showed + /// the handles while the picked color came from the document under them. Drop the capture + /// a few times early in the drag, so the next tick takes it again from the redrawn canvas. + /// + public static void UpdateWindowLocation_Prefix(object __instance) + { + if (!_pickerActive || _recaptureIndex >= RecaptureAfterMs.Length) + return; + + int elapsed = Environment.TickCount - _dragStartTicks; + if (elapsed < RecaptureAfterMs[_recaptureIndex]) + return; + while (_recaptureIndex < RecaptureAfterMs.Length && elapsed >= RecaptureAfterMs[_recaptureIndex]) + _recaptureIndex++; + + if (_screenBmpField.GetValue(__instance) is Bitmap old) + { + _screenBmpField.SetValue(__instance, null); + old.Dispose(); + } } // ── Exact mode: override picked colour with the zoom preview center pixel ── diff --git a/WineFix/WineFixPlugin.cs b/WineFix/WineFixPlugin.cs index d05ea2c..63e7c63 100644 --- a/WineFix/WineFixPlugin.cs +++ b/WineFix/WineFixPlugin.cs @@ -119,7 +119,7 @@ public override void OnPatch(Harmony harmony, IPluginContext context) var magnifierFix = context.Settings.GetEffectiveValue(ColorPickerMagnifierFixKey); bool applyMagnifierFix = magnifierFix == "enabled" || - (magnifierFix == "auto" && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))); + (magnifierFix == "auto" && IsWaylandSession()); if (applyMagnifierFix) { @@ -141,5 +141,24 @@ public override void OnPatch(Harmony harmony, IPluginContext context) Logger.Info("Skipping Canva sign-in helper (disabled in settings)"); } } + + /// + /// Whether the desktop session is Wayland; Wine itself may be running on XWayland. + /// Wine 11.16 and later leave WAYLAND_DISPLAY out of the Windows environment, but + /// pass the XDG session variables through with a WINE_HOST_ prefix. + /// + private static bool IsWaylandSession() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))) + return true; + + foreach (var name in new[] { "WINE_HOST_XDG_SESSION_TYPE", "XDG_SESSION_TYPE" }) + { + if (string.Equals(Environment.GetEnvironmentVariable(name), "wayland", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } } } diff --git a/docs/winefix/configuration.md b/docs/winefix/configuration.md index de2a45a..747412c 100644 --- a/docs/winefix/configuration.md +++ b/docs/winefix/configuration.md @@ -26,7 +26,7 @@ WineFix uses plugin ID `winefix`. Settings are stored in `apl/config/winefix.tom | Value | Behavior | |---|---| -| `auto` | Apply the fix only if Wayland or XWayland is detected (checks `WAYLAND_DISPLAY`). | +| `auto` | Apply the fix only if Wayland or XWayland is detected (checks `WAYLAND_DISPLAY`, then `XDG_SESSION_TYPE`; Wine 11.16 and later pass only the latter, as `WINE_HOST_XDG_SESSION_TYPE`). | | `enabled` | Always apply the fix. | | `disabled` | Never apply the fix. |