diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f28bb..0f92b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ ## [Unreleased] +### Добавлено +- **Края тачпада работают как ползунки** — провести пальцем вверх-вниз вдоль левого края меняет + яркость, вдоль правого громкость, как в родном софте производителя. Курсор в этих полосах не + двигается: используется та же штатная зона Windows, что и у мёртвой зоны снизу, а сам жест + читается сырым вводом. Что одно другому не мешает — измерено, а не предположено. Движение, + начатое в центре панели, спокойно продолжается в полосы и ползунком не становится: одним + движением делается что-то одно. Двумя пальцами по-прежнему прокрутка. Чувствительность + выбирается тремя пресетами, причём яркость и громкость идут в ногу — раньше такие шкалы + разъезжались бы, потому что шаг громкости в Windows равен двум процентам, а не пяти. + Выключено по умолчанию, нужен Precision Touchpad (XIC-61). + ## [0.13.0] — 2026-08-31 ### Добавлено diff --git a/README.md b/README.md index 503ec48..0494c4c 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,12 @@ rate is set with `ChangeDisplaySettingsEx` — no driver needed): alias) cycles the built-in panel's available modes and shows the resulting rate in the localized OSD. With **Keep refresh rate** enabled, that deliberate choice becomes the configured rate for the current power source, so the guard does not undo it 1.5 seconds later. +- **Touchpad edges as sliders.** Slide a finger up and down along the left edge for brightness, + along the right edge for volume (Settings → Touchpad). The pointer does not move inside those + strips: it uses the same stock Windows curtain zone as the bottom dead zone. A movement that + began in the middle of the pad continues into the strips as usual and never becomes a slider — + one gesture does one thing. Two fingers still scroll. Sensitivity has three presets, and + brightness and volume move in step. Requires a Windows Precision Touchpad. - **No such key on your model?** The same cycle is available as the assignable action `hz` ("Cycle screen refresh rate") in Settings → Keys, so it can be put on the Mi button or any other remappable key. It obeys the same **Refresh rate control** toggle: with the feature off, nothing diff --git a/README.ru.md b/README.ru.md index ed789d6..0276708 100644 --- a/README.ru.md +++ b/README.ru.md @@ -484,6 +484,12 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu перебирает доступные режимы встроенной панели и показывает итоговую частоту в локализованном OSD. При включённом **«Удерживать частоту»** этот осознанный выбор становится значением профиля текущего питания, поэтому guard не отменяет его через 1,5 секунды. +- **Края тачпада как ползунки.** Провести пальцем вверх-вниз вдоль левого края — яркость, + вдоль правого — громкость (Настройки → Тачпад). Курсор в этих полосах не двигается: используется + та же штатная curtain-зона Windows, что и у мёртвой зоны снизу. Движение, начатое в центре + панели, продолжается в полосы как обычно и ползунком не становится — одним движением делается + что-то одно. Двумя пальцами по-прежнему прокрутка. Чувствительность — три пресета, яркость и + громкость идут в ногу. Нужен Precision Touchpad. - **На твоей модели такой клавиши нет?** Тот же цикл доступен как назначаемое действие `hz` («Частота экрана по кругу») в Настройки → Клавиши — вешается на Mi-кнопку или любую другую переназначаемую клавишу. Подчиняется тому же тумблеру **«Управление частотой»**: фича выключена — diff --git a/src/Config/AppConfig.cs b/src/Config/AppConfig.cs index 928c4d6..e7413de 100644 --- a/src/Config/AppConfig.cs +++ b/src/Config/AppConfig.cs @@ -133,6 +133,23 @@ public sealed class AppConfig /// только по явному выбору пользователя. public bool RememberBrightness { get; set; } = false; + /// Края тачпада работают как ползунки: слева яркость, справа громкость (XIC-61). + /// Курсорное движение в полосах гасится штатными curtain-зонами Windows, сам жест читается + /// через Raw Input. Требует Precision Touchpad; выключено по умолчанию. + public bool TouchpadEdgeSliders { get; set; } = false; + + /// Ширина краевой полосы, мм. Потолок не косметический: измерено, что заведомо + /// большое значение PTP-маппер игнорирует вовсе и зона просто не работает. + public int TouchpadEdgeWidthMm { get; set; } = SystemIntegration.TouchpadEdgeSliders.DefaultWidthMm; + + /// Чувствительность краевых ползунков: сколько проходов вдоль края нужно, чтобы + /// пройти шкалу целиком. Один — резко, три — плавно. Яркость и громкость идут по этой + /// величине в ногу; до неё они расходились в 2,5 раза (шаг громкости Windows — 2%). + public int TouchpadEdgeSwipesPerRange { get; set; } = 2; + + /// Поменять края местами: слева громкость, справа яркость. + public bool TouchpadEdgeSwap { get; set; } = false; + /// /// Отладка: читать температуру из ACPI-термозоны, даже если Intel DPTF доступен (XIC-41). /// Только правкой config.json — на Intel-машине путь фолбэка иначе не прогнать, он работает diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index 1e2281b..a395b2a 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -180,6 +180,18 @@ "settings.touchpad.deadzone.unit": "mm", "settings.touchpad.deadzone.note": "Only the start of a touch is suppressed: a finger first placed in the strip doesn't move the cursor and doesn't tap. A gesture started higher up keeps working all the way down, and pressing inside the zone still clicks.", "settings.touchpad.deadzone.aap": "The zone is inactive right now: touchpad sensitivity in Windows settings is set to maximum, which turns palm rejection off entirely. Pick a lower sensitivity.", + "settings.touchpad.edges": "Edges as sliders", + "settings.touchpad.edges.desc": "Slide a finger up and down along an edge: brightness on the left, volume on the right.", + "settings.touchpad.edges.size": "Strip width", + "settings.touchpad.edges.size.desc": "Measured from the left and right edges of the pad. Requires the option above.", + "settings.touchpad.edges.speed": "Sensitivity", + "settings.touchpad.edges.speed.desc": "How much brightness and volume change per stroke along an edge. Both scales move in step.", + "settings.touchpad.edges.speed.1": "Fast", + "settings.touchpad.edges.speed.2": "Normal", + "settings.touchpad.edges.speed.3": "Smooth", + "settings.touchpad.edges.swap": "Swap the edges", + "settings.touchpad.edges.swap.desc": "Volume on the left, brightness on the right.", + "settings.touchpad.edges.note": "Only the start of a contact is suppressed: a finger placed directly into a strip does not move the pointer. A movement that began in the central part of the pad continues into the side strips as usual — the pointer does not hit an invisible wall. Such a movement never becomes a slider: one gesture does one thing. Two fingers still scroll.", "settings.updates.check": "Check for updates", "settings.updates.check.desc": "Ask GitHub once a day whether a new version is out. When off, the app makes no network requests at all. Updates are never installed automatically — we only tell you.", "settings.updates.available": "Version {0} is available", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index 20eb516..f3ef350 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -180,6 +180,18 @@ "settings.touchpad.deadzone.unit": "мм", "settings.touchpad.deadzone.note": "Гасится только начало касания: если палец коснулся панели сразу в этой полосе, курсор не сдвинется и тапа не будет. Жест, начатый выше, работает до самого низа, а нажатие в зоне по-прежнему срабатывает.", "settings.touchpad.deadzone.aap": "Сейчас зона не работает: в параметрах Windows для тачпада выбрана максимальная чувствительность — она отключает защиту от ладони целиком. Выберите чувствительность пониже.", + "settings.touchpad.edges": "Края как ползунки", + "settings.touchpad.edges.desc": "Провести пальцем вверх-вниз вдоль края: слева яркость, справа громкость.", + "settings.touchpad.edges.size": "Ширина полос", + "settings.touchpad.edges.size.desc": "Отсчитывается от левого и правого края панели. Нужен включённый пункт выше.", + "settings.touchpad.edges.speed": "Чувствительность", + "settings.touchpad.edges.speed.desc": "Насколько сильно меняются яркость и громкость за одно движение вдоль края. Обе шкалы идут в ногу.", + "settings.touchpad.edges.speed.1": "Резко", + "settings.touchpad.edges.speed.2": "Обычно", + "settings.touchpad.edges.speed.3": "Плавно", + "settings.touchpad.edges.swap": "Поменять края местами", + "settings.touchpad.edges.swap.desc": "Слева станет громкость, справа яркость.", + "settings.touchpad.edges.note": "Гасится только начало касания: палец, опущенный сразу в полосу, курсор не двигает. Движение, начатое в центральной части панели, спокойно продолжается в боковые зоны — курсор не упирается в невидимую стену. Ползунком такое движение не становится: одним движением делается что-то одно. Двумя пальцами по-прежнему прокрутка.", "settings.updates.check": "Проверять обновления", "settings.updates.check.desc": "Раз в сутки спрашивать у GitHub, вышла ли новая версия. Выключено — приложение не обращается в сеть вообще. Обновление не устанавливается само: мы только сообщаем.", "settings.updates.available": "Доступна версия {0}", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index b01716a..3b3dbbd 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -180,6 +180,18 @@ "settings.touchpad.deadzone.unit": "毫米", "settings.touchpad.deadzone.note": "仅抑制触摸的开始:手指首次落在该区域时不会移动光标,也不会触发轻点。从上方开始的手势可以一直滑到底部,在该区域按压仍然可以点击。", "settings.touchpad.deadzone.aap": "死区当前无效:Windows 设置中触摸板灵敏度为最高,这会完全关闭手掌防误触。请选择较低的灵敏度。", + "settings.touchpad.edges": "边缘作为滑块", + "settings.touchpad.edges.desc": "沿边缘上下滑动手指:左侧调亮度,右侧调音量。", + "settings.touchpad.edges.size": "边缘条宽度", + "settings.touchpad.edges.size.desc": "从触控板左右边缘算起。需先启用上方选项。", + "settings.touchpad.edges.speed": "灵敏度", + "settings.touchpad.edges.speed.desc": "沿边缘滑动一次时亮度与音量的变化幅度。两者保持一致。", + "settings.touchpad.edges.speed.1": "快速", + "settings.touchpad.edges.speed.2": "标准", + "settings.touchpad.edges.speed.3": "平缓", + "settings.touchpad.edges.swap": "左右互换", + "settings.touchpad.edges.swap.desc": "左侧调音量,右侧调亮度。", + "settings.touchpad.edges.note": "仅抑制触摸的起始:手指直接落在边缘条内时不会移动指针。从触控板中间区域开始的移动会照常延伸到边缘条 — 指针不会撞上看不见的墙。这样的移动不会变成滑块:一次动作只做一件事。双指滚动不受影响。", "settings.updates.check": "检查更新", "settings.updates.check.desc": "每天向 GitHub 查询一次是否有新版本。关闭后,应用完全不会发起任何网络请求。更新不会自动安装——我们只做提醒。", "settings.updates.available": "有可用版本 {0}", diff --git a/src/Program.cs b/src/Program.cs index 167dc19..e8d0f4e 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -36,6 +36,7 @@ private static void Main() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // «В дорогу» временно снимает защиту (заряд до 100%) — гард бережёт 80% только когда travel выключен services.AddSingleton(sp => { diff --git a/src/SystemIntegration/Brightness.cs b/src/SystemIntegration/Brightness.cs index 5856ae9..b76451d 100644 --- a/src/SystemIntegration/Brightness.cs +++ b/src/SystemIntegration/Brightness.cs @@ -55,6 +55,23 @@ public static void Apply(int percent) }); } + /// + /// Установить яркость КАК ПРАВКУ ЧЕЛОВЕКА — без метки Own (XIC-61, краевой ползунок). + /// Разница принципиальная: помеченную запись авто-яркость и лимит считают своей и + /// игнорируют, а ползунок под пальцем — это выбор пользователя, кривая обязана на нём + /// учиться, а лимит — принять его за осознанный. Иначе яркость уезжала бы обратно через + /// минуту, и виноваты были бы мы. + /// + public static void ApplyAsUser(int percent) + { + int lvl = Math.Clamp(percent, 0, 100); + Task.Run(() => + { + try { if (Get() != lvl) Set(lvl); } + catch (Exception ex) { Log.Ex("Brightness.ApplyAsUser", ex); } + }); + } + /// /// Плавный ход от к шагами по 1% (шкала /// непрерывная, проверено на TM2424). Весь путь занимает ~ diff --git a/src/SystemIntegration/EdgeSlideScale.cs b/src/SystemIntegration/EdgeSlideScale.cs new file mode 100644 index 0000000..b9d55ae --- /dev/null +++ b/src/SystemIntegration/EdgeSlideScale.cs @@ -0,0 +1,65 @@ +namespace XiControl.SystemIntegration; + +/// +/// Пересчёт шагов жеста в проценты яркости и нажатия клавиш громкости (XIC-61) — чистая логика +/// под тестами. +/// +/// Зачем вообще отдельный класс. Первая версия двигала яркость на 5% за шаг, а громкость +/// на одно нажатие VK_VOLUME_UP, то есть на 2% — и шкалы разъехались в два с половиной +/// раза: яркость проходилась за пару проходов вдоль края, громкость за четыре. Обе величины +/// теперь измеряются в ОДНИХ единицах (процент своей шкалы), а расхождение по громкости +/// добирается накоплением: два процента дают одно нажатие, остаток переносится. +/// +/// Чувствительность задаётся тем, что человек и так чувствует пальцем: сколько проходов +/// вдоль края нужно, чтобы пройти шкалу целиком. Один — резко, три — плавно. +/// +public sealed class EdgeSlideScale(int swipesPerRange) +{ + /// Доля высоты панели на один шаг жеста. Мелкая нарочно: шкала должна ощущаться + /// непрерывной, а крупность движения задаётся чувствительностью, а не размером шага. + public const double StepFraction = 0.02; + + /// Шагов в одном полном проходе вдоль края (сверху вниз). + public const int StepsPerSwipe = (int)(1 / StepFraction); + + /// Шаг громкости Windows по VK_VOLUME_UP — 2% шкалы. Отсюда и пересчёт + /// процентов в нажатия: дробить мельче нечем. + private const double VolumeTapPercent = 2.0; + + /// Пресеты для настроек: сколько проходов на всю шкалу. + public static readonly int[] Presets = [1, 2, 3]; + + /// Клэмп: config.json правится руками, а ноль проходов — деление на ноль. + /// Верх совпадает с набором пресетов намеренно: иначе вписанное руками значение не имело + /// бы подписи, и комбо в настройках показывало бы не то, что реально работает. + public static int Normalize(int swipes) => Math.Clamp(swipes, Presets[0], Presets[^1]); + + private readonly double _perStep = 100.0 / Normalize(swipesPerRange) / StepsPerSwipe; + private double _brightness; + private double _volume; + + /// Процент своей шкалы на один шаг жеста — для диагностики и тестов. + public double PercentPerStep => _perStep; + + /// Целые проценты яркости из шагов; дробный остаток переносится в следующий вызов, + /// иначе медленное движение не давало бы ничего вовсе. + public int Brightness(int steps) => Take(ref _brightness, steps, 1.0); + + /// Число нажатий клавиши громкости: 2% шкалы за нажатие, остаток переносится. + public int VolumeTaps(int steps) => Take(ref _volume, steps, VolumeTapPercent); + + /// Забыть накопленное — палец оторван, новый жест считает с нуля. + public void Reset() + { + _brightness = 0; + _volume = 0; + } + + private int Take(ref double acc, int steps, double unit) + { + acc += steps * _perStep; + int whole = (int)(acc / unit); + acc -= whole * unit; + return whole; + } +} diff --git a/src/SystemIntegration/KeyActions.cs b/src/SystemIntegration/KeyActions.cs index 57b9a1b..70c39e7 100644 --- a/src/SystemIntegration/KeyActions.cs +++ b/src/SystemIntegration/KeyActions.cs @@ -9,6 +9,7 @@ public static class KeyActions private const byte VK_LWIN = 0x5B, VK_SHIFT = 0x10, VK_P = 0x50, VK_C = 0x43, VK_S = 0x53, VK_TAB = 0x09; // Мультимедиа и «калькулятор» — стандартные VK, их ловит шелл, а не конкретное окно + private const byte VK_VOLUME_DOWN = 0xAE, VK_VOLUME_UP = 0xAF; private const byte VK_MEDIA_NEXT = 0xB0, VK_MEDIA_PREV = 0xB1, VK_MEDIA_STOP = 0xB2, VK_MEDIA_PLAY_PAUSE = 0xB3, VK_LAUNCH_APP2 = 0xB7; private const uint KEYEVENTF_KEYUP = 0x02; @@ -105,6 +106,15 @@ public static void Launch(string path, string? args = null) /// Воспроизведение / пауза. Медиа-клавишу получает владелец медиа-сессии /// Windows (SMTC), поэтому работает с любым плеером — своей интеграции не нужно. + /// Шаг громкости: положительный — громче, отрицательный — тише. Штатными + /// клавишами, а не через IAudioEndpointVolume: так работает системный OSD громкости, + /// и человек видит привычную шкалу вместо нашей выдумки (XIC-61). + public static void VolumeStep(int taps) + { + byte key = taps > 0 ? VK_VOLUME_UP : VK_VOLUME_DOWN; + for (int i = 0; i < Math.Min(Math.Abs(taps), 20); i++) TapKey(key); + } + public static void MediaPlayPause() => TapKey(VK_MEDIA_PLAY_PAUSE); /// Следующий трек. diff --git a/src/SystemIntegration/RawTouchpadReader.cs b/src/SystemIntegration/RawTouchpadReader.cs new file mode 100644 index 0000000..5045859 --- /dev/null +++ b/src/SystemIntegration/RawTouchpadReader.cs @@ -0,0 +1,312 @@ +using System.Runtime.InteropServices; + +namespace XiControl.SystemIntegration; + +/// +/// Сырые касания Precision Touchpad через Raw Input (XIC-61). Driver-free: штатный Win32 плюс +/// разбор HID через hid.dll, права администратора не нужны — проверено пробником +/// (reference/touchpad-edges), он работал обычным процессом. +/// +/// Живёт на СВОЁМ потоке с окном-только-для-сообщений: Raw Input требует HWND и цикла сообщений, +/// а вешать это на UI-поток нельзя — поток касаний идёт сотнями отчётов в секунду и мешал бы +/// панели и OSD. Наружу отдаётся готовый кадр контактов в долях панели; решение о жесте — +/// , оно чистое и под тестами. +/// +/// Координаты — доли, а не миллиметры. Физические единицы PTP объявляются в дюймах +/// с показателем (на TM2424 Units=0x13, UnitExponent=-2), и трактовка «на глазок» +/// однажды уже дала 54,7 мм вместо 139. Доля от логического диапазона от этого не зависит. +/// +/// Тачпада нет или коллекция не PTP — тихо не стартуем и пишем строку в журнал: молчащая фича +/// без следа выглядит как поломка при разборе чужого log.txt. +/// +public sealed class RawTouchpadReader : IDisposable +{ + private const int WM_INPUT = 0x00FF, WM_CLOSE = 0x0010, WM_DESTROY = 0x0002; + private const uint RIDEV_INPUTSINK = 0x00000100, RIDEV_REMOVE = 0x00000001; + private const uint RID_INPUT = 0x10000003, RIM_TYPEHID = 2, RIDI_PREPARSEDDATA = 0x20000005; + private const ushort PageGeneric = 0x01, UsageX = 0x30, UsageY = 0x31; + private const ushort PageDigitizer = 0x0D, UsageTouchPad = 0x05, UsageTipSwitch = 0x42, UsageContactId = 0x51; + private const int HidpSuccess = 0x00110000; + private static readonly IntPtr HwndMessage = new(-3); + + private readonly Action> _onFrame; + private readonly Dictionary _devices = []; + private readonly List _frame = []; + private readonly byte[] _buffer = new byte[8192]; + + private Thread? _thread; + private IntPtr _hwnd; + private WndProc? _proc; // держим ссылку: GC не должен собрать делегат под окном + private volatile bool _stopping; + + public RawTouchpadReader(Action> onFrame) => _onFrame = onFrame; + + /// Читаем ли мы сейчас касания. + public bool Running => _thread is not null; + + /// Поднять чтение. Повторный вызов игнорируется. + public void Start() + { + if (_thread is not null) return; + _stopping = false; + _thread = new Thread(Pump) { IsBackground = true, Name = "XiRawTouchpad" }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + } + + /// Остановить чтение и дождаться потока. Идемпотентно. + public void Stop() + { + var t = _thread; + if (t is null) return; + _stopping = true; + if (_hwnd != IntPtr.Zero) PostMessageW(_hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + if (!t.Join(2000)) + { + // Ссылку НЕ обнуляем: иначе следующий Start поднял бы второй читатель, оба окна + // получали бы WM_INPUT, и каждый жест считался бы дважды. Застрявший одиночка + // безопаснее пары. + Log.Write("RawTouchpad: поток не завершился за 2 с — чтение больше не поднимаем"); + return; + } + _thread = null; + } + + private void Pump() + { + try + { + _proc = WindowProc; + var wc = new WNDCLASSEXW + { + cbSize = (uint)Marshal.SizeOf(), + lpfnWndProc = Marshal.GetFunctionPointerForDelegate(_proc), + hInstance = GetModuleHandleW(null), + lpszClassName = "XiControlRawTouchpad", + }; + // класс мог остаться от прошлого запуска в этом же процессе — это не ошибка + RegisterClassExW(ref wc); + + _hwnd = CreateWindowExW(0, wc.lpszClassName, "", 0, 0, 0, 0, 0, + HwndMessage, IntPtr.Zero, wc.hInstance, IntPtr.Zero); + if (_hwnd == IntPtr.Zero) + { Log.Write($"RawTouchpad: окно не создано ({Marshal.GetLastWin32Error()})"); return; } + + var rid = new RAWINPUTDEVICE + { usUsagePage = PageDigitizer, usUsage = UsageTouchPad, dwFlags = RIDEV_INPUTSINK, hwndTarget = _hwnd }; + if (!RegisterRawInputDevices([rid], 1, (uint)Marshal.SizeOf())) + { + Log.Write("RawTouchpad: Precision Touchpad не найден — краевые ползунки недоступны"); + return; + } + Log.Write("RawTouchpad: чтение касаний запущено"); + + while (!_stopping && GetMessageW(out MSG msg, IntPtr.Zero, 0, 0) > 0) + { + TranslateMessage(ref msg); + DispatchMessageW(ref msg); + } + } + catch (Exception ex) { Log.Ex("RawTouchpad.Pump", ex); } + finally + { + Unregister(); + foreach (var d in _devices.Values) Marshal.FreeHGlobal(d.Preparsed); + _devices.Clear(); + _hwnd = IntPtr.Zero; + } + } + + private static void Unregister() + { + try + { + var rid = new RAWINPUTDEVICE + { usUsagePage = PageDigitizer, usUsage = UsageTouchPad, dwFlags = RIDEV_REMOVE, hwndTarget = IntPtr.Zero }; + RegisterRawInputDevices([rid], 1, (uint)Marshal.SizeOf()); + } + catch (Exception ex) { Log.Ex("RawTouchpad.Unregister", ex); } + } + + private IntPtr WindowProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam) + { + switch (msg) + { + case WM_INPUT: + try { OnInput(lParam); } + catch (Exception ex) { Log.Ex("RawTouchpad.OnInput", ex); } + break; + case WM_CLOSE: + DestroyWindow(hwnd); + return IntPtr.Zero; + case WM_DESTROY: + PostQuitMessage(0); + return IntPtr.Zero; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + + private void OnInput(IntPtr lParam) + { + uint size = (uint)_buffer.Length; + var pin = GCHandle.Alloc(_buffer, GCHandleType.Pinned); + try + { + uint got = GetRawInputData(lParam, RID_INPUT, pin.AddrOfPinnedObject(), ref size, + (uint)Marshal.SizeOf()); + if (got == unchecked((uint)-1) || got == 0) return; + if (BitConverter.ToUInt32(_buffer, 0) != RIM_TYPEHID) return; + + IntPtr device = Marshal.ReadIntPtr(pin.AddrOfPinnedObject(), 8); + if (Describe(device) is not DeviceInfo info) return; + + int hdr = Marshal.SizeOf(); + uint reportSize = BitConverter.ToUInt32(_buffer, hdr); + uint reportCount = BitConverter.ToUInt32(_buffer, hdr + 4); + if (reportSize == 0 || reportCount == 0) return; + + var report = new byte[reportSize]; + for (uint r = 0; r < reportCount; r++) + { + Array.Copy(_buffer, hdr + 8 + (int)(r * reportSize), report, 0, (int)reportSize); + _frame.Clear(); + foreach (var link in info.Links) + { + // Tip Switch — это КНОПКА, а не значение: HidP_GetUsageValue на ней всегда + // отвечает ошибкой, и строгая проверка отбрасывала бы каждый контакт. + // Нажатые кнопки коллекции отдаёт HidP_GetUsages списком usage-кодов. + if (!IsTouching(info.Preparsed, link, report, reportSize)) continue; + if (HidP_GetUsageValue(0, PageGeneric, link, UsageX, out uint x, + info.Preparsed, report, reportSize) != HidpSuccess) continue; + if (HidP_GetUsageValue(0, PageGeneric, link, UsageY, out uint y, + info.Preparsed, report, reportSize) != HidpSuccess) continue; + // Contact Identifier есть не у всех прошивок; без него различаем пальцы + // по номеру коллекции — жесту нужна только различимость, не сам номер. + int id = HidP_GetUsageValue(0, PageDigitizer, link, UsageContactId, out uint raw, + info.Preparsed, report, reportSize) == HidpSuccess ? (int)raw : link; + + _frame.Add(new TouchContact(id, + Fraction(x, info.XMin, info.XMax), Fraction(y, info.YMin, info.YMax))); + } + _onFrame(_frame); + } + } + finally { pin.Free(); } + } + + // Палец на панели: ищем Tip Switch среди нажатых кнопок этой коллекции. Буфер с запасом — + // кнопок в PTP-коллекции единицы, а перевыделять его на каждый контакт незачем. + private readonly ushort[] _usages = new ushort[32]; + + private bool IsTouching(IntPtr preparsed, ushort link, byte[] report, uint reportSize) + { + uint length = (uint)_usages.Length; + if (HidP_GetUsages(0, PageDigitizer, link, _usages, ref length, preparsed, report, reportSize) != HidpSuccess) + return false; + for (uint i = 0; i < length; i++) + if (_usages[i] == UsageTipSwitch) return true; + return false; + } + + private static double Fraction(uint raw, int min, int max) => + max > min ? Math.Clamp((raw - (double)min) / (max - min), 0, 1) : 0; + + // Дескриптор устройства разбираем один раз: он не меняется, а HidP_GetValueCaps недёшев. + private DeviceInfo? Describe(IntPtr device) + { + if (_devices.TryGetValue(device, out var known)) return known.Links.Length > 0 ? known : null; + + uint size = 0; + // первый вызов только спрашивает размер: успех — это 0, а не число байт + if (GetRawInputDeviceInfoW(device, RIDI_PREPARSEDDATA, IntPtr.Zero, ref size) != 0 || size == 0) + return null; + IntPtr pp = Marshal.AllocHGlobal((int)size); + var empty = new DeviceInfo(pp, [], 0, 0, 0, 0); + + if (GetRawInputDeviceInfoW(device, RIDI_PREPARSEDDATA, pp, ref size) == unchecked((uint)-1) || + HidP_GetCaps(pp, out HIDP_CAPS caps) != HidpSuccess) + { _devices[device] = empty; return null; } + + ushort count = caps.NumberInputValueCaps; + var all = new HIDP_VALUE_CAPS[count]; + if (count == 0 || HidP_GetValueCaps(0, all, ref count, pp) != HidpSuccess) + { _devices[device] = empty; return null; } + + var xs = all.Take(count).Where(c => c.UsagePage == PageGeneric && c.NotRange_Usage == UsageX).ToArray(); + var ys = all.Take(count).Where(c => c.UsagePage == PageGeneric && c.NotRange_Usage == UsageY).ToArray(); + if (xs.Length == 0 || ys.Length == 0) { _devices[device] = empty; return null; } + + var info = new DeviceInfo(pp, [.. xs.Select(c => c.LinkCollection)], + xs[0].LogicalMin, xs[0].LogicalMax, ys[0].LogicalMin, ys[0].LogicalMax); + _devices[device] = info; + Log.Write($"RawTouchpad: контактов {info.Links.Length}, X {info.XMin}..{info.XMax}, Y {info.YMin}..{info.YMax}"); + return info; + } + + public void Dispose() => Stop(); + + private sealed record DeviceInfo(IntPtr Preparsed, ushort[] Links, int XMin, int XMax, int YMin, int YMax); + + private delegate IntPtr WndProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] private static extern ushort RegisterClassExW(ref WNDCLASSEXW c); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateWindowExW(uint ex, string cls, string name, uint style, + int x, int y, int w, int h, IntPtr parent, IntPtr menu, IntPtr inst, IntPtr param); + [DllImport("user32.dll")] private static extern bool DestroyWindow(IntPtr hwnd); + [DllImport("user32.dll")] private static extern void PostQuitMessage(int code); + [DllImport("user32.dll")] private static extern IntPtr DefWindowProcW(IntPtr h, uint m, IntPtr w, IntPtr l); + [DllImport("user32.dll")] private static extern bool PostMessageW(IntPtr h, uint m, IntPtr w, IntPtr l); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandleW(string? n); + [DllImport("user32.dll", SetLastError = true)] private static extern bool RegisterRawInputDevices(RAWINPUTDEVICE[] d, uint n, uint s); + [DllImport("user32.dll", SetLastError = true)] private static extern uint GetRawInputData(IntPtr h, uint cmd, IntPtr data, ref uint size, uint hdr); + [DllImport("user32.dll", SetLastError = true)] private static extern uint GetRawInputDeviceInfoW(IntPtr h, uint cmd, IntPtr data, ref uint size); + [DllImport("user32.dll")] private static extern int GetMessageW(out MSG m, IntPtr h, uint min, uint max); + [DllImport("user32.dll")] private static extern bool TranslateMessage(ref MSG m); + [DllImport("user32.dll")] private static extern IntPtr DispatchMessageW(ref MSG m); + [DllImport("hid.dll")] private static extern int HidP_GetCaps(IntPtr pp, out HIDP_CAPS caps); + [DllImport("hid.dll")] private static extern int HidP_GetValueCaps(int type, [In, Out] HIDP_VALUE_CAPS[] caps, ref ushort len, IntPtr pp); + [DllImport("hid.dll")] private static extern int HidP_GetUsageValue(int type, ushort page, ushort link, ushort usage, out uint value, IntPtr pp, byte[] report, uint len); + [DllImport("hid.dll")] private static extern int HidP_GetUsages(int type, ushort page, ushort link, [In, Out] ushort[] usages, ref uint length, IntPtr pp, byte[] report, uint len); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASSEXW + { + public uint cbSize, style; public IntPtr lpfnWndProc; public int cbClsExtra, cbWndExtra; + public IntPtr hInstance, hIcon, hCursor, hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; public IntPtr hIconSm; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE { public ushort usUsagePage, usUsage; public uint dwFlags; public IntPtr hwndTarget; } + + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTHEADER { public uint dwType, dwSize; public IntPtr hDevice; public IntPtr wParam; } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam, lParam; public uint time; public int ptX, ptY; } + + [StructLayout(LayoutKind.Sequential)] + private struct HIDP_CAPS + { + public ushort Usage, UsagePage, InputReportByteLength, OutputReportByteLength, FeatureReportByteLength; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)] public ushort[] Reserved; + public ushort NumberLinkCollectionNodes, NumberInputButtonCaps, NumberInputValueCaps, NumberInputDataIndices; + public ushort NumberOutputButtonCaps, NumberOutputValueCaps, NumberOutputDataIndices; + public ushort NumberFeatureButtonCaps, NumberFeatureValueCaps, NumberFeatureDataIndices; + } + + [StructLayout(LayoutKind.Sequential)] + private struct HIDP_VALUE_CAPS + { + public ushort UsagePage; public byte ReportID; [MarshalAs(UnmanagedType.U1)] public bool IsAlias; + public ushort BitField, LinkCollection, LinkUsage, LinkUsagePage; + [MarshalAs(UnmanagedType.U1)] public bool IsRange, IsStringRange, IsDesignatorRange, IsAbsolute, HasNull; + public byte Reserved; public ushort BitSize, ReportCount; + public ushort Reserved2a, Reserved2b, Reserved2c, Reserved2d, Reserved2e; + public uint UnitsExp, Units; public int LogicalMin, LogicalMax, PhysicalMin, PhysicalMax; + public ushort NotRange_Usage, NotRange_Reserved1, NotRange_StringIndex, NotRange_Reserved2, + NotRange_DesignatorIndex, NotRange_Reserved3, NotRange_DataIndex, NotRange_Reserved4; + } +} diff --git a/src/SystemIntegration/TouchpadEdgeGesture.cs b/src/SystemIntegration/TouchpadEdgeGesture.cs new file mode 100644 index 0000000..de44cce --- /dev/null +++ b/src/SystemIntegration/TouchpadEdgeGesture.cs @@ -0,0 +1,90 @@ +namespace XiControl.SystemIntegration; + +/// Край тачпада, отданный под ползунок. +public enum TouchpadEdge { None, Left, Right } + +/// +/// Одно касание в нормализованных координатах: 0 — левый/верхний край панели, 1 — правый/нижний. +/// Доля, а не миллиметры, намеренно: физические единицы PTP объявляются в дюймах с показателем +/// (на TM2424 Units=0x13, UnitExponent=-2), и пересчёт «на глазок» уже однажды дал +/// 54,7 мм вместо 139 — доля от логического диапазона от этой трактовки не зависит вовсе. +/// +public readonly record struct TouchContact(int Id, double X, double Y); + +/// +/// Распознавание жеста «ползунок у края тачпада» (XIC-61): палец, опустившийся в краевую полосу, +/// вертикальным движением набирает шаги — вверх положительные, вниз отрицательные. Чистая логика +/// без Win32 и таймеров, целиком под тестами; сырые касания приносит . +/// +/// Принимаются только контакты, НАЧАВШИЕСЯ в полосе. Это не придирка, а стыковка с +/// curtain-зоной, которая гасит именно инициацию касания (XIC-24): палец, начатый в середине +/// панели и заехавший в полосу, курсор двигать продолжает — и крутить им ещё и ползунок значило бы +/// делать два действия одним движением. +/// +/// Два пальца отменяют жест. Двухпальцевое движение у Windows — прокрутка и масштаб; +/// перехватывать его нельзя, поэтому при втором касании начатый жест сбрасывается. +/// +public sealed class TouchpadEdgeGesture(double stripFraction = 0.1, double stepFraction = 0.06) +{ + /// Ширина краевой полосы как доля ширины панели. Клэмп — защита от кривого + /// config.json: 0 отключил бы жест молча, а половина панели сделала бы тачпад неюзабельным. + private readonly double _strip = Math.Clamp(stripFraction, 0.02, 0.35); + + /// Сколько высоты панели нужно пройти на один шаг. Мелкий шаг превращает ползунок + /// в дёрганый, крупный — в тугой; доля, а не пиксели, чтобы не зависеть от размера панели. + private readonly double _step = Math.Clamp(stepFraction, 0.01, 0.5); + + private int _id = -1; // контакт, ведущий жест; -1 — жеста нет + private TouchpadEdge _edge; // край, в котором он начался + private double _anchor; // Y, от которого отсчитывается следующий шаг + private bool _cancelled; // жест отменён (второй палец) — до отрыва не возобновляем + + /// Идёт ли сейчас жест — для диагностики и тестов. + public TouchpadEdge Active => _id >= 0 && !_cancelled ? _edge : TouchpadEdge.None; + + /// + /// Очередной кадр касаний. Возвращает край и число шагов: положительное — вверх (ярче/громче), + /// отрицательное — вниз. Ноль шагов означает «жест идёт, но порог ещё не набран». + /// Пустой кадр (палец отпущен) сбрасывает состояние. + /// + public (TouchpadEdge Edge, int Steps) Update(IReadOnlyList contacts) + { + if (contacts.Count == 0) { Reset(); return (TouchpadEdge.None, 0); } + + // Второй палец — это прокрутка Windows, а не наш ползунок. Отменяем до полного отрыва, + // иначе подняв один палец человек неожиданно продолжил бы крутить яркость. + if (contacts.Count > 1) { _cancelled = true; _id = -1; return (TouchpadEdge.None, 0); } + if (_cancelled) return (TouchpadEdge.None, 0); + + var c = contacts[0]; + if (_id != c.Id) + { + // Новое касание: жест начинается, только если палец опустился внутри полосы. + var edge = EdgeOf(c.X); + if (edge == TouchpadEdge.None) { _cancelled = true; _id = -1; return (TouchpadEdge.None, 0); } + _id = c.Id; + _edge = edge; + _anchor = c.Y; + return (edge, 0); + } + + // Y растёт вниз, а «вверх» человек ожидает как «больше» — отсюда знак минус. + double moved = _anchor - c.Y; + int steps = (int)(moved / _step); + if (steps == 0) return (_edge, 0); + _anchor -= steps * _step; // не обнуляем: остаток переносится в следующий шаг + return (_edge, steps); + } + + /// Сбросить состояние: смена настроек, потеря фокуса, выключение фичи. + public void Reset() + { + _id = -1; + _edge = TouchpadEdge.None; + _cancelled = false; + } + + /// В какой полосе лежит X (доля ширины). Середина панели — None. + public TouchpadEdge EdgeOf(double x) => + x <= _strip ? TouchpadEdge.Left : x >= 1 - _strip ? TouchpadEdge.Right : TouchpadEdge.None; +} diff --git a/src/SystemIntegration/TouchpadEdgeSliders.cs b/src/SystemIntegration/TouchpadEdgeSliders.cs new file mode 100644 index 0000000..22886e4 --- /dev/null +++ b/src/SystemIntegration/TouchpadEdgeSliders.cs @@ -0,0 +1,264 @@ +using Microsoft.Win32; +using XiControl.Config; + +namespace XiControl.SystemIntegration; + +/// +/// Края тачпада как ползунки (XIC-61): палец, опустившийся в краевую полосу, вертикальным +/// движением крутит яркость (слева) и громкость (справа) — как Free Touch у Huawei. +/// +/// Две половины, обе driver-free: +/// +/// курсор в полосе гасится штатной curtain-зоной Windows (SuperCurtainLeft/Right, +/// те же ручки, что у мёртвой зоны снизу в ); +/// сам жест читается через Raw Input () — контакт внутри +/// зоны туда доходит, это измерено: 12447 касаний из левых 10% ширины при активной +/// 30-миллиметровой зоне. Фильтрация живёт в PTP-маппере Windows, выше HID-драйвера. +/// +/// +/// Мёртвой зоне снизу мы не мешаем и она нам: значения в реестре разные +/// (SuperCurtainBottom против Left/Right), общий у них только перезапуск +/// узла тачпада — и он идемпотентен. +/// +/// Яркость меняется как ручная правка человека, а не как наша запись: ползунок — это +/// он и есть, поэтому кривая авто-яркости обязана на нём учиться, а лимит — считать его +/// осознанным выбором. Иначе вышло бы, что палец крутит яркость, а через минуту она уезжает +/// обратно, и виноваты в этом мы. +/// +public sealed class TouchpadEdgeSliders : IDisposable +{ + private const string RegKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\PrecisionTouchPad"; + private const string LeftValue = "SuperCurtainLeft", RightValue = "SuperCurtainRight"; + + /// Ширина полосы по умолчанию, мм. Панель TM2424 — 139 мм по горизонтали + /// (посчитано из дескриптора: 5,47 дюйма), так что 12 мм — меньше десятой части. + public const int DefaultWidthMm = 12; + + /// Потолок ширины: измерено, что заведомо большое значение (150 мм) PTP-маппер + /// игнорирует ВОВСЕ — курсор ездит как без зоны. Молча неработающая фича хуже узкой, + /// поэтому не даём выставить столько. + public static int NormalizeWidthMm(int mm) => Math.Clamp(mm, 4, 30); + + private readonly AppConfig _cfg; + private readonly TouchpadControl _pad; + private TouchpadEdgeGesture _gesture; + private readonly RawTouchpadReader _reader; + private readonly Action _volume; + private readonly Action _brightness; + private EdgeSlideScale _scale; + private readonly IAppTimer _pump; + private readonly object _lock = new(); + + private int _pendingBrightness; // накоплено с потока чтения, применяет воркер + private int _pendingTaps; + private int _level; // кэш яркости; трогает только воркер — замок не нужен + private volatile bool _resync = true; // перечитать яркость: жест начался заново + private int _busy; // тик воркера уже идёт (WorkerTimer умеет входить повторно) + + public TouchpadEdgeSliders(AppConfig cfg, TouchpadControl pad, + Action? volume = null, Action? brightness = null, IAppTimer? pump = null) + { + _cfg = cfg; + _pad = pad; + _volume = volume ?? KeyActions.VolumeStep; + _brightness = brightness ?? BrightnessStep; + _gesture = new TouchpadEdgeGesture(WidthFraction(cfg), EdgeSlideScale.StepFraction); + _scale = new EdgeSlideScale(cfg.TouchpadEdgeSwipesPerRange); + // дальше их пересобирает Reconfigure — настройки живут не только на старте + _reader = new RawTouchpadReader(OnFrame); + + // Применение вынесено с потока чтения намеренно. Первая версия звала WMI прямо в + // обработчике кадра: синхронный Brightness.Get на каждый шаг плюс Task.Run на каждую + // запись. При быстром движении это десятки одновременных WMI-операций в секунду — + // цикл сообщений забивается, пул потоков голодает, и ползунок через минуту-другую + // просто переставал отвечать. Теперь поток чтения только копит намерение. + _pump = pump ?? new WorkerTimer(); + _pump.Interval = PumpMs; + _pump.Tick += Drain; + } + + /// Как часто применяем накопленное. 50 мс — быстрее человеческого «плавно», + /// но на два порядка реже, чем приходят кадры касаний. + private const int PumpMs = 50; + + /// Тачпад в системе есть — без него опция бессмысленна. + public bool Available => _pad.Available; + + /// Поднять чтение, если фича включена. Зовётся на старте и при смене настройки. + public void Start() + { + // Пересобираем ВСЕГДА, даже если фича выключена: ширина полосы и чувствительность + // берутся из конфига, а он меняется на ходу. Первая версия строила жест и шкалу + // один раз в конструкторе — и настройка чувствительности молча ничего не делала: + // Apply перезапускал чтение, но считали его прежние объекты со старыми числами. + Reconfigure(); + if (!_cfg.TouchpadEdgeSliders || !Available) return; + _reader.Start(); + _pump.Start(); + } + + /// Остановить чтение (выключение фичи, выход). + public void Stop() + { + _pump.Stop(); + _reader.Stop(); + _gesture.Reset(); + ResetPending(); + } + + /// + /// Применить состояние из конфига: записать или убрать боковые зоны и перезапустить узел, + /// затем поднять или опустить чтение. Как и у мёртвой зоны, зовётся ТОЛЬКО по явному + /// переключению пользователем — на старте реестр не трогаем (CLAUDE.md). + /// + public bool Apply() + { + if (!Write()) return false; + if (!_pad.Restart()) + Log.Write("TouchpadEdges: узел не перезапустился — зоны применятся после перезахода в сеанс"); + + Stop(); + Start(); + return true; + } + + private bool Write() + { + try + { + using var key = Registry.LocalMachine.CreateSubKey(RegKey); + if (key is null) { Log.Write($"TouchpadEdges: не открыть HKLM\\{RegKey}"); return false; } + + if (_cfg.TouchpadEdgeSliders) + { + int mm = NormalizeWidthMm(_cfg.TouchpadEdgeWidthMm); + int himetric = mm * 100; + key.SetValue(LeftValue, himetric, RegistryValueKind.DWord); + key.SetValue(RightValue, himetric, RegistryValueKind.DWord); + Log.Write($"TouchpadEdges: полосы {mm} мм ({himetric} himetric)"); + } + else + { + // выключение — УДАЛЕНИЕ значений, а не запись нуля: не оставляем за собой мусор + key.DeleteValue(LeftValue, throwOnMissingValue: false); + key.DeleteValue(RightValue, throwOnMissingValue: false); + Log.Write("TouchpadEdges: полосы выключены (значения удалены)"); + } + return true; + } + catch (Exception ex) { Log.Ex("TouchpadEdges.Write", ex); return false; } + } + + // Кадр касаний приходит с потока читателя — сотни раз в секунду. Здесь только чистое + // решение и вызов действия; ничего тяжёлого, иначе очередь сообщений начнёт отставать. + private void OnFrame(IReadOnlyList contacts) + { + lock (_lock) + { + var result = _gesture.Update(contacts); + if (contacts.Count == 0) + { + // Палец оторван: остаток шкалы к следующему жесту не относится, а кэш яркости + // протух — в следующий раз читаем реальную (её могли сменить и мимо нас). + // Флагом, а не записью под замком: замок берёт и воркер, а он ходит в WMI, + // и поток чтения касаний вставал бы на нём в очередь. + _scale.Reset(); + _resync = true; + return; + } + if (result.Steps == 0) return; + + bool toBrightness = (result.Edge == TouchpadEdge.Left) != _cfg.TouchpadEdgeSwap; + if (toBrightness) _pendingBrightness += _scale.Brightness(result.Steps); + else _pendingTaps += _scale.VolumeTaps(result.Steps); + } + } + + private static double WidthFraction(AppConfig cfg) + { + // Доля ширины панели: сама панель у каждой модели своя, а зона задаётся в мм. + // 139 мм — измеренная ширина TM2424; для других моделей это приближение, и оно + // безопасно: жест всё равно ограничен той же curtain-зоной, что и подавление. + const double PadWidthMm = 139.0; + return NormalizeWidthMm(cfg.TouchpadEdgeWidthMm) / PadWidthMm; + } + + // Воркер: применяем накопленное пачкой. Здесь можно ходить в WMI — поток свой. + // WorkerTimer периодический и умеет войти повторно, если тик затянулся: WMI-запись + // изредка занимает больше 50 мс. Пропускаем такой тик — накопленное дождётся следующего. + private void Drain() + { + if (Interlocked.Exchange(ref _busy, 1) != 0) return; + try + { + int percent, taps; + lock (_lock) + { + percent = _pendingBrightness; + taps = _pendingTaps; + _pendingBrightness = 0; + _pendingTaps = 0; + } + if (taps != 0) _volume(taps); + if (percent != 0) _brightness(percent); + } + catch (Exception ex) { Log.Ex("TouchpadEdges.Drain", ex); } + finally { Interlocked.Exchange(ref _busy, 0); } + } + + /// + /// Перечитать настройки без похода в реестр: ширина полосы и чувствительность живут в + /// конфиге, а меняются на ходу. Отдельно от намеренно — смена + /// чувствительности не трогает зоны, и гонять ради неё перезапуск узла (тачпад на секунду + /// пропадает) было бы наказанием за движение ползунка в настройках. + /// + public void Reconfigure() + { + lock (_lock) + { + _gesture = new TouchpadEdgeGesture(WidthFraction(_cfg), EdgeSlideScale.StepFraction); + _scale = new EdgeSlideScale(_cfg.TouchpadEdgeSwipesPerRange); + _pendingBrightness = 0; + _pendingTaps = 0; + } + _resync = true; + } + + private void ResetPending() + { + lock (_lock) + { + _pendingBrightness = 0; + _pendingTaps = 0; + _scale.Reset(); + } + _resync = true; + } + + // Яркость — ручная правка: пишем БЕЗ метки Own, чтобы кривая авто-яркости училась, + // а лимит считал это осознанным выбором человека (см. XIC-56 про метки). + // Уровень кэшируем на время жеста: WMI-чтение на каждый шаг и было тем, что вешало + // ползунок при быстром движении. + private void BrightnessStep(int percent) + { + // _level и _resync принадлежат воркеру (Drain защищён от повторного входа), поэтому + // замок здесь не нужен — и не должен браться: под ним стоит WMI-чтение, а тот же + // замок берёт поток чтения касаний. + if (_resync) + { + if (Brightness.Get() is not int now) return; + _level = now; + _resync = false; + } + int next = Math.Clamp(_level + percent, 0, 100); + if (next == _level) return; + _level = next; + Brightness.ApplyAsUser(next); + } + + public void Dispose() + { + _pump.Dispose(); + _reader.Dispose(); + } +} diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index 24d233f..c44feb3 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -27,6 +27,7 @@ public sealed class AppController private readonly TouchpadControl _touchpad; private readonly TouchscreenControl _touchscreen; private readonly TouchpadDeadZone _deadZone; + private readonly TouchpadEdgeSliders _edges; private PerfMode[] _modes = []; private bool _autoStart; // кэш состояния автозапуска (не дёргаем schtasks на каждое меню) @@ -59,7 +60,7 @@ public AppController(IMifsClient mifs, AppConfig cfg, IPowerEvents power, ILocal ChargeGuard charge, RefreshRateGuard hz, PowerProfileGuard profiles, BrightnessCapGuard capGuard, AutoBrightnessGuard autoGuard, AlsWatcher als, TravelChargeMonitor travel, TouchpadControl touchpad, TouchscreenControl touchscreen, - TouchpadDeadZone deadZone) + TouchpadDeadZone deadZone, TouchpadEdgeSliders edges) { _mifs = mifs; _cfg = cfg; @@ -76,6 +77,7 @@ public AppController(IMifsClient mifs, AppConfig cfg, IPowerEvents power, ILocal _touchpad = touchpad; _touchscreen = touchscreen; _deadZone = deadZone; + _edges = edges; ApplyModeVisibility(); } @@ -147,6 +149,10 @@ public void Startup() if (_cfg.AutoBrightness) SeedCurves(); _als.Start(); + // Краевые ползунки тачпада (XIC-61): реестр на старте НЕ трогаем (зоны там уже лежат + // с прошлого включения), поднимаем только чтение касаний. + _edges.Start(); + // «Режим совы»: восстановить после сбоя, включить заново, либо погасить, если фичу отключили if (_cfg.Awake && !_cfg.OwlMode) { AwakeMode.Disable(_cfg); _cfg.Awake = false; _cfg.Save(); } else if (_cfg.Awake) { AwakeMode.Enable(_cfg); _cfg.Save(); } @@ -165,6 +171,7 @@ public void Startup() public void Shutdown() { if (_cfg.Awake) { AwakeMode.Disable(_cfg); _cfg.Save(); } + _edges.Stop(); } // ---- Заряд и «в дорогу» ---- @@ -783,6 +790,47 @@ public void SetTouchpadDeadZoneMm(int mm) if (_cfg.TouchpadDeadZone) Task.Run(() => Safe(_deadZone.Apply, false)); } + /// + /// Краевые ползунки тачпада вкл/выкл (XIC-61). Как и мёртвая зона, трогает реестр только + /// по явному переключению; перезапуск узла и подъём чтения касаний — в фоне, окно настроек + /// не морозим. + /// + public void SetTouchpadEdgeSliders(bool on) + { + _cfg.TouchpadEdgeSliders = on; + _cfg.Save(); + Task.Run(() => Safe(_edges.Apply, false)); + } + + /// Ширина краевых полос (мм). Применяем сразу, но только при включённой фиче — + /// иначе выбор «про запас» молча включил бы её. + public void SetTouchpadEdgeWidthMm(int mm) + { + _cfg.TouchpadEdgeWidthMm = mm; + _cfg.Save(); + if (_cfg.TouchpadEdgeSliders) Task.Run(() => Safe(_edges.Apply, false)); + } + + /// Чувствительность краевых ползунков (проходов на всю шкалу). Реестра не касается — + /// пересобираем только пересчёт шагов, поэтому ни перезапуска узла, ни перечитывания зон. + public void SetTouchpadEdgeSwipes(int swipes) + { + _cfg.TouchpadEdgeSwipesPerRange = swipes; + _cfg.Save(); + Safe(() => { _edges.Reconfigure(); return true; }, false); + } + + /// Поменять края местами. Реестра не касается — меняется только трактовка жеста, + /// поэтому ни перезапуска узла, ни перечитывания зон не нужно. + public void SetTouchpadEdgeSwap(bool on) + { + _cfg.TouchpadEdgeSwap = on; + _cfg.Save(); + } + + /// Тачпад в системе есть — иначе опция в настройках бессмысленна. + public bool TouchpadEdgesAvailable => Safe(() => _edges.Available, false); + private static T Safe(Func f, T fallback, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") { diff --git a/src/Ui/Settings/TouchpadTab.cs b/src/Ui/Settings/TouchpadTab.cs index b32220d..c718e6c 100644 --- a/src/Ui/Settings/TouchpadTab.cs +++ b/src/Ui/Settings/TouchpadTab.cs @@ -28,6 +28,48 @@ public TouchpadTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act, Actio ui.AddNote(this, "settings.touchpad.deadzone.note"); // предупреждаем только когда это правда мешает — иначе строка была бы шумом if (TouchpadDeadZone.AapDisabled == true) ui.AddNote(this, "settings.touchpad.deadzone.aap"); + + // Краевые ползунки (XIC-61). Механика та же, что у зоны снизу: подавление курсора — + // штатной curtain-зоной, а сам жест читается сырым вводом. + ui.AddGroup(this, "settings.touchpad.edges"); + ui.AddRow(this, "settings.touchpad.edges", "settings.touchpad.edges.desc", + ui.Toggle(cfg.TouchpadEdgeSliders, on => { act.SetTouchpadEdgeSliders(on); rebuild(); })); + + var width = EdgeCombo(cfg.TouchpadEdgeWidthMm, act.SetTouchpadEdgeWidthMm); + width.Enabled = cfg.TouchpadEdgeSliders; + ui.AddRow(this, "settings.touchpad.edges.size", "settings.touchpad.edges.size.desc", width); + + var speed = SwipeCombo(cfg.TouchpadEdgeSwipesPerRange, act.SetTouchpadEdgeSwipes); + speed.Enabled = cfg.TouchpadEdgeSliders; + ui.AddRow(this, "settings.touchpad.edges.speed", "settings.touchpad.edges.speed.desc", speed); + + var swap = ui.Toggle(cfg.TouchpadEdgeSwap, act.SetTouchpadEdgeSwap); + swap.Enabled = cfg.TouchpadEdgeSliders; + ui.AddRow(this, "settings.touchpad.edges.swap", "settings.touchpad.edges.swap.desc", swap); + + ui.AddNote(this, "settings.touchpad.edges.note"); + } + + /// Чувствительность: сколько проходов вдоль края покрывают шкалу целиком. + /// Величина выбрана потому, что её человек чувствует пальцем, а не потому, что её удобно + /// хранить: «шаг в процентах высоты» ни о чём не говорит, пока не поводишь. + private ComboBox SwipeCombo(int current, Action apply) + { + int[] presets = EdgeSlideScale.Presets; + int swipes = presets.Contains(current) ? current : 2; + string[] names = [.. presets.Select(p => Loc.T($"settings.touchpad.edges.speed.{p}"))]; + return Ui.Combo(names, Array.IndexOf(presets, swipes), i => apply(presets[i]), Ui.Sc(140)); + } + + /// Ширина краевой полосы. Верх списка ограничен намеренно: измерено, что заведомо + /// большое значение PTP-маппер игнорирует вовсе и зона молча перестаёт работать. + private ComboBox EdgeCombo(int current, Action apply) + { + int mm = TouchpadEdgeSliders.NormalizeWidthMm(current); + int[] presets = [8, 10, 12, 15, 20]; + int[] sizes = presets.Contains(mm) ? presets : [.. presets.Append(mm).Order()]; + return Ui.Combo([.. sizes.Select(x => $"{x} " + Loc.T("settings.touchpad.deadzone.unit"))], + Array.IndexOf(sizes, mm), i => apply(sizes[i]), Ui.Sc(110)); } // Высота зоны: пресеты + текущее значение из config.json, если оно нестандартное diff --git a/src/Ui/SettingsActions.cs b/src/Ui/SettingsActions.cs index b0bb560..dd0095d 100644 --- a/src/Ui/SettingsActions.cs +++ b/src/Ui/SettingsActions.cs @@ -51,6 +51,10 @@ public sealed class SettingsActions public required Action CheckUpdatesNow; // проверить по кнопке; колбэк — перерисовать вкладку public required Action SetTouchpadDeadZone; // мёртвая зона у нижнего края тачпада public required Action SetTouchpadDeadZoneMm; // её высота в мм + public required Action SetTouchpadEdgeSliders; // края тачпада как ползунки (XIC-61) + public required Action SetTouchpadEdgeWidthMm; // ширина краевых полос в мм + public required Action SetTouchpadEdgeSwipes; // чувствительность: проходов на всю шкалу + public required Action SetTouchpadEdgeSwap; // поменять края местами public required Action SetOwlFeature; public required Action SetCareLimit; // порог «беречь батарею», % (применить на железе) public required Func GetBatteryReport; // здоровье батареи (WMI + SOH1) diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs index 04cb990..982d3c1 100644 --- a/src/Ui/TrayApp.cs +++ b/src/Ui/TrayApp.cs @@ -709,6 +709,10 @@ private void OpenSettings() }), SetTouchpadDeadZone = _controller.SetTouchpadDeadZone, SetTouchpadDeadZoneMm = _controller.SetTouchpadDeadZoneMm, + SetTouchpadEdgeSliders = _controller.SetTouchpadEdgeSliders, + SetTouchpadEdgeWidthMm = _controller.SetTouchpadEdgeWidthMm, + SetTouchpadEdgeSwipes = _controller.SetTouchpadEdgeSwipes, + SetTouchpadEdgeSwap = _controller.SetTouchpadEdgeSwap, SetOwlFeature = _controller.ToggleOwlFeature, SetCareLimit = _controller.SetCareLimit, GetBatteryReport = BatteryReportCached, diff --git a/tests/XiControl.Tests/AppControllerTests.cs b/tests/XiControl.Tests/AppControllerTests.cs index 0855f42..ecb0772 100644 --- a/tests/XiControl.Tests/AppControllerTests.cs +++ b/tests/XiControl.Tests/AppControllerTests.cs @@ -36,7 +36,10 @@ public AppControllerTests() new PowerProfileGuard(_mifs, _cfg, _power, cap, auto, new FakeTimer()), cap, auto, new AlsWatcher(), new TravelChargeMonitor(_cfg, _power, new FakeTimer()), new TouchpadControl(_cfg), new TouchscreenControl(_cfg), - new TouchpadDeadZone(_cfg, new TouchpadControl(_cfg))) + new TouchpadDeadZone(_cfg, new TouchpadControl(_cfg)), + // краевые ползунки: действия подменены пустышками — реальные крутили бы + // яркость и громкость машины прямо во время прогона тестов + new TouchpadEdgeSliders(_cfg, new TouchpadControl(_cfg), _ => { }, _ => { })) { CareChanged = on => _events.Add($"care:{on}"), TravelChanged = on => _events.Add($"travel:{on}"), diff --git a/tests/XiControl.Tests/EdgeSlideScaleTests.cs b/tests/XiControl.Tests/EdgeSlideScaleTests.cs new file mode 100644 index 0000000..cff85b1 --- /dev/null +++ b/tests/XiControl.Tests/EdgeSlideScaleTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using XiControl.SystemIntegration; +using Xunit; + +namespace XiControl.Tests; + +/// +/// Пересчёт шагов краевого жеста в яркость и громкость (XIC-61). Главное, что здесь +/// проверяется, — обе шкалы проходятся за одно и то же число проходов вдоль края: +/// именно это разъехалось в первой версии (яркость за два прохода, громкость за четыре). +/// +public sealed class EdgeSlideScaleTests +{ + // Полный проход вдоль края = StepsPerSwipe шагов жеста. + private static int Sum(Func take, int steps) + { + int total = 0; + for (int i = 0; i < steps; i++) total += take(1); + return total; + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void ЯркостьИГромкость_ПроходятШкалуЗаОдноЧислоПроходов(int swipes) + { + var bright = new EdgeSlideScale(swipes); + var volume = new EdgeSlideScale(swipes); + int steps = EdgeSlideScale.StepsPerSwipe * swipes; + + int brightPercent = Sum(bright.Brightness, steps); + int volumePercent = Sum(volume.VolumeTaps, steps) * 2; // нажатие громкости — 2% шкалы + + brightPercent.Should().Be(100, "полная шкала за заявленное число проходов"); + volumePercent.Should().Be(100, "громкость обязана идти в ногу с яркостью"); + } + + [Fact] + public void ОдинПроход_ЭтоВсяШкала() + { + var s = new EdgeSlideScale(1); + + Sum(s.Brightness, EdgeSlideScale.StepsPerSwipe).Should().Be(100); + } + + [Fact] + public void ТриПрохода_ЗаОдинПроходТреть() + { + var s = new EdgeSlideScale(3); + + Sum(s.Brightness, EdgeSlideScale.StepsPerSwipe).Should().BeCloseTo(33, 1); + } + + // Без переноса остатка медленное движение не давало бы ничего: каждый шаг по отдельности + // меньше одного процента при плавной чувствительности. + [Fact] + public void ОстатокПереносится_ПлавнаяЧувствительностьНеТеряетДвижение() + { + var s = new EdgeSlideScale(3); + + s.Brightness(1).Should().Be(0, "два трети процента — меньше целого"); + s.Brightness(1).Should().Be(1, "накопилось больше процента"); + } + + [Fact] + public void Сброс_ЗабываетНакопленное() + { + var s = new EdgeSlideScale(3); + s.Brightness(1); + + s.Reset(); + + s.Brightness(1).Should().Be(0, "после отрыва пальца счёт начинается заново"); + } + + [Fact] + public void ДвижениеВниз_ДаётОтрицательныеЗначения() + { + var s = new EdgeSlideScale(1); + + s.Brightness(-10).Should().Be(-20, "десять шагов вниз при шаге в 2% шкалы"); + } + + // config.json правится руками: ноль проходов — деление на ноль, десять — мёртвый ползунок. + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(99, 3)] + public void Normalize_ДержитРазумныйДиапазон(int given, int expected) => + EdgeSlideScale.Normalize(given).Should().Be(expected); +} diff --git a/tests/XiControl.Tests/TouchpadEdgeGestureTests.cs b/tests/XiControl.Tests/TouchpadEdgeGestureTests.cs new file mode 100644 index 0000000..5f0e362 --- /dev/null +++ b/tests/XiControl.Tests/TouchpadEdgeGestureTests.cs @@ -0,0 +1,161 @@ +using FluentAssertions; +using XiControl.SystemIntegration; +using Xunit; + +namespace XiControl.Tests; + +/// +/// Жест «ползунок у края тачпада» (XIC-61): чистая логика без Win32. Живое чтение сырых +/// касаний — глазами и пробником reference/touchpad-edges. +/// +public sealed class TouchpadEdgeGestureTests +{ + // Полоса 10% ширины, шаг 6% высоты — те же значения, что и дефолт в проде. + private readonly TouchpadEdgeGesture _g = new(stripFraction: 0.1, stepFraction: 0.06); + + private static TouchContact[] One(double x, double y, int id = 1) => [new(id, x, y)]; + + // ---- Где начинается жест ---- + + [Theory] + [InlineData(0.00, TouchpadEdge.Left)] + [InlineData(0.10, TouchpadEdge.Left)] + [InlineData(0.11, TouchpadEdge.None)] + [InlineData(0.50, TouchpadEdge.None)] + [InlineData(0.89, TouchpadEdge.None)] + [InlineData(0.90, TouchpadEdge.Right)] + [InlineData(1.00, TouchpadEdge.Right)] + public void EdgeOf_ДелитПанельНаТриЗоны(double x, TouchpadEdge expected) => + _g.EdgeOf(x).Should().Be(expected); + + [Fact] + public void КасаниеВПолосе_НачинаетЖест() + { + var (edge, steps) = _g.Update(One(0.05, 0.5)); + + edge.Should().Be(TouchpadEdge.Left); + steps.Should().Be(0, "первый кадр только фиксирует точку отсчёта"); + _g.Active.Should().Be(TouchpadEdge.Left); + } + + // Ключевая стыковка с curtain-зоной (XIC-24): она гасит ИНИЦИАЦИЮ касания в полосе. + // Палец, начатый в середине, курсор двигать продолжает — крутить им ещё и ползунок + // значило бы делать два действия одним движением. + [Fact] + public void КасаниеИзвне_ЗаехавшееВПолосу_ЖестНеНачинает() + { + _g.Update(One(0.5, 0.9)); // начали в середине + var (edge, steps) = _g.Update(One(0.03, 0.2)); // доехали до левого края и вверх + + edge.Should().Be(TouchpadEdge.None); + steps.Should().Be(0); + _g.Active.Should().Be(TouchpadEdge.None); + } + + // ---- Набор шагов ---- + + [Fact] + public void ДвижениеВверх_ДаётПоложительныеШаги() + { + _g.Update(One(0.05, 0.80)); + + var (edge, steps) = _g.Update(One(0.05, 0.62)); // прошли 18% высоты = три шага по 6% + + edge.Should().Be(TouchpadEdge.Left); + steps.Should().Be(3); + } + + [Fact] + public void ДвижениеВниз_ДаётОтрицательныеШаги() + { + _g.Update(One(0.95, 0.20)); + + var (edge, steps) = _g.Update(One(0.95, 0.33)); // 13% вниз = два шага + + edge.Should().Be(TouchpadEdge.Right); + steps.Should().Be(-2); + } + + [Fact] + public void ДвижениеМеньшеШага_ШагаНеДаёт() + { + _g.Update(One(0.05, 0.50)); + + _g.Update(One(0.05, 0.47)).Steps.Should().Be(0, "3% меньше порога в 6%"); + } + + // Остаток переносится, а не теряется: иначе медленное непрерывное движение не давало бы + // шагов вовсе — каждый кадр по отдельности меньше порога. + [Fact] + public void ОстатокПереносится_МедленноеДвижениеНеТеряется() + { + _g.Update(One(0.05, 0.50)); + + // Значения намеренно не попадают ровно на границу шага: на точном кратном + // результат зависел бы от округления double, а не от логики. + _g.Update(One(0.05, 0.46)).Steps.Should().Be(0); // прошли 4% — меньше порога + _g.Update(One(0.05, 0.43)).Steps.Should().Be(1); // всего 7% — первый шаг, остаток 1% + _g.Update(One(0.05, 0.40)).Steps.Should().Be(0); // от новой точки 4% — снова мало + _g.Update(One(0.05, 0.37)).Steps.Should().Be(1); // 7% от неё же — второй шаг + } + + // ---- Отмена и сброс ---- + + [Fact] + public void ВторойПалец_ОтменяетЖест() + { + _g.Update(One(0.05, 0.80)); + + _g.Update([new(1, 0.05, 0.80), new(2, 0.40, 0.50)]).Steps.Should().Be(0); + _g.Active.Should().Be(TouchpadEdge.None, "два пальца — это прокрутка Windows, не наш ползунок"); + } + + [Fact] + public void ПослеОтмены_ОдинПалецНеВозобновляетЖест() + { + _g.Update(One(0.05, 0.80)); + _g.Update([new(1, 0.05, 0.80), new(2, 0.40, 0.50)]); + + // второй палец убрали, первый ведём вверх — до полного отрыва жест не оживает + _g.Update(One(0.05, 0.50)).Steps.Should().Be(0); + _g.Active.Should().Be(TouchpadEdge.None); + } + + [Fact] + public void ОтрывПальца_СбрасываетСостояние() + { + _g.Update(One(0.05, 0.80)); + _g.Update([]); + + _g.Active.Should().Be(TouchpadEdge.None); + _g.Update(One(0.05, 0.80)).Edge.Should().Be(TouchpadEdge.Left, "после отрыва жест начинается заново"); + } + + [Fact] + public void НовоеКасаниеВПолосе_ОтсчётСНуля() + { + _g.Update(One(0.05, 0.80, id: 1)); + _g.Update([]); + + _g.Update(One(0.05, 0.20, id: 2)).Steps.Should().Be(0, + "точка отсчёта берётся у нового касания, а не тянется от прошлого"); + } + + // ---- Клэмпы настроек: config.json правится руками ---- + + [Fact] + public void НулеваяПолоса_КлэмпитсяИЖестОстаётсяВозможен() + { + var g = new TouchpadEdgeGesture(stripFraction: 0, stepFraction: 0.06); + + g.EdgeOf(0.01).Should().Be(TouchpadEdge.Left, "иначе фича молча перестала бы работать"); + } + + [Fact] + public void ОгромнаяПолоса_НеСъедаетВсюПанель() + { + var g = new TouchpadEdgeGesture(stripFraction: 0.9, stepFraction: 0.06); + + g.EdgeOf(0.5).Should().Be(TouchpadEdge.None, "середина панели обязана остаться свободной"); + } +}