diff --git a/CHANGELOG.md b/CHANGELOG.md index d2c2dd9..89560cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,14 @@ All notable changes to Sketchover. The release workflow takes the notes for each version from this file. -## [0.3.1] +## [0.4.0] + +### Added +- Every canvas key can be changed: Settings → *Canvas keys* → click a key and press the new one. A key already in use moves to the new action; reset one key or all of them. The cheat sheet and toolbar tooltips show your keys. +- Choose the double-tap key for click-through in Settings: `Alt`, `Ctrl`, `Shift`, `⌘` (macOS) or off. + +### Changed +- macOS: double-tap `⌘` toggles click-through by default instead of `⌥` — double `⌥` opens other apps (Claude's quick entry, for one). Holding `⌥` still sends the mouse to the windows below. ### Fixed - Anonymous usage statistics were never sent in 0.3.0: the request ran outside the async runtime and stopped the statistics thread. Drawing was not affected. diff --git a/CLAUDE.md b/CLAUDE.md index e0dcfc3..1c2b146 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ src/i18n/ словари; используется только из ui ## Простота использования (UX) - Работает «из коробки»: запустил — рисуй. Минимум настроек, разумные значения по умолчанию. -- Каждое действие доступно и мышью, и горячей клавишей. Глобальный хоткей — **один** (по умолчанию `` Alt+` ``, переназначаемый); не больше 2–3 клавиш в комбинации. Внутри холста: цифры — инструменты, `Alt+цифра` — цвет, `Shift+цифра` — толщина, зажатый `Alt` — мышь окнам под холстом, двойной тап `Alt` — сквозной режим вкл/выкл. +- Каждое действие доступно и мышью, и горячей клавишей. Глобальный хоткей — **один** (по умолчанию `` Alt+` ``, переназначаемый); не больше 2–3 клавиш в комбинации. Внутри холста: цифры — инструменты, `Alt+цифра` — цвет, `Shift+цифра` — толщина, зажатый `Alt` — мышь окнам под холстом, двойной тап — сквозной режим вкл/выкл (`Alt` в Windows, `⌘` в macOS, выбирается в настройках). Клавиши холста переназначаются в настройках — новое действие добавляется в реестр `ui/shortcuts.js`. - Клавиши определяем по `e.code` (физическая клавиша), не по `e.key`: иначе ломаются раскладки и модификаторы. - Новый хоткей — сразу в памятку (`ui/help.js`) и README. - Состояние режима (рисование / сквозные клики) всегда явно видно на экране. diff --git a/README.md b/README.md index fcf07d0..eeaaf81 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ Sketchover puts a transparent canvas over every window. Press one hotkey on a Zo ## Features - **Draw on top of any app**: brush, line, arrow, rectangle, ellipse, text and a fading laser pointer. -- **Click-through**: hold `Alt` to click and scroll the windows below; double-tap `Alt` to stay in click-through mode. The drawing stays, semi-transparent. +- **Click-through**: hold `Alt` to click and scroll the windows below; double-tap `Alt` (`⌘` on macOS) to stay in click-through mode. The drawing stays, semi-transparent. - **Text blocks with Markdown**: headings, bullet and numbered lists (they continue on `Enter`), **bold**, *italic*, `code`, ~~strikethrough~~. - **Arrows that stick**: arrows attach to text blocks and screenshots and follow them when you move them. - **Region screenshots**: press `S`, select part of the screen, and it lands on the canvas — move it, resize it, rotate it. - **Paste into the chat**: `Ctrl+Shift+C` copies your drawing as an image — paste it into Zoom, Teams, Slack or Telegram. - **Save and keep editing**: `Ctrl+S` saves a PNG that opens anywhere and still contains the editable canvas — open it later with `Ctrl+O`. -- **Keyboard first, layout independent**: `1`…`0` pick tools, `Alt+1…8` colors, `Shift+1…3` stroke width, `F1` shows a cheat sheet. +- **Keyboard first, layout independent**: `1`…`0` pick tools, `Alt+1…8` colors, `Shift+1…3` stroke width, `F1` shows a cheat sheet — and every canvas key can be changed in Settings. - **Always ready, never in the way**: starts with your system and waits in the tray; one global hotkey (`` Alt+` ``, configurable) opens it on the monitor under your cursor. - **Small and private**: a ~3 MB installer, no account; only an anonymous update check and anonymous usage statistics leave your computer, and the statistics can be turned off. - **11 languages**: English, Русский, Deutsch, Español, Français, Português, 中文, 日本語, 한국어, العربية, हिन्दी. @@ -61,7 +61,7 @@ Sketchover opens the canvas right after install — press `` Alt+` `` (the key l |---|---| | Show the canvas / drawing ⇄ click-through | `` Alt+` `` (global, configurable) | | Mouse to the windows below while held | hold `Alt` (`⌥` on macOS; Windows and macOS) | -| Click-through on / off | double-tap `Alt` (Windows, macOS); `Esc`, the mode button or the global hotkey | +| Click-through on / off | double-tap `Alt` on Windows, `⌘` on macOS (the key is chosen in Settings); `Esc`, the mode button or the global hotkey | | Tools | `1` select, `2` brush, `3` line, `4` arrow, `5` rectangle, `6` ellipse, `7` text block, `8` text, `9` laser, `0` eraser | | Region screenshot | `S` | | Color / stroke width | `Alt+1…8` / `Shift+1…3` | @@ -70,6 +70,8 @@ Sketchover opens the canvas right after install — press `` Alt+` `` (the key l | Save / open (PNG with the editable canvas inside) | `Ctrl+S` / `Ctrl+O` | | Delete selected / clear all | `Delete` / `Shift+Delete` | | Edit a text block | double-click it with any tool | + +These are the defaults. To change a key: Settings (⚙) → **Canvas keys** → click the key and press the new one (`Esc` cancels). A key already in use moves to the new action; ↺ brings one key back, *Reset all keys* brings them all. Keys follow the physical key, so they work on any keyboard layout; on macOS, `Ctrl` in the table means `⌘`. Holding `Alt` is fixed. The double-tap key is chosen in Settings — `Alt`, `Ctrl`, `Shift`, `⌘` (macOS) or off; on macOS it is `⌘` by default, because double `⌥` already opens other apps (Claude's quick entry, for one). | Cheat sheet | `F1` | Viewers see your drawing when you share the **entire screen**, not a single window. @@ -88,7 +90,7 @@ If you are looking for an **Epic Pen, ZoomIt, gInk, ppInk or Presentify alternat **How do I keep a drawing?** `Ctrl+S` saves a regular PNG — it opens in any image viewer — with the canvas embedded, so `Ctrl+O` brings it back fully editable. Or `Ctrl+Shift+C` and paste it straight into the chat. -**Is everything the same on Linux?** Almost: holding or double-tapping `Alt` is Windows and macOS only for now. On Linux, switch click-through with `Esc`, the mode button or the global hotkey. +**Is everything the same on Linux?** Almost: holding `Alt` and the double tap are Windows and macOS only for now. On Linux, switch click-through with `Esc`, the mode button or the global hotkey. ## Build from source diff --git a/README.ru.md b/README.ru.md index 8108f4d..394119c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -54,7 +54,7 @@ npm start |---|---| | Показать холст / рисование ⇄ сквозные клики | `` Alt+` `` (клавиша Ё) — глобально, меняется в настройках | | Мышь — окнам под холстом, пока держишь | зажать `Alt` (macOS: `⌥`; Windows и macOS) | -| Сквозные клики вкл / выкл | двойной тап `Alt` (Windows и macOS); `Esc`, кнопка режима или глобальный хоткей | +| Сквозные клики вкл / выкл | двойной тап `Alt` в Windows, `⌘` в macOS (клавиша выбирается в настройках); `Esc`, кнопка режима или глобальный хоткей | | Спрятать в трей | ✕ на панели; показать — хоткей или клик по иконке в трее | | Инструменты | `1` выбор, `2` кисть, `3` линия, `4` стрелка, `5` прямоугольник, `6` эллипс, `7` блок, `8` текст, `9` лазер, `0` ластик | | Скриншот области | `S` или 📷 на панели → выделить область мышью (`Esc` — отмена) | @@ -66,6 +66,8 @@ npm start | Памятка по клавишам | `F1` или `?` на панели (по умолчанию открыта справа) | | Настройки, обновления, о программе | ⚙ на панели | +Это клавиши по умолчанию. Поменять любую клавишу холста: ⚙ → **Клавиши холста** → клик по клавише и нажать новую (`Esc` — отмена). Если клавиша уже занята, она переходит к новому действию; ↺ возвращает одну клавишу, «Сбросить все клавиши» — все. Клавиши — по физическим кнопкам и работают на любой раскладке; на macOS `Ctrl` из таблицы — это `⌘`. Удержание `Alt` не меняется. Клавиша двойного тапа выбирается в настройках — `Alt`, `Ctrl`, `Shift`, `⌘` (macOS) или выключено; в macOS по умолчанию `⌘`, потому что двойной `⌥` уже открывает другие программы (например, быстрый ввод Claude). + - **Блоки**: протяни рамку или кликни, введи текст (`Enter` — готово, `Shift+Enter` — новая строка). Двойной клик по блоку или тексту любым инструментом — правка. - **Markdown** в блоках и тексте: `#`/`##`/`###` заголовки, `-` и `1.` списки, `**жирный**`, `*курсив*`, `` `код` ``, `~~зачёркнутый~~`. Блок растёт под текст. - **Скриншоты** появляются на месте выделенной области и сразу выделены: тащи, тяни за угол (пропорции сохраняются), вращай за круглую ручку сверху (прилипает к шагу 15°). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 768d7b1..50a8552 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,10 +28,12 @@ ### Хоткеи - Глобальный — **один**, по умолчанию **`` Alt+` ``** (клавиша слева от `1`; на русской раскладке — Ё): холст спрятан → показать; виден → рисование ⇄ сквозные клики. Переназначается в настройках (до 3 клавиш, нужен модификатор); если комбинацию держит другая программа — остаётся прежняя, и это видно в настройках. - Внутри холста — по физическим клавишам (`e.code`), на любой раскладке: `1…9, 0` — инструменты по порядку панели, `Alt+1…8` — цвет, `Shift+1…3` — толщина, `S` — скриншот, `Esc` — сквозной режим, `Ctrl+Z/Y`, `Del`/`Shift+Del`, `F1` — памятка. +- Клавиши холста переназначаются в настройках («Клавиши холста»): клик по клавише → нажать сочетание. Реестр действий и клавиш по умолчанию — `ui/shortcuts.js`, логика сочетаний — `core/keymap.js` (`Mod` — Ctrl, на macOS ⌘). Одна клавиша — одно действие: занятая переходит к новому. В `settings.json` (поле `keys`) — только отличия от значений по умолчанию; Rust проверяет форму. Памятка и подсказки кнопок показывают текущие клавиши. Удержание `Alt` — в Rust (`keys.rs`), не переназначается; клавиша двойного тапа выбирается в настройках (ниже). - **Удержание `Alt`** (macOS — `⌥`) — мышь окнам под холстом, рисунок полупрозрачный; отпустил — снова рисование, фокус возвращается холсту. Опрос состояния клавиш (Windows — `GetAsyncKeyState`, macOS — `CGEventSource*`), без хуков и особых разрешений. - Почему `Alt`, а не `Ctrl`: модификатор доходит до окна под холстом, а `Ctrl+колесо` в браузере — масштаб вместо прокрутки. `Alt+колесо` в Chrome/Edge прокручивает как обычно. - Остаточные эффекты: `Alt+клик` по ссылке в Chrome скачивает её; в Firefox `Alt+колесо` может листать историю. Если будет мешать — клавиша удержания станет настройкой. -- **Двойной тап `Alt`** — сквозной режим вкл/выкл, и когда фокус в другом окне. Тап — `Alt` отпущен за ≤300 мс без других клавиш и кнопок мыши; второй — в пределах 400 мс. Поэтому `Alt+Tab`, `Alt+Shift`, `Alt+цифра` режим не переключают. Чистая функция с юнит-тестами (`overlay.rs`). +- **Двойной тап** — сквозной режим вкл/выкл, и когда фокус в другом окне. Тап — клавиша отпущена за ≤300 мс без других клавиш и кнопок мыши; второй — в пределах 400 мс. Поэтому `Alt+Tab`, `Alt+Shift`, `Alt+цифра` режим не переключают. Чистая функция с юнит-тестами (`overlay.rs`). + - Клавиша тапа — в настройках (`tap_key`, `keys::Tap`): `Alt`, `Ctrl`, `Shift`, `⌘` (только macOS) или выключено. По умолчанию — `Alt` в Windows, `⌘` в macOS: двойной `⌥` там занят (быстрый ввод Claude), двойные `⇧` и `⌃` — в IDE JetBrains. - Одиночный `Alt` гасится в окне холста: иначе Windows уводит окно в «режим меню» и теряет следующую клавишу. - **Linux:** удержания и двойного тапа нет (на Wayland состояние чужих клавиш недоступно) — переключение `Esc`, кнопкой или хоткеем. - Памятка — справа, открыта по умолчанию, сворачивается `F1`/`?`, скрыта в сквозном режиме. diff --git a/src-tauri/src/keys.rs b/src-tauri/src/keys.rs index 368139d..b1a0e32 100644 --- a/src-tauri/src/keys.rs +++ b/src-tauri/src/keys.rs @@ -1,34 +1,91 @@ -//! Состояние клавиши удержания (Alt / ⌥) и прочих клавиш — даже когда фокус в другом окне -//! (после клика сквозь холст keyup до нас не доходит). +//! Состояние клавиши удержания (Alt / ⌥), клавиши двойного тапа и прочих клавиш — даже когда +//! фокус в другом окне (после клика сквозь холст keyup до нас не доходит). //! Опрос состояния клавиш, без хуков клавиатуры и без особых разрешений системы. //! -//! Почему Alt: он почти не меняет поведение окна под холстом — колесо прокручивает как обычно -//! (с Ctrl браузер масштабирует страницу, с Shift — прокручивает вбок). +//! Почему удержание — Alt: он почти не меняет поведение окна под холстом — колесо прокручивает +//! как обычно (с Ctrl браузер масштабирует страницу, с Shift — прокручивает вбок). + +/// Клавиша двойного тапа (сквозной режим вкл/выкл) — выбирается в настройках. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tap { + Off, + Alt, + Ctrl, + Shift, + /// ⌘ — только macOS: клавишу Win на Windows держит система. + Cmd, +} + +impl Tap { + /// На macOS — ⌘: двойной ⌥ занят (быстрый ввод Claude), двойные ⇧ и ⌃ — в IDE JetBrains. + pub const DEFAULT: Tap = if cfg!(target_os = "macos") { + Tap::Cmd + } else { + Tap::Alt + }; + + pub fn parse(name: &str) -> Option { + match name { + "off" => Some(Tap::Off), + "alt" => Some(Tap::Alt), + "ctrl" => Some(Tap::Ctrl), + "shift" => Some(Tap::Shift), + "cmd" if cfg!(target_os = "macos") => Some(Tap::Cmd), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Tap::Off => "off", + Tap::Alt => "alt", + Tap::Ctrl => "ctrl", + Tap::Shift => "shift", + Tap::Cmd => "cmd", + } + } +} + +impl Default for Tap { + fn default() -> Self { + Tap::DEFAULT + } +} #[derive(Clone, Copy)] pub struct Keys { - /// Зажат Alt. + /// Зажат Alt — удержание. pub hold: bool, - /// Зажата любая другая клавиша или кнопка мыши: значит, Alt — часть комбинации, а не тап. + /// Зажата клавиша двойного тапа. + pub tap: bool, + /// Зажата любая другая клавиша или кнопка мыши: значит, клавиша тапа — часть комбинации. pub other: bool, } #[cfg(target_os = "windows")] -pub fn poll() -> Option { +pub fn poll(tap: Tap) -> Option { use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - GetAsyncKeyState, VK_LMENU, VK_MENU, VK_RMENU, + GetAsyncKeyState, VK_CONTROL, VK_LCONTROL, VK_LMENU, VK_LSHIFT, VK_MENU, VK_RCONTROL, + VK_RMENU, VK_RSHIFT, VK_SHIFT, }; // Старший бит — клавиша нажата сейчас. let down = |vk: u16| unsafe { GetAsyncKeyState(i32::from(vk)) } as u16 & 0x8000 != 0; - let alt = [VK_MENU, VK_LMENU, VK_RMENU]; + // Общий код клавиши и её левый и правый варианты: при нажатии отмечаются оба. + let codes: &[u16] = match tap { + Tap::Alt => &[VK_MENU, VK_LMENU, VK_RMENU], + Tap::Ctrl => &[VK_CONTROL, VK_LCONTROL, VK_RCONTROL], + Tap::Shift => &[VK_SHIFT, VK_LSHIFT, VK_RSHIFT], + Tap::Off | Tap::Cmd => &[], + }; Some(Keys { hold: down(VK_MENU), - other: (0x01..=0xFE).filter(|vk| !alt.contains(vk)).any(down), + tap: codes.first().is_some_and(|&vk| down(vk)), + other: (0x01..=0xFE).filter(|vk| !codes.contains(vk)).any(down), }) } #[cfg(target_os = "macos")] -pub fn poll() -> Option { +pub fn poll(tap: Tap) -> Option { #[link(name = "CoreGraphics", kind = "framework")] extern "C" { fn CGEventSourceFlagsState(state_id: i32) -> u64; @@ -37,15 +94,23 @@ pub fn poll() -> Option { } const COMBINED_SESSION_STATE: i32 = 0; const ALTERNATE_MASK: u64 = 0x0008_0000; - const OPTION_KEYS: [u16; 2] = [58, 61]; + // Флаг модификатора и коды его левой и правой клавиш. + let (mask, codes): (u64, &[u16]) = match tap { + Tap::Alt => (ALTERNATE_MASK, &[58, 61]), + Tap::Ctrl => (0x0004_0000, &[59, 62]), + Tap::Shift => (0x0002_0000, &[56, 60]), + Tap::Cmd => (0x0010_0000, &[55, 54]), + Tap::Off => (0, &[]), + }; unsafe { - let hold = CGEventSourceFlagsState(COMBINED_SESSION_STATE) & ALTERNATE_MASK != 0; + let flags = CGEventSourceFlagsState(COMBINED_SESSION_STATE); let key = (0..128u16) - .filter(|k| !OPTION_KEYS.contains(k)) + .filter(|k| !codes.contains(k)) .any(|k| CGEventSourceKeyState(COMBINED_SESSION_STATE, k)); let button = (0..3).any(|b| CGEventSourceButtonState(COMBINED_SESSION_STATE, b)); Some(Keys { - hold, + hold: flags & ALTERNATE_MASK != 0, + tap: mask != 0 && flags & mask != 0, other: key || button, }) } @@ -53,6 +118,21 @@ pub fn poll() -> Option { /// Linux: пока не поддерживается (на Wayland состояние чужих клавиш недоступно в принципе). #[cfg(not(any(target_os = "windows", target_os = "macos")))] -pub fn poll() -> Option { +pub fn poll(_tap: Tap) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tap_names_round_trip_and_cmd_is_mac_only() { + for tap in [Tap::Off, Tap::Alt, Tap::Ctrl, Tap::Shift] { + assert_eq!(Tap::parse(tap.name()), Some(tap)); + } + assert_eq!(Tap::parse("cmd").is_some(), cfg!(target_os = "macos")); + assert_eq!(Tap::parse("Super"), None); + assert_eq!(Tap::parse(Tap::DEFAULT.name()), Some(Tap::DEFAULT)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b62c8c..4c02086 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,8 @@ mod stats; mod tray; mod updates; +use std::collections::BTreeMap; + use serde::Serialize; use tauri::{AppHandle, Manager, State, WebviewWindow}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; @@ -34,6 +36,8 @@ struct AppInfo { autostart: bool, auto_update: bool, share_stats: bool, + keys: BTreeMap, + tap_key: String, /// Работает ли удержание Alt для сквозных кликов на этой системе. hold_supported: bool, } @@ -50,7 +54,9 @@ fn get_settings(app: AppHandle, store: State, status: State, enabled: bool) { store.update(|s| s.share_stats = enabled); } +/// Переназначенные клавиши холста целиком; интерфейсу не доверяем — проверяем форму. +#[tauri::command] +fn set_keys(store: State, keys: BTreeMap) -> Result<(), String> { + if !settings::valid_keys(&keys) { + return Err("invalid".into()); + } + store.update(|s| s.keys = keys); + Ok(()) +} + +#[tauri::command] +fn set_tap_key( + store: State, + clicks: State, + key: String, +) -> Result<(), String> { + let tap = keys::Tap::parse(&key).ok_or("invalid")?; + clicks.set_tap(tap); + store.update(|s| s.tap_key = key); + Ok(()) +} + #[tauri::command] fn set_pass_through( app: AppHandle, @@ -170,6 +198,8 @@ pub fn run() { let handle = app.handle(); let store = Store::load(handle); let accelerator = store.get().hotkey; + app.state::() + .set_tap(keys::Tap::parse(&store.get().tap_key).unwrap_or_default()); app.manage(store); overlay::create(handle)?; app.manage(tray::create(handle)?); @@ -191,6 +221,8 @@ pub fn run() { set_autostart, set_auto_update, set_share_stats, + set_keys, + set_tap_key, set_pass_through, focus_window, hide_overlay, diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 52e16e0..62d0243 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -1,5 +1,5 @@ //! Окно-оверлей: показ на мониторе с курсором, сквозные клики (режим, удержание Alt, -//! двойной тап Alt) с кликабельной панелью. +//! двойной тап выбранной клавиши) с кликабельной панелью. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -14,7 +14,7 @@ use crate::keys; pub const LABEL: &str = "overlay"; /// Как часто, пока холст виден, проверяем Alt и курсор: ~30 раз в секунду незаметно глазу и почти не грузит CPU. const POLL: Duration = Duration::from_millis(33); -/// Тап — Alt отпущен не позже, чем через столько после нажатия. +/// Тап — клавиша отпущена не позже, чем через столько после нажатия. const TAP_MAX: Duration = Duration::from_millis(300); /// Двойной тап — второй тап не позже, чем через столько после первого. const DOUBLE_TAP_GAP: Duration = Duration::from_millis(400); @@ -50,25 +50,27 @@ struct State { panel: Rect, /// Что сейчас выставлено окну; `None` — неизвестно, выставить заново. ignoring: Option, - /// Когда зажали Alt и нажималось ли вместе с ним что-то ещё (тогда это комбинация, не тап). - alt_since: Option, - alt_combo: bool, + /// Клавиша двойного тапа (из настроек). + tap: keys::Tap, + /// Когда её зажали и нажималось ли вместе с ней что-то ещё (тогда это комбинация, не тап). + tap_since: Option, + tap_combo: bool, /// Когда закончился предыдущий тап. last_tap: Option, } impl State { - /// Отслеживает тапы Alt; `true` — только что случился двойной тап. + /// Отслеживает тапы клавиши тапа; `true` — только что случился двойной тап. fn track_taps(&mut self, k: keys::Keys, now: Instant) -> bool { - match (k.hold, self.alt_since) { + match (k.tap, self.tap_since) { (true, None) => { - self.alt_since = Some(now); - self.alt_combo = k.other; + self.tap_since = Some(now); + self.tap_combo = k.other; } - (true, Some(_)) => self.alt_combo |= k.other, + (true, Some(_)) => self.tap_combo |= k.other, (false, Some(since)) => { - self.alt_since = None; - let tap = !self.alt_combo && now - since <= TAP_MAX; + self.tap_since = None; + let tap = !self.tap_combo && now - since <= TAP_MAX; if !tap { self.last_tap = None; } else if self.last_tap.is_some_and(|t| now - t <= DOUBLE_TAP_GAP) { @@ -103,6 +105,13 @@ impl ClickThrough { self.state.lock().unwrap_or_else(|e| e.into_inner()) } + pub fn set_tap(&self, tap: keys::Tap) { + let mut s = self.lock(); + s.tap = tap; + s.tap_since = None; + s.last_tap = None; + } + pub fn set_pass(&self, app: &AppHandle, pass: bool, panel: Rect) { { let mut s = self.lock(); @@ -133,7 +142,7 @@ fn request_sync(app: &AppHandle) { let _ = app.run_on_main_thread(move || sync(&handle)); } -/// Только в главном потоке: сверяет Alt (удержание, двойной тап) и курсор и выставляет окну, пропускать ли мышь. +/// Только в главном потоке: сверяет удержание Alt, двойной тап и курсор и выставляет окну, пропускать ли мышь. fn sync(app: &AppHandle) { let Some(win) = app.get_webview_window(LABEL) else { return; @@ -142,12 +151,13 @@ fn sync(app: &AppHandle) { let visible = clicks.visible.load(Ordering::Relaxed); let mut s = clicks.lock(); - let k = keys::poll().filter(|_| visible).unwrap_or(keys::Keys { + let k = keys::poll(s.tap).filter(|_| visible).unwrap_or(keys::Keys { hold: false, + tap: false, other: false, }); if s.track_taps(k, Instant::now()) { - // Двойной тап Alt: рисование ⇄ сквозные клики — как глобальный хоткей. + // Двойной тап: рисование ⇄ сквозные клики — как глобальный хоткей. let _ = app.emit("overlay:toggle", ()); } let held = k.hold; @@ -287,14 +297,23 @@ mod tests { const UP: keys::Keys = keys::Keys { hold: false, + tap: false, other: false, }; const ALT: keys::Keys = keys::Keys { - hold: true, + hold: false, + tap: true, other: false, }; const ALT_TAB: keys::Keys = keys::Keys { + hold: false, + tap: true, + other: true, + }; + /// Зажат Alt удержания, а клавиша тапа — другая (⌘ на macOS). + const HOLD_ONLY: keys::Keys = keys::Keys { hold: true, + tap: false, other: true, }; @@ -312,6 +331,16 @@ mod tests { assert!(run(&[(ALT, 0), (UP, 100), (ALT, 250), (UP, 350)])); } + #[test] + fn holding_alt_is_not_a_tap_of_another_key() { + assert!(!run(&[ + (HOLD_ONLY, 0), + (UP, 100), + (HOLD_ONLY, 250), + (UP, 350) + ])); + } + #[test] fn single_tap_does_not() { assert!(!run(&[(ALT, 0), (UP, 100)])); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8910e02..0892405 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,5 +1,6 @@ //! Настройки пользователя: JSON в каталоге конфигурации приложения. +use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use std::sync::Mutex; @@ -7,6 +8,8 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; +use crate::keys::Tap; + /// Две клавиши, слева сверху, рядом с Esc (на русской раскладке — Ё). pub const DEFAULT_HOTKEY: &str = "Alt+Backquote"; /// Прежнее значение по умолчанию — переводим на новое, пользователь его не выбирал. @@ -15,6 +18,43 @@ const LEGACY_HOTKEY: &str = "CommandOrControl+Alt+D"; pub const LANGS: [&str; 11] = [ "ru", "en", "de", "es", "fr", "pt", "zh", "ja", "ko", "ar", "hi", ]; +/// Переназначенных клавиш холста не больше — действий в интерфейсе меньше. +const MAX_KEYS: usize = 64; +/// Сочетаний у одного действия не больше. +const MAX_COMBOS: usize = 4; +const MODIFIERS: [&str; 4] = ["Mod", "Ctrl", "Alt", "Shift"]; + +/// Сочетание из `src/core/keymap.js`: «Mod+Shift+KeyC» — модификаторы без повторов и +/// физическая клавиша (`e.code`). +fn valid_combo(combo: &str) -> bool { + let mut parts: Vec<&str> = combo.split('+').collect(); + let Some(code) = parts.pop() else { + return false; + }; + let code_ok = (1..=24).contains(&code.len()) && code.bytes().all(|b| b.is_ascii_alphanumeric()); + let mut seen = Vec::new(); + let mods_ok = parts.iter().all(|m| { + let fresh = MODIFIERS.contains(m) && !seen.contains(m); + seen.push(*m); + fresh + }); + code_ok && mods_ok +} + +/// Действие (`undo`, `tool.pen`, `color.3`) и его сочетания через запятую; «» — без клавиши. +/// Какие действия есть, знает интерфейс; здесь — только форма, чтобы в файл не попал мусор. +pub fn valid_key(action: &str, combos: &str) -> bool { + let action_ok = (1..=32).contains(&action.len()) + && action + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.'); + let list: Vec<&str> = combos.split(',').filter(|c| !c.is_empty()).collect(); + action_ok && list.len() <= MAX_COMBOS && list.iter().all(|c| valid_combo(c)) +} + +pub fn valid_keys(keys: &BTreeMap) -> bool { + keys.len() <= MAX_KEYS && keys.iter().all(|(a, c)| valid_key(a, c)) +} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -29,6 +69,10 @@ pub struct Settings { pub install_id: String, /// Автозапуск по умолчанию уже включали. Дальше решает пользователь: выключил — не включаем снова. pub autostart_default_applied: bool, + /// Переназначенные клавиши холста — только отличия от значений по умолчанию. + pub keys: BTreeMap, + /// Клавиша двойного тапа — `keys::Tap` по имени (`off`, `alt`, `ctrl`, `shift`, `cmd`). + pub tap_key: String, } impl Default for Settings { @@ -40,6 +84,8 @@ impl Default for Settings { share_stats: true, install_id: String::new(), autostart_default_applied: false, + keys: BTreeMap::new(), + tap_key: Tap::DEFAULT.name().into(), } } } @@ -56,6 +102,15 @@ impl Settings { if uuid::Uuid::parse_str(&self.install_id).is_err() { self.install_id = uuid::Uuid::new_v4().to_string(); } + if Tap::parse(&self.tap_key).is_none() { + self.tap_key = Tap::DEFAULT.name().into(); + } + if !valid_keys(&self.keys) { + self.keys.retain(|a, c| valid_key(a, c)); + if self.keys.len() > MAX_KEYS { + self.keys.clear(); + } + } self } } @@ -129,6 +184,28 @@ mod tests { "статистика включена и у старых настроек без этого поля" ); assert!(!s.autostart_default_applied); + assert!(s.keys.is_empty()); + assert_eq!(s.tap_key, Tap::DEFAULT.name()); + } + + #[test] + fn canvas_keys_keep_only_valid_entries() { + assert!(valid_key("redo", "Mod+KeyY,Mod+Shift+KeyZ")); + assert!(valid_key("tool.pen", "Digit2")); + assert!(valid_key("delete", ""), "без клавиши"); + assert!(!valid_key("Undo", "Mod+KeyZ"), "имя действия — строчными"); + assert!(!valid_key("undo", "Mod+Mod+KeyZ"), "модификатор дважды"); + assert!(!valid_key("undo", "Hyper+KeyZ")); + assert!(!valid_key("undo", "Mod+")); + assert!(!valid_key("undo", "Mod+Key Z")); + assert!(!valid_key("undo", "A,B,C,D,E"), "больше четырёх сочетаний"); + let s: Settings = serde_json::from_str( + r#"{"keys":{"shot":"KeyP","bad action":"KeyQ","undo":"