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, "середина панели обязана остаться свободной");
+ }
+}