diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd4c499..77c3715 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,13 @@
щелчка** (слабый / средний / сильный — отдельно от вибрации при нажатии и тоже хранится в
самом тачпаде) и **частота** (каждый шаг / через шаг / редко, как в PC Manager; любое
значение 50–500 мс — в `config.json`, `TouchpadEdgeHapticsMs`). По просьбе с 4PDA (XIC-73).
+- **Сильное нажатие на тачпад как назначаемое действие.** Продавите тачпад заметно сильнее
+ обычного клика — выполнится выбранное действие, тот же список, что у клавиш (Настройки →
+ Тачпад → «Сильное нажатие»). Как в Xiaomi PC Manager, где это скриншот; прошивка в этот момент
+ щёлкает второй раз, так что «дожатие» чувствуется. Распознаётся по давлению пальца из
+ обычного ввода тачпада; с «Нет действия» ничего лишнего не работает (XIC-78).
+- **Действие «Скриншот» (Win+Shift+S) для любой клавиши.** Раньше скриншот делала только
+ штатная клавиша.
## [0.15.0] — 2026-09-22
diff --git a/CLAUDE.md b/CLAUDE.md
index d512945..ba1ec85 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,6 +10,7 @@
Mi-кнопка и «мёртвые» клавиши (включая мультимедиа и калькулятор), авто-герцовка (по питанию +
удержание частоты), запоминание и лимит яркости по питанию, «режим совы», тачпад и сенсорный экран
вкл/выкл, мёртвая зона у нижнего края тачпада, сила вибрации и усилие нажатия тачпада,
+сильное нажатие на тачпад как назначаемое действие,
виджет «Монитор», индикатор-метрика в трее (Вт/CPU/GPU/RAM/°C цифрой на втором значке),
окно настроек в стиле Win11, проверка обновлений (оповещение, без самообновления),
опциональный HTTP API для управления из локальной сети (телефон/Home Assistant). Всё управление железом —
@@ -222,6 +223,14 @@ WMI-событий). `Program.cs`: single-instance mutex → DI-контейне
`PluginGesture.dll`, XIC-73): зовётся с воркера `TouchpadEdgeSliders` на шаге ползунка, не
ждёт замка — если идёт запись настроек, щелчок пропускается; сила щелчка — команда `0x58`,
тоже в самом тачпаде, частота — `TouchpadEdgeHapticsMs` в конфиге),
+ `TouchpadInput` (XIC-78: **один** `RawTouchpadReader` на процесс — Raw Input даёт процессу одно
+ окно-получатель на usage, второй читатель молча отобрал бы ввод у первого; счётчик
+ пользователей Acquire/Release, давление разбирается только пока кому-то нужно — краевым
+ ползункам хватает координат), `TouchpadHeavyPress` + `HeavyPressDetector` (сильное нажатие:
+ Tip Pressure контакта с Confidence > 500, раз за касание — как в `PluginGesture.dll` PC Manager;
+ на TM2424 клик ≈ 130, сильное ≈ 1000. Действие — через `KeyRouter.Run`, список общий с клавишами;
+ второй щелчок прошивки `0x59` пишется только при явном вкл/выкл, с «Нет действия» не подписан
+ и давление не читается),
`UpdateCheck` (оповещение о новой версии: один GET к GitHub `/releases/latest` — эндпоинт сам
отсекает скользящий pre-release; не чаще раза в сутки, тумблер = выключатель трафика,
самообновления нет намеренно — запущенный exe не перезаписать, XIC-20),
diff --git a/README.md b/README.md
index 65049c9..f73d098 100644
--- a/README.md
+++ b/README.md
@@ -266,7 +266,7 @@ touchscreen, refresh-rate control), Battery (charge threshold with a hint, "trav
lock-screen sound and toast, charger-wattage OSD, "weak PSU" threshold, battery health), Display
(brightness limit and memory; auto refresh rate, "Keep refresh rate" and the rates — the section is
hidden if refresh-rate control is off), Touchpad (bottom dead zone, edge sliders, vibration
-strength and click force), Performance (mode visibility,
+strength and click force, force press), Performance (mode visibility,
separately for AC and battery, with a switch above the list; startup mode, power profiles),
Keys (remapping), Notifications (where popups appear, how long
they stay, which lock-key popups to show), HTTP API (port, token, permissions) and About
@@ -603,6 +603,14 @@ The values live in the touchpad itself, so they survive sleep and reboots and ar
checking every write by reading it back. On a laptop with a different touchpad the section
simply doesn't appear.
+**Force press.** Press the touchpad noticeably harder than a normal click to run any action —
+the same list as for keys, plus "Screenshot (Win+Shift+S)" (Settings → Touchpad → "Force press",
+`TouchpadHeavyPressAction` in `config.json`). Like in Xiaomi PC Manager, where it takes a
+screenshot. The press is recognised from the finger pressure the touchpad already reports
+(normal click ≈ 130, force press ≈ 1000, threshold 500), and while the action is on the firmware
+clicks a second time so you feel it went through. The normal click still happens as well. With
+"No action" nothing extra runs: pressure isn't even parsed.
+
### Key remapping
Each key gets its own action: **Settings → Keys**. The slots are the single click, double click and
@@ -638,9 +646,9 @@ never touches: disable the touchscreen in Device Manager and it stays that way.
the launched program inherits them.
In `config.json` these are `*Action`/`*Command` pairs (`MiClick`, `MiDouble`, `MiHold`,
-`SettingsKey`, `AiKey`, `ProjKey`); action values: `modes`, `charge`, `panel`, `owl`, `monitor`,
-`touchpad`, `touchscreen`, `autobright`, `hz`, `travel`, `projection`, `settings`, `copilot`, `play`, `next`, `prev`,
-`stop`, `calc`, `launch`, `none`:
+`SettingsKey`, `AiKey`, `ProjKey`, and `TouchpadHeavyPress` for a force press); action values:
+`modes`, `charge`, `panel`, `owl`, `monitor`, `touchpad`, `touchscreen`, `autobright`, `hz`, `travel`,
+`projection`, `screenshot`, `settings`, `copilot`, `play`, `next`, `prev`, `stop`, `calc`, `launch`, `none`:
```json
"MiClickAction": "modes",
diff --git a/README.ru.md b/README.ru.md
index 57367bc..5f953fe 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -270,7 +270,7 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu
звук и файл «В дорогу», звук и уведомление на локскрине, OSD мощности зарядника, порог
«слабого блока», здоровье батареи), Экран (лимит и запоминание яркости; авто-герцовка,
«Удерживать частоту» и частоты — раздел скрыт, если управление частотой выключено),
-Тачпад (мёртвая зона у нижнего края, края-ползунки, сила вибрации и усилие нажатия),
+Тачпад (мёртвая зона у нижнего края, края-ползунки, сила вибрации и усилие нажатия, сильное нажатие),
Производительность (видимость режимов — отдельно для сети и для батареи, переключатель наверху
списка; режим при старте, профили питания), Клавиши
(переназначение), Уведомления (положение подсказок на экране, сколько висят, какие из
@@ -601,6 +601,14 @@ PC Manager и проверен на TM2424.
попадают: приложение читает их один раз на старте и пишет только по вашему выбору, проверяя
каждую запись чтением обратно. На ноутбуке с другим тачпадом раздела просто нет.
+**Сильное нажатие.** Продавите тачпад заметно сильнее обычного клика — выполнится любое
+действие из того же списка, что у клавиш, плюс «Скриншот (Win+Shift+S)» (Настройки → Тачпад →
+«Сильное нажатие», `TouchpadHeavyPressAction` в `config.json`). Как в Xiaomi PC Manager, где на
+нём скриншот. Нажатие распознаётся по давлению пальца, которое тачпад и так сообщает (обычный
+клик ≈ 130, сильное нажатие ≈ 1000, порог 500), а пока действие выбрано, прошивка щёлкает второй
+раз — чувствуется, что «дожали». Обычный клик при этом тоже срабатывает. С «Нет действия»
+ничего лишнего не работает: давление даже не разбирается.
+
### Переназначение клавиш
Каждой клавише — своё действие: **Настройки → Клавиши**. Слоты — одиночный клик, двойной
@@ -637,9 +645,9 @@ Windows. Громкости в списке нет намеренно: для н
XiControl работает с правами администратора — запущенная программа их унаследует.
В `config.json` это пары `*Action`/`*Command` (`MiClick`, `MiDouble`, `MiHold`, `SettingsKey`,
-`AiKey`, `ProjKey`), значения действий: `modes`, `charge`, `panel`, `owl`, `monitor`,
-`travel`, `touchpad`, `touchscreen`, `autobright`, `hz`, `projection`, `settings`, `copilot`, `play`, `next`,
-`prev`, `stop`, `calc`, `launch`, `none`:
+`AiKey`, `ProjKey` и `TouchpadHeavyPress` для сильного нажатия), значения действий: `modes`,
+`charge`, `panel`, `owl`, `monitor`, `travel`, `touchpad`, `touchscreen`, `autobright`, `hz`, `projection`,
+`screenshot`, `settings`, `copilot`, `play`, `next`, `prev`, `stop`, `calc`, `launch`, `none`:
```json
"MiClickAction": "modes",
diff --git a/ROADMAP.md b/ROADMAP.md
index 6beb3b6..1a41330 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -278,6 +278,10 @@
настроек, а в его плагине краевых жестов (`PluginGesture.dll`). Первый шаг жеста щёлкает сразу,
дальше — на каждой порции шагов; у Xiaomi раз в 250 мс, но на пальце это ощущалось отставанием.
Сила щелчка (команда `0x58`) и частота — в настройках (XIC-73, по просьбе с 4PDA).
+- **Сильное нажатие на тачпад как назначаемое действие** — распознаётся по давлению пальца из
+ обычного ввода (как в PC Manager: > 500 при клике ≈ 130 и сильном ≈ 1000), прошивка отвечает
+ вторым щелчком (`0x59`). Писать в тачпад для распознавания не нужно; с «Нет действия» давление
+ даже не разбирается. Заодно — действие «Скриншот» для клавиш (XIC-78).
## 🔜 Ближайшее
- Зафиксированных ближайших задач нет — см. «Идеи» ниже.
diff --git a/docs/05-open-questions.md b/docs/05-open-questions.md
index b03dc6a..6ea3baa 100644
--- a/docs/05-open-questions.md
+++ b/docs/05-open-questions.md
@@ -140,6 +140,12 @@ PC Manager шлёт её на каждом шаге яркости/громко
скольжения», 1 short; PC Manager держит 80, на TM2424 так и стоит). Проверено на железе и сделано:
щелчок на шаге наших краевых ползунков.
+**Сильное нажатие (XIC-78)** вендорского канала не требует вовсе: PC Manager распознаёт его по
+Tip Pressure (`0x0D/0x30`) в обычном PTP-вводе — давление контакта с Confidence больше 500. Замер
+на TM2424: касание 35–55, клик 125–150, сильное 900–1200, и давление приходит всегда, при любом
+`0x59`. Сам `0x59` (`[01|02, 00]`) — только второй щелчок прошивки в момент продавливания. Сделано
+назначаемым действием.
+
Источники: [Precision touchpad tuning](https://learn.microsoft.com/en-us/windows-hardware/design/component-guidelines/touchpad-tuning-guidelines),
[Input Device Haptics Implementation Guide](https://learn.microsoft.com/en-us/windows-hardware/design/component-guidelines/input-haptics-implementation-guide).
diff --git a/docs/07-keymap.md b/docs/07-keymap.md
index 0686d4f..7b3c157 100644
--- a/docs/07-keymap.md
+++ b/docs/07-keymap.md
@@ -36,6 +36,7 @@
| `autobright` | Авто-яркость вкл/выкл (только на машинах с датчиком освещённости) |
| `hz` | Частота экрана по кругу — то же, что аппаратная клавиша герцовки, для моделей без неё. Подчиняется тумблеру «Управление частотой» (XIC-58) |
| `projection` | Проекция (`DisplaySwitch.exe`, фолбэк Win+P) |
+| `screenshot` | Скриншот области (Win+Shift+S) — раньше был только у штатной клавиши скриншота; удобен на сильном нажатии тачпада (XIC-78) |
| `settings` | Параметры Windows |
| `copilot` | Copilot (Win+C) |
| `play` | Воспроизведение / пауза (`VK_MEDIA_PLAY_PAUSE`) |
diff --git a/src/Config/AppConfig.cs b/src/Config/AppConfig.cs
index e1576e1..88a2b6b 100644
--- a/src/Config/AppConfig.cs
+++ b/src/Config/AppConfig.cs
@@ -190,6 +190,13 @@ public sealed class AppConfig
/// PC Manager держит 250, но так щелчки отстают от пальца и ощущаются странно.
public int TouchpadEdgeHapticsMs { get; set; } = 50;
+ /// Действие на сильное нажатие тачпада (XIC-78) — те же значения, что у клавиш
+ /// (*Action), плюс "screenshot". "none" — фича выключена (по умолчанию).
+ public string? TouchpadHeavyPressAction { get; set; } = "none";
+
+ /// Команда для действия "launch" (путь + аргументы), как у клавиш.
+ public string? TouchpadHeavyPressCommand { get; set; }
+
///
/// Отладка: читать температуру из ACPI-термозоны, даже если Intel DPTF доступен (XIC-41).
/// Только правкой config.json — на Intel-машине путь фолбэка иначе не прогнать, он работает
diff --git a/src/Input/KeyRouter.cs b/src/Input/KeyRouter.cs
index 6585e60..cd1f371 100644
--- a/src/Input/KeyRouter.cs
+++ b/src/Input/KeyRouter.cs
@@ -137,6 +137,7 @@ public void Run(string? action, string? command)
case "autobright": if (AutoBrightnessAvailable()) ToggleAutoBrightness?.Invoke(); break; // нет датчика — нечего включать
case "hz": if (_cfg.RefreshRateFeature) CycleRefreshRate?.Invoke(); break; // фича убрана — экран не трогаем
case "projection": Projection?.Invoke(); break;
+ case "screenshot": Screenshot?.Invoke(); break; // Win+Shift+S; и для сильного нажатия тачпада (XIC-78)
case "settings": OpenSettings?.Invoke(); break;
case "copilot": Copilot?.Invoke(); break;
case "play": MediaPlayPause?.Invoke(); break;
diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json
index e55facb..a158645 100644
--- a/src/Localization/lang/en.json
+++ b/src/Localization/lang/en.json
@@ -175,6 +175,7 @@
"settings.act.autobright": "Auto-brightness on/off",
"settings.act.hz": "Cycle screen refresh rate",
"settings.act.projection": "Projection (Win+P)",
+ "settings.act.screenshot": "Screenshot (Win+Shift+S)",
"settings.act.settings": "Windows Settings",
"settings.act.copilot": "Copilot (Win+C)",
"settings.act.play": "Play / pause",
@@ -241,6 +242,10 @@
"settings.touchpad.pressure.140": "Firm",
"settings.touchpad.pressure.custom": "Custom ({0})",
"settings.touchpad.haptics.note": "These settings are stored in the touchpad itself and survive sleep and restarts.",
+ "settings.touchpad.heavy": "Force press",
+ "settings.touchpad.heavy.action": "Action",
+ "settings.touchpad.heavy.action.desc": "Press the touchpad noticeably harder than a normal click to run the chosen action, just like a key.",
+ "settings.touchpad.heavy.note": "The touchpad firmware detects a force press by itself and clicks a second time at that moment, so you can feel it went through. The normal click still happens too — you cannot press the pad hard without pressing it. Like in Xiaomi PC Manager, where a force press takes a screenshot.",
"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 788339a..65920ad 100644
--- a/src/Localization/lang/ru.json
+++ b/src/Localization/lang/ru.json
@@ -175,6 +175,7 @@
"settings.act.autobright": "Авто-яркость вкл/выкл",
"settings.act.hz": "Частота экрана по кругу",
"settings.act.projection": "Проекция (Win+P)",
+ "settings.act.screenshot": "Скриншот (Win+Shift+S)",
"settings.act.settings": "Параметры Windows",
"settings.act.copilot": "Copilot (Win+C)",
"settings.act.play": "Воспроизведение / пауза",
@@ -241,6 +242,10 @@
"settings.touchpad.pressure.140": "Тугое",
"settings.touchpad.pressure.custom": "Своё ({0})",
"settings.touchpad.haptics.note": "Настройки хранятся в самом тачпаде и сохраняются после сна и перезагрузки.",
+ "settings.touchpad.heavy": "Сильное нажатие",
+ "settings.touchpad.heavy.action": "Действие",
+ "settings.touchpad.heavy.action.desc": "Продавите тачпад заметно сильнее обычного клика — выполнится выбранное действие, как у клавиш.",
+ "settings.touchpad.heavy.note": "Прошивка тачпада сама различает сильное нажатие и в этот момент щёлкает второй раз — так понятно, что «дожали». Обычный клик при этом тоже срабатывает: продавить панель, не нажав, нельзя. Как в Xiaomi PC Manager, где на сильное нажатие стоит скриншот.",
"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 b0ab85e..f255d65 100644
--- a/src/Localization/lang/zh.json
+++ b/src/Localization/lang/zh.json
@@ -175,6 +175,7 @@
"settings.act.autobright": "自动亮度开关",
"settings.act.hz": "循环切换屏幕刷新率",
"settings.act.projection": "投影(Win+P)",
+ "settings.act.screenshot": "截图 (Win+Shift+S)",
"settings.act.settings": "Windows 设置",
"settings.act.copilot": "Copilot(Win+C)",
"settings.act.play": "播放 / 暂停",
@@ -241,6 +242,10 @@
"settings.touchpad.pressure.140": "重",
"settings.touchpad.pressure.custom": "自定义({0})",
"settings.touchpad.haptics.note": "这些设置保存在触控板本身,睡眠和重启后依然有效。",
+ "settings.touchpad.heavy": "重按",
+ "settings.touchpad.heavy.action": "操作",
+ "settings.touchpad.heavy.action.desc": "比普通点击明显更用力地按下触控板,即可执行所选操作,与按键相同。",
+ "settings.touchpad.heavy.note": "触控板固件会自行识别重按,并在此时再振动一下,让你感知已按到位。普通点击同样会触发——不按下就无法重按。与小米电脑管家相同,其重按默认为截图。",
"settings.updates.check": "检查更新",
"settings.updates.check.desc": "每天向 GitHub 查询一次是否有新版本。关闭后,应用完全不会发起任何网络请求。更新不会自动安装——我们只做提醒。",
"settings.updates.available": "有可用版本 {0}",
diff --git a/src/Program.cs b/src/Program.cs
index fb36a2c..23c9a03 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -39,6 +39,8 @@ private static void Main()
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton(); // один читатель касаний на процесс (XIC-78)
+ services.AddSingleton();
// «В дорогу» временно снимает защиту (заряд до 100%) — гард бережёт 80% только когда travel выключен
services.AddSingleton(sp =>
{
diff --git a/src/SystemIntegration/HeavyPressDetector.cs b/src/SystemIntegration/HeavyPressDetector.cs
new file mode 100644
index 0000000..ea24a87
--- /dev/null
+++ b/src/SystemIntegration/HeavyPressDetector.cs
@@ -0,0 +1,30 @@
+namespace XiControl.SystemIntegration;
+
+///
+/// Сильное нажатие на тачпад (XIC-78) — чистая логика, без HID: палец продавил панель сильнее
+/// порога. Ровно так же решает Xiaomi PC Manager (плагин PluginGesture.dll): Tip Pressure
+/// контакта больше 500 — событие, один раз за касание, повтор только после отрыва всех пальцев.
+///
+/// Порог не произвольный. На TM2424 измерено: касание 35–55, обычный клик 125–150, сильное
+/// нажатие 900–1200 — 500 стоит посередине с запасом в разы в обе стороны. То же число — третье
+/// значение команды порога 0x5B (thr, thr×0.33, 500, 400); похоже, им же прошивка решает,
+/// когда щёлкнуть второй раз (0x59), но это догадка, не проверено.
+///
+public sealed class HeavyPressDetector(int threshold = HeavyPressDetector.DefaultThreshold)
+{
+ public const int DefaultThreshold = 500;
+
+ private bool _fired;
+
+ /// Очередной кадр касаний; true — сильное нажатие только что случилось.
+ public bool Update(IReadOnlyList contacts)
+ {
+ // отрыв всех пальцев — единственный взвод: удержание продавленного пальца, дрожь
+ // давления у порога и ведение после нажатия повторов давать не должны
+ if (contacts.Count == 0) { _fired = false; return false; }
+ if (_fired) return false;
+ foreach (var c in contacts)
+ if (c.Pressure > threshold) { _fired = true; return true; }
+ return false;
+ }
+}
diff --git a/src/SystemIntegration/RawTouchpadReader.cs b/src/SystemIntegration/RawTouchpadReader.cs
index 216f09f..5e5fa4b 100644
--- a/src/SystemIntegration/RawTouchpadReader.cs
+++ b/src/SystemIntegration/RawTouchpadReader.cs
@@ -26,6 +26,7 @@ public sealed class RawTouchpadReader : IDisposable
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 ushort UsageTipPressure = 0x30, UsageConfidence = 0x47;
private const int HidpSuccess = 0x00110000;
private static readonly IntPtr HwndMessage = new(-3);
@@ -41,6 +42,11 @@ public sealed class RawTouchpadReader : IDisposable
public RawTouchpadReader(Action> onFrame) => _onFrame = onFrame;
+ /// Читать ли давление (XIC-78). Выключено, пока оно никому не нужно: краевым
+ /// ползункам хватает координат, и лишний разбор поля на каждый контакт каждого кадра
+ /// — это сотни вызовов в секунду впустую.
+ public volatile bool ReadPressure;
+
/// Читаем ли мы сейчас касания.
public bool Running => _thread is not null;
@@ -183,7 +189,8 @@ private void OnInput(IntPtr lParam)
// Tip Switch — это КНОПКА, а не значение: HidP_GetUsageValue на ней всегда
// отвечает ошибкой, и строгая проверка отбрасывала бы каждый контакт.
// Нажатые кнопки коллекции отдаёт HidP_GetUsages списком usage-кодов.
- if (!IsTouching(info.Preparsed, link, report, reportSize)) continue;
+ var (touching, confident) = Buttons(info.Preparsed, link, report, reportSize);
+ if (!touching) 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,
@@ -193,8 +200,14 @@ private void OnInput(IntPtr lParam)
int id = HidP_GetUsageValue(0, PageDigitizer, link, UsageContactId, out uint raw,
info.Preparsed, report, reportSize) == HidpSuccess ? (int)raw : link;
+ // Давление (Tip Pressure) — для сильного нажатия (XIC-78). Только у контакта с
+ // Confidence: без него прошивка считает касание ладонью, и её вес не должен
+ // сойти за продавленный палец. Нет поля в дескрипторе — просто 0.
+ int pressure = ReadPressure && confident && HidP_GetUsageValue(0, PageDigitizer, link, UsageTipPressure,
+ out uint p, info.Preparsed, report, reportSize) == HidpSuccess ? (int)p : 0;
+
_frame.Add(new TouchContact(id,
- Fraction(x, info.XMin, info.XMax), Fraction(y, info.YMin, info.YMax)));
+ Fraction(x, info.XMin, info.XMax), Fraction(y, info.YMin, info.YMax), pressure));
}
_onFrame(_frame);
}
@@ -202,18 +215,23 @@ private void OnInput(IntPtr lParam)
finally { pin.Free(); }
}
- // Палец на панели: ищем Tip Switch среди нажатых кнопок этой коллекции. Буфер с запасом —
- // кнопок в PTP-коллекции единицы, а перевыделять его на каждый контакт незачем.
+ // Кнопки контакта: Tip Switch (палец на панели) и Confidence (палец, а не ладонь) — оба в
+ // списке нажатых usage-кодов коллекции. Буфер с запасом: кнопок в PTP-коллекции единицы,
+ // а перевыделять его на каждый контакт незачем.
private readonly ushort[] _usages = new ushort[32];
- private bool IsTouching(IntPtr preparsed, ushort link, byte[] report, uint reportSize)
+ private (bool Touching, bool Confident) Buttons(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;
+ return (false, false);
+ bool touching = false, confident = false;
for (uint i = 0; i < length; i++)
- if (_usages[i] == UsageTipSwitch) return true;
- return false;
+ {
+ touching |= _usages[i] == UsageTipSwitch;
+ confident |= _usages[i] == UsageConfidence;
+ }
+ return (touching, confident);
}
private static double Fraction(uint raw, int min, int max) =>
diff --git a/src/SystemIntegration/TouchpadEdgeGesture.cs b/src/SystemIntegration/TouchpadEdgeGesture.cs
index de44cce..3a18c41 100644
--- a/src/SystemIntegration/TouchpadEdgeGesture.cs
+++ b/src/SystemIntegration/TouchpadEdgeGesture.cs
@@ -8,8 +8,10 @@ public enum TouchpadEdge { None, Left, Right }
/// Доля, а не миллиметры, намеренно: физические единицы PTP объявляются в дюймах с показателем
/// (на TM2424 Units=0x13, UnitExponent=-2), и пересчёт «на глазок» уже однажды дал
/// 54,7 мм вместо 139 — доля от логического диапазона от этой трактовки не зависит вовсе.
+/// — сырой Tip Pressure прошивки (0 — поля нет или это ладонь), для
+/// сильного нажатия (XIC-78). На TM2424: касание 35–55, клик 125–150, сильное нажатие 900–1200.
///
-public readonly record struct TouchContact(int Id, double X, double Y);
+public readonly record struct TouchContact(int Id, double X, double Y, int Pressure = 0);
///
/// Распознавание жеста «ползунок у края тачпада» (XIC-61): палец, опустившийся в краевую полосу,
diff --git a/src/SystemIntegration/TouchpadEdgeSliders.cs b/src/SystemIntegration/TouchpadEdgeSliders.cs
index 2fb8849..ed28b0c 100644
--- a/src/SystemIntegration/TouchpadEdgeSliders.cs
+++ b/src/SystemIntegration/TouchpadEdgeSliders.cs
@@ -42,7 +42,9 @@ public sealed class TouchpadEdgeSliders : IDisposable
private readonly AppConfig _cfg;
private readonly TouchpadControl _pad;
private TouchpadEdgeGesture _gesture;
- private readonly RawTouchpadReader _reader;
+ private readonly TouchpadInput _input;
+ private readonly bool _ownsInput; // свой источник только в тестах — общий диспоузит DI
+ private bool _attached; // подписаны и держим читатель (Start/Stop зовутся парами не всегда)
private readonly Action _volume;
private readonly Action _brightness;
private EdgeSlideScale _scale;
@@ -64,7 +66,7 @@ public sealed class TouchpadEdgeSliders : IDisposable
public TouchpadEdgeSliders(AppConfig cfg, TouchpadControl pad,
Action? volume = null, Action? brightness = null, IAppTimer? pump = null,
- TouchpadHaptics? haptics = null)
+ TouchpadHaptics? haptics = null, TouchpadInput? input = null)
{
_cfg = cfg;
_pad = pad;
@@ -74,7 +76,10 @@ public TouchpadEdgeSliders(AppConfig cfg, TouchpadControl pad,
_gesture = new TouchpadEdgeGesture(WidthFraction(cfg), EdgeSlideScale.StepFraction);
_scale = new EdgeSlideScale(cfg.TouchpadEdgeSwipesPerRange);
// дальше их пересобирает Reconfigure — настройки живут не только на старте
- _reader = new RawTouchpadReader(OnFrame);
+ // Касания — из общего источника (XIC-78): читатель у процесса может быть только один,
+ // его делят с сильным нажатием
+ _ownsInput = input is null;
+ _input = input ?? new TouchpadInput();
// Применение вынесено с потока чтения намеренно. Первая версия звала WMI прямо в
// обработчике кадра: синхронный Brightness.Get на каждый шаг плюс Task.Run на каждую
@@ -102,7 +107,12 @@ public void Start()
// Apply перезапускал чтение, но считали его прежние объекты со старыми числами.
Reconfigure();
if (!_cfg.TouchpadEdgeSliders || !Available) return;
- _reader.Start();
+ if (!_attached)
+ {
+ _input.Frame += OnFrame;
+ _input.Acquire();
+ _attached = true;
+ }
_pump.Start();
}
@@ -110,7 +120,12 @@ public void Start()
public void Stop()
{
_pump.Stop();
- _reader.Stop();
+ if (_attached)
+ {
+ _input.Frame -= OnFrame;
+ _input.Release();
+ _attached = false;
+ }
_gesture.Reset();
ResetPending();
}
@@ -299,7 +314,8 @@ private void BrightnessStep(int percent)
public void Dispose()
{
+ Stop();
_pump.Dispose();
- _reader.Dispose();
+ if (_ownsInput) _input.Dispose();
}
}
diff --git a/src/SystemIntegration/TouchpadHaptics.cs b/src/SystemIntegration/TouchpadHaptics.cs
index 4100192..ee369f2 100644
--- a/src/SystemIntegration/TouchpadHaptics.cs
+++ b/src/SystemIntegration/TouchpadHaptics.cs
@@ -55,6 +55,14 @@ public bool SetPressure(int threshold) =>
public bool SetSlide(int strength) =>
Write(CmdSlide, [(ushort)strength], v => v[0] == strength);
+ /// Второй щелчок прошивки на сильном нажатии (команда 0x59, XIC-78) — тактильное
+ /// «продавлено». Живёт в тачпаде, как остальные настройки.
+ public bool SetHeavyPress(bool on)
+ {
+ ushort value = on ? HeavyPressOn : HeavyPressOff;
+ return Write(CmdHeavyPress, [value], v => v[0] == value);
+ }
+
/// Один щелчок мотора (XIC-73). Зовётся на шаге краевого ползунка, поэтому не
/// ждёт: если в этот момент идёт запись настроек (~0,5 с), щелчок просто пропускается —
/// жест важнее отклика. false — не отправлен.
diff --git a/src/SystemIntegration/TouchpadHapticsProtocol.cs b/src/SystemIntegration/TouchpadHapticsProtocol.cs
index 84ce4e5..563a2a7 100644
--- a/src/SystemIntegration/TouchpadHapticsProtocol.cs
+++ b/src/SystemIntegration/TouchpadHapticsProtocol.cs
@@ -28,6 +28,12 @@ public static class TouchpadHapticsProtocol
public const byte CmdVibration = 0x5D; // 2 short: пара «передач» мотора
public const byte CmdPressure = 0x5B; // 4 short: порог, порог×0.33, 500, 400
public const byte CmdSlide = 0x58; // 1 short: сила щелчка PulseFrame (XIC-73)
+ public const byte CmdHeavyPress = 0x59; // 1 short: второй щелчок прошивки на сильном нажатии (XIC-78)
+
+ /// Значения 0x59 — ровно как шлёт PC Manager (WriteEnableHeavyPress):
+ /// 1 — прошивка щёлкает второй раз, когда нажатие продавлено, 2 — не щёлкает (заводское).
+ /// Само распознавание от флага не зависит: давление приходит в обычном вводе всегда.
+ public const ushort HeavyPressOn = 1, HeavyPressOff = 2;
/// Сила щелчка краевых ползунков: PC Manager ставит 80. Шкала та же, что у мотора
/// кликов (56…128), но щелчок на каждом шаге и 128 на живом пальце — перебор, а 40 —
diff --git a/src/SystemIntegration/TouchpadHeavyPress.cs b/src/SystemIntegration/TouchpadHeavyPress.cs
new file mode 100644
index 0000000..58417e6
--- /dev/null
+++ b/src/SystemIntegration/TouchpadHeavyPress.cs
@@ -0,0 +1,94 @@
+using XiControl.Config;
+
+namespace XiControl.SystemIntegration;
+
+///
+/// Сильное нажатие на тачпад как назначаемое действие (XIC-78): продавили панель сильнее
+/// обычного клика — выполняется выбранное действие, тот же список, что у клавиш. Как в
+/// Xiaomi PC Manager, где это скриншот, только действие своё.
+///
+/// Две половины, обе driver-free:
+///
+/// - распознавание — давление из обычного ввода тачпада (),
+/// в тачпад для этого не пишется ни байта;
+/// - ощущение — второй щелчок прошивки в момент продавливания (0x59): без него
+/// человек не чувствует, что «дожал». Пишется ТОЛЬКО по явному переключению пользователем,
+/// на старте тачпад не трогаем (живёт в самом тачпаде, как и прочие настройки).
+///
+/// Обычный клик при сильном нажатии никуда не девается — продавить можно только нажав.
+///
+public sealed class TouchpadHeavyPress : IDisposable
+{
+ private readonly AppConfig _cfg;
+ private readonly TouchpadInput _input;
+ private readonly TouchpadHaptics? _haptics;
+ private readonly HeavyPressDetector _detector = new();
+ private readonly object _lock = new();
+ private bool _attached;
+
+ /// Сильное нажатие случилось (поток читателя касаний!).
+ public event Action? Pressed;
+
+ public TouchpadHeavyPress(AppConfig cfg, TouchpadInput input, TouchpadHaptics? haptics = null)
+ {
+ _cfg = cfg;
+ _input = input;
+ _haptics = haptics;
+ }
+
+ /// Назначено ли действие — «нет действия» и есть выключенная фича.
+ public bool Enabled => !string.IsNullOrEmpty(_cfg.TouchpadHeavyPressAction) && _cfg.TouchpadHeavyPressAction != "none";
+
+ /// Старт приложения: подписаться на касания, если фича включена. В тачпад не пишем.
+ public void Start()
+ {
+ if (Enabled) Attach();
+ }
+
+ ///
+ /// Явное включение/выключение пользователем: второй щелчок прошивки — вслед за фичей
+ /// (выключили — возвращаем заводское «не щёлкать»), подписка — тоже. Зовётся с фона:
+ /// запись в тачпад занимает ~0,5 с.
+ ///
+ public void Apply()
+ {
+ bool on = Enabled;
+ if (_haptics is not null && !_haptics.SetHeavyPress(on))
+ Log.Write($"TouchpadHeavyPress: второй щелчок прошивки не {(on ? "включился" : "выключился")}");
+ if (on) Attach(); else Detach();
+ }
+
+ private void OnFrame(IReadOnlyList contacts)
+ {
+ bool fired;
+ lock (_lock) fired = _detector.Update(contacts);
+ if (fired) Pressed?.Invoke();
+ }
+
+ // Подписка и Acquire/Release — СНАРУЖИ замка: Release останавливает читатель и ждёт его
+ // поток, а тот может как раз стоять в OnFrame на этом же замке — вышел бы тупик до
+ // таймаута, после которого читатель уже не поднимается. Замок сторожит только флаг.
+ private void Attach()
+ {
+ lock (_lock)
+ {
+ if (_attached) return;
+ _attached = true;
+ }
+ _input.Frame += OnFrame;
+ _input.Acquire(pressure: true);
+ }
+
+ private void Detach()
+ {
+ lock (_lock)
+ {
+ if (!_attached) return;
+ _attached = false;
+ }
+ _input.Frame -= OnFrame;
+ _input.Release(pressure: true);
+ }
+
+ public void Dispose() => Detach();
+}
diff --git a/src/SystemIntegration/TouchpadInput.cs b/src/SystemIntegration/TouchpadInput.cs
new file mode 100644
index 0000000..ba7c6a0
--- /dev/null
+++ b/src/SystemIntegration/TouchpadInput.cs
@@ -0,0 +1,50 @@
+namespace XiControl.SystemIntegration;
+
+///
+/// Общий источник касаний тачпада (XIC-78): один на процесс,
+/// кадры раздаются всем подписчикам. Делить нужно не ради экономии: Raw Input регистрирует
+/// у процесса ровно одно окно-получатель на пару «страница/usage», и второй независимый
+/// читатель молча отобрал бы ввод у первого — краевые ползунки или сильное нажатие перестали
+/// бы видеть касания без всякой ошибки.
+///
+/// Читатель живёт, пока нужен хоть кому-то: / —
+/// счётчик пользователей, а не флаг. Кадры приходят с потока читателя; подписчики держат
+/// обработчики лёгкими — очередь сообщений под ними одна на всех.
+///
+public sealed class TouchpadInput : IDisposable
+{
+ private readonly RawTouchpadReader _reader;
+ private readonly object _lock = new();
+ private int _users;
+ private int _pressureUsers;
+
+ /// Кадр касаний (поток читателя!). Пустой список — все пальцы оторваны.
+ public event Action>? Frame;
+
+ public TouchpadInput() => _reader = new RawTouchpadReader(f => Frame?.Invoke(f));
+
+ /// Мне нужны касания: первый пользователь поднимает чтение.
+ /// — нужно ли давление: без него поле не разбирается вовсе.
+ public void Acquire(bool pressure = false)
+ {
+ lock (_lock)
+ {
+ if (pressure && _pressureUsers++ == 0) _reader.ReadPressure = true;
+ if (_users++ == 0) _reader.Start();
+ }
+ }
+
+ /// Касания больше не нужны: последний пользователь гасит чтение, последний
+ /// нуждавшийся в давлении — его разбор. Флаг — тот же, что при Acquire.
+ public void Release(bool pressure = false)
+ {
+ lock (_lock)
+ {
+ if (pressure && _pressureUsers > 0 && --_pressureUsers == 0) _reader.ReadPressure = false;
+ if (_users == 0) return;
+ if (--_users == 0) _reader.Stop();
+ }
+ }
+
+ public void Dispose() => _reader.Dispose();
+}
diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs
index 1513ec8..73bf2ce 100644
--- a/src/Ui/AppController.cs
+++ b/src/Ui/AppController.cs
@@ -29,6 +29,7 @@ public sealed class AppController
private readonly TouchpadDeadZone _deadZone;
private readonly TouchpadEdgeSliders _edges;
private readonly TouchpadHaptics? _haptics;
+ private readonly TouchpadHeavyPress? _heavy;
private PerfMode[] _modes = [];
private bool _autoStart; // кэш состояния автозапуска (не дёргаем schtasks на каждое меню)
@@ -56,13 +57,14 @@ public sealed class AppController
public Action? TouchpadToggled; // тачпад вкл/выкл (колбэк с фонового потока!)
public Action? TouchscreenToggled; // сенсорный экран вкл/выкл (тоже фон)
public Action? FirmwareFailed; // команда прошивке не прошла — UI показывает честную ошибку
+ public Action? TouchpadHeavyPressed; // сильное нажатие на тачпад (поток касаний!) — XIC-78
public AppController(IMifsClient mifs, AppConfig cfg, IPowerEvents power, ILocalizer loc,
ChargeGuard charge, RefreshRateGuard hz, PowerProfileGuard profiles, BrightnessCapGuard capGuard,
AutoBrightnessGuard autoGuard, AlsWatcher als,
TravelChargeMonitor travel, TouchpadControl touchpad, TouchscreenControl touchscreen,
TouchpadDeadZone deadZone, TouchpadEdgeSliders edges,
- TouchpadHaptics? haptics = null)
+ TouchpadHaptics? haptics = null, TouchpadHeavyPress? heavy = null)
{
_mifs = mifs;
_cfg = cfg;
@@ -81,6 +83,8 @@ public AppController(IMifsClient mifs, AppConfig cfg, IPowerEvents power, ILocal
_deadZone = deadZone;
_edges = edges;
_haptics = haptics; // null в тестах: юниты не должны писать в настоящий тачпад
+ _heavy = heavy; // тоже null в тестах — касаний настоящего тачпада там нет
+ if (_heavy is not null) _heavy.Pressed += () => TouchpadHeavyPressed?.Invoke();
ApplyModeVisibility();
}
@@ -159,6 +163,8 @@ public void Startup()
// Краевые ползунки тачпада (XIC-61): реестр на старте НЕ трогаем (зоны там уже лежат
// с прошлого включения), поднимаем только чтение касаний.
_edges.Start();
+ // Сильное нажатие (XIC-78): то же — только чтение касаний, в тачпад на старте не пишем
+ _heavy?.Start();
// «Режим совы»: восстановить после сбоя, включить заново, либо погасить, если фичу отключили
if (_cfg.Awake && !_cfg.OwlMode) { AwakeMode.Disable(_cfg); _cfg.Awake = false; _cfg.Save(); }
@@ -187,6 +193,7 @@ public void Shutdown()
{
if (_cfg.Awake) { AwakeMode.Disable(_cfg); _cfg.Save(); }
_edges.Stop();
+ _heavy?.Dispose();
}
// ---- Заряд и «в дорогу» ----
@@ -924,6 +931,25 @@ public void SetTouchpadEdgeHaptics(bool on)
_cfg.Save();
}
+ /// Действие на сильное нажатие тачпада (XIC-78). Смена одного действия на другое —
+ /// только конфиг; включение/выключение фичи ещё и переключает второй щелчок прошивки (0x59)
+ /// и подписку на касания — в фоне, запись в тачпад ~0,5 с.
+ public void SetTouchpadHeavyPress(string action)
+ {
+ bool was = _heavy?.Enabled ?? false;
+ _cfg.TouchpadHeavyPressAction = action;
+ _cfg.Save();
+ if (_heavy is { } heavy && heavy.Enabled != was)
+ Task.Run(() => Safe(() => { heavy.Apply(); return true; }, false));
+ }
+
+ /// Команда для действия «Запустить программу» на сильном нажатии.
+ public void SetTouchpadHeavyPressCommand(string? command)
+ {
+ _cfg.TouchpadHeavyPressCommand = command;
+ _cfg.Save();
+ }
+
/// Минимальный промежуток между щелчками краевых ползунков, мс. Ползунки читают
/// его на каждом шаге — применяется сразу.
public void SetTouchpadEdgeHapticsMs(int ms)
diff --git a/src/Ui/Settings/KeysTab.cs b/src/Ui/Settings/KeysTab.cs
index d13126a..95226f6 100644
--- a/src/Ui/Settings/KeysTab.cs
+++ b/src/Ui/Settings/KeysTab.cs
@@ -6,16 +6,17 @@ namespace XiControl.Ui.Settings;
/// Вкладка «Клавиши»: действия Mi-кнопки и «мёртвых» клавиш (per-slot из конфига).
public sealed class KeysTab : SettingsPane
{
- // Общий список действий для всех клавиш; порядок = порядок в комбо
- private static readonly string[] KeyActionValues =
+ // Общий список действий для всех клавиш — и для сильного нажатия тачпада (XIC-78);
+ // порядок = порядок в комбо
+ internal static readonly string[] KeyActionValues =
[
"modes", "charge", "panel", "owl", "monitor", "travel", "touchpad", "touchscreen",
- "autobright", "hz", "projection", "settings", "copilot", "play", "next", "prev", "stop",
+ "autobright", "hz", "projection", "screenshot", "settings", "copilot", "play", "next", "prev", "stop",
"calc", "launch", "none",
];
// Единственное действие с полем ввода команды — по нему решаем, пересобирать ли слот.
- private const string Launch = "launch";
+ internal const string Launch = "launch";
private readonly AppConfig _cfg;
private readonly Action _rebuild;
diff --git a/src/Ui/Settings/TouchpadTab.cs b/src/Ui/Settings/TouchpadTab.cs
index be75371..95309ef 100644
--- a/src/Ui/Settings/TouchpadTab.cs
+++ b/src/Ui/Settings/TouchpadTab.cs
@@ -79,9 +79,41 @@ public TouchpadTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act, Actio
ui.AddRow(this, "settings.touchpad.pressure", "settings.touchpad.pressure.desc",
PressureCombo(haptics.Pressure, act.SetTouchpadPressure));
ui.AddNote(this, "settings.touchpad.haptics.note");
+
+ // Сильное нажатие (XIC-78): порог давления и второй щелчок — прошивочные, у этого
+ // тачпада, поэтому раздел живёт там же, где вендорский канал
+ ui.AddGroup(this, "settings.touchpad.heavy");
+ AddHeavyPressSlot(cfg, act, rebuild);
+ ui.AddNote(this, "settings.touchpad.heavy.note");
}
}
+ /// Действие на сильное нажатие — список общий с клавишами, «Запустить программу»
+ /// добавляет поле команды (как слот клавиши на вкладке «Клавиши»).
+ private void AddHeavyPressSlot(AppConfig cfg, SettingsActions act, Action rebuild)
+ {
+ string[] values = KeysTab.KeyActionValues;
+ string cur = cfg.TouchpadHeavyPressAction ?? "none";
+ int idx = Array.IndexOf(values, cur);
+ if (idx < 0) idx = Array.IndexOf(values, "none"); // неизвестное значение из конфига
+ string prev = cur;
+ Ui.AddRow(this, "settings.touchpad.heavy.action", "settings.touchpad.heavy.action.desc",
+ Ui.Combo([.. values.Select(a => Loc.T("settings.act." + a))], idx, i =>
+ {
+ string val = values[i];
+ act.SetTouchpadHeavyPress(val);
+ // пересборка — только чтобы показать/убрать поле команды
+ bool again = prev == KeysTab.Launch || val == KeysTab.Launch;
+ prev = val;
+ if (again) rebuild();
+ }, Ui.Sc(210)));
+ if (cur != KeysTab.Launch) return;
+ var tf = Ui.TextField(cfg.TouchpadHeavyPressCommand ?? "", Ui.Sc(300),
+ s => act.SetTouchpadHeavyPressCommand(string.IsNullOrWhiteSpace(s) ? null : s.Trim()));
+ tf.PlaceholderText = "\"C:\\Program Files\\App\\app.exe\" --flag";
+ Controls.Add(Ui.SubRow("settings.key.command", tf));
+ }
+
/// Сила щелчка краевых ползунков: вниз от заводской, 128 на каждом шаге — перебор.
/// Чужое значение из тачпада добавляется как есть — по образцу MmCombo.
private ComboBox StrengthCombo(int current, Action apply)
diff --git a/src/Ui/SettingsActions.cs b/src/Ui/SettingsActions.cs
index 4cd73db..2ef6199 100644
--- a/src/Ui/SettingsActions.cs
+++ b/src/Ui/SettingsActions.cs
@@ -64,6 +64,8 @@ public sealed class SettingsActions
public required Action SetTouchpadEdgeHaptics; // щелчок мотора на шаге ползунка (XIC-73)
public required Action SetTouchpadEdgeHapticsMs; // не чаще одного щелчка за столько мс
public required Action SetTouchpadSlideStrength; // сила щелчка (пишется в сам тачпад)
+ public required Action SetTouchpadHeavyPress; // действие на сильное нажатие (XIC-78)
+ public required Action SetTouchpadHeavyPressCommand; // команда для «Запустить программу»
public required Func GetTouchpadHaptics; // прочитанное из тачпада; null — раздела нет (XIC-77)
public required Action SetTouchpadVibration; // сила вибрации (пишется в сам тачпад)
public required Action SetTouchpadPressure; // порог нажатия, единицы прошивки
diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs
index 6ae6d17..e380600 100644
--- a/src/Ui/TrayApp.cs
+++ b/src/Ui/TrayApp.cs
@@ -318,6 +318,10 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, IPowerEv
PanelVisible = () => _panel.Visible,
};
_events.KeyPressed += OnKey;
+ // Сильное нажатие на тачпад (XIC-78) — назначаемое действие по тем же правилам, что у
+ // клавиш, и тем же путём: из потока касаний в UI-поток, там — роутер
+ _controller.TouchpadHeavyPressed = () => _panel.BeginInvoke(() =>
+ _router.Run(_cfg.TouchpadHeavyPressAction, _cfg.TouchpadHeavyPressCommand));
// Реакция на смену темы Windows
SystemEvents.UserPreferenceChanged += OnUserPref;
@@ -805,6 +809,8 @@ private void OpenSettings()
SetTouchpadEdgeHaptics = _controller.SetTouchpadEdgeHaptics,
SetTouchpadEdgeHapticsMs = _controller.SetTouchpadEdgeHapticsMs,
SetTouchpadSlideStrength = _controller.SetTouchpadSlideStrength,
+ SetTouchpadHeavyPress = _controller.SetTouchpadHeavyPress,
+ SetTouchpadHeavyPressCommand = _controller.SetTouchpadHeavyPressCommand,
GetTouchpadHaptics = () => _controller.TouchpadHaptics,
SetTouchpadVibration = _controller.SetTouchpadVibration,
SetTouchpadPressure = _controller.SetTouchpadPressure,
diff --git a/tests/XiControl.Tests/HeavyPressDetectorTests.cs b/tests/XiControl.Tests/HeavyPressDetectorTests.cs
new file mode 100644
index 0000000..dd25930
--- /dev/null
+++ b/tests/XiControl.Tests/HeavyPressDetectorTests.cs
@@ -0,0 +1,59 @@
+using FluentAssertions;
+using XiControl.SystemIntegration;
+using Xunit;
+
+namespace XiControl.Tests;
+
+///
+/// Сильное нажатие на тачпад (XIC-78): порог и «один раз за касание». Давления — из замеров
+/// на TM2424: касание 35–55, обычный клик 125–150, сильное нажатие 900–1200.
+///
+public sealed class HeavyPressDetectorTests
+{
+ private readonly HeavyPressDetector _d = new();
+
+ private static TouchContact[] Finger(int pressure, int id = 0) => [new TouchContact(id, 0.5, 0.5, pressure)];
+ private static readonly TouchContact[] Lifted = [];
+
+ [Theory]
+ [InlineData(47)] // касание
+ [InlineData(148)] // обычный клик
+ [InlineData(500)] // ровно порог — ещё не сильное
+ public void Touch_and_normal_click_do_not_fire(int pressure) =>
+ _d.Update(Finger(pressure)).Should().BeFalse();
+
+ [Fact]
+ public void Force_press_fires_once_per_touch()
+ {
+ // нарастание давления внутри одного касания, как в живом кадре
+ _d.Update(Finger(40)).Should().BeFalse();
+ _d.Update(Finger(140)).Should().BeFalse();
+ _d.Update(Finger(1028)).Should().BeTrue();
+ // удержание продавленным и дрожь у порога — повторов нет
+ _d.Update(Finger(1217)).Should().BeFalse();
+ _d.Update(Finger(480)).Should().BeFalse();
+ _d.Update(Finger(900)).Should().BeFalse();
+ }
+
+ [Fact]
+ public void Lifting_all_fingers_rearms()
+ {
+ _d.Update(Finger(1000)).Should().BeTrue();
+ _d.Update(Lifted).Should().BeFalse();
+ _d.Update(Finger(1000)).Should().BeTrue();
+ }
+
+ [Fact]
+ public void Any_finger_can_force_press()
+ {
+ // второй палец продавил, первый просто лежит
+ _d.Update([new TouchContact(0, 0.2, 0.5, 45), new TouchContact(1, 0.7, 0.5, 1100)]).Should().BeTrue();
+ }
+
+ [Fact]
+ public void Palm_without_pressure_never_fires()
+ {
+ // ладонь (без Confidence) читатель отдаёт с давлением 0, как бы сильно она ни давила
+ _d.Update(Finger(0)).Should().BeFalse();
+ }
+}
diff --git a/tests/XiControl.Tests/KeyRouterTests.cs b/tests/XiControl.Tests/KeyRouterTests.cs
index 10b9ab4..15de8b6 100644
--- a/tests/XiControl.Tests/KeyRouterTests.cs
+++ b/tests/XiControl.Tests/KeyRouterTests.cs
@@ -239,6 +239,16 @@ public void Run_HiddenFeature_DoesNothing(string action)
_hits.Should().BeEmpty();
}
+ // «Скриншот» — действие для любого слота и для сильного нажатия тачпада (XIC-78);
+ // раньше был доступен только штатной клавише скриншота
+ [Fact]
+ public void Run_Screenshot_Fires()
+ {
+ _router.Run("screenshot", null);
+
+ _hits.Should().Equal("screenshot");
+ }
+
[Fact]
public void Run_EnabledFeatures_Fire()
{
diff --git a/tests/XiControl.Tests/TouchpadHapticsProtocolTests.cs b/tests/XiControl.Tests/TouchpadHapticsProtocolTests.cs
index 1b8f379..c1c882d 100644
--- a/tests/XiControl.Tests/TouchpadHapticsProtocolTests.cs
+++ b/tests/XiControl.Tests/TouchpadHapticsProtocolTests.cs
@@ -59,6 +59,17 @@ public void Slide_strength_frames_match_live_bytes()
ParseResponse(Padded("0D 04 51 00 50 00"), 1).Should().Equal(80); // заводское
}
+ // второй щелчок на сильном нажатии 0x59 (XIC-78): включение — кадр, который тачпад принял
+ // и вернул 1; до включения читалось заводское 2
+ [Fact]
+ public void Heavy_press_frames_match_live_bytes()
+ {
+ WriteFrame(CmdHeavyPress, [HeavyPressOn]).Should().Equal(Padded("0D 09 5C 00 00 03 00 00 59 01 00"));
+ CommitFrame(CmdHeavyPress).Should().Equal(Padded("0D 09 FE 01 00 03 00 00 01 59 A7"));
+ ParseResponse(Padded("0D 04 02 00 01 00"), 1).Should().Equal(HeavyPressOn);
+ ParseResponse(Padded("0D 04 03 00 02 00"), 1).Should().Equal(HeavyPressOff);
+ }
+
[Fact]
public void Read_request_matches_ReadMotorGears() =>
ReadRequest(CmdVibration, 2).Should().Equal(Padded("0D 07 5A 01 00 01 00 04 5D"));