├── src ├── vite-env.d.ts ├── indicator.tsx ├── settings.tsx ├── main.tsx ├── log.ts ├── indicator │ ├── widgets │ │ ├── CapsLockWidget.tsx │ │ ├── KeystrokeBufferWidget.tsx │ │ ├── BatteryWidget.tsx │ │ ├── TimeWidget.tsx │ │ ├── DateWidget.tsx │ │ ├── SelectionWidget.tsx │ │ └── index.tsx │ ├── types.ts │ ├── usePollingData.ts │ ├── windowPosition.ts │ └── Indicator.tsx ├── components │ ├── AppList.tsx │ ├── IgnoredAppsSettings.tsx │ ├── keyRecording.ts │ ├── WidgetSettings.tsx │ ├── GeneralSettings.tsx │ └── SettingsApp.tsx ├── App.tsx ├── App.css └── assets │ └── react.svg ├── src-tauri ├── build.rs ├── icons │ ├── icon.ico │ ├── icon.png │ ├── 32x32.png │ ├── icon.icns │ ├── 128x128.png │ ├── StoreLogo.png │ ├── tray-icon.png │ ├── 128x128@2x.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── tray-icon-insert.png │ ├── tray-icon-normal.png │ ├── tray-icon-visual.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square310x310Logo.png │ └── README.md ├── src │ ├── widgets │ │ ├── mod.rs │ │ ├── capslock.rs │ │ ├── battery.rs │ │ └── selection.rs │ ├── window │ │ ├── mod.rs │ │ └── indicator.rs │ ├── config │ │ └── mod.rs │ ├── vim │ │ ├── mod.rs │ │ ├── modes.rs │ │ ├── state │ │ │ ├── normal_mode │ │ │ │ ├── text_objects.rs │ │ │ │ └── motions.rs │ │ │ ├── action.rs │ │ │ ├── visual_mode.rs │ │ │ └── mod.rs │ │ └── commands.rs │ ├── main.rs │ ├── commands │ │ ├── mod.rs │ │ ├── vim_mode.rs │ │ ├── keys.rs │ │ ├── widgets.rs │ │ └── permissions.rs │ ├── keyboard │ │ ├── mod.rs │ │ ├── permission.rs │ │ ├── keycode.rs │ │ └── capture.rs │ ├── nvim_edit │ │ ├── terminals │ │ │ ├── terminal_app.rs │ │ │ ├── iterm.rs │ │ │ ├── kitty.rs │ │ │ ├── wezterm.rs │ │ │ ├── ghostty.rs │ │ │ ├── mod.rs │ │ │ ├── process_utils.rs │ │ │ └── alacritty.rs │ │ └── session.rs │ ├── cli.rs │ ├── ipc.rs │ └── keyboard_handler.rs ├── .gitignore ├── capabilities │ └── default.json ├── Cargo.toml ├── tauri.conf.json └── examples │ ├── test_suppress.rs │ └── test_suppress_raw.rs ├── docs ├── images │ ├── widgets.png │ ├── edit-popup.gif │ ├── Component-2.png │ ├── Component-3.png │ ├── Component-4.png │ ├── visual-C-u-d.gif │ ├── modes-animated.gif │ ├── change-indicator-position.gif │ └── indicator-modes-explanation.gif ├── keybindings.md └── cli.md ├── tsconfig.node.json ├── .gitignore ├── index.html ├── tsconfig.json ├── settings.html ├── indicator.html ├── package.json ├── LICENSE ├── vite.config.ts ├── public ├── vite.svg └── tauri.svg ├── etc └── update-version.sh ├── keybindings.md ├── README.md └── .github └── workflows └── release.yml /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build(); 3 | } 4 | -------------------------------------------------------------------------------- /docs/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/widgets.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod battery; 2 | pub mod capslock; 3 | pub mod selection; 4 | -------------------------------------------------------------------------------- /src-tauri/src/window/mod.rs: -------------------------------------------------------------------------------- 1 | mod indicator; 2 | 3 | pub use indicator::setup_indicator_window; 4 | -------------------------------------------------------------------------------- /docs/images/edit-popup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/edit-popup.gif -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod settings; 2 | 3 | pub use settings::{NvimEditSettings, Settings}; 4 | -------------------------------------------------------------------------------- /docs/images/Component-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/Component-2.png -------------------------------------------------------------------------------- /docs/images/Component-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/Component-3.png -------------------------------------------------------------------------------- /docs/images/Component-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/Component-4.png -------------------------------------------------------------------------------- /docs/images/visual-C-u-d.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/visual-C-u-d.gif -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/tray-icon.png -------------------------------------------------------------------------------- /docs/images/modes-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/modes-animated.gif -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-insert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/tray-icon-insert.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/tray-icon-normal.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-visual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/tray-icon-visual.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /docs/images/change-indicator-position.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/change-indicator-position.gif -------------------------------------------------------------------------------- /docs/images/indicator-modes-explanation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonisives/ovim/HEAD/docs/images/indicator-modes-explanation.gif -------------------------------------------------------------------------------- /src-tauri/src/vim/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod state; 2 | pub mod modes; 3 | pub mod commands; 4 | 5 | pub use state::{VimState, ProcessResult, VimAction}; 6 | pub use modes::VimMode; 7 | -------------------------------------------------------------------------------- /src/indicator.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client" 2 | import { Indicator } from "./indicator/Indicator" 3 | 4 | ReactDOM.createRoot(document.getElementById("root")!).render() 5 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | ti_vim_rust_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src/settings.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import { SettingsApp } from "./components/SettingsApp"; 3 | import "./settings.css"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render(); 6 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 6 | 7 | 8 | , 9 | ); 10 | -------------------------------------------------------------------------------- /src-tauri/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | //! Tauri command handlers 2 | 3 | mod keys; 4 | mod permissions; 5 | mod settings; 6 | mod vim_mode; 7 | mod widgets; 8 | 9 | pub use keys::*; 10 | pub use permissions::*; 11 | pub use settings::*; 12 | pub use vim_mode::*; 13 | pub use widgets::*; 14 | -------------------------------------------------------------------------------- /src-tauri/src/keyboard/mod.rs: -------------------------------------------------------------------------------- 1 | mod capture; 2 | mod inject; 3 | pub mod keycode; 4 | mod permission; 5 | 6 | pub use capture::KeyboardCapture; 7 | pub use inject::*; 8 | pub use keycode::{KeyCode, KeyEvent, Modifiers}; 9 | pub use permission::{check_accessibility_permission, request_accessibility_permission}; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + React + Typescript 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/src/commands/vim_mode.rs: -------------------------------------------------------------------------------- 1 | //! Vim mode Tauri commands 2 | 3 | use tauri::State; 4 | 5 | use crate::AppState; 6 | 7 | #[tauri::command] 8 | pub fn get_vim_mode(state: State) -> String { 9 | let vim_state = state.vim_state.lock().unwrap(); 10 | vim_state.mode().as_str().to_string() 11 | } 12 | 13 | #[tauri::command] 14 | pub fn get_pending_keys(state: State) -> String { 15 | let vim_state = state.vim_state.lock().unwrap(); 16 | vim_state.get_pending_keys() 17 | } 18 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for all windows", 5 | "windows": ["main", "indicator", "settings"], 6 | "permissions": [ 7 | "core:default", 8 | "core:window:allow-set-size", 9 | "core:window:allow-set-position", 10 | "core:window:allow-available-monitors", 11 | "core:window:allow-show", 12 | "core:window:allow-hide", 13 | "core:event:default", 14 | "opener:default", 15 | "dialog:default" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src-tauri/src/widgets/capslock.rs: -------------------------------------------------------------------------------- 1 | use core_graphics::event::CGEventFlags; 2 | 3 | #[link(name = "CoreGraphics", kind = "framework")] 4 | extern "C" { 5 | fn CGEventSourceFlagsState(stateID: i32) -> u64; 6 | } 7 | 8 | const COMBINED_SESSION_STATE: i32 = 0; 9 | 10 | /// Check if Caps Lock is currently on 11 | pub fn is_caps_lock_on() -> bool { 12 | unsafe { 13 | let flags = CGEventSourceFlagsState(COMBINED_SESSION_STATE); 14 | // CGEventFlags::CGEventFlagAlphaShift = 0x00010000 15 | (flags & CGEventFlags::CGEventFlagAlphaShift.bits()) != 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | 3 | // Logger that writes to /tmp/ovim-webview.log via Rust backend 4 | export const log = { 5 | info: (message: string) => { 6 | invoke("webview_log", { level: "info", message }).catch(console.error); 7 | }, 8 | warn: (message: string) => { 9 | invoke("webview_log", { level: "warn", message }).catch(console.error); 10 | }, 11 | error: (message: string) => { 12 | invoke("webview_log", { level: "error", message }).catch(console.error); 13 | }, 14 | debug: (message: string) => { 15 | invoke("webview_log", { level: "debug", message }).catch(console.error); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/indicator/widgets/CapsLockWidget.tsx: -------------------------------------------------------------------------------- 1 | import { usePollingData } from "../usePollingData" 2 | 3 | export function CapsLockWidget({ fontFamily }: { fontFamily: string }) { 4 | const capsLock = usePollingData({ 5 | command: "get_caps_lock_state", 6 | interval: 200, 7 | initialValue: false, 8 | eventName: "caps-lock-changed", 9 | }) 10 | 11 | if (!capsLock) { 12 | return null 13 | } 14 | 15 | return ( 16 |
25 | CAPS 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/indicator/widgets/KeystrokeBufferWidget.tsx: -------------------------------------------------------------------------------- 1 | import { usePollingData } from "../usePollingData" 2 | 3 | export function KeystrokeBufferWidget({ fontFamily }: { fontFamily: string }) { 4 | const pendingKeys = usePollingData({ 5 | command: "get_pending_keys", 6 | interval: 100, 7 | initialValue: "", 8 | eventName: "pending-keys-changed", 9 | }) 10 | 11 | if (!pendingKeys) { 12 | return null 13 | } 14 | 15 | return ( 16 |
25 | {pendingKeys} 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/indicator/widgets/BatteryWidget.tsx: -------------------------------------------------------------------------------- 1 | import { usePollingData } from "../usePollingData" 2 | import type { BatteryInfo } from "../types" 3 | 4 | export function BatteryWidget({ fontFamily }: { fontFamily: string }) { 5 | const battery = usePollingData({ 6 | command: "get_battery_info", 7 | interval: 60000, 8 | initialValue: null, 9 | }) 10 | 11 | const content = battery ? `${battery.percentage}%` : "-" 12 | 13 | return ( 14 |
23 | {content} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/indicator/widgets/TimeWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useIntervalValue } from "../usePollingData" 2 | 3 | function formatTime(): string { 4 | const now = new Date() 5 | const hours = now.getHours().toString().padStart(2, "0") 6 | const minutes = now.getMinutes().toString().padStart(2, "0") 7 | return `${hours}:${minutes}` 8 | } 9 | 10 | export function TimeWidget({ fontFamily }: { fontFamily: string }) { 11 | const time = useIntervalValue(formatTime, 1000) 12 | 13 | return ( 14 |
23 | {time} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/indicator/widgets/DateWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useIntervalValue } from "../usePollingData" 2 | 3 | function formatDate(): string { 4 | const now = new Date() 5 | const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] 6 | const day = days[now.getDay()] 7 | const date = now.getDate() 8 | return `${day} ${date}` 9 | } 10 | 11 | export function DateWidget({ fontFamily }: { fontFamily: string }) { 12 | const date = useIntervalValue(formatDate, 60000) 13 | 14 | return ( 15 |
24 | {date} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ovim Settings 7 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /indicator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Indicator 7 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src-tauri/icons/README.md: -------------------------------------------------------------------------------- 1 | magick -size 44x44 xc:transparent \ 2 | -fill none -stroke white -strokewidth 2 \ 3 | -draw "roundrectangle 2,2 41,41 6,6" \ 4 | -fill white -stroke none -font "Helvetica-Bold" -pointsize 26 -gravity center \ 5 | -draw "text 0,2 'V'" \ 6 | tray-icon-visual.png 7 | 8 | ➜ magick -size 44x44 xc:transparent \ 9 | -fill none -stroke white -strokewidth 2 \ 10 | -draw "roundrectangle 2,2 41,41 6,6" \ 11 | -fill white -stroke none -font "Helvetica-Bold" -pointsize 26 -gravity center \ 12 | -draw "text 0,2 'I'" \ 13 | tray-icon-insert.png 14 | 15 | ➜ magick -size 44x44 xc:transparent \ 16 | -fill none -stroke white -strokewidth 2 \ 17 | -draw "roundrectangle 2,2 41,41 6,6" \ 18 | -fill white -stroke none -font "Helvetica-Bold" -pointsize 26 -gravity center \ 19 | -draw "text 1,2 'N'" \ 20 | tray-icon-normal.png 21 | -------------------------------------------------------------------------------- /src-tauri/src/vim/modes.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// The three vim modes 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] 5 | #[serde(rename_all = "lowercase")] 6 | pub enum VimMode { 7 | /// Normal typing mode 8 | #[default] 9 | Insert, 10 | /// Vim command mode (hjkl navigation, operators) 11 | Normal, 12 | /// Visual selection mode 13 | Visual, 14 | } 15 | 16 | impl VimMode { 17 | pub fn as_str(&self) -> &'static str { 18 | match self { 19 | Self::Insert => "insert", 20 | Self::Normal => "normal", 21 | Self::Visual => "visual", 22 | } 23 | } 24 | } 25 | 26 | impl std::fmt::Display for VimMode { 27 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 | write!(f, "{}", self.as_str()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ovim-rust", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0", 15 | "@tauri-apps/api": "^2", 16 | "@tauri-apps/plugin-opener": "^2", 17 | "@tauri-apps/plugin-dialog": "^2" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^19.1.8", 21 | "@types/react-dom": "^19.1.6", 22 | "@vitejs/plugin-react": "^4.6.0", 23 | "typescript": "~5.8.3", 24 | "vite": "^7.0.4", 25 | "@tauri-apps/cli": "^2" 26 | }, 27 | "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" 28 | } 29 | -------------------------------------------------------------------------------- /src/components/AppList.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | items: string[]; 3 | onAdd: () => void; 4 | onRemove: (item: string) => void; 5 | } 6 | 7 | export function AppList({ items, onAdd, onRemove }: Props) { 8 | return ( 9 |
10 |
    11 | {items.map((item) => ( 12 |
  • 13 | {item} 14 | 21 |
  • 22 | ))} 23 | {items.length === 0 && ( 24 |
  • No apps configured
  • 25 | )} 26 |
27 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/indicator/types.ts: -------------------------------------------------------------------------------- 1 | export type VimMode = "insert" | "normal" | "visual" 2 | 3 | export type WidgetType = 4 | | "None" 5 | | "Time" 6 | | "Date" 7 | | "CharacterCount" 8 | | "LineCount" 9 | | "CharacterAndLineCount" 10 | | "Battery" 11 | | "CapsLock" 12 | | "KeystrokeBuffer" 13 | 14 | export interface RgbColor { 15 | r: number 16 | g: number 17 | b: number 18 | } 19 | 20 | export interface ModeColors { 21 | insert: RgbColor 22 | normal: RgbColor 23 | visual: RgbColor 24 | } 25 | 26 | export interface Settings { 27 | enabled: boolean 28 | indicator_position: number 29 | indicator_opacity: number 30 | indicator_size: number 31 | indicator_offset_x: number 32 | indicator_offset_y: number 33 | indicator_visible: boolean 34 | show_mode_in_menu_bar: boolean 35 | mode_colors: ModeColors 36 | indicator_font: string 37 | top_widget: WidgetType 38 | bottom_widget: WidgetType 39 | } 40 | 41 | export interface SelectionInfo { 42 | char_count: number 43 | line_count: number 44 | } 45 | 46 | export interface BatteryInfo { 47 | percentage: number 48 | is_charging: boolean 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/indicator/widgets/SelectionWidget.tsx: -------------------------------------------------------------------------------- 1 | import { usePollingData } from "../usePollingData" 2 | import type { SelectionInfo } from "../types" 3 | 4 | interface SelectionWidgetProps { 5 | fontFamily: string 6 | showChars: boolean 7 | showLines: boolean 8 | } 9 | 10 | export function SelectionWidget({ 11 | fontFamily, 12 | showChars, 13 | showLines, 14 | }: SelectionWidgetProps) { 15 | const selection = usePollingData({ 16 | command: "get_selection_info", 17 | interval: 500, 18 | initialValue: null, 19 | }) 20 | 21 | let content: string 22 | if (!selection) { 23 | content = "-" 24 | } else if (showChars && showLines) { 25 | content = `${selection.char_count}c ${selection.line_count}L` 26 | } else if (showChars) { 27 | content = `${selection.char_count}c` 28 | } else { 29 | content = `${selection.line_count}L` 30 | } 31 | 32 | return ( 33 |
42 | {content} 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src-tauri/src/keyboard/permission.rs: -------------------------------------------------------------------------------- 1 | use core_foundation::base::TCFType; 2 | use core_foundation::boolean::CFBoolean; 3 | use core_foundation::dictionary::CFDictionary; 4 | use core_foundation::string::CFString; 5 | 6 | // ApplicationServices framework binding for accessibility 7 | #[link(name = "ApplicationServices", kind = "framework")] 8 | extern "C" { 9 | fn AXIsProcessTrustedWithOptions(options: core_foundation::dictionary::CFDictionaryRef) -> bool; 10 | } 11 | 12 | const K_AX_TRUSTED_CHECK_OPTION_PROMPT: &str = "AXTrustedCheckOptionPrompt"; 13 | 14 | /// Check if the app has accessibility/input monitoring permission 15 | pub fn check_accessibility_permission() -> bool { 16 | unsafe { AXIsProcessTrustedWithOptions(std::ptr::null()) } 17 | } 18 | 19 | /// Request accessibility permission (shows system prompt) 20 | pub fn request_accessibility_permission() -> bool { 21 | unsafe { 22 | let key = CFString::new(K_AX_TRUSTED_CHECK_OPTION_PROMPT); 23 | let value = CFBoolean::true_value(); 24 | 25 | let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); 26 | 27 | AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src-tauri/src/window/indicator.rs: -------------------------------------------------------------------------------- 1 | use tauri::WebviewWindow; 2 | 3 | /// Set up the indicator window with special properties 4 | #[allow(unused_variables)] 5 | pub fn setup_indicator_window(window: &WebviewWindow) -> Result<(), String> { 6 | #[cfg(target_os = "macos")] 7 | #[allow(deprecated)] 8 | { 9 | use cocoa::appkit::NSWindowCollectionBehavior; 10 | use cocoa::base::id; 11 | 12 | let ns_window = window.ns_window().map_err(|e| e.to_string())? as id; 13 | 14 | unsafe { 15 | // Make window ignore mouse events (click-through) 16 | use objc::*; 17 | let _: () = msg_send![ns_window, setIgnoresMouseEvents: true]; 18 | 19 | // Set window level to floating 20 | let _: () = msg_send![ns_window, setLevel: 3i64]; // NSFloatingWindowLevel 21 | 22 | // Set collection behavior to appear on all spaces 23 | use cocoa::appkit::NSWindow; 24 | ns_window.setCollectionBehavior_( 25 | NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces 26 | | NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary, 27 | ); 28 | } 29 | } 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import react from "@vitejs/plugin-react" 3 | import { resolve } from "path" 4 | 5 | // @ts-expect-error process is a nodejs global 6 | const host = process.env.TAURI_DEV_HOST 7 | 8 | // https://vite.dev/config/ 9 | export default defineConfig(async () => ({ 10 | plugins: [react()], 11 | build: { 12 | rollupOptions: { 13 | input: { 14 | main: resolve(__dirname, "index.html"), 15 | indicator: resolve(__dirname, "indicator.html"), 16 | settings: resolve(__dirname, "settings.html"), 17 | }, 18 | }, 19 | }, 20 | 21 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 22 | // 23 | // 1. prevent Vite from obscuring rust errors 24 | clearScreen: false, 25 | // 2. tauri expects a fixed port, fail if that port is not available 26 | server: { 27 | port: 1422, 28 | strictPort: true, 29 | host: host || false, 30 | hmr: host 31 | ? { 32 | protocol: "ws", 33 | host, 34 | port: 1422, 35 | } 36 | : undefined, 37 | watch: { 38 | // 3. tell Vite to ignore watching `src-tauri` 39 | ignored: ["**/src-tauri/**"], 40 | }, 41 | }, 42 | })) 43 | -------------------------------------------------------------------------------- /src/components/IgnoredAppsSettings.tsx: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | import type { Settings } from "./SettingsApp"; 3 | import { AppList } from "./AppList"; 4 | 5 | interface Props { 6 | settings: Settings; 7 | onUpdate: (updates: Partial) => void; 8 | } 9 | 10 | export function IgnoredAppsSettings({ settings, onUpdate }: Props) { 11 | const handleAddApp = async () => { 12 | try { 13 | const bundleId = await invoke("pick_app"); 14 | if (bundleId && !settings.ignored_apps.includes(bundleId)) { 15 | onUpdate({ ignored_apps: [...settings.ignored_apps, bundleId] }); 16 | } 17 | } catch (e) { 18 | console.error("Failed to pick app:", e); 19 | } 20 | }; 21 | 22 | const handleRemoveApp = (bundleId: string) => { 23 | onUpdate({ 24 | ignored_apps: settings.ignored_apps.filter((id) => id !== bundleId), 25 | }); 26 | }; 27 | 28 | return ( 29 |
30 |

Ignored Apps

31 |

32 | Vim modifications are disabled in these applications. 33 |

34 | 35 | 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/keyRecording.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core" 2 | 3 | export interface VimKeyModifiers { 4 | shift: boolean 5 | control: boolean 6 | option: boolean 7 | command: boolean 8 | } 9 | 10 | export interface RecordedKey { 11 | name: string 12 | display_name: string 13 | modifiers: VimKeyModifiers 14 | } 15 | 16 | export function formatKeyWithModifiers( 17 | displayName: string, 18 | modifiers: VimKeyModifiers, 19 | ): string { 20 | const parts: string[] = [] 21 | if (modifiers.control) parts.push("Ctrl") 22 | if (modifiers.option) parts.push("Opt") 23 | if (modifiers.shift) parts.push("Shift") 24 | if (modifiers.command) parts.push("Cmd") 25 | parts.push(displayName) 26 | return parts.join(" + ") 27 | } 28 | 29 | export function hasAnyModifier(modifiers: VimKeyModifiers): boolean { 30 | return modifiers.shift || modifiers.control || modifiers.option || modifiers.command 31 | } 32 | 33 | export async function recordKey(): Promise { 34 | return invoke("record_key") 35 | } 36 | 37 | export async function cancelRecordKey(): Promise { 38 | await invoke("cancel_record_key") 39 | } 40 | 41 | export async function getKeyDisplayName(keyName: string): Promise { 42 | return invoke("get_key_display_name", { keyName }) 43 | } 44 | -------------------------------------------------------------------------------- /src-tauri/src/commands/keys.rs: -------------------------------------------------------------------------------- 1 | //! Key recording and display Tauri commands 2 | 3 | use tauri::State; 4 | 5 | use crate::keyboard::KeyCode; 6 | use crate::AppState; 7 | 8 | /// Recorded key info returned to frontend 9 | #[derive(Debug, Clone, serde::Serialize)] 10 | pub struct RecordedKey { 11 | pub name: String, 12 | pub display_name: String, 13 | pub modifiers: RecordedModifiers, 14 | } 15 | 16 | /// Modifier state for recorded key 17 | #[derive(Debug, Clone, serde::Serialize)] 18 | pub struct RecordedModifiers { 19 | pub shift: bool, 20 | pub control: bool, 21 | pub option: bool, 22 | pub command: bool, 23 | } 24 | 25 | #[tauri::command] 26 | pub fn get_key_display_name(key_name: String) -> Option { 27 | KeyCode::from_name(&key_name).map(|k| k.to_display_name().to_string()) 28 | } 29 | 30 | #[tauri::command] 31 | pub async fn record_key(state: State<'_, AppState>) -> Result { 32 | let (tx, rx) = tokio::sync::oneshot::channel(); 33 | 34 | { 35 | let mut record_tx = state.record_key_tx.lock().unwrap(); 36 | *record_tx = Some(tx); 37 | } 38 | 39 | rx.await.map_err(|_| "Key recording cancelled".to_string()) 40 | } 41 | 42 | #[tauri::command] 43 | pub fn cancel_record_key(state: State) { 44 | let mut record_tx = state.record_key_tx.lock().unwrap(); 45 | *record_tx = None; 46 | } 47 | -------------------------------------------------------------------------------- /src/indicator/widgets/index.tsx: -------------------------------------------------------------------------------- 1 | import type { WidgetType } from "../types" 2 | import { TimeWidget } from "./TimeWidget" 3 | import { DateWidget } from "./DateWidget" 4 | import { SelectionWidget } from "./SelectionWidget" 5 | import { BatteryWidget } from "./BatteryWidget" 6 | import { CapsLockWidget } from "./CapsLockWidget" 7 | import { KeystrokeBufferWidget } from "./KeystrokeBufferWidget" 8 | 9 | interface WidgetProps { 10 | type: WidgetType 11 | fontFamily: string 12 | } 13 | 14 | export function Widget({ type, fontFamily }: WidgetProps) { 15 | switch (type) { 16 | case "None": 17 | return null 18 | case "Time": 19 | return 20 | case "Date": 21 | return 22 | case "CharacterCount": 23 | return 24 | case "LineCount": 25 | return 26 | case "CharacterAndLineCount": 27 | return 28 | case "Battery": 29 | return 30 | case "CapsLock": 31 | return 32 | case "KeystrokeBuffer": 33 | return 34 | default: 35 | return null 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src-tauri/src/commands/widgets.rs: -------------------------------------------------------------------------------- 1 | //! Widget info Tauri commands 2 | 3 | use crate::widgets::{battery, capslock, selection}; 4 | 5 | #[tauri::command] 6 | pub fn get_selection_info() -> selection::SelectionInfo { 7 | selection::get_selection_info() 8 | } 9 | 10 | #[tauri::command] 11 | pub fn get_battery_info() -> Option { 12 | battery::get_battery_info() 13 | } 14 | 15 | #[tauri::command] 16 | pub fn get_caps_lock_state() -> bool { 17 | capslock::is_caps_lock_on() 18 | } 19 | 20 | /// Log message from webview to /tmp/ovim-webview.log 21 | #[tauri::command] 22 | pub fn webview_log(level: String, message: String) { 23 | use std::fs::OpenOptions; 24 | use std::io::Write; 25 | 26 | let timestamp = chrono::Local::now().format("%H:%M:%S%.3f"); 27 | let line = format!( 28 | "[{}] {} - {}\n", 29 | timestamp, 30 | level.to_uppercase(), 31 | message 32 | ); 33 | 34 | if let Ok(mut file) = OpenOptions::new() 35 | .create(true) 36 | .append(true) 37 | .open("/tmp/ovim-webview.log") 38 | { 39 | let _ = file.write_all(line.as_bytes()); 40 | } 41 | 42 | match level.to_lowercase().as_str() { 43 | "error" => log::error!("[webview] {}", message), 44 | "warn" => log::warn!("[webview] {}", message), 45 | "debug" => log::debug!("[webview] {}", message), 46 | _ => log::info!("[webview] {}", message), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/keybindings.md: -------------------------------------------------------------------------------- 1 | # Vim Keybindings 2 | 3 | ## Mode Switching 4 | 5 | | Key | Action | 6 | | --- | ------ | 7 | | `Esc` | Return to Normal mode | 8 | | `i` / `I` | Insert at cursor / line start | 9 | | `a` / `A` | Append after cursor / line end | 10 | | `o` / `O` | Open line below / above | 11 | | `v` | Enter Visual mode | 12 | | `s` / `S` | Substitute character / line | 13 | 14 | ## Motions 15 | 16 | | Key | Action | 17 | | --- | ------ | 18 | | `h` `j` `k` `l` | Left, down, up, right | 19 | | `w` / `b` / `e` | Word forward / backward / end | 20 | | `0` / `$` | Line start / end | 21 | | `{` / `}` | Paragraph up / down | 22 | | `gg` / `G` | Document start / end | 23 | | `Ctrl+u` / `Ctrl+d` | Half page up / down | 24 | 25 | ## Operators + Text Objects 26 | 27 | Operators combine with motions (e.g., `dw` deletes word, `y$` yanks to line end). 28 | 29 | | Operator | Action | 30 | | -------- | ------ | 31 | | `d` | Delete | 32 | | `y` | Yank (copy) | 33 | | `c` | Change (delete + insert) | 34 | 35 | | Text Object | Action | 36 | | ----------- | ------ | 37 | | `iw` / `aw` | Inner word / around word | 38 | 39 | ## Commands 40 | 41 | | Key | Action | 42 | | --- | ------ | 43 | | `x` / `X` | Delete char under / before cursor | 44 | | `D` / `C` / `Y` | Delete / change / yank to line end | 45 | | `dd` / `yy` / `cc` | Delete / yank / change line | 46 | | `J` | Join lines | 47 | | `p` / `P` | Paste after / before cursor | 48 | | `u` / `Ctrl+r` | Undo / redo | 49 | | `>>` / `<<` | Indent / outdent line | 50 | 51 | ## Counts 52 | 53 | Prefix with numbers: `5j` (move down 5), `3dw` (delete 3 words), `10x` (delete 10 chars). 54 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import reactLogo from "./assets/react.svg"; 3 | import { invoke } from "@tauri-apps/api/core"; 4 | import "./App.css"; 5 | 6 | function App() { 7 | const [greetMsg, setGreetMsg] = useState(""); 8 | const [name, setName] = useState(""); 9 | 10 | async function greet() { 11 | // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 12 | setGreetMsg(await invoke("greet", { name })); 13 | } 14 | 15 | return ( 16 |
17 |

Welcome to Tauri + React

18 | 19 | 30 |

Click on the Tauri, Vite, and React logos to learn more.

31 | 32 |
{ 35 | e.preventDefault(); 36 | greet(); 37 | }} 38 | > 39 | setName(e.currentTarget.value)} 42 | placeholder="Enter a name..." 43 | /> 44 | 45 |
46 |

{greetMsg}

47 |
48 | ); 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /etc/update-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to update version numbers across the project when releasing with v-prefix 4 | # Usage: ./update-version.sh 5 | 6 | set -e 7 | 8 | TAG_NAME="${1:-$GITHUB_REF_NAME}" 9 | 10 | echo "Release tag: $TAG_NAME" 11 | 12 | if [[ "$TAG_NAME" =~ ^v ]]; then 13 | # Extract version by removing 'v' prefix 14 | VERSION="${TAG_NAME:1}" 15 | echo "Extracted version: $VERSION" 16 | 17 | # Update package.json version 18 | echo "Updating package.json to version $VERSION" 19 | pnpm version --no-git-tag-version "$VERSION" 20 | 21 | # Update tauri.conf.json version 22 | echo "Updating tauri.conf.json to version $VERSION" 23 | sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json 24 | rm -f src-tauri/tauri.conf.json.bak 25 | 26 | # Update Cargo.toml version 27 | echo "Updating Cargo.toml version to $VERSION" 28 | sed -i.bak "s/^version = .*/version = \"$VERSION\"/" src-tauri/Cargo.toml 29 | rm -f src-tauri/Cargo.toml.bak 30 | 31 | echo "Successfully updated all version files to: $VERSION" 32 | 33 | # In CI environment, commit the changes 34 | if [[ -n "$GITHUB_ACTIONS" ]]; then 35 | git config --global user.name "github-actions[bot]" 36 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 37 | git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml 38 | git commit -m "chore: update version to $VERSION" || true 39 | git push origin HEAD || true 40 | echo "Committed version changes for release $TAG_NAME" 41 | fi 42 | else 43 | echo "Tag '$TAG_NAME' doesn't start with 'v', skipping version update" 44 | exit 0 45 | fi 46 | -------------------------------------------------------------------------------- /keybindings.md: -------------------------------------------------------------------------------- 1 | Motions 2 | 3 | - $ - Move to end of line (Shift+4) 4 | - ^ - Move to first non-blank (Shift+6, same as 0 on macOS) 5 | - { / } - Paragraph up/down (Option+Up/Down) 6 | - ge - End of previous word 7 | 8 | Shortcuts 9 | 10 | - X - Delete char before cursor (backspace) 11 | - D - Delete to end of line 12 | - C - Change to end of line 13 | - Y - Yank line (same as yy) 14 | - s - Substitute char (delete + insert mode) 15 | - S - Substitute line (same as cc) 16 | - J - Join lines 17 | - r{char} - Replace character at cursor 18 | 19 | Text Objects 20 | 21 | - diw / yiw / ciw - Inner word operations 22 | - daw / yaw / caw - Around word operations 23 | - viw / vaw - Visual mode word selection 24 | 25 | Extended Operator Motions 26 | 27 | All operators (d, y, c) now work with: 28 | 29 | - $, ^ (line boundaries) 30 | - {, } (paragraph) 31 | - gg, G (document) 32 | 33 | g-Prefix Commands 34 | 35 | - gg - Document start (existing) 36 | - ge - Previous word end 37 | - gj / gk - Visual line movement (same as j/k) 38 | - g0 / g$ - Screen line start/end 39 | 40 | Indent/Outdent 41 | 42 | - > > - Indent line (Tab) 43 | - << - Outdent line (Shift+Tab) 44 | 45 | Visual Mode 46 | 47 | - All new motions work with selection 48 | - Text object selection (viw, vaw) 49 | - Count support for motions 50 | 51 | Files Modified 52 | 53 | - src-tauri/src/keyboard/inject.rs - New injection helpers 54 | - src-tauri/src/keyboard/keycode.rs - Added to_char() method 55 | - src-tauri/src/vim/commands.rs - New command variants 56 | - src-tauri/src/vim/state/mod.rs - New state fields 57 | - src-tauri/src/vim/state/normal_mode.rs - All new command handling 58 | - src-tauri/src/vim/state/visual_mode.rs - Extended visual mode 59 | - src-tauri/src/vim/state/action.rs - New action types 60 | -------------------------------------------------------------------------------- /src-tauri/src/widgets/battery.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::process::Command; 3 | 4 | #[derive(Debug, Clone, Serialize)] 5 | pub struct BatteryInfo { 6 | pub percentage: u8, 7 | pub is_charging: bool, 8 | } 9 | 10 | /// Get battery information using pmset command 11 | pub fn get_battery_info() -> Option { 12 | let output = Command::new("pmset") 13 | .args(["-g", "batt"]) 14 | .output() 15 | .ok()?; 16 | 17 | if !output.status.success() { 18 | return None; 19 | } 20 | 21 | let stdout = String::from_utf8_lossy(&output.stdout); 22 | 23 | // Parse output like: 24 | // Now drawing from 'Battery Power' 25 | // -InternalBattery-0 (id=...) 85%; discharging; 3:45 remaining 26 | // or 27 | // -InternalBattery-0 (id=...) 85%; charging; 0:45 until full 28 | 29 | for line in stdout.lines() { 30 | if line.contains("InternalBattery") { 31 | // Extract percentage 32 | if let Some(pct_idx) = line.find('%') { 33 | // Find the start of the number (work backwards from %) 34 | let before_pct = &line[..pct_idx]; 35 | let pct_start = before_pct 36 | .rfind(|c: char| !c.is_ascii_digit()) 37 | .map(|i| i + 1) 38 | .unwrap_or(0); 39 | 40 | if let Ok(percentage) = before_pct[pct_start..].parse::() { 41 | let is_charging = line.contains("charging") && !line.contains("discharging"); 42 | return Some(BatteryInfo { 43 | percentage, 44 | is_charging, 45 | }); 46 | } 47 | } 48 | } 49 | } 50 | 51 | None 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ovim-rust" 3 | version = "0.1.0" 4 | description = "System-wide Vim mode for macOS" 5 | authors = ["tonis"] 6 | edition = "2021" 7 | default-run = "ovim-rust" 8 | 9 | [lib] 10 | name = "ti_vim_rust_lib" 11 | crate-type = ["staticlib", "cdylib", "rlib"] 12 | 13 | [[bin]] 14 | name = "ovim" 15 | path = "src/cli.rs" 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } 22 | tauri-plugin-opener = "2" 23 | tauri-plugin-dialog = "2" 24 | serde = { version = "1", features = ["derive"] } 25 | serde_json = "1" 26 | serde_yml = "0.0" 27 | 28 | # macOS frameworks for keyboard capture 29 | core-graphics = "0.25" 30 | core-foundation = "0.10" 31 | 32 | # Async runtime 33 | tokio = { version = "1", features = ["sync", "rt", "net", "io-util", "macros"] } 34 | 35 | # Neovim RPC for live buffer sync 36 | nvim-rs = { version = "0.9", features = ["use_tokio"] } 37 | async-trait = "0.1" 38 | 39 | # Logging 40 | log = "0.4" 41 | env_logger = "0.11" 42 | chrono = "0.4" 43 | 44 | # Error handling 45 | thiserror = "2" 46 | anyhow = "1" 47 | 48 | # Directories 49 | dirs = "6" 50 | 51 | # Image decoding for tray icons 52 | image = "0.25" 53 | 54 | # UUID for session IDs 55 | uuid = { version = "1", features = ["v4"] } 56 | 57 | # Low-level system calls 58 | libc = "0.2" 59 | 60 | [target.'cfg(target_os = "macos")'.dependencies] 61 | cocoa = "0.26" 62 | objc = "0.2.7" 63 | 64 | [features] 65 | default = ["custom-protocol"] 66 | custom-protocol = ["tauri/custom-protocol"] 67 | 68 | [lints.rust] 69 | warnings = "deny" 70 | unused = "deny" 71 | 72 | [lints.clippy] 73 | all = "deny" 74 | # Selectively enable useful pedantic lints 75 | needless_pass_by_ref_mut = "deny" 76 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "ovim", 4 | "version": "0.1.0", 5 | "identifier": "com.tonis.ovim", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:1422", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "macOSPrivateApi": true, 14 | "trayIcon": { 15 | "id": "main", 16 | "iconPath": "icons/tray-icon.png", 17 | "iconAsTemplate": true 18 | }, 19 | "windows": [ 20 | { 21 | "label": "indicator", 22 | "title": "", 23 | "url": "/indicator.html", 24 | "width": 40, 25 | "height": 40, 26 | "x": 20, 27 | "y": 100, 28 | "resizable": false, 29 | "decorations": false, 30 | "transparent": true, 31 | "alwaysOnTop": true, 32 | "skipTaskbar": true, 33 | "visible": true 34 | }, 35 | { 36 | "label": "settings", 37 | "title": "ovim Settings", 38 | "url": "/settings.html", 39 | "width": 600, 40 | "height": 400, 41 | "resizable": true, 42 | "decorations": true, 43 | "transparent": false, 44 | "visible": false, 45 | "center": true 46 | } 47 | ], 48 | "security": { 49 | "csp": null 50 | } 51 | }, 52 | "bundle": { 53 | "active": true, 54 | "targets": "all", 55 | "icon": [ 56 | "icons/32x32.png", 57 | "icons/128x128.png", 58 | "icons/128x128@2x.png", 59 | "icons/icon.icns", 60 | "icons/icon.ico" 61 | ], 62 | "macOS": { 63 | "entitlements": null, 64 | "exceptionDomain": null, 65 | "frameworks": [], 66 | "minimumSystemVersion": "10.15" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src-tauri/src/commands/permissions.rs: -------------------------------------------------------------------------------- 1 | //! Permission-related Tauri commands 2 | 3 | use tauri::State; 4 | 5 | use crate::keyboard::{check_accessibility_permission, request_accessibility_permission}; 6 | use crate::AppState; 7 | 8 | #[derive(Debug, Clone, serde::Serialize)] 9 | pub struct PermissionStatus { 10 | pub accessibility: bool, 11 | pub capture_running: bool, 12 | } 13 | 14 | #[tauri::command] 15 | pub fn check_permission() -> bool { 16 | check_accessibility_permission() 17 | } 18 | 19 | #[tauri::command] 20 | pub fn request_permission() -> bool { 21 | request_accessibility_permission() 22 | } 23 | 24 | #[tauri::command] 25 | pub fn get_permission_status(state: State) -> PermissionStatus { 26 | PermissionStatus { 27 | accessibility: check_accessibility_permission(), 28 | capture_running: state.keyboard_capture.is_running(), 29 | } 30 | } 31 | 32 | #[tauri::command] 33 | pub fn open_accessibility_settings() { 34 | use std::process::Command; 35 | let _ = Command::new("open") 36 | .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") 37 | .spawn(); 38 | } 39 | 40 | #[tauri::command] 41 | pub fn open_input_monitoring_settings() { 42 | use std::process::Command; 43 | let _ = Command::new("open") 44 | .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") 45 | .spawn(); 46 | } 47 | 48 | #[tauri::command] 49 | pub fn start_capture(state: State) -> Result<(), String> { 50 | state.keyboard_capture.start() 51 | } 52 | 53 | #[tauri::command] 54 | pub fn stop_capture(state: State) { 55 | state.keyboard_capture.stop() 56 | } 57 | 58 | #[tauri::command] 59 | pub fn is_capture_running(state: State) -> bool { 60 | state.keyboard_capture.is_running() 61 | } 62 | -------------------------------------------------------------------------------- /src-tauri/src/vim/state/normal_mode/text_objects.rs: -------------------------------------------------------------------------------- 1 | //! Text object handling for normal mode (iw, aw, etc.) 2 | 3 | use crate::keyboard::KeyCode; 4 | 5 | use super::super::super::commands::{Operator, VimCommand}; 6 | use super::super::super::modes::VimMode; 7 | use super::super::action::VimAction; 8 | use super::super::{ProcessResult, TextObjectModifier, VimState}; 9 | 10 | impl VimState { 11 | pub(super) fn handle_text_object(&mut self, keycode: KeyCode) -> ProcessResult { 12 | let modifier = match self.pending_text_object.take() { 13 | Some(m) => m, 14 | None => return ProcessResult::PassThrough, 15 | }; 16 | 17 | let operator = match self.pending_operator.take() { 18 | Some(op) => op, 19 | None => return ProcessResult::PassThrough, 20 | }; 21 | 22 | let count = self.get_count(); 23 | self.pending_count = None; 24 | 25 | // Only 'w' text object is supported for now 26 | if keycode == KeyCode::W { 27 | let text_object = match modifier { 28 | TextObjectModifier::Inner => VimCommand::InnerWord, 29 | TextObjectModifier::Around => VimCommand::AroundWord, 30 | }; 31 | 32 | if operator == Operator::Change { 33 | self.set_mode(VimMode::Insert); 34 | ProcessResult::ModeChanged( 35 | VimMode::Insert, 36 | Some(VimAction::TextObject { 37 | operator, 38 | text_object, 39 | count, 40 | }), 41 | ) 42 | } else { 43 | ProcessResult::SuppressWithAction(VimAction::TextObject { 44 | operator, 45 | text_object, 46 | count, 47 | }) 48 | } 49 | } else { 50 | // Unsupported text object 51 | self.reset_pending(); 52 | ProcessResult::Suppress 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/indicator/usePollingData.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { listen } from "@tauri-apps/api/event" 3 | import { invoke } from "@tauri-apps/api/core" 4 | 5 | interface PollingOptions { 6 | /** Command to invoke to fetch data */ 7 | command: string 8 | /** Polling interval in milliseconds */ 9 | interval: number 10 | /** Initial value */ 11 | initialValue: T 12 | /** Optional event name to listen for real-time updates */ 13 | eventName?: string 14 | } 15 | 16 | /** 17 | * Hook for polling data from Tauri backend with optional event-based updates 18 | */ 19 | export function usePollingData({ 20 | command, 21 | interval, 22 | initialValue, 23 | eventName, 24 | }: PollingOptions): T { 25 | const [data, setData] = useState(initialValue) 26 | 27 | useEffect(() => { 28 | const fetchData = async () => { 29 | try { 30 | const result = await invoke(command) 31 | setData(result) 32 | } catch { 33 | // Keep previous value on error 34 | } 35 | } 36 | 37 | // Initial fetch 38 | fetchData() 39 | 40 | // Set up polling 41 | const intervalId = setInterval(fetchData, interval) 42 | 43 | // Set up event listener if event name provided 44 | let cleanupEvent: (() => void) | undefined 45 | if (eventName) { 46 | const unlisten = listen(eventName, (event) => { 47 | setData(event.payload) 48 | }) 49 | 50 | cleanupEvent = () => { 51 | unlisten.then((fn) => fn()) 52 | } 53 | } 54 | 55 | return () => { 56 | clearInterval(intervalId) 57 | cleanupEvent?.() 58 | } 59 | }, [command, interval, eventName]) 60 | 61 | return data 62 | } 63 | 64 | /** 65 | * Hook for data that only needs local state updates (no backend fetching) 66 | */ 67 | export function useIntervalValue( 68 | getValue: () => T, 69 | interval: number, 70 | ): T { 71 | const [value, setValue] = useState(getValue) 72 | 73 | useEffect(() => { 74 | const intervalId = setInterval(() => { 75 | setValue(getValue()) 76 | }, interval) 77 | 78 | return () => clearInterval(intervalId) 79 | }, [getValue, interval]) 80 | 81 | return value 82 | } 83 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .logo.vite:hover { 2 | filter: drop-shadow(0 0 2em #747bff); 3 | } 4 | 5 | .logo.react:hover { 6 | filter: drop-shadow(0 0 2em #61dafb); 7 | } 8 | :root { 9 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 10 | font-size: 16px; 11 | line-height: 24px; 12 | font-weight: 400; 13 | 14 | color: #0f0f0f; 15 | background-color: #f6f6f6; 16 | 17 | font-synthesis: none; 18 | text-rendering: optimizeLegibility; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | -webkit-text-size-adjust: 100%; 22 | } 23 | 24 | .container { 25 | margin: 0; 26 | padding-top: 10vh; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: center; 30 | text-align: center; 31 | } 32 | 33 | .logo { 34 | height: 6em; 35 | padding: 1.5em; 36 | will-change: filter; 37 | transition: 0.75s; 38 | } 39 | 40 | .logo.tauri:hover { 41 | filter: drop-shadow(0 0 2em #24c8db); 42 | } 43 | 44 | .row { 45 | display: flex; 46 | justify-content: center; 47 | } 48 | 49 | a { 50 | font-weight: 500; 51 | color: #646cff; 52 | text-decoration: inherit; 53 | } 54 | 55 | a:hover { 56 | color: #535bf2; 57 | } 58 | 59 | h1 { 60 | text-align: center; 61 | } 62 | 63 | input, 64 | button { 65 | border-radius: 8px; 66 | border: 1px solid transparent; 67 | padding: 0.6em 1.2em; 68 | font-size: 1em; 69 | font-weight: 500; 70 | font-family: inherit; 71 | color: #0f0f0f; 72 | background-color: #ffffff; 73 | transition: border-color 0.25s; 74 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); 75 | } 76 | 77 | button { 78 | cursor: pointer; 79 | } 80 | 81 | button:hover { 82 | border-color: #396cd8; 83 | } 84 | button:active { 85 | border-color: #396cd8; 86 | background-color: #e8e8e8; 87 | } 88 | 89 | input, 90 | button { 91 | outline: none; 92 | } 93 | 94 | #greet-input { 95 | margin-right: 5px; 96 | } 97 | 98 | @media (prefers-color-scheme: dark) { 99 | :root { 100 | color: #f6f6f6; 101 | background-color: #2f2f2f; 102 | } 103 | 104 | a:hover { 105 | color: #24c8db; 106 | } 107 | 108 | input, 109 | button { 110 | color: #ffffff; 111 | background-color: #0f0f0f98; 112 | } 113 | button:active { 114 | background-color: #0f0f0f69; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src-tauri/src/vim/state/normal_mode/motions.rs: -------------------------------------------------------------------------------- 1 | //! Motion handling for normal mode (g combos, replace char) 2 | 3 | use crate::keyboard::{KeyCode, Modifiers}; 4 | 5 | use super::super::super::commands::VimCommand; 6 | use super::super::action::VimAction; 7 | use super::super::{ProcessResult, VimState}; 8 | 9 | impl VimState { 10 | pub(super) fn handle_g_combo( 11 | &mut self, 12 | keycode: KeyCode, 13 | modifiers: &Modifiers, 14 | ) -> ProcessResult { 15 | let count = self.get_count(); 16 | self.pending_count = None; 17 | 18 | match keycode { 19 | KeyCode::G => ProcessResult::SuppressWithAction(VimAction::Command { 20 | command: VimCommand::DocumentStart, 21 | count: 1, 22 | select: false, 23 | }), 24 | KeyCode::E => ProcessResult::SuppressWithAction(VimAction::Command { 25 | command: VimCommand::WordEndBackward, 26 | count, 27 | select: false, 28 | }), 29 | KeyCode::J => ProcessResult::SuppressWithAction(VimAction::Command { 30 | command: VimCommand::MoveDown, 31 | count, 32 | select: false, 33 | }), 34 | KeyCode::K => ProcessResult::SuppressWithAction(VimAction::Command { 35 | command: VimCommand::MoveUp, 36 | count, 37 | select: false, 38 | }), 39 | KeyCode::Num0 => ProcessResult::SuppressWithAction(VimAction::Command { 40 | command: VimCommand::LineStart, 41 | count: 1, 42 | select: false, 43 | }), 44 | KeyCode::Num4 if modifiers.shift => ProcessResult::SuppressWithAction(VimAction::Command { 45 | command: VimCommand::LineEnd, 46 | count: 1, 47 | select: false, 48 | }), 49 | _ => ProcessResult::PassThrough, 50 | } 51 | } 52 | 53 | pub(super) fn handle_replace_char( 54 | &mut self, 55 | keycode: KeyCode, 56 | modifiers: &Modifiers, 57 | ) -> ProcessResult { 58 | let count = self.get_count(); 59 | self.pending_count = None; 60 | 61 | if keycode.to_char().is_some() { 62 | ProcessResult::SuppressWithAction(VimAction::ReplaceChar { 63 | keycode, 64 | shift: modifiers.shift, 65 | count, 66 | }) 67 | } else { 68 | ProcessResult::Suppress 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src-tauri/src/vim/state/action.rs: -------------------------------------------------------------------------------- 1 | use crate::keyboard::{self, KeyCode}; 2 | use super::super::commands::{Operator, VimCommand}; 3 | 4 | /// Action to execute after suppressing the key event 5 | #[derive(Debug, Clone)] 6 | pub enum VimAction { 7 | /// Execute a vim command 8 | Command { command: VimCommand, count: u32, select: bool }, 9 | /// Execute an operator with a motion 10 | OperatorMotion { operator: Operator, motion: VimCommand, count: u32 }, 11 | /// Execute an operator with a text object 12 | TextObject { operator: Operator, text_object: VimCommand, count: u32 }, 13 | /// Replace character at cursor 14 | ReplaceChar { keycode: KeyCode, shift: bool, count: u32 }, 15 | /// Cut (Cmd+X) 16 | Cut, 17 | /// Copy (Cmd+C) 18 | Copy, 19 | } 20 | 21 | impl VimAction { 22 | /// Execute the action 23 | pub fn execute(&self) -> Result { 24 | match self { 25 | VimAction::Command { command, count, select } => { 26 | command.execute(*count, *select)?; 27 | Ok(false) 28 | } 29 | VimAction::OperatorMotion { operator, motion, count } => { 30 | operator.execute_with_motion(*motion, *count) 31 | } 32 | VimAction::TextObject { operator, text_object, count } => { 33 | // Execute the text object selection 34 | for _ in 0..*count { 35 | text_object.execute(1, false)?; 36 | } 37 | // Apply the operator 38 | match operator { 39 | Operator::Delete => { 40 | keyboard::cut()?; 41 | Ok(false) 42 | } 43 | Operator::Yank => { 44 | keyboard::copy()?; 45 | keyboard::cursor_left(1, false)?; 46 | Ok(false) 47 | } 48 | Operator::Change => { 49 | keyboard::cut()?; 50 | Ok(true) // Enter insert mode 51 | } 52 | } 53 | } 54 | VimAction::ReplaceChar { keycode, shift, count } => { 55 | // Delete char(s) and type replacement 56 | for _ in 0..*count { 57 | keyboard::delete_char()?; 58 | keyboard::type_char(*keycode, *shift)?; 59 | } 60 | Ok(false) 61 | } 62 | VimAction::Cut => { 63 | keyboard::cut()?; 64 | Ok(false) 65 | } 66 | VimAction::Copy => { 67 | keyboard::copy()?; 68 | Ok(false) 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/indicator/windowPosition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getCurrentWindow, 3 | LogicalSize, 4 | LogicalPosition, 5 | availableMonitors, 6 | } from "@tauri-apps/api/window" 7 | import type { Settings } from "./types" 8 | 9 | const BASE_SIZE = 40 10 | 11 | export async function applyWindowSettings(settings: Settings): Promise { 12 | const window = getCurrentWindow() 13 | 14 | if (!settings.enabled || !settings.indicator_visible) { 15 | await window.hide() 16 | return 17 | } 18 | await window.show() 19 | 20 | const baseSize = Math.round(BASE_SIZE * settings.indicator_size) 21 | 22 | // Calculate height based on active widgets 23 | const widgetHeight = 12 24 | const hasTopWidget = settings.top_widget !== "None" 25 | const hasBottomWidget = settings.bottom_widget !== "None" 26 | const widgetCount = (hasTopWidget ? 1 : 0) + (hasBottomWidget ? 1 : 0) 27 | 28 | const width = baseSize - 4 29 | const height = baseSize + widgetCount * widgetHeight - 2 30 | 31 | const monitors = await availableMonitors() 32 | const monitor = monitors[0] 33 | 34 | if (!monitor) { 35 | console.error("No monitor found!") 36 | return 37 | } 38 | 39 | const screenWidth = monitor.size.width / monitor.scaleFactor 40 | const screenHeight = monitor.size.height / monitor.scaleFactor 41 | const padding = 20 42 | 43 | const { x, y } = calculatePosition( 44 | settings.indicator_position, 45 | screenWidth, 46 | screenHeight, 47 | width, 48 | height, 49 | padding, 50 | settings.indicator_offset_x ?? 0, 51 | settings.indicator_offset_y ?? 0, 52 | ) 53 | 54 | try { 55 | await window.setSize(new LogicalSize(width, height)) 56 | await window.setPosition(new LogicalPosition(Math.round(x), Math.round(y))) 57 | } catch (err) { 58 | console.error("Failed to apply window settings:", err) 59 | } 60 | } 61 | 62 | function calculatePosition( 63 | position: number, 64 | screenWidth: number, 65 | screenHeight: number, 66 | width: number, 67 | height: number, 68 | padding: number, 69 | offsetX: number, 70 | offsetY: number, 71 | ): { x: number; y: number } { 72 | const col = position % 3 73 | const row = Math.floor(position / 3) 74 | 75 | let x: number 76 | let y: number 77 | 78 | switch (col) { 79 | case 0: // Left 80 | x = padding 81 | break 82 | case 1: // Middle 83 | x = (screenWidth - width) / 2 84 | break 85 | case 2: // Right 86 | x = screenWidth - width - padding 87 | break 88 | default: 89 | x = padding 90 | } 91 | 92 | switch (row) { 93 | case 0: // Top 94 | y = padding + 30 // Account for menu bar 95 | break 96 | case 1: // Bottom 97 | y = screenHeight - height - padding 98 | break 99 | default: 100 | y = padding + 30 101 | } 102 | 103 | return { x: x + offsetX, y: y + offsetY } 104 | } 105 | -------------------------------------------------------------------------------- /src-tauri/src/widgets/selection.rs: -------------------------------------------------------------------------------- 1 | use core_foundation::base::{CFRelease, CFTypeRef, TCFType}; 2 | use core_foundation::string::CFString; 3 | use serde::Serialize; 4 | 5 | #[derive(Debug, Clone, Serialize, Default)] 6 | pub struct SelectionInfo { 7 | pub char_count: usize, 8 | pub line_count: usize, 9 | } 10 | 11 | #[link(name = "ApplicationServices", kind = "framework")] 12 | extern "C" { 13 | fn AXUIElementCreateSystemWide() -> CFTypeRef; 14 | fn AXUIElementCopyAttributeValue( 15 | element: CFTypeRef, 16 | attribute: CFTypeRef, 17 | value: *mut CFTypeRef, 18 | ) -> i32; 19 | } 20 | 21 | /// Get selection info from the focused application using Accessibility APIs 22 | pub fn get_selection_info() -> SelectionInfo { 23 | match get_selected_text() { 24 | Some(text) if !text.is_empty() => { 25 | let char_count = text.chars().count(); 26 | let line_count = text.lines().count().max(1); 27 | SelectionInfo { 28 | char_count, 29 | line_count, 30 | } 31 | } 32 | _ => SelectionInfo::default(), 33 | } 34 | } 35 | 36 | /// Get the selected text from the currently focused application 37 | fn get_selected_text() -> Option { 38 | unsafe { 39 | let system_wide = AXUIElementCreateSystemWide(); 40 | if system_wide.is_null() { 41 | return None; 42 | } 43 | 44 | // Get focused application 45 | let focused_app_attr = CFString::new("AXFocusedApplication"); 46 | let mut focused_app: CFTypeRef = std::ptr::null(); 47 | let result = AXUIElementCopyAttributeValue( 48 | system_wide, 49 | focused_app_attr.as_CFTypeRef(), 50 | &mut focused_app, 51 | ); 52 | 53 | if result != 0 || focused_app.is_null() { 54 | CFRelease(system_wide); 55 | return None; 56 | } 57 | 58 | // Get focused UI element from the application 59 | let focused_element_attr = CFString::new("AXFocusedUIElement"); 60 | let mut focused_element: CFTypeRef = std::ptr::null(); 61 | let result = AXUIElementCopyAttributeValue( 62 | focused_app, 63 | focused_element_attr.as_CFTypeRef(), 64 | &mut focused_element, 65 | ); 66 | 67 | if result != 0 || focused_element.is_null() { 68 | CFRelease(focused_app); 69 | CFRelease(system_wide); 70 | return None; 71 | } 72 | 73 | // Get selected text 74 | let selected_text_attr = CFString::new("AXSelectedText"); 75 | let mut selected_text: CFTypeRef = std::ptr::null(); 76 | let result = AXUIElementCopyAttributeValue( 77 | focused_element, 78 | selected_text_attr.as_CFTypeRef(), 79 | &mut selected_text, 80 | ); 81 | 82 | CFRelease(focused_element); 83 | CFRelease(focused_app); 84 | CFRelease(system_wide); 85 | 86 | if result != 0 || selected_text.is_null() { 87 | return None; 88 | } 89 | 90 | // Convert CFString to Rust String 91 | let cf_string: CFString = CFString::wrap_under_create_rule(selected_text as _); 92 | Some(cf_string.to_string()) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/terminal_app.rs: -------------------------------------------------------------------------------- 1 | //! Terminal.app spawner (macOS default terminal) 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use super::process_utils::find_editor_pid_for_file; 7 | use super::{SpawnInfo, TerminalSpawner, TerminalType, WindowGeometry}; 8 | use crate::config::NvimEditSettings; 9 | 10 | pub struct TerminalAppSpawner; 11 | 12 | impl TerminalSpawner for TerminalAppSpawner { 13 | fn terminal_type(&self) -> TerminalType { 14 | TerminalType::Default 15 | } 16 | 17 | fn spawn( 18 | &self, 19 | settings: &NvimEditSettings, 20 | file_path: &str, 21 | geometry: Option, 22 | socket_path: Option<&Path>, 23 | ) -> Result { 24 | // Get editor path and args from settings 25 | let editor_path = settings.editor_path(); 26 | let editor_args = settings.editor_args(); 27 | let process_name = settings.editor_process_name(); 28 | 29 | // Build socket args for nvim RPC if socket_path provided and using nvim 30 | let socket_args: Vec = if let Some(socket) = socket_path { 31 | if editor_path.contains("nvim") || editor_path == "nvim" { 32 | vec!["--listen".to_string(), socket.to_string_lossy().to_string()] 33 | } else { 34 | vec![] 35 | } 36 | } else { 37 | vec![] 38 | }; 39 | 40 | // Build the command string for AppleScript (socket args + editor args) 41 | let mut all_args: Vec = socket_args; 42 | all_args.extend(editor_args.iter().map(|s| s.to_string())); 43 | let args_str = if all_args.is_empty() { 44 | String::new() 45 | } else { 46 | format!(" {}", all_args.join(" ")) 47 | }; 48 | 49 | let script = if let Some(geo) = geometry { 50 | format!( 51 | r#" 52 | tell application "Terminal" 53 | activate 54 | do script "{}{} '{}'" 55 | set bounds of front window to {{{}, {}, {}, {}}} 56 | end tell 57 | "#, 58 | editor_path, 59 | args_str, 60 | file_path, 61 | geo.x, 62 | geo.y, 63 | geo.x + geo.width as i32, 64 | geo.y + geo.height as i32 65 | ) 66 | } else { 67 | format!( 68 | r#" 69 | tell application "Terminal" 70 | activate 71 | do script "{}{} '{}'" 72 | end tell 73 | "#, 74 | editor_path, args_str, file_path 75 | ) 76 | }; 77 | 78 | Command::new("osascript") 79 | .arg("-e") 80 | .arg(&script) 81 | .output() 82 | .map_err(|e| format!("Failed to run Terminal AppleScript: {}", e))?; 83 | 84 | // Try to find the editor process ID by the file it's editing 85 | let pid = find_editor_pid_for_file(file_path, process_name); 86 | log::info!("Found editor PID: {:?} for file: {}", pid, file_path); 87 | 88 | Ok(SpawnInfo { 89 | terminal_type: TerminalType::Default, 90 | process_id: pid, 91 | child: None, 92 | window_title: None, 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/WidgetSettings.tsx: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | import type { Settings } from "./SettingsApp"; 3 | import { AppList } from "./AppList"; 4 | 5 | interface Props { 6 | settings: Settings; 7 | onUpdate: (updates: Partial) => void; 8 | } 9 | 10 | const WIDGET_OPTIONS = [ 11 | { value: "None", label: "None" }, 12 | { value: "Time", label: "Time (HH:MM)" }, 13 | { value: "Date", label: "Date" }, 14 | { value: "CharacterCount", label: "Selected chars" }, 15 | { value: "LineCount", label: "Selected lines" }, 16 | { value: "CharacterAndLineCount", label: "Chars + lines" }, 17 | { value: "Battery", label: "Battery %" }, 18 | { value: "CapsLock", label: "Caps Lock" }, 19 | { value: "KeystrokeBuffer", label: "Pending keys" }, 20 | ]; 21 | 22 | export function WidgetSettings({ settings, onUpdate }: Props) { 23 | const handleAddElectronApp = async () => { 24 | try { 25 | const bundleId = await invoke("pick_app"); 26 | if (bundleId && !settings.electron_apps.includes(bundleId)) { 27 | onUpdate({ electron_apps: [...settings.electron_apps, bundleId] }); 28 | } 29 | } catch (e) { 30 | console.error("Failed to pick app:", e); 31 | } 32 | }; 33 | 34 | const handleRemoveElectronApp = (bundleId: string) => { 35 | onUpdate({ 36 | electron_apps: settings.electron_apps.filter((id) => id !== bundleId), 37 | }); 38 | }; 39 | 40 | return ( 41 |
42 |

Widgets

43 | 44 |
45 |
46 | 47 | 58 |
59 | 60 |
61 | 62 | 73 |
74 |
75 | 76 |

77 | Accessibility is used to get the selected text. Check that it is enabled 78 | in Privacy settings. 79 |

80 | 81 |
82 |

Enable selection observing in Electron apps

83 | 88 |

89 | Observing selection in Electron apps requires more performance. 90 |

91 |

92 | Removing app from the list requires a re-login. 93 |

94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src-tauri/examples/test_suppress.rs: -------------------------------------------------------------------------------- 1 | // Test different key suppression methods 2 | // Run with: cargo run --example test_suppress 3 | 4 | use core_foundation::runloop::{kCFRunLoopDefaultMode, CFRunLoop}; 5 | use core_graphics::event::{ 6 | CGEvent, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, 7 | CGEventTapProxy, CGEventType, EventField, 8 | }; 9 | use std::time::Duration; 10 | 11 | fn main() { 12 | println!("Key suppression test - press 'b' to test suppression"); 13 | println!("Press Ctrl+C to exit"); 14 | println!(); 15 | 16 | // Try different tap locations 17 | let locations = [ 18 | ("HID", CGEventTapLocation::HID), 19 | ("Session", CGEventTapLocation::Session), 20 | ("AnnotatedSession", CGEventTapLocation::AnnotatedSession), 21 | ]; 22 | 23 | // Use Session location (most common for user-space apps) 24 | let location = CGEventTapLocation::HID; 25 | println!("Using tap location: HID"); 26 | 27 | let tap = CGEventTap::new( 28 | location, 29 | CGEventTapPlacement::HeadInsertEventTap, 30 | CGEventTapOptions::Default, // Must be Default (not ListenOnly) to suppress 31 | vec![CGEventType::KeyDown, CGEventType::KeyUp], 32 | move |_proxy: CGEventTapProxy, 33 | event_type: CGEventType, 34 | event: &CGEvent| 35 | -> Option { 36 | let keycode = 37 | event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16; 38 | 39 | // Only process KeyDown and KeyUp 40 | let is_key_down = matches!(event_type, CGEventType::KeyDown); 41 | let event_type_str = if is_key_down { "KeyDown" } else { "KeyUp" }; 42 | 43 | // Suppress 'b' key (keycode 11) 44 | if keycode == 11 { 45 | println!( 46 | "[SUPPRESS] keycode={} ({}) - returning None", 47 | keycode, event_type_str 48 | ); 49 | return None; // This should suppress the event 50 | } 51 | 52 | // Pass through all other keys 53 | println!( 54 | "[PASS] keycode={} ({}) - returning Some(event)", 55 | keycode, event_type_str 56 | ); 57 | Some(event.clone()) 58 | }, 59 | ); 60 | 61 | match tap { 62 | Ok(tap) => { 63 | let loop_source = tap 64 | .mach_port 65 | .create_runloop_source(0) 66 | .expect("Failed to create run loop source"); 67 | 68 | let run_loop = CFRunLoop::get_current(); 69 | unsafe { 70 | run_loop.add_source(&loop_source, kCFRunLoopDefaultMode); 71 | } 72 | 73 | tap.enable(); 74 | println!("Event tap enabled successfully!"); 75 | println!(); 76 | 77 | // Run the event loop 78 | loop { 79 | CFRunLoop::run_in_mode( 80 | unsafe { kCFRunLoopDefaultMode }, 81 | Duration::from_millis(100), 82 | false, 83 | ); 84 | } 85 | } 86 | Err(()) => { 87 | eprintln!("Failed to create event tap!"); 88 | eprintln!("Make sure you have Accessibility permissions granted."); 89 | std::process::exit(1); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/GeneralSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react" 2 | import { invoke } from "@tauri-apps/api/core" 3 | import type { Settings } from "./SettingsApp" 4 | 5 | interface Props { 6 | settings: Settings 7 | onUpdate: (updates: Partial) => void 8 | } 9 | 10 | interface PermissionStatus { 11 | accessibility: boolean 12 | capture_running: boolean 13 | } 14 | 15 | export function GeneralSettings({ settings, onUpdate }: Props) { 16 | const [permissionStatus, setPermissionStatus] = useState(null) 17 | 18 | useEffect(() => { 19 | const checkPermissions = () => { 20 | invoke("get_permission_status") 21 | .then(setPermissionStatus) 22 | .catch((e) => console.error("Failed to get permission status:", e)) 23 | } 24 | checkPermissions() 25 | const interval = setInterval(checkPermissions, 2000) 26 | return () => clearInterval(interval) 27 | }, []) 28 | 29 | const handleOpenAccessibility = () => { 30 | invoke("open_accessibility_settings").catch(console.error) 31 | } 32 | 33 | const handleOpenInputMonitoring = () => { 34 | invoke("open_input_monitoring_settings").catch(console.error) 35 | } 36 | 37 | const handleRequestPermission = async () => { 38 | await invoke("request_permission") 39 | const status = await invoke("get_permission_status") 40 | setPermissionStatus(status) 41 | } 42 | 43 | const permissionsOk = permissionStatus?.accessibility && permissionStatus?.capture_running 44 | 45 | return ( 46 |
47 |

General Settings

48 | 49 | {permissionStatus && !permissionsOk && ( 50 |
51 |
Permissions Required
52 |
53 | {!permissionStatus.accessibility && ( 54 |
55 | Accessibility 56 | 59 | 62 |
63 | )} 64 | {!permissionStatus.capture_running && ( 65 |
66 | Input Monitoring 67 | 70 |
71 | )} 72 |
73 |
74 | Grant permissions and restart the app for changes to take effect. 75 |
76 |
77 | )} 78 | 79 |
80 | 88 |
89 | 90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/indicator/Indicator.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { listen } from "@tauri-apps/api/event" 3 | import { invoke } from "@tauri-apps/api/core" 4 | import { Widget } from "./widgets" 5 | import { applyWindowSettings } from "./windowPosition" 6 | import type { VimMode, Settings, ModeColors } from "./types" 7 | 8 | const defaultColors: ModeColors = { 9 | insert: { r: 74, g: 144, b: 217 }, 10 | normal: { r: 232, g: 148, b: 74 }, 11 | visual: { r: 155, g: 109, b: 215 }, 12 | } 13 | 14 | export function Indicator() { 15 | const [mode, setMode] = useState("insert") 16 | const [settings, setSettings] = useState(null) 17 | 18 | useEffect(() => { 19 | invoke("get_settings") 20 | .then(async (s) => { 21 | setSettings(s) 22 | await applyWindowSettings(s) 23 | }) 24 | .catch((e) => console.error("Failed to get settings:", e)) 25 | 26 | const unlistenSettings = listen("settings-changed", async (event) => { 27 | setSettings(event.payload) 28 | await applyWindowSettings(event.payload) 29 | }) 30 | 31 | return () => { 32 | unlistenSettings.then((fn) => fn()) 33 | } 34 | }, []) 35 | 36 | useEffect(() => { 37 | invoke("get_vim_mode") 38 | .then((m) => setMode(m as VimMode)) 39 | .catch((e) => console.error("Failed to get initial mode:", e)) 40 | 41 | const unlisten = listen("mode-change", (event) => { 42 | setMode(event.payload as VimMode) 43 | }) 44 | 45 | return () => { 46 | unlisten.then((fn) => fn()) 47 | } 48 | }, []) 49 | 50 | const modeChar = mode === "insert" ? "i" : mode === "normal" ? "n" : "v" 51 | const opacity = settings?.indicator_opacity ?? 0.9 52 | const colors = settings?.mode_colors ?? defaultColors 53 | const color = colors[mode] 54 | const bgColor = `rgb(${color.r}, ${color.g}, ${color.b})` 55 | 56 | const fontFamily = settings?.indicator_font ?? "system-ui, -apple-system, sans-serif" 57 | const topWidget = settings?.top_widget ?? "None" 58 | const bottomWidget = settings?.bottom_widget ?? "None" 59 | 60 | const hasTop = topWidget !== "None" 61 | const hasBottom = bottomWidget !== "None" 62 | let gridTemplateRows = "1fr" 63 | if (hasTop && hasBottom) { 64 | gridTemplateRows = "auto 1fr auto" 65 | } else if (hasTop) { 66 | gridTemplateRows = "auto 1fr" 67 | } else if (hasBottom) { 68 | gridTemplateRows = "1fr auto" 69 | } 70 | 71 | return ( 72 |
90 | {hasTop && } 91 |
99 | 109 | {modeChar} 110 | 111 |
112 | {hasBottom && } 113 |
114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/iterm.rs: -------------------------------------------------------------------------------- 1 | //! iTerm2 terminal spawner 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use super::process_utils::find_editor_pid_for_file; 7 | use super::{SpawnInfo, TerminalSpawner, TerminalType, WindowGeometry}; 8 | use crate::config::NvimEditSettings; 9 | 10 | pub struct ITermSpawner; 11 | 12 | impl TerminalSpawner for ITermSpawner { 13 | fn terminal_type(&self) -> TerminalType { 14 | TerminalType::ITerm 15 | } 16 | 17 | fn spawn( 18 | &self, 19 | settings: &NvimEditSettings, 20 | file_path: &str, 21 | geometry: Option, 22 | socket_path: Option<&Path>, 23 | ) -> Result { 24 | // Get editor path and args from settings 25 | let editor_path = settings.editor_path(); 26 | let editor_args = settings.editor_args(); 27 | let process_name = settings.editor_process_name(); 28 | 29 | // Build socket args for nvim RPC if socket_path provided and using nvim 30 | let socket_args: Vec = if let Some(socket) = socket_path { 31 | if editor_path.contains("nvim") || editor_path == "nvim" { 32 | vec!["--listen".to_string(), socket.to_string_lossy().to_string()] 33 | } else { 34 | vec![] 35 | } 36 | } else { 37 | vec![] 38 | }; 39 | 40 | // Build the command string for AppleScript (socket args + editor args) 41 | let mut all_args: Vec = socket_args; 42 | all_args.extend(editor_args.iter().map(|s| s.to_string())); 43 | let args_str = if all_args.is_empty() { 44 | String::new() 45 | } else { 46 | format!(" {}", all_args.join(" ")) 47 | }; 48 | 49 | // Use AppleScript to open iTerm and run editor with position/size 50 | let script = if let Some(geo) = geometry { 51 | format!( 52 | r#" 53 | tell application "iTerm" 54 | activate 55 | set newWindow to (create window with default profile) 56 | set bounds of newWindow to {{{}, {}, {}, {}}} 57 | tell current session of newWindow 58 | write text "{}{} '{}'; exit" 59 | end tell 60 | end tell 61 | "#, 62 | geo.x, 63 | geo.y, 64 | geo.x + geo.width as i32, 65 | geo.y + geo.height as i32, 66 | editor_path, 67 | args_str, 68 | file_path 69 | ) 70 | } else { 71 | format!( 72 | r#" 73 | tell application "iTerm" 74 | activate 75 | set newWindow to (create window with default profile) 76 | tell current session of newWindow 77 | write text "{}{} '{}'; exit" 78 | end tell 79 | end tell 80 | "#, 81 | editor_path, args_str, file_path 82 | ) 83 | }; 84 | 85 | Command::new("osascript") 86 | .arg("-e") 87 | .arg(&script) 88 | .output() 89 | .map_err(|e| format!("Failed to run iTerm AppleScript: {}", e))?; 90 | 91 | // Try to find the editor process ID by the file it's editing 92 | let pid = find_editor_pid_for_file(file_path, process_name); 93 | log::info!("Found editor PID: {:?} for file: {}", pid, file_path); 94 | 95 | Ok(SpawnInfo { 96 | terminal_type: TerminalType::ITerm, 97 | process_id: pid, 98 | child: None, 99 | window_title: None, 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/kitty.rs: -------------------------------------------------------------------------------- 1 | //! Kitty terminal spawner 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use super::process_utils::{find_editor_pid_for_file, resolve_command_path, resolve_terminal_path}; 7 | use super::{SpawnInfo, TerminalSpawner, TerminalType, WindowGeometry}; 8 | use crate::config::NvimEditSettings; 9 | 10 | pub struct KittySpawner; 11 | 12 | impl TerminalSpawner for KittySpawner { 13 | fn terminal_type(&self) -> TerminalType { 14 | TerminalType::Kitty 15 | } 16 | 17 | fn spawn( 18 | &self, 19 | settings: &NvimEditSettings, 20 | file_path: &str, 21 | geometry: Option, 22 | socket_path: Option<&Path>, 23 | ) -> Result { 24 | // Generate a unique window title 25 | let unique_title = format!("ovim-edit-{}", std::process::id()); 26 | 27 | // Get editor path and args from settings 28 | let editor_path = settings.editor_path(); 29 | let editor_args = settings.editor_args(); 30 | let process_name = settings.editor_process_name(); 31 | 32 | // Build socket args for nvim RPC if socket_path provided and using nvim 33 | let socket_args: Vec = if let Some(socket) = socket_path { 34 | if editor_path.contains("nvim") || editor_path == "nvim" { 35 | vec!["--listen".to_string(), socket.to_string_lossy().to_string()] 36 | } else { 37 | vec![] 38 | } 39 | } else { 40 | vec![] 41 | }; 42 | 43 | // Resolve editor path 44 | let resolved_editor = resolve_command_path(&editor_path); 45 | log::info!("Resolved editor path: {} -> {}", editor_path, resolved_editor); 46 | 47 | // Resolve terminal path (uses user setting or auto-detects) 48 | let terminal_cmd = settings.get_terminal_path(); 49 | let resolved_terminal = resolve_terminal_path(&terminal_cmd); 50 | log::info!("Resolved terminal path: {} -> {}", terminal_cmd, resolved_terminal); 51 | 52 | let mut cmd = Command::new(&resolved_terminal); 53 | 54 | // Use single instance to avoid multiple dock icons, close window when editor exits 55 | cmd.args(["--single-instance", "--wait-for-single-instance-window-close"]); 56 | cmd.args(["--title", &unique_title]); 57 | cmd.args(["-o", "close_on_child_death=yes"]); 58 | 59 | // Add window position/size if provided 60 | if let Some(ref geo) = geometry { 61 | cmd.args([ 62 | "--position", 63 | &format!("{}x{}", geo.x, geo.y), 64 | "-o", 65 | &format!("initial_window_width={}c", geo.width / 8), 66 | "-o", 67 | &format!("initial_window_height={}c", geo.height / 16), 68 | "-o", 69 | "remember_window_size=no", 70 | ]); 71 | } 72 | 73 | // Kitty runs the command directly (no -e flag needed) 74 | cmd.arg(&resolved_editor); 75 | for arg in &socket_args { 76 | cmd.arg(arg); 77 | } 78 | for arg in &editor_args { 79 | cmd.arg(arg); 80 | } 81 | cmd.arg(file_path); 82 | 83 | let child = cmd 84 | .spawn() 85 | .map_err(|e| format!("Failed to spawn kitty: {}", e))?; 86 | 87 | // Wait a bit for editor to start, then find its PID by the file it's editing 88 | let pid = find_editor_pid_for_file(file_path, process_name); 89 | log::info!("Found editor PID: {:?} for file: {}", pid, file_path); 90 | 91 | Ok(SpawnInfo { 92 | terminal_type: TerminalType::Kitty, 93 | process_id: pid, 94 | child: Some(child), 95 | window_title: Some(unique_title), 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/wezterm.rs: -------------------------------------------------------------------------------- 1 | //! WezTerm terminal spawner 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use super::applescript_utils::set_window_size; 7 | use super::process_utils::{resolve_command_path, resolve_terminal_path}; 8 | use super::{SpawnInfo, TerminalSpawner, TerminalType, WindowGeometry}; 9 | use crate::config::NvimEditSettings; 10 | 11 | pub struct WezTermSpawner; 12 | 13 | impl TerminalSpawner for WezTermSpawner { 14 | fn terminal_type(&self) -> TerminalType { 15 | TerminalType::WezTerm 16 | } 17 | 18 | fn spawn( 19 | &self, 20 | settings: &NvimEditSettings, 21 | file_path: &str, 22 | geometry: Option, 23 | socket_path: Option<&Path>, 24 | ) -> Result { 25 | // Get editor path and args from settings 26 | let editor_path = settings.editor_path(); 27 | let editor_args = settings.editor_args(); 28 | 29 | // Build socket args for nvim RPC if socket_path provided and using nvim 30 | let socket_args: Vec = if let Some(socket) = socket_path { 31 | if editor_path.contains("nvim") || editor_path == "nvim" { 32 | vec!["--listen".to_string(), socket.to_string_lossy().to_string()] 33 | } else { 34 | vec![] 35 | } 36 | } else { 37 | vec![] 38 | }; 39 | 40 | // Resolve editor path 41 | let resolved_editor = resolve_command_path(&editor_path); 42 | log::info!("Resolved editor path: {} -> {}", editor_path, resolved_editor); 43 | 44 | // Resolve terminal path (uses user setting or auto-detects) 45 | let terminal_cmd = settings.get_terminal_path(); 46 | let resolved_terminal = resolve_terminal_path(&terminal_cmd); 47 | log::info!("Resolved terminal path: {} -> {}", terminal_cmd, resolved_terminal); 48 | 49 | let mut cmd = Command::new(&resolved_terminal); 50 | 51 | // Use --always-new-process so wezterm blocks until the command exits. 52 | // WezTerm only supports --position for window placement (no --width/--height) 53 | if let Some(ref geo) = geometry { 54 | cmd.args([ 55 | "start", 56 | "--always-new-process", 57 | "--position", 58 | &format!("screen:{},{}", geo.x, geo.y), 59 | "--", 60 | ]); 61 | } else { 62 | cmd.args(["start", "--always-new-process", "--"]); 63 | } 64 | 65 | cmd.arg(&resolved_editor); 66 | for arg in &socket_args { 67 | cmd.arg(arg); 68 | } 69 | for arg in &editor_args { 70 | cmd.arg(arg); 71 | } 72 | cmd.arg(file_path); 73 | 74 | let child = cmd 75 | .spawn() 76 | .map_err(|e| format!("Failed to spawn wezterm: {}", e))?; 77 | 78 | // Get the wezterm process PID - with --always-new-process, the wezterm 79 | // process itself will block until editor exits, so we can track it directly 80 | let wezterm_pid = child.id(); 81 | log::info!("WezTerm process PID: {}", wezterm_pid); 82 | 83 | // If geometry specified, try to resize using AppleScript after window appears 84 | if let Some(ref geo) = geometry { 85 | let width = geo.width; 86 | let height = geo.height; 87 | std::thread::spawn(move || { 88 | std::thread::sleep(std::time::Duration::from_millis(300)); 89 | set_window_size("WezTerm", width, height); 90 | }); 91 | } 92 | 93 | Ok(SpawnInfo { 94 | terminal_type: TerminalType::WezTerm, 95 | process_id: Some(wezterm_pid), 96 | child: Some(child), 97 | window_title: None, 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/ghostty.rs: -------------------------------------------------------------------------------- 1 | //! Ghostty terminal spawner 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use super::process_utils::{find_editor_pid_for_file, resolve_command_path}; 7 | use super::{SpawnInfo, TerminalSpawner, TerminalType, WindowGeometry}; 8 | use crate::config::NvimEditSettings; 9 | 10 | pub struct GhosttySpawner; 11 | 12 | impl TerminalSpawner for GhosttySpawner { 13 | fn terminal_type(&self) -> TerminalType { 14 | TerminalType::Ghostty 15 | } 16 | 17 | fn spawn( 18 | &self, 19 | settings: &NvimEditSettings, 20 | file_path: &str, 21 | geometry: Option, 22 | socket_path: Option<&Path>, 23 | ) -> Result { 24 | // Generate a unique window title so we can find it 25 | let unique_title = format!("ovim-edit-{}", std::process::id()); 26 | 27 | // Get editor path and args from settings 28 | let editor_path = settings.editor_path(); 29 | let editor_args = settings.editor_args(); 30 | let process_name = settings.editor_process_name(); 31 | 32 | // Build socket args for nvim RPC if socket_path provided and using nvim 33 | let socket_args: Vec = if let Some(socket) = socket_path { 34 | if editor_path.contains("nvim") || editor_path == "nvim" { 35 | vec!["--listen".to_string(), socket.to_string_lossy().to_string()] 36 | } else { 37 | vec![] 38 | } 39 | } else { 40 | vec![] 41 | }; 42 | 43 | // Resolve editor path to absolute path 44 | let resolved_editor = resolve_command_path(&editor_path); 45 | log::info!("Resolved editor path: {} -> {}", editor_path, resolved_editor); 46 | 47 | // On macOS, Ghostty can be launched via `open -na Ghostty.app --args ...` 48 | // or directly via the binary if user provides custom path 49 | let terminal_path = settings.get_terminal_path(); 50 | let use_direct_binary = !terminal_path.is_empty() 51 | && terminal_path != "ghostty" 52 | && terminal_path.starts_with('/'); 53 | 54 | let mut cmd = if use_direct_binary { 55 | log::info!("Using direct Ghostty binary: {}", terminal_path); 56 | Command::new(&terminal_path) 57 | } else { 58 | let mut c = Command::new("open"); 59 | c.args(["-na", "Ghostty.app", "--args"]); 60 | c 61 | }; 62 | 63 | // Add window title 64 | cmd.args([&format!("--title={}", unique_title)]); 65 | 66 | // Add geometry if provided 67 | if let Some(ref geo) = geometry { 68 | // Ghostty window-width/height are in terminal grid cells, not pixels 69 | let cols = (geo.width / 8).max(10); 70 | let rows = (geo.height / 16).max(4); 71 | cmd.args([ 72 | &format!("--window-width={}", cols), 73 | &format!("--window-height={}", rows), 74 | &format!("--window-position-x={}", geo.x), 75 | &format!("--window-position-y={}", geo.y), 76 | ]); 77 | } 78 | 79 | // Execute editor using -e flag 80 | cmd.arg("-e"); 81 | cmd.arg(&resolved_editor); 82 | for arg in &socket_args { 83 | cmd.arg(arg); 84 | } 85 | for arg in &editor_args { 86 | cmd.arg(arg); 87 | } 88 | cmd.arg(file_path); 89 | 90 | cmd.spawn() 91 | .map_err(|e| format!("Failed to spawn ghostty: {}", e))?; 92 | 93 | // Wait a bit for editor to start, then find its PID by the file it's editing 94 | let pid = find_editor_pid_for_file(file_path, process_name); 95 | log::info!("Found editor PID: {:?} for file: {}", pid, file_path); 96 | 97 | Ok(SpawnInfo { 98 | terminal_type: TerminalType::Ghostty, 99 | process_id: pid, 100 | child: None, // open command returns immediately 101 | window_title: Some(unique_title), 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src-tauri/src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 4 | use tokio::net::UnixStream; 5 | 6 | /// IPC command from CLI to main app 7 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 8 | pub enum IpcCommand { 9 | GetMode, 10 | SetMode(String), 11 | Toggle, 12 | Insert, 13 | Normal, 14 | Visual, 15 | } 16 | 17 | /// IPC response from main app to CLI 18 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 19 | pub enum IpcResponse { 20 | Mode(String), 21 | Ok, 22 | Error(String), 23 | } 24 | 25 | fn socket_path() -> PathBuf { 26 | let runtime_dir = dirs::runtime_dir() 27 | .or_else(dirs::cache_dir) 28 | .unwrap_or_else(|| PathBuf::from("/tmp")); 29 | runtime_dir.join("ovim.sock") 30 | } 31 | 32 | async fn send_command(cmd: IpcCommand) -> Result { 33 | let path = socket_path(); 34 | 35 | let stream = UnixStream::connect(&path) 36 | .await 37 | .map_err(|e| format!("Failed to connect to ovim (is it running?): {}", e))?; 38 | 39 | let (reader, mut writer) = stream.into_split(); 40 | let mut reader = BufReader::new(reader); 41 | 42 | let cmd_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?; 43 | writer 44 | .write_all(cmd_str.as_bytes()) 45 | .await 46 | .map_err(|e| e.to_string())?; 47 | writer.write_all(b"\n").await.map_err(|e| e.to_string())?; 48 | writer.flush().await.map_err(|e| e.to_string())?; 49 | 50 | let mut line = String::new(); 51 | reader.read_line(&mut line).await.map_err(|e| e.to_string())?; 52 | 53 | let response: IpcResponse = serde_json::from_str(line.trim()) 54 | .map_err(|e| format!("Invalid response: {}", e))?; 55 | 56 | Ok(response) 57 | } 58 | 59 | fn print_usage() { 60 | eprintln!("ovim - System-wide Vim mode control"); 61 | eprintln!(); 62 | eprintln!("Usage: ovim "); 63 | eprintln!(); 64 | eprintln!("Commands:"); 65 | eprintln!(" mode Get current mode"); 66 | eprintln!(" toggle Toggle between insert and normal mode"); 67 | eprintln!(" insert, i Switch to insert mode"); 68 | eprintln!(" normal, n Switch to normal mode"); 69 | eprintln!(" visual, v Switch to visual mode"); 70 | eprintln!(" set Set mode to insert/normal/visual"); 71 | eprintln!(); 72 | eprintln!("Examples:"); 73 | eprintln!(" ovim toggle # Toggle mode (useful for Karabiner)"); 74 | eprintln!(" ovim normal # Enter normal mode"); 75 | eprintln!(" ovim insert # Enter insert mode"); 76 | } 77 | 78 | #[tokio::main(flavor = "current_thread")] 79 | async fn main() { 80 | let args: Vec = env::args().collect(); 81 | 82 | if args.len() < 2 { 83 | print_usage(); 84 | std::process::exit(1); 85 | } 86 | 87 | let command = args[1].as_str(); 88 | 89 | let ipc_cmd = match command { 90 | "mode" | "get" | "status" => IpcCommand::GetMode, 91 | "toggle" | "t" => IpcCommand::Toggle, 92 | "insert" | "i" => IpcCommand::Insert, 93 | "normal" | "n" => IpcCommand::Normal, 94 | "visual" | "v" => IpcCommand::Visual, 95 | "set" => { 96 | if args.len() < 3 { 97 | eprintln!("Error: 'set' requires a mode argument (insert/normal/visual)"); 98 | std::process::exit(1); 99 | } 100 | IpcCommand::SetMode(args[2].clone()) 101 | } 102 | "help" | "-h" | "--help" => { 103 | print_usage(); 104 | std::process::exit(0); 105 | } 106 | _ => { 107 | eprintln!("Unknown command: {}", command); 108 | print_usage(); 109 | std::process::exit(1); 110 | } 111 | }; 112 | 113 | match send_command(ipc_cmd).await { 114 | Ok(response) => match response { 115 | IpcResponse::Mode(mode) => { 116 | println!("{}", mode); 117 | } 118 | IpcResponse::Ok => { 119 | // Success, no output needed 120 | } 121 | IpcResponse::Error(msg) => { 122 | eprintln!("Error: {}", msg); 123 | std::process::exit(1); 124 | } 125 | }, 126 | Err(e) => { 127 | eprintln!("Error: {}", e); 128 | std::process::exit(1); 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src-tauri/src/ipc.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 3 | use tokio::net::{UnixListener, UnixStream}; 4 | 5 | /// Get the socket path for IPC 6 | pub fn socket_path() -> PathBuf { 7 | let runtime_dir = dirs::runtime_dir() 8 | .or_else(dirs::cache_dir) 9 | .unwrap_or_else(|| PathBuf::from("/tmp")); 10 | runtime_dir.join("ovim.sock") 11 | } 12 | 13 | /// IPC command from CLI to main app 14 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 15 | pub enum IpcCommand { 16 | /// Get current mode 17 | GetMode, 18 | /// Set mode to specific value 19 | SetMode(String), 20 | /// Toggle between insert and normal 21 | Toggle, 22 | /// Set to insert mode 23 | Insert, 24 | /// Set to normal mode 25 | Normal, 26 | /// Set to visual mode 27 | Visual, 28 | } 29 | 30 | /// IPC response from main app to CLI 31 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 32 | pub enum IpcResponse { 33 | /// Current mode 34 | Mode(String), 35 | /// Success 36 | Ok, 37 | /// Error message 38 | Error(String), 39 | } 40 | 41 | /// Start the IPC server 42 | pub async fn start_ipc_server(handler: F) -> Result<(), String> 43 | where 44 | F: Fn(IpcCommand) -> IpcResponse + Send + Sync + 'static, 45 | { 46 | let path = socket_path(); 47 | 48 | // Remove existing socket if present 49 | let _ = std::fs::remove_file(&path); 50 | 51 | let listener = UnixListener::bind(&path).map_err(|e| format!("Failed to bind socket: {}", e))?; 52 | 53 | log::info!("IPC server listening on {:?}", path); 54 | 55 | let handler = std::sync::Arc::new(handler); 56 | 57 | loop { 58 | match listener.accept().await { 59 | Ok((stream, _)) => { 60 | let handler = handler.clone(); 61 | tokio::spawn(async move { 62 | if let Err(e) = handle_client(stream, handler).await { 63 | log::error!("Error handling IPC client: {}", e); 64 | } 65 | }); 66 | } 67 | Err(e) => { 68 | log::error!("Error accepting IPC connection: {}", e); 69 | } 70 | } 71 | } 72 | } 73 | 74 | async fn handle_client(stream: UnixStream, handler: std::sync::Arc) -> Result<(), String> 75 | where 76 | F: Fn(IpcCommand) -> IpcResponse, 77 | { 78 | let (reader, mut writer) = stream.into_split(); 79 | let mut reader = BufReader::new(reader); 80 | let mut line = String::new(); 81 | 82 | while reader.read_line(&mut line).await.map_err(|e| e.to_string())? > 0 { 83 | let trimmed = line.trim(); 84 | if trimmed.is_empty() { 85 | line.clear(); 86 | continue; 87 | } 88 | 89 | let cmd: IpcCommand = serde_json::from_str(trimmed) 90 | .map_err(|e| format!("Invalid command: {}", e))?; 91 | 92 | let response = handler(cmd); 93 | let response_str = serde_json::to_string(&response).map_err(|e| e.to_string())?; 94 | 95 | writer 96 | .write_all(response_str.as_bytes()) 97 | .await 98 | .map_err(|e| e.to_string())?; 99 | writer.write_all(b"\n").await.map_err(|e| e.to_string())?; 100 | writer.flush().await.map_err(|e| e.to_string())?; 101 | 102 | line.clear(); 103 | } 104 | 105 | Ok(()) 106 | } 107 | 108 | /// Send a command to the running ovim instance 109 | pub async fn send_command(cmd: IpcCommand) -> Result { 110 | let path = socket_path(); 111 | 112 | let stream = UnixStream::connect(&path) 113 | .await 114 | .map_err(|e| format!("Failed to connect to ovim (is it running?): {}", e))?; 115 | 116 | let (reader, mut writer) = stream.into_split(); 117 | let mut reader = BufReader::new(reader); 118 | 119 | let cmd_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?; 120 | writer 121 | .write_all(cmd_str.as_bytes()) 122 | .await 123 | .map_err(|e| e.to_string())?; 124 | writer.write_all(b"\n").await.map_err(|e| e.to_string())?; 125 | writer.flush().await.map_err(|e| e.to_string())?; 126 | 127 | let mut line = String::new(); 128 | reader.read_line(&mut line).await.map_err(|e| e.to_string())?; 129 | 130 | let response: IpcResponse = serde_json::from_str(line.trim()) 131 | .map_err(|e| format!("Invalid response: {}", e))?; 132 | 133 | Ok(response) 134 | } 135 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/mod.rs: -------------------------------------------------------------------------------- 1 | //! Terminal spawning for Edit Popup feature 2 | //! 3 | //! This module provides a unified interface for spawning different terminal emulators 4 | //! with various text editors (Neovim, Vim, Helix, etc.) 5 | 6 | mod alacritty; 7 | mod applescript_utils; 8 | mod ghostty; 9 | mod iterm; 10 | mod kitty; 11 | pub mod process_utils; 12 | mod terminal_app; 13 | mod wezterm; 14 | 15 | pub use alacritty::AlacrittySpawner; 16 | pub use ghostty::GhosttySpawner; 17 | pub use iterm::ITermSpawner; 18 | pub use kitty::KittySpawner; 19 | pub use terminal_app::TerminalAppSpawner; 20 | pub use wezterm::WezTermSpawner; 21 | 22 | use crate::config::NvimEditSettings; 23 | use std::path::Path; 24 | use std::process::Child; 25 | 26 | /// Window position and size for popup mode 27 | #[derive(Debug, Clone, Default)] 28 | pub struct WindowGeometry { 29 | pub x: i32, 30 | pub y: i32, 31 | pub width: u32, 32 | pub height: u32, 33 | } 34 | 35 | /// Terminal types supported 36 | #[derive(Debug, Clone, PartialEq)] 37 | pub enum TerminalType { 38 | Alacritty, 39 | Ghostty, 40 | Kitty, 41 | WezTerm, 42 | ITerm, 43 | Default, // Terminal.app 44 | } 45 | 46 | impl TerminalType { 47 | pub fn from_string(s: &str) -> Self { 48 | match s.to_lowercase().as_str() { 49 | "alacritty" => TerminalType::Alacritty, 50 | "ghostty" => TerminalType::Ghostty, 51 | "kitty" => TerminalType::Kitty, 52 | "wezterm" => TerminalType::WezTerm, 53 | "iterm" | "iterm2" => TerminalType::ITerm, 54 | _ => TerminalType::Default, 55 | } 56 | } 57 | } 58 | 59 | /// Spawn info returned after launching terminal 60 | pub struct SpawnInfo { 61 | pub terminal_type: TerminalType, 62 | pub process_id: Option, 63 | #[allow(dead_code)] 64 | pub child: Option, 65 | pub window_title: Option, 66 | } 67 | 68 | /// Trait for terminal spawners 69 | pub trait TerminalSpawner { 70 | /// The terminal type this spawner handles 71 | #[allow(dead_code)] 72 | fn terminal_type(&self) -> TerminalType; 73 | 74 | /// Spawn a terminal with the configured editor editing the given file 75 | /// 76 | /// If `socket_path` is provided, the editor will be started with RPC enabled 77 | /// (e.g., nvim --listen ) for live buffer sync. 78 | fn spawn( 79 | &self, 80 | settings: &NvimEditSettings, 81 | file_path: &str, 82 | geometry: Option, 83 | socket_path: Option<&Path>, 84 | ) -> Result; 85 | } 86 | 87 | /// Spawn a terminal with the configured editor editing the given file 88 | /// 89 | /// If `socket_path` is provided, the editor will be started with RPC enabled 90 | /// for live buffer sync. 91 | pub fn spawn_terminal( 92 | settings: &NvimEditSettings, 93 | temp_file: &Path, 94 | geometry: Option, 95 | socket_path: Option<&Path>, 96 | ) -> Result { 97 | let terminal_type = TerminalType::from_string(&settings.terminal); 98 | let file_path = temp_file.to_string_lossy(); 99 | 100 | match terminal_type { 101 | TerminalType::Alacritty => AlacrittySpawner.spawn(settings, &file_path, geometry, socket_path), 102 | TerminalType::Ghostty => GhosttySpawner.spawn(settings, &file_path, geometry, socket_path), 103 | TerminalType::Kitty => KittySpawner.spawn(settings, &file_path, geometry, socket_path), 104 | TerminalType::WezTerm => WezTermSpawner.spawn(settings, &file_path, geometry, socket_path), 105 | TerminalType::ITerm => ITermSpawner.spawn(settings, &file_path, geometry, socket_path), 106 | TerminalType::Default => TerminalAppSpawner.spawn(settings, &file_path, geometry, socket_path), 107 | } 108 | } 109 | 110 | /// Wait for the terminal/nvim process to exit 111 | pub fn wait_for_process( 112 | terminal_type: &TerminalType, 113 | process_id: Option, 114 | ) -> Result<(), String> { 115 | match terminal_type { 116 | TerminalType::Alacritty 117 | | TerminalType::Ghostty 118 | | TerminalType::Kitty 119 | | TerminalType::WezTerm => { 120 | if let Some(pid) = process_id { 121 | process_utils::wait_for_pid(pid) 122 | } else { 123 | Err("No process ID to wait for".to_string()) 124 | } 125 | } 126 | TerminalType::ITerm | TerminalType::Default => { 127 | if let Some(pid) = process_id { 128 | process_utils::wait_for_pid(pid) 129 | } else { 130 | // Fallback: wait a fixed time (not ideal) 131 | std::thread::sleep(std::time::Duration::from_secs(60)); 132 | Ok(()) 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src-tauri/examples/test_suppress_raw.rs: -------------------------------------------------------------------------------- 1 | // Test key suppression using raw C API 2 | // Run with: cargo run --example test_suppress_raw 3 | 4 | use core_foundation::base::TCFType; 5 | use core_foundation::runloop::{kCFRunLoopCommonModes, CFRunLoop}; 6 | use core_graphics::event::{CGEvent, CGEventTapLocation, CGEventType}; 7 | use std::ffi::c_void; 8 | use std::ptr; 9 | 10 | // Raw C types and functions 11 | type CGEventRef = *mut c_void; 12 | type CGEventTapProxy = *mut c_void; 13 | type CFMachPortRef = *mut c_void; 14 | type CFRunLoopSourceRef = *mut c_void; 15 | 16 | type CGEventTapCallBack = extern "C" fn( 17 | proxy: CGEventTapProxy, 18 | event_type: u32, 19 | event: CGEventRef, 20 | user_info: *mut c_void, 21 | ) -> CGEventRef; 22 | 23 | #[link(name = "CoreGraphics", kind = "framework")] 24 | extern "C" { 25 | fn CGEventTapCreate( 26 | tap: u32, 27 | place: u32, 28 | options: u32, 29 | events_of_interest: u64, 30 | callback: CGEventTapCallBack, 31 | user_info: *mut c_void, 32 | ) -> CFMachPortRef; 33 | 34 | fn CGEventTapEnable(tap: CFMachPortRef, enable: bool); 35 | 36 | fn CGEventGetIntegerValueField(event: CGEventRef, field: u32) -> i64; 37 | } 38 | 39 | #[link(name = "CoreFoundation", kind = "framework")] 40 | extern "C" { 41 | fn CFMachPortCreateRunLoopSource( 42 | allocator: *const c_void, 43 | port: CFMachPortRef, 44 | order: i64, 45 | ) -> CFRunLoopSourceRef; 46 | 47 | fn CFRunLoopAddSource( 48 | run_loop: *const c_void, 49 | source: CFRunLoopSourceRef, 50 | mode: *const c_void, 51 | ); 52 | 53 | fn CFRunLoopGetCurrent() -> *const c_void; 54 | 55 | fn CFRunLoopRun(); 56 | } 57 | 58 | const kCGSessionEventTap: u32 = 1; 59 | const kCGHIDEventTap: u32 = 0; 60 | const kCGHeadInsertEventTap: u32 = 0; 61 | const kCGEventTapOptionDefault: u32 = 0; 62 | 63 | const kCGEventKeyDown: u64 = 1 << 10; 64 | const kCGEventKeyUp: u64 = 1 << 11; 65 | 66 | const kCGKeyboardEventKeycode: u32 = 9; 67 | 68 | // Callback function 69 | extern "C" fn event_callback( 70 | _proxy: CGEventTapProxy, 71 | event_type: u32, 72 | event: CGEventRef, 73 | _user_info: *mut c_void, 74 | ) -> CGEventRef { 75 | if event.is_null() { 76 | return event; 77 | } 78 | 79 | let keycode = unsafe { CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) } as u16; 80 | 81 | let type_str = match event_type { 82 | 10 => "KeyDown", 83 | 11 => "KeyUp", 84 | _ => "Other", 85 | }; 86 | 87 | // Suppress 'b' key (keycode 11) 88 | if keycode == 11 { 89 | println!("[RAW SUPPRESS] keycode={} ({}) - returning NULL", keycode, type_str); 90 | return ptr::null_mut(); // Return NULL to suppress 91 | } 92 | 93 | println!("[RAW PASS] keycode={} ({}) - returning event", keycode, type_str); 94 | event 95 | } 96 | 97 | fn main() { 98 | println!("Raw key suppression test - press 'b' to test suppression"); 99 | println!("Press Ctrl+C to exit"); 100 | println!(); 101 | 102 | unsafe { 103 | let event_mask = kCGEventKeyDown | kCGEventKeyUp; 104 | 105 | // Try HID tap location first 106 | let tap = CGEventTapCreate( 107 | kCGHIDEventTap, 108 | kCGHeadInsertEventTap, 109 | kCGEventTapOptionDefault, 110 | event_mask, 111 | event_callback, 112 | ptr::null_mut(), 113 | ); 114 | 115 | if tap.is_null() { 116 | eprintln!("Failed to create event tap at HID level, trying Session..."); 117 | 118 | let tap = CGEventTapCreate( 119 | kCGSessionEventTap, 120 | kCGHeadInsertEventTap, 121 | kCGEventTapOptionDefault, 122 | event_mask, 123 | event_callback, 124 | ptr::null_mut(), 125 | ); 126 | 127 | if tap.is_null() { 128 | eprintln!("Failed to create event tap!"); 129 | eprintln!("Make sure you have Accessibility permissions granted."); 130 | std::process::exit(1); 131 | } 132 | } 133 | 134 | println!("Event tap created successfully!"); 135 | 136 | // Create run loop source 137 | let source = CFMachPortCreateRunLoopSource(ptr::null(), tap, 0); 138 | if source.is_null() { 139 | eprintln!("Failed to create run loop source!"); 140 | std::process::exit(1); 141 | } 142 | 143 | // Add to run loop 144 | let run_loop = CFRunLoopGetCurrent(); 145 | CFRunLoopAddSource(run_loop, source, kCFRunLoopCommonModes as *const c_void); 146 | 147 | // Enable the tap 148 | CGEventTapEnable(tap, true); 149 | 150 | println!("Event tap enabled!"); 151 | println!(); 152 | 153 | // Run the loop 154 | CFRunLoopRun(); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/session.rs: -------------------------------------------------------------------------------- 1 | //! Edit session management for "Edit with Neovim" feature 2 | 3 | use std::collections::HashMap; 4 | use std::path::PathBuf; 5 | use std::sync::{Arc, Mutex}; 6 | use std::time::SystemTime; 7 | use uuid::Uuid; 8 | 9 | use super::accessibility::FocusContext; 10 | use super::terminals::{spawn_terminal, SpawnInfo, TerminalType, WindowGeometry}; 11 | use crate::config::NvimEditSettings; 12 | 13 | /// An active edit session 14 | pub struct EditSession { 15 | pub id: Uuid, 16 | pub focus_context: FocusContext, 17 | pub original_text: String, 18 | pub temp_file: PathBuf, 19 | pub file_mtime: SystemTime, 20 | pub terminal_type: TerminalType, 21 | pub process_id: Option, 22 | pub window_title: Option, 23 | /// Socket path for RPC communication with nvim 24 | pub socket_path: PathBuf, 25 | } 26 | 27 | /// Manager for edit sessions 28 | pub struct EditSessionManager { 29 | sessions: Arc>>, 30 | } 31 | 32 | impl EditSessionManager { 33 | pub fn new() -> Self { 34 | Self { 35 | sessions: Arc::new(Mutex::new(HashMap::new())), 36 | } 37 | } 38 | 39 | /// Start a new edit session 40 | pub fn start_session( 41 | &self, 42 | focus_context: FocusContext, 43 | text: String, 44 | settings: NvimEditSettings, 45 | geometry: Option, 46 | ) -> Result { 47 | // Create temp directory if needed 48 | let cache_dir = dirs::cache_dir() 49 | .ok_or("Could not determine cache directory")? 50 | .join("ovim"); 51 | std::fs::create_dir_all(&cache_dir) 52 | .map_err(|e| format!("Failed to create cache directory: {}", e))?; 53 | 54 | // Generate session ID and temp file 55 | let session_id = Uuid::new_v4(); 56 | let temp_file = cache_dir.join(format!("edit_{}.txt", session_id)); 57 | 58 | // Generate socket path for RPC 59 | let socket_path = cache_dir.join(format!("nvim_{}.sock", session_id)); 60 | 61 | // Clean up any stale socket file 62 | let _ = std::fs::remove_file(&socket_path); 63 | 64 | // Write text to temp file 65 | std::fs::write(&temp_file, &text) 66 | .map_err(|e| format!("Failed to write temp file: {}", e))?; 67 | 68 | // Get file modification time after writing 69 | let file_mtime = std::fs::metadata(&temp_file) 70 | .and_then(|m| m.modified()) 71 | .map_err(|e| format!("Failed to get file mtime: {}", e))?; 72 | 73 | // Spawn terminal with RPC socket for live buffer sync 74 | let SpawnInfo { 75 | terminal_type, 76 | process_id, 77 | child: _, 78 | window_title, 79 | } = spawn_terminal(&settings, &temp_file, geometry, Some(&socket_path))?; 80 | 81 | // Create session 82 | let session = EditSession { 83 | id: session_id, 84 | focus_context, 85 | original_text: text, 86 | temp_file, 87 | file_mtime, 88 | terminal_type, 89 | process_id, 90 | window_title, 91 | socket_path, 92 | }; 93 | 94 | // Store session 95 | let mut sessions = self.sessions.lock().unwrap(); 96 | sessions.insert(session_id, session); 97 | 98 | Ok(session_id) 99 | } 100 | 101 | /// Get a session by ID 102 | pub fn get_session(&self, id: &Uuid) -> Option { 103 | let sessions = self.sessions.lock().unwrap(); 104 | sessions.get(id).map(|s| EditSession { 105 | id: s.id, 106 | focus_context: s.focus_context.clone(), 107 | original_text: s.original_text.clone(), 108 | temp_file: s.temp_file.clone(), 109 | file_mtime: s.file_mtime, 110 | terminal_type: s.terminal_type.clone(), 111 | process_id: s.process_id, 112 | window_title: s.window_title.clone(), 113 | socket_path: s.socket_path.clone(), 114 | }) 115 | } 116 | 117 | /// Cancel a session (clean up without applying changes) 118 | pub fn cancel_session(&self, id: &Uuid) { 119 | let mut sessions = self.sessions.lock().unwrap(); 120 | if let Some(session) = sessions.remove(id) { 121 | // Clean up temp file and socket 122 | let _ = std::fs::remove_file(&session.temp_file); 123 | let _ = std::fs::remove_file(&session.socket_path); 124 | } 125 | } 126 | 127 | /// Remove a session after completion 128 | pub fn remove_session(&self, id: &Uuid) { 129 | let mut sessions = self.sessions.lock().unwrap(); 130 | sessions.remove(id); 131 | } 132 | 133 | /// Check if there are any active sessions 134 | #[allow(dead_code)] 135 | pub fn has_active_sessions(&self) -> bool { 136 | let sessions = self.sessions.lock().unwrap(); 137 | !sessions.is_empty() 138 | } 139 | } 140 | 141 | impl Default for EditSessionManager { 142 | fn default() -> Self { 143 | Self::new() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # CLI Tool 2 | 3 | ovim includes a command-line tool for controlling modes from scripts or other applications like Karabiner-Elements. 4 | 5 | ## Commands 6 | 7 | ```bash 8 | ovim mode # Get current mode 9 | ovim toggle # Toggle between insert and normal mode 10 | ovim insert # Switch to insert mode (alias: i) 11 | ovim normal # Switch to normal mode (alias: n) 12 | ovim visual # Switch to visual mode (alias: v) 13 | ovim set # Set mode to insert/normal/visual 14 | ``` 15 | 16 | ## Installation 17 | 18 | The CLI is bundled with the ovim.app: 19 | 20 | ```bash 21 | # Use directly from the app bundle 22 | /Applications/ovim.app/Contents/MacOS/ovim toggle 23 | 24 | # Or create a symlink for convenience 25 | sudo ln -s /Applications/ovim.app/Contents/MacOS/ovim /usr/local/bin/ovim 26 | ``` 27 | 28 | After creating the symlink, you can use `ovim` directly from anywhere. 29 | 30 | ## How It Works 31 | 32 | The CLI communicates with the running ovim app via a Unix socket at `~/Library/Caches/ovim.sock` (or `/tmp/ovim.sock` as fallback). The main ovim app must be running for CLI commands to work. 33 | 34 | ## Karabiner-Elements Integration 35 | 36 | [Karabiner-Elements](https://karabiner-elements.pqrs.org/) can execute shell commands via `shell_command`, making it easy to trigger ovim mode changes from custom key mappings. 37 | 38 | Note: The examples below use the full app bundle path. If you created a symlink to `/usr/local/bin/ovim`, you can use just `ovim` instead. 39 | 40 | ### Example: Caps Lock Toggle 41 | 42 | This example uses Caps Lock to toggle between normal and insert modes: 43 | 44 | ```json 45 | { 46 | "description": "Caps Lock toggles ovim mode", 47 | "manipulators": [ 48 | { 49 | "type": "basic", 50 | "from": { "key_code": "caps_lock" }, 51 | "to": [ 52 | { "shell_command": "/Applications/ovim.app/Contents/MacOS/ovim toggle" } 53 | ] 54 | } 55 | ] 56 | } 57 | ``` 58 | 59 | ### Example: Escape Enters Normal Mode 60 | 61 | Enter normal mode when pressing Escape (excluding terminal apps): 62 | 63 | ```json 64 | { 65 | "description": "Escape enters ovim normal mode", 66 | "manipulators": [ 67 | { 68 | "conditions": [ 69 | { 70 | "bundle_identifiers": [ 71 | "^com\\.apple\\.Terminal$", 72 | "^com\\.googlecode\\.iterm2$", 73 | "^net\\.kovidgoyal\\.kitty$" 74 | ], 75 | "type": "frontmost_application_unless" 76 | } 77 | ], 78 | "type": "basic", 79 | "from": { "key_code": "escape" }, 80 | "to": [ 81 | { "shell_command": "/Applications/ovim.app/Contents/MacOS/ovim normal" } 82 | ] 83 | } 84 | ] 85 | } 86 | ``` 87 | 88 | ### Example: Mouse Click Enters Insert Mode 89 | 90 | Automatically enter insert mode when clicking (useful for text editing): 91 | 92 | ```json 93 | { 94 | "description": "Mouse click enters ovim insert mode", 95 | "manipulators": [ 96 | { 97 | "conditions": [ 98 | { 99 | "bundle_identifiers": [ 100 | "^com\\.apple\\.Terminal$", 101 | "^com\\.googlecode\\.iterm2$" 102 | ], 103 | "type": "frontmost_application_unless" 104 | } 105 | ], 106 | "type": "basic", 107 | "from": { "any": "pointing_button" }, 108 | "to": [ 109 | { "shell_command": "/Applications/ovim.app/Contents/MacOS/ovim insert" }, 110 | { "pointing_button": "button1" } 111 | ] 112 | } 113 | ] 114 | } 115 | ``` 116 | 117 | ### Full Karabiner Complex Modification 118 | 119 | Here's a complete complex modification you can add to your `~/.config/karabiner/karabiner.json`: 120 | 121 | ```json 122 | { 123 | "description": "ovim mode control", 124 | "manipulators": [ 125 | { 126 | "type": "basic", 127 | "from": { "key_code": "caps_lock" }, 128 | "to": [ 129 | { "shell_command": "/Applications/ovim.app/Contents/MacOS/ovim toggle" } 130 | ] 131 | }, 132 | { 133 | "conditions": [ 134 | { 135 | "bundle_identifiers": [ 136 | "^com\\.apple\\.Terminal$", 137 | "^com\\.googlecode\\.iterm2$", 138 | "^net\\.kovidgoyal\\.kitty$", 139 | "^io\\.alacritty$" 140 | ], 141 | "type": "frontmost_application_unless" 142 | } 143 | ], 144 | "type": "basic", 145 | "from": { "key_code": "escape" }, 146 | "to": [ 147 | { "shell_command": "/Applications/ovim.app/Contents/MacOS/ovim normal" } 148 | ] 149 | } 150 | ] 151 | } 152 | ``` 153 | 154 | ## Tips 155 | 156 | - The CLI returns immediately after sending the command; it doesn't wait for mode change confirmation 157 | - If ovim is not running, the CLI will print an error and exit with code 1 158 | - You can check the current mode with `ovim mode` in scripts 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ovim 2 | 3 | macOS system-wide Vim keybindings and modal editor. 4 | 5 | **ovim has two independent editing modes:** 6 | 7 | | In-Place Mode | Edit Popup | 8 | | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | 9 | | Simulates Vim motions by intercepting keystrokes and injecting native macOS key events. Works instantly in any app. Supports a subset of Vim commands. | Opens your actual Neovim installation in a terminal window with your full config and plugins. Edit complex text, then paste back with `:wq`. | 10 | | ![Normal](docs/images/Component-2.png) ![Visual](docs/images/Component-3.png) ![Insert](docs/images/Component-4.png) | ![Edit Popup](docs/images/edit-popup.gif) | 11 | 12 | ## Features 13 | 14 | | Feature | In-Place Mode | Edit Popup | 15 | | ----------------- | ------------------------------------------------------------------------ | ---------------------------------------- | 16 | | Vim support | Basic motions, operators, text objects ([see list](docs/keybindings.md)) | Full Neovim with all your plugins | 17 | | User config | In app configuration for widgets, ignore list | Uses your `~/.config/nvim` | 18 | | Speed | Instant | ~500ms (terminal startup) | 19 | | App compatibility | All apps (with Accessibility permission) | Apps with Accessibility API or browsers | 20 | | Use case | Quick edits, modal navigation | Complex edits, regex, macros, multi-line | 21 | 22 | ## Installation 23 | 24 | ### Homebrew 25 | 26 | ```bash 27 | brew install --cask tonisives/tap/ovim 28 | ``` 29 | 30 | ### GitHub Releases 31 | 32 | Download the latest `.dmg` from the [Releases](https://github.com/tonisives/ovim/releases) page. 33 | 34 | ### Build from Source 35 | 36 | ```bash 37 | git clone https://github.com/tonisives/ovim.git 38 | cd ovim 39 | pnpm install 40 | pnpm tauri build 41 | # Built app in src-tauri/target/release/bundle/ 42 | ``` 43 | 44 | Requires [Rust](https://rustup.rs/), [Node.js](https://nodejs.org/) v18+, and [pnpm](https://pnpm.io/). 45 | 46 | ## Requirements 47 | 48 | - macOS 10.15 (Catalina) or later 49 | - **Accessibility permission** - Grant in System Settings > Privacy & Security > Accessibility 50 | - Terminal app. `Alacritty` is recommended. We also include Kitty, Terminal.app and WezTerm limited support. 51 | 52 | ## Quick Start 53 | 54 | 1. Launch ovim - it appears in your menu bar 55 | 2. Grant Accessibility permission when prompted 56 | 3. Access Settings from the menu bar icon 57 | 58 | | In-Place Mode | Edit Popup | 59 | | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 60 | | Press **Caps Lock** to toggle between Normal/Insert modes. Use Vim motions directly in any app. | Assign a shortcut to "Toggle Edit Popup" in Settings. Press it to open Neovim, edit, then `:wq` to paste back. | 61 | 62 | ## In-Place Mode 63 | 64 | Toggle between Normal and Insert modes with Caps Lock (configurable). In Normal mode, use Vim motions directly in any application. 65 | 66 | **Supported commands:** `hjkl`, `w/b/e`, `0/$`, `gg/G`, `d/y/c` + motions, `dd/yy/cc`, `x`, `p/P`, `u/Ctrl+r`, Visual mode, counts, and more. See [docs/keybindings.md](docs/keybindings.md) for the full list. 67 | 68 | ![Indicator Position](docs/images/change-indicator-position.gif) 69 | 70 | ### Widgets 71 | 72 | Display battery status, time, or selection info 73 | 74 | ## Edit Popup 75 | 76 | Opens your actual Neovim installation in a terminal window. Your full config (`~/.config/nvim`) and all plugins are available. 77 | 78 | **How it works:** 79 | 80 | 1. Assign a shortcut to "Toggle Edit Popup" in Settings 81 | 2. Select text in any application (optional - captures existing text) 82 | 3. Press your shortcut to open Neovim in a popup terminal 83 | 4. Edit with your full Neovim setup (plugins, keybindings, macros, etc.) 84 | 5. Type `:wq` to save and paste back, or close the window to cancel 85 | 86 | **Supported terminals:** Alacritty, Kitty, WezTerm, iTerm2, Terminal.app 87 | 88 | ## CLI Tool 89 | 90 | ovim includes a CLI for controlling modes from scripts or tools like Karabiner-Elements: 91 | 92 | ```bash 93 | ovim toggle # Toggle between insert/normal mode 94 | ovim normal # Enter normal mode 95 | ovim insert # Enter insert mode 96 | ovim mode # Get current mode 97 | ``` 98 | 99 | See [docs/cli.md](docs/cli.md) for full CLI documentation and Karabiner integration examples. 100 | 101 | 102 | ## Issues 103 | Please check logs at `/tmp/ovim-rust.log` and submit an [issue](https://github.com/tonisives/ovim/issues) 104 | 105 | 106 | ## License 107 | 108 | MIT License - see [LICENSE](LICENSE) for details. 109 | -------------------------------------------------------------------------------- /src/components/SettingsApp.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef, useCallback } from "react"; 2 | import { invoke } from "@tauri-apps/api/core"; 3 | import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; 4 | import { GeneralSettings } from "./GeneralSettings"; 5 | import { IndicatorSettings } from "./IndicatorSettings"; 6 | import { WidgetSettings } from "./WidgetSettings"; 7 | import { IgnoredAppsSettings } from "./IgnoredAppsSettings"; 8 | import { NvimEditSettings } from "./NvimEditSettings"; 9 | 10 | export interface VimKeyModifiers { 11 | shift: boolean; 12 | control: boolean; 13 | option: boolean; 14 | command: boolean; 15 | } 16 | 17 | export interface NvimEditSettings { 18 | enabled: boolean; 19 | shortcut_key: string; 20 | shortcut_modifiers: VimKeyModifiers; 21 | terminal: string; 22 | terminal_path: string; 23 | editor: string; 24 | nvim_path: string; 25 | popup_mode: boolean; 26 | popup_width: number; 27 | popup_height: number; 28 | live_sync_enabled: boolean; 29 | } 30 | 31 | export interface RgbColor { 32 | r: number; 33 | g: number; 34 | b: number; 35 | } 36 | 37 | export interface ModeColors { 38 | insert: RgbColor; 39 | normal: RgbColor; 40 | visual: RgbColor; 41 | } 42 | 43 | export interface Settings { 44 | enabled: boolean; 45 | vim_key: string; 46 | vim_key_modifiers: VimKeyModifiers; 47 | indicator_position: number; 48 | indicator_opacity: number; 49 | indicator_size: number; 50 | indicator_offset_x: number; 51 | indicator_offset_y: number; 52 | indicator_visible: boolean; 53 | show_mode_in_menu_bar: boolean; 54 | mode_colors: ModeColors; 55 | indicator_font: string; 56 | ignored_apps: string[]; 57 | launch_at_login: boolean; 58 | show_in_menu_bar: boolean; 59 | top_widget: string; 60 | bottom_widget: string; 61 | electron_apps: string[]; 62 | nvim_edit: NvimEditSettings; 63 | } 64 | 65 | type TabId = "general" | "indicator" | "widgets" | "ignored" | "nvim"; 66 | 67 | const MIN_HEIGHT = 400; 68 | const MAX_HEIGHT = 800; 69 | const WINDOW_WIDTH = 600; 70 | const TABS_HEIGHT = 45; // Height of tabs bar 71 | 72 | export function SettingsApp() { 73 | const [settings, setSettings] = useState(null); 74 | const [activeTab, setActiveTab] = useState("general"); 75 | const contentRef = useRef(null); 76 | 77 | const resizeWindow = useCallback(async () => { 78 | if (!contentRef.current) return; 79 | 80 | // Get the scrollHeight of the content (actual content height) 81 | const contentHeight = contentRef.current.scrollHeight; 82 | // Add tabs height and padding buffer 83 | const totalHeight = contentHeight + TABS_HEIGHT + 40; 84 | // Clamp between min and max 85 | const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, totalHeight)); 86 | 87 | try { 88 | const window = getCurrentWindow(); 89 | await window.setSize(new LogicalSize(WINDOW_WIDTH, newHeight)); 90 | } catch (e) { 91 | console.error("Failed to resize window:", e); 92 | } 93 | }, []); 94 | 95 | useEffect(() => { 96 | invoke("get_settings") 97 | .then(setSettings) 98 | .catch((e) => console.error("Failed to load settings:", e)); 99 | }, []); 100 | 101 | // Resize window when tab changes or settings change 102 | useEffect(() => { 103 | // Small delay to let content render 104 | const timer = setTimeout(resizeWindow, 50); 105 | return () => clearTimeout(timer); 106 | }, [activeTab, settings, resizeWindow]); 107 | 108 | const updateSettings = async (updates: Partial) => { 109 | if (!settings) return; 110 | 111 | const newSettings = { ...settings, ...updates }; 112 | setSettings(newSettings); 113 | 114 | try { 115 | await invoke("set_settings", { newSettings }); 116 | } catch (e) { 117 | console.error("Failed to save settings:", e); 118 | } 119 | }; 120 | 121 | if (!settings) { 122 | return
Loading settings...
; 123 | } 124 | 125 | const inPlaceModeTabs: { id: TabId; label: string; icon: string }[] = [ 126 | { id: "indicator", label: "Indicator", icon: "diamond" }, 127 | { id: "widgets", label: "Widgets", icon: "ruler" }, 128 | { id: "ignored", label: "Ignored Apps", icon: "pause" }, 129 | ]; 130 | 131 | return ( 132 |
133 |
134 | 141 | 142 |
143 | In-Place Mode 144 |
145 | {inPlaceModeTabs.map((tab) => ( 146 | 154 | ))} 155 |
156 |
157 | 158 | 165 |
166 | 167 |
168 | {activeTab === "general" && ( 169 | 170 | )} 171 | {activeTab === "indicator" && ( 172 | 173 | )} 174 | {activeTab === "widgets" && ( 175 | 176 | )} 177 | {activeTab === "ignored" && ( 178 | 179 | )} 180 | {activeTab === "nvim" && ( 181 | 182 | )} 183 |
184 | 185 |
186 | ); 187 | } 188 | 189 | function getIcon(name: string): string { 190 | const icons: Record = { 191 | gear: "\u2699", 192 | diamond: "\u25C6", 193 | ruler: "\u25A6", 194 | pause: "\u23F8", 195 | edit: "\u270E", 196 | }; 197 | return icons[name] || ""; 198 | } 199 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/process_utils.rs: -------------------------------------------------------------------------------- 1 | //! Process management utilities for terminal spawning 2 | 3 | use std::process::Command; 4 | use std::thread; 5 | use std::time::Duration; 6 | 7 | /// Wait for a specific PID to exit 8 | pub fn wait_for_pid(pid: u32) -> Result<(), String> { 9 | loop { 10 | // First try waitpid with WNOHANG to reap zombie children (for processes we spawned) 11 | let mut status: libc::c_int = 0; 12 | let wait_result = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }; 13 | 14 | if wait_result == pid as i32 { 15 | // Process has exited and been reaped 16 | log::info!("Process {} reaped via waitpid", pid); 17 | break; 18 | } else if wait_result == -1 { 19 | // Error - check errno 20 | let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); 21 | if errno == libc::ECHILD { 22 | // Not our child - fall back to kill(pid, 0) check 23 | let kill_result = unsafe { libc::kill(pid as i32, 0) }; 24 | if kill_result != 0 { 25 | // Process doesn't exist 26 | log::info!("Process {} no longer exists (kill check)", pid); 27 | break; 28 | } 29 | } else { 30 | // Other error 31 | log::warn!("waitpid error for {}: errno={}", pid, errno); 32 | break; 33 | } 34 | } 35 | // wait_result == 0 means process still running, continue polling 36 | 37 | // Poll very fast (10ms) so we can restore focus before the window closes 38 | thread::sleep(Duration::from_millis(10)); 39 | } 40 | 41 | Ok(()) 42 | } 43 | 44 | /// Find the editor process editing a specific file 45 | pub fn find_editor_pid_for_file(file_path: &str, process_name: &str) -> Option { 46 | // Small delay to let editor start 47 | thread::sleep(Duration::from_millis(500)); 48 | 49 | // Use lsof to find the process that has our file open 50 | let output = Command::new("lsof").args(["-t", file_path]).output().ok()?; 51 | 52 | if output.status.success() { 53 | let pids = String::from_utf8_lossy(&output.stdout); 54 | // Take the first PID (there should only be one) 55 | for line in pids.lines() { 56 | if let Ok(pid) = line.trim().parse::() { 57 | return Some(pid); 58 | } 59 | } 60 | } 61 | 62 | // Fallback: find most recent process matching the editor name 63 | if !process_name.is_empty() { 64 | find_process_by_name(process_name) 65 | } else { 66 | None 67 | } 68 | } 69 | 70 | /// Find the most recently started process by name 71 | fn find_process_by_name(name: &str) -> Option { 72 | let output = Command::new("pgrep").args(["-n", name]).output().ok()?; 73 | 74 | if output.status.success() { 75 | let pid_str = String::from_utf8_lossy(&output.stdout); 76 | pid_str.trim().parse().ok() 77 | } else { 78 | None 79 | } 80 | } 81 | 82 | /// Common installation paths to check for binaries on macOS 83 | /// These are checked when the app is launched from GUI and has limited PATH 84 | const COMMON_BIN_PATHS: &[&str] = &[ 85 | "/opt/homebrew/bin", // Apple Silicon Homebrew 86 | "/usr/local/bin", // Intel Homebrew / manual installs 87 | "/usr/bin", // System binaries 88 | "/bin", // Core system binaries 89 | ]; 90 | 91 | /// Common application bundle paths for terminal emulators on macOS 92 | const TERMINAL_APP_PATHS: &[(&str, &str)] = &[ 93 | ("alacritty", "/Applications/Alacritty.app/Contents/MacOS/alacritty"), 94 | ("kitty", "/Applications/kitty.app/Contents/MacOS/kitty"), 95 | ("wezterm", "/Applications/WezTerm.app/Contents/MacOS/wezterm"), 96 | ("ghostty", "/Applications/Ghostty.app/Contents/MacOS/ghostty"), 97 | ]; 98 | 99 | /// Resolve a command name to its absolute path 100 | /// Checks common installation locations for GUI launches with limited PATH 101 | pub fn resolve_command_path(cmd: &str) -> String { 102 | // If already absolute path, return as-is 103 | if cmd.starts_with('/') { 104 | return cmd.to_string(); 105 | } 106 | 107 | // Check common binary paths first (for GUI launches with minimal PATH) 108 | for base in COMMON_BIN_PATHS { 109 | let full_path = format!("{}/{}", base, cmd); 110 | if std::path::Path::new(&full_path).exists() { 111 | log::info!("Found {} at {}", cmd, full_path); 112 | return full_path; 113 | } 114 | } 115 | 116 | // Try to resolve using `which` (works if PATH is set correctly) 117 | if let Ok(output) = Command::new("which").arg(cmd).output() { 118 | if output.status.success() { 119 | let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); 120 | if !path.is_empty() { 121 | return path; 122 | } 123 | } 124 | } 125 | 126 | // Fallback: return original (might work if PATH is set) 127 | cmd.to_string() 128 | } 129 | 130 | /// Resolve a terminal command to its absolute path 131 | /// First checks common macOS application bundle locations, then falls back to resolve_command_path 132 | pub fn resolve_terminal_path(terminal_name: &str) -> String { 133 | // If it's a .app bundle path, extract the binary from inside 134 | if terminal_name.ends_with(".app") { 135 | // Get the app name without .app extension 136 | let app_name = std::path::Path::new(terminal_name) 137 | .file_stem() 138 | .and_then(|s| s.to_str()) 139 | .unwrap_or(""); 140 | 141 | // Try to find the binary inside Contents/MacOS 142 | let binary_path = format!("{}/Contents/MacOS/{}", terminal_name, app_name); 143 | if std::path::Path::new(&binary_path).exists() { 144 | log::info!("Resolved .app bundle {} to binary {}", terminal_name, binary_path); 145 | return binary_path; 146 | } 147 | 148 | // Some apps have lowercase binary names 149 | let lowercase_binary = format!("{}/Contents/MacOS/{}", terminal_name, app_name.to_lowercase()); 150 | if std::path::Path::new(&lowercase_binary).exists() { 151 | log::info!("Resolved .app bundle {} to binary {}", terminal_name, lowercase_binary); 152 | return lowercase_binary; 153 | } 154 | 155 | log::warn!("Could not find binary in .app bundle: {}", terminal_name); 156 | } 157 | 158 | // Check for known terminal app bundle paths 159 | let lowercase = terminal_name.to_lowercase(); 160 | for (name, app_path) in TERMINAL_APP_PATHS { 161 | if lowercase == *name && std::path::Path::new(app_path).exists() { 162 | log::info!("Found {} at {}", terminal_name, app_path); 163 | return app_path.to_string(); 164 | } 165 | } 166 | 167 | // Fall back to general command resolution 168 | resolve_command_path(terminal_name) 169 | } 170 | -------------------------------------------------------------------------------- /src-tauri/src/vim/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::keyboard; 2 | 3 | /// Vim commands that can be executed 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum VimCommand { 6 | // Basic motions 7 | MoveLeft, 8 | MoveRight, 9 | MoveUp, 10 | MoveDown, 11 | 12 | // Word motions 13 | WordForward, 14 | WordEnd, 15 | WordBackward, 16 | WordEndBackward, // ge 17 | 18 | // Line motions 19 | LineStart, 20 | LineEnd, 21 | 22 | // Paragraph motions 23 | ParagraphUp, // { 24 | ParagraphDown, // } 25 | 26 | // Document motions 27 | DocumentStart, 28 | DocumentEnd, 29 | 30 | // Page motions 31 | PageUp, 32 | PageDown, 33 | HalfPageUp, 34 | HalfPageDown, 35 | 36 | // Insert mode transitions 37 | InsertAtLineStart, 38 | AppendAfterCursor, 39 | AppendAtLineEnd, 40 | OpenLineBelow, 41 | OpenLineAbove, 42 | SubstituteChar, // s - delete char and enter insert 43 | SubstituteLine, // S - delete line and enter insert 44 | 45 | // Operations 46 | DeleteChar, 47 | DeleteCharBefore, // X 48 | DeleteLine, 49 | DeleteToLineEnd, // D 50 | YankLine, 51 | ChangeLine, 52 | ChangeToLineEnd, // C 53 | JoinLines, // J 54 | 55 | // Text objects 56 | InnerWord, // iw - select word 57 | AroundWord, // aw - select word + space 58 | 59 | // Indent 60 | IndentLine, // >> 61 | OutdentLine, // << 62 | 63 | // Clipboard 64 | Paste, 65 | PasteBefore, 66 | 67 | // Undo/Redo 68 | Undo, 69 | Redo, 70 | 71 | } 72 | 73 | impl VimCommand { 74 | /// Execute the command, optionally with visual selection 75 | pub fn execute(&self, count: u32, select: bool) -> Result<(), String> { 76 | match self { 77 | // Basic motions 78 | Self::MoveLeft => keyboard::cursor_left(count, select), 79 | Self::MoveRight => keyboard::cursor_right(count, select), 80 | Self::MoveUp => keyboard::cursor_up(count, select), 81 | Self::MoveDown => keyboard::cursor_down(count, select), 82 | 83 | // Word motions 84 | Self::WordForward | Self::WordEnd => keyboard::word_forward(count, select), 85 | Self::WordBackward | Self::WordEndBackward => keyboard::word_backward(count, select), 86 | 87 | // Line motions 88 | Self::LineStart => keyboard::line_start(select), 89 | Self::LineEnd => keyboard::line_end(select), 90 | 91 | // Paragraph motions 92 | Self::ParagraphUp => keyboard::paragraph_up(count, select), 93 | Self::ParagraphDown => keyboard::paragraph_down(count, select), 94 | 95 | // Document motions 96 | Self::DocumentStart => keyboard::document_start(select), 97 | Self::DocumentEnd => keyboard::document_end(select), 98 | 99 | // Page motions 100 | Self::PageUp | Self::HalfPageUp => keyboard::page_up(select), 101 | Self::PageDown | Self::HalfPageDown => keyboard::page_down(select), 102 | 103 | // Insert mode transitions 104 | Self::InsertAtLineStart => keyboard::line_start(false), 105 | Self::AppendAfterCursor => keyboard::cursor_right(1, false), 106 | Self::AppendAtLineEnd => keyboard::line_end(false), 107 | Self::OpenLineBelow => keyboard::new_line_below(), 108 | Self::OpenLineAbove => keyboard::new_line_above(), 109 | Self::SubstituteChar => keyboard::delete_char(), 110 | Self::SubstituteLine => { 111 | keyboard::line_start(false)?; 112 | keyboard::line_end(true)?; 113 | keyboard::cut() 114 | } 115 | 116 | // Operations 117 | Self::DeleteChar => { 118 | for _ in 0..count { 119 | keyboard::delete_char()?; 120 | } 121 | Ok(()) 122 | } 123 | Self::DeleteCharBefore => { 124 | for _ in 0..count { 125 | keyboard::backspace()?; 126 | } 127 | Ok(()) 128 | } 129 | Self::DeleteLine => { 130 | keyboard::line_start(false)?; 131 | keyboard::line_end(true)?; 132 | keyboard::cut() 133 | } 134 | Self::DeleteToLineEnd => { 135 | keyboard::line_end(true)?; 136 | keyboard::cut() 137 | } 138 | Self::YankLine => { 139 | keyboard::line_start(false)?; 140 | keyboard::line_end(true)?; 141 | keyboard::copy() 142 | } 143 | Self::ChangeLine => { 144 | keyboard::line_start(false)?; 145 | keyboard::line_end(true)?; 146 | keyboard::cut() 147 | } 148 | Self::ChangeToLineEnd => { 149 | keyboard::line_end(true)?; 150 | keyboard::cut() 151 | } 152 | Self::JoinLines => { 153 | for _ in 0..count { 154 | keyboard::join_lines()?; 155 | } 156 | Ok(()) 157 | } 158 | 159 | // Text objects 160 | Self::InnerWord => keyboard::select_inner_word(), 161 | Self::AroundWord => keyboard::select_around_word(), 162 | 163 | // Indent 164 | Self::IndentLine => { 165 | for _ in 0..count { 166 | keyboard::indent_line()?; 167 | } 168 | Ok(()) 169 | } 170 | Self::OutdentLine => { 171 | for _ in 0..count { 172 | keyboard::outdent_line()?; 173 | } 174 | Ok(()) 175 | } 176 | 177 | // Clipboard 178 | Self::Paste | Self::PasteBefore => keyboard::paste(), 179 | 180 | // Undo/Redo 181 | Self::Undo => keyboard::undo(), 182 | Self::Redo => keyboard::redo(), 183 | } 184 | } 185 | } 186 | 187 | /// Pending operator (d, y, c) 188 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 189 | pub enum Operator { 190 | Delete, 191 | Yank, 192 | Change, 193 | } 194 | 195 | impl Operator { 196 | /// Execute operator with the given motion 197 | pub fn execute_with_motion(&self, motion: VimCommand, count: u32) -> Result { 198 | // First, select the text 199 | motion.execute(count, true)?; 200 | 201 | // Then apply the operator 202 | match self { 203 | Self::Delete => { 204 | keyboard::cut()?; 205 | Ok(false) // Stay in normal mode 206 | } 207 | Self::Yank => { 208 | keyboard::copy()?; 209 | // Move cursor back (yank doesn't delete) 210 | keyboard::cursor_left(1, false)?; 211 | Ok(false) // Stay in normal mode 212 | } 213 | Self::Change => { 214 | keyboard::cut()?; 215 | Ok(true) // Enter insert mode 216 | } 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src-tauri/src/nvim_edit/terminals/alacritty.rs: -------------------------------------------------------------------------------- 1 | //! Alacritty terminal spawner 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use super::applescript_utils::{ 7 | find_alacritty_window_by_title, focus_alacritty_window_by_index, set_window_bounds_atomic, 8 | }; 9 | use super::process_utils::{find_editor_pid_for_file, resolve_command_path, resolve_terminal_path}; 10 | use super::{SpawnInfo, TerminalSpawner, TerminalType, WindowGeometry}; 11 | use crate::config::NvimEditSettings; 12 | 13 | pub struct AlacrittySpawner; 14 | 15 | impl TerminalSpawner for AlacrittySpawner { 16 | fn terminal_type(&self) -> TerminalType { 17 | TerminalType::Alacritty 18 | } 19 | 20 | fn spawn( 21 | &self, 22 | settings: &NvimEditSettings, 23 | file_path: &str, 24 | geometry: Option, 25 | socket_path: Option<&Path>, 26 | ) -> Result { 27 | // Generate a unique window title so we can find it 28 | let unique_title = format!("ovim-edit-{}", std::process::id()); 29 | 30 | // Get editor path and args from settings 31 | let editor_path = settings.editor_path(); 32 | let editor_args = settings.editor_args(); 33 | let process_name = settings.editor_process_name(); 34 | 35 | // Build socket args for nvim RPC if socket_path provided and using nvim 36 | let socket_args: Vec = if let Some(socket) = socket_path { 37 | if editor_path.contains("nvim") || editor_path == "nvim" { 38 | vec!["--listen".to_string(), socket.to_string_lossy().to_string()] 39 | } else { 40 | vec![] 41 | } 42 | } else { 43 | vec![] 44 | }; 45 | 46 | // Resolve editor path to absolute path (msg create-window doesn't inherit PATH) 47 | let resolved_editor = resolve_command_path(&editor_path); 48 | log::info!("Resolved editor path: {} -> {}", editor_path, resolved_editor); 49 | 50 | // Start a watcher thread to find the window, set bounds, and focus it 51 | { 52 | let title = unique_title.clone(); 53 | let geo = geometry.clone(); 54 | std::thread::spawn(move || { 55 | // Poll rapidly to catch the window as soon as it appears 56 | for _attempt in 0..200 { 57 | if let Some(index) = find_alacritty_window_by_title(&title) { 58 | log::info!("Found window '{}' at index {}", title, index); 59 | if let Some(ref g) = geo { 60 | set_window_bounds_atomic("Alacritty", index, g.x, g.y, g.width, g.height); 61 | } 62 | // Focus the new window 63 | focus_alacritty_window_by_index(index); 64 | return; 65 | } 66 | std::thread::sleep(std::time::Duration::from_millis(10)); 67 | } 68 | log::warn!("Timeout waiting for Alacritty window '{}'", title); 69 | }); 70 | } 71 | 72 | // Calculate initial window size 73 | let (init_columns, init_lines) = if let Some(ref geo) = geometry { 74 | ((geo.width / 8).max(40) as u32, (geo.height / 16).max(10) as u32) 75 | } else { 76 | (80, 24) 77 | }; 78 | 79 | // Build the command arguments: editor path, socket args, editor args, file path 80 | let mut cmd_args: Vec = vec![ 81 | "msg".to_string(), 82 | "create-window".to_string(), 83 | "-o".to_string(), 84 | format!("window.title=\"{}\"", unique_title), 85 | "-o".to_string(), 86 | "window.dynamic_title=false".to_string(), 87 | "-o".to_string(), 88 | "window.startup_mode=\"Windowed\"".to_string(), 89 | "-o".to_string(), 90 | format!("window.dimensions.columns={}", init_columns), 91 | "-o".to_string(), 92 | format!("window.dimensions.lines={}", init_lines), 93 | "-e".to_string(), 94 | resolved_editor.clone(), 95 | ]; 96 | cmd_args.extend(socket_args.iter().cloned()); 97 | for arg in &editor_args { 98 | cmd_args.push(arg.to_string()); 99 | } 100 | cmd_args.push(file_path.to_string()); 101 | 102 | // Resolve terminal path (uses user setting or auto-detects) 103 | let terminal_cmd = settings.get_terminal_path(); 104 | let resolved_terminal = resolve_terminal_path(&terminal_cmd); 105 | log::info!("Resolved terminal path: {} -> {}", terminal_cmd, resolved_terminal); 106 | 107 | // Try `alacritty msg create-window` first - this works if Alacritty daemon is running 108 | // We use status() to wait for the command to complete and check exit code 109 | let msg_result = Command::new(&resolved_terminal) 110 | .args(&cmd_args) 111 | .status(); 112 | 113 | // If msg create-window failed (no daemon running), fall back to regular spawn 114 | let child = match msg_result { 115 | Ok(status) if status.success() => { 116 | log::info!("msg create-window succeeded"); 117 | None // No child process to track - window was created in existing daemon 118 | } 119 | _ => { 120 | log::info!("msg create-window failed, falling back to regular spawn"); 121 | // Build fallback args (without msg create-window) 122 | let mut fallback_args: Vec = vec![ 123 | "-o".to_string(), 124 | format!("window.title=\"{}\"", unique_title), 125 | "-o".to_string(), 126 | "window.dynamic_title=false".to_string(), 127 | "-o".to_string(), 128 | "window.startup_mode=\"Windowed\"".to_string(), 129 | "-o".to_string(), 130 | format!("window.dimensions.columns={}", init_columns), 131 | "-o".to_string(), 132 | format!("window.dimensions.lines={}", init_lines), 133 | "-e".to_string(), 134 | resolved_editor.clone(), 135 | ]; 136 | fallback_args.extend(socket_args.iter().cloned()); 137 | for arg in &editor_args { 138 | fallback_args.push(arg.to_string()); 139 | } 140 | fallback_args.push(file_path.to_string()); 141 | 142 | Some( 143 | Command::new(&resolved_terminal) 144 | .args(&fallback_args) 145 | .spawn() 146 | .map_err(|e| format!("Failed to spawn alacritty: {}", e))?, 147 | ) 148 | } 149 | }; 150 | 151 | // Wait a bit for editor to start, then find its PID by the file it's editing 152 | let pid = find_editor_pid_for_file(file_path, process_name); 153 | log::info!("Found editor PID: {:?} for file: {}", pid, file_path); 154 | 155 | Ok(SpawnInfo { 156 | terminal_type: TerminalType::Alacritty, 157 | process_id: pid, 158 | child, 159 | window_title: Some(unique_title), 160 | }) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src-tauri/src/vim/state/visual_mode.rs: -------------------------------------------------------------------------------- 1 | use crate::keyboard::{KeyCode, Modifiers}; 2 | use super::super::commands::VimCommand; 3 | use super::super::modes::VimMode; 4 | use super::action::VimAction; 5 | use super::{ProcessResult, TextObjectModifier}; 6 | use super::VimState; 7 | 8 | impl VimState { 9 | pub(super) fn process_visual_mode_with_modifiers(&mut self, keycode: KeyCode, modifiers: &Modifiers) -> ProcessResult { 10 | // Escape exits visual mode 11 | if keycode == KeyCode::Escape { 12 | self.set_mode(VimMode::Normal); 13 | return ProcessResult::ModeChanged(VimMode::Normal, None); 14 | } 15 | 16 | // v toggles back to normal 17 | if keycode == KeyCode::V { 18 | self.set_mode(VimMode::Normal); 19 | return ProcessResult::ModeChanged(VimMode::Normal, None); 20 | } 21 | 22 | // Handle pending g 23 | if self.pending_g { 24 | self.pending_g = false; 25 | return self.handle_visual_g_combo(keycode); 26 | } 27 | 28 | // Handle pending text object modifier 29 | if let Some(modifier) = self.pending_text_object.take() { 30 | return self.handle_visual_text_object(keycode, modifier); 31 | } 32 | 33 | // Handle count accumulation (1-9, then 0-9) 34 | // Must check this BEFORE processing other keys 35 | // Only accumulate if shift is NOT pressed (shift+number = special chars like $ ^) 36 | if !modifiers.shift { 37 | if let Some(digit) = keycode.to_digit() { 38 | if digit != 0 || self.pending_count.is_some() { 39 | let current = self.pending_count.unwrap_or(0); 40 | self.pending_count = Some(current * 10 + digit); 41 | return ProcessResult::Suppress; 42 | } 43 | } 44 | } 45 | 46 | let count = self.get_count(); 47 | self.pending_count = None; 48 | 49 | match keycode { 50 | // Basic motions (with selection) 51 | KeyCode::H => ProcessResult::SuppressWithAction(VimAction::Command { 52 | command: VimCommand::MoveLeft, count, select: true 53 | }), 54 | KeyCode::J => ProcessResult::SuppressWithAction(VimAction::Command { 55 | command: VimCommand::MoveDown, count, select: true 56 | }), 57 | KeyCode::K => ProcessResult::SuppressWithAction(VimAction::Command { 58 | command: VimCommand::MoveUp, count, select: true 59 | }), 60 | KeyCode::L => ProcessResult::SuppressWithAction(VimAction::Command { 61 | command: VimCommand::MoveRight, count, select: true 62 | }), 63 | 64 | // Word motions 65 | KeyCode::W | KeyCode::E => ProcessResult::SuppressWithAction(VimAction::Command { 66 | command: VimCommand::WordForward, count, select: true 67 | }), 68 | KeyCode::B => ProcessResult::SuppressWithAction(VimAction::Command { 69 | command: VimCommand::WordBackward, count, select: true 70 | }), 71 | 72 | // Line motions 73 | KeyCode::Num0 => ProcessResult::SuppressWithAction(VimAction::Command { 74 | command: VimCommand::LineStart, count: 1, select: true 75 | }), 76 | // $ = line end 77 | KeyCode::Num4 if modifiers.shift => ProcessResult::SuppressWithAction(VimAction::Command { 78 | command: VimCommand::LineEnd, count: 1, select: true 79 | }), 80 | // ^ = line start 81 | KeyCode::Num6 if modifiers.shift => ProcessResult::SuppressWithAction(VimAction::Command { 82 | command: VimCommand::LineStart, count: 1, select: true 83 | }), 84 | // _ = first non-blank character 85 | KeyCode::Minus if modifiers.shift => ProcessResult::SuppressWithAction(VimAction::Command { 86 | command: VimCommand::LineStart, count: 1, select: true 87 | }), 88 | 89 | // Paragraph motions 90 | KeyCode::LeftBracket if modifiers.shift => ProcessResult::SuppressWithAction(VimAction::Command { 91 | command: VimCommand::ParagraphUp, count, select: true 92 | }), 93 | KeyCode::RightBracket if modifiers.shift => ProcessResult::SuppressWithAction(VimAction::Command { 94 | command: VimCommand::ParagraphDown, count, select: true 95 | }), 96 | 97 | // Document motions 98 | KeyCode::G => { 99 | if modifiers.shift { 100 | // G = document end 101 | ProcessResult::SuppressWithAction(VimAction::Command { 102 | command: VimCommand::DocumentEnd, count: 1, select: true 103 | }) 104 | } else { 105 | // g = start g combo 106 | self.pending_g = true; 107 | ProcessResult::Suppress 108 | } 109 | } 110 | 111 | // Text object modifiers 112 | KeyCode::I if !modifiers.shift => { 113 | self.pending_text_object = Some(TextObjectModifier::Inner); 114 | ProcessResult::Suppress 115 | } 116 | KeyCode::A if !modifiers.shift => { 117 | self.pending_text_object = Some(TextObjectModifier::Around); 118 | ProcessResult::Suppress 119 | } 120 | 121 | // Operations on selection 122 | KeyCode::D | KeyCode::X => { 123 | self.set_mode(VimMode::Normal); 124 | ProcessResult::ModeChanged(VimMode::Normal, Some(VimAction::Cut)) 125 | } 126 | KeyCode::Y => { 127 | self.set_mode(VimMode::Normal); 128 | ProcessResult::ModeChanged(VimMode::Normal, Some(VimAction::Copy)) 129 | } 130 | KeyCode::C => { 131 | self.set_mode(VimMode::Insert); 132 | ProcessResult::ModeChanged(VimMode::Insert, Some(VimAction::Cut)) 133 | } 134 | 135 | _ => ProcessResult::PassThrough, 136 | } 137 | } 138 | 139 | fn handle_visual_g_combo(&mut self, keycode: KeyCode) -> ProcessResult { 140 | let count = self.get_count(); 141 | self.pending_count = None; 142 | 143 | match keycode { 144 | KeyCode::G => { 145 | // gg = document start with selection 146 | ProcessResult::SuppressWithAction(VimAction::Command { 147 | command: VimCommand::DocumentStart, count: 1, select: true 148 | }) 149 | } 150 | KeyCode::E => { 151 | // ge = end of previous word with selection 152 | ProcessResult::SuppressWithAction(VimAction::Command { 153 | command: VimCommand::WordEndBackward, count, select: true 154 | }) 155 | } 156 | _ => ProcessResult::PassThrough, 157 | } 158 | } 159 | 160 | fn handle_visual_text_object(&self, keycode: KeyCode, modifier: TextObjectModifier) -> ProcessResult { 161 | // In visual mode, text objects extend the selection 162 | if keycode == KeyCode::W { 163 | let text_object = match modifier { 164 | TextObjectModifier::Inner => VimCommand::InnerWord, 165 | TextObjectModifier::Around => VimCommand::AroundWord, 166 | }; 167 | // Execute the text object to extend selection 168 | ProcessResult::SuppressWithAction(VimAction::Command { 169 | command: text_object, count: 1, select: false 170 | }) 171 | } else { 172 | ProcessResult::PassThrough 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src-tauri/src/vim/state/mod.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod normal_mode; 3 | mod visual_mode; 4 | 5 | pub use action::VimAction; 6 | 7 | use tokio::sync::broadcast; 8 | 9 | use crate::keyboard::{KeyCode, KeyEvent}; 10 | use super::commands::Operator; 11 | use super::modes::VimMode; 12 | 13 | /// Result of processing a key event 14 | #[derive(Debug, Clone)] 15 | pub enum ProcessResult { 16 | /// Suppress the key event entirely (no action needed) 17 | Suppress, 18 | /// Suppress and execute an action (deferred execution) 19 | SuppressWithAction(VimAction), 20 | /// Pass the key event through unchanged 21 | PassThrough, 22 | /// Mode changed (emit event), with optional action to execute 23 | ModeChanged(VimMode, Option), 24 | } 25 | 26 | /// Text object modifier (i for inner, a for around) 27 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 28 | pub enum TextObjectModifier { 29 | Inner, // i 30 | Around, // a 31 | } 32 | 33 | /// Indent direction 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 35 | pub enum IndentDirection { 36 | Indent, // > 37 | Outdent, // < 38 | } 39 | 40 | /// Vim state machine 41 | pub struct VimState { 42 | mode: VimMode, 43 | /// Pending count for repeat (e.g., "5" in "5j") 44 | pending_count: Option, 45 | /// Pending operator (d, y, c) 46 | pending_operator: Option, 47 | /// Pending g key for gg, gt, gT, ge, etc 48 | pending_g: bool, 49 | /// Pending r key for r{char} replace 50 | pending_r: bool, 51 | /// Pending text object modifier (i or a after d/y/c) 52 | pending_text_object: Option, 53 | /// Pending indent direction (> or <) 54 | pending_indent: Option, 55 | /// Channel to emit mode changes 56 | mode_tx: broadcast::Sender, 57 | } 58 | 59 | impl VimState { 60 | pub fn new() -> (Self, broadcast::Receiver) { 61 | let (mode_tx, mode_rx) = broadcast::channel(16); 62 | ( 63 | Self { 64 | mode: VimMode::Insert, 65 | pending_count: None, 66 | pending_operator: None, 67 | pending_g: false, 68 | pending_r: false, 69 | pending_text_object: None, 70 | pending_indent: None, 71 | mode_tx, 72 | }, 73 | mode_rx, 74 | ) 75 | } 76 | 77 | pub fn mode(&self) -> VimMode { 78 | self.mode 79 | } 80 | 81 | pub(super) fn set_mode(&mut self, mode: VimMode) { 82 | if self.mode != mode { 83 | self.mode = mode; 84 | self.reset_pending(); 85 | let _ = self.mode_tx.send(mode); 86 | } 87 | } 88 | 89 | /// Set mode externally (from CLI/IPC) 90 | pub fn set_mode_external(&mut self, mode: VimMode) { 91 | self.set_mode(mode); 92 | } 93 | 94 | /// Toggle between insert and normal mode (for CLI/IPC) 95 | pub fn toggle_mode(&mut self) -> VimMode { 96 | let new_mode = match self.mode { 97 | VimMode::Insert => VimMode::Normal, 98 | VimMode::Normal | VimMode::Visual => VimMode::Insert, 99 | }; 100 | self.set_mode(new_mode); 101 | new_mode 102 | } 103 | 104 | pub(super) fn reset_pending(&mut self) { 105 | self.pending_count = None; 106 | self.pending_operator = None; 107 | self.pending_g = false; 108 | self.pending_r = false; 109 | self.pending_text_object = None; 110 | self.pending_indent = None; 111 | } 112 | 113 | pub(super) fn get_count(&self) -> u32 { 114 | self.pending_count.unwrap_or(1) 115 | } 116 | 117 | /// Get a string representation of pending keys for display 118 | pub fn get_pending_keys(&self) -> String { 119 | let mut buf = String::new(); 120 | if let Some(count) = self.pending_count { 121 | buf.push_str(&count.to_string()); 122 | } 123 | if let Some(ref op) = self.pending_operator { 124 | buf.push(match op { 125 | Operator::Delete => 'd', 126 | Operator::Yank => 'y', 127 | Operator::Change => 'c', 128 | }); 129 | } 130 | if self.pending_g { 131 | buf.push('g'); 132 | } 133 | if self.pending_r { 134 | buf.push('r'); 135 | } 136 | if let Some(ref modifier) = self.pending_text_object { 137 | buf.push(match modifier { 138 | TextObjectModifier::Inner => 'i', 139 | TextObjectModifier::Around => 'a', 140 | }); 141 | } 142 | if let Some(ref dir) = self.pending_indent { 143 | buf.push(match dir { 144 | IndentDirection::Indent => '>', 145 | IndentDirection::Outdent => '<', 146 | }); 147 | } 148 | buf 149 | } 150 | 151 | /// Process a key event and return what to do with it 152 | pub fn process_key(&mut self, event: KeyEvent) -> ProcessResult { 153 | // For key up events in Normal/Visual mode, suppress keys that we would suppress on key down 154 | if !event.is_key_down { 155 | return self.process_key_up(&event); 156 | } 157 | 158 | let keycode = match event.keycode() { 159 | Some(k) => k, 160 | None => return ProcessResult::PassThrough, 161 | }; 162 | 163 | match self.mode { 164 | VimMode::Insert => ProcessResult::PassThrough, 165 | VimMode::Normal => self.process_normal_mode(keycode, &event.modifiers), 166 | VimMode::Visual => self.process_visual_mode_with_modifiers(keycode, &event.modifiers), 167 | } 168 | } 169 | 170 | fn process_key_up(&self, event: &KeyEvent) -> ProcessResult { 171 | // In Insert mode, pass through all key up events 172 | if self.mode == VimMode::Insert { 173 | return ProcessResult::PassThrough; 174 | } 175 | 176 | // In Normal/Visual mode, suppress key up for keys we handle 177 | let keycode = match event.keycode() { 178 | Some(k) => k, 179 | None => return ProcessResult::PassThrough, 180 | }; 181 | 182 | // Check if this is a key we would handle (suppress its key up too) 183 | let should_suppress = matches!( 184 | keycode, 185 | KeyCode::H | KeyCode::J | KeyCode::K | KeyCode::L | 186 | KeyCode::W | KeyCode::E | KeyCode::B | 187 | KeyCode::Num0 | KeyCode::Num1 | KeyCode::Num2 | KeyCode::Num3 | 188 | KeyCode::Num4 | KeyCode::Num5 | KeyCode::Num6 | KeyCode::Num7 | 189 | KeyCode::Num8 | KeyCode::Num9 | KeyCode::G | KeyCode::R | 190 | KeyCode::D | KeyCode::Y | KeyCode::C | KeyCode::X | 191 | KeyCode::I | KeyCode::A | KeyCode::O | KeyCode::S | 192 | KeyCode::V | KeyCode::P | KeyCode::U | 193 | KeyCode::LeftBracket | KeyCode::RightBracket | 194 | KeyCode::Period | KeyCode::Comma 195 | ); 196 | 197 | if should_suppress { 198 | ProcessResult::Suppress 199 | } else { 200 | ProcessResult::PassThrough 201 | } 202 | } 203 | 204 | /// Handle vim key toggle (called externally from keyboard callback) 205 | pub fn handle_vim_key(&mut self) -> ProcessResult { 206 | match self.mode { 207 | VimMode::Insert => { 208 | self.set_mode(VimMode::Normal); 209 | ProcessResult::ModeChanged(VimMode::Normal, None) 210 | } 211 | VimMode::Normal | VimMode::Visual => { 212 | self.set_mode(VimMode::Insert); 213 | ProcessResult::ModeChanged(VimMode::Insert, None) 214 | } 215 | } 216 | } 217 | } 218 | 219 | impl Default for VimState { 220 | fn default() -> Self { 221 | Self::new().0 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | workflow_dispatch: 8 | 9 | jobs: 10 | # Build GUI app with Tauri (unsigned) 11 | build: 12 | permissions: 13 | contents: write 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - platform: 'macos-latest' 19 | args: '--target aarch64-apple-darwin' 20 | arch: 'aarch64' 21 | - platform: 'macos-latest' 22 | args: '--target x86_64-apple-darwin' 23 | arch: 'x64' 24 | 25 | runs-on: ${{ matrix.platform }} 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Rust setup 31 | uses: dtolnay/rust-toolchain@stable 32 | with: 33 | targets: aarch64-apple-darwin,x86_64-apple-darwin 34 | 35 | - name: Rust cache 36 | uses: swatinem/rust-cache@v2 37 | 38 | - name: Setup Node.js 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: lts/* 42 | 43 | - name: Setup pnpm 44 | uses: pnpm/action-setup@v4 45 | 46 | - name: Update version if release starts with 'v' 47 | shell: bash 48 | run: ./etc/update-version.sh "${{ github.ref_name }}" 49 | 50 | - name: Install frontend dependencies 51 | run: pnpm install 52 | 53 | - name: Build Tauri app (unsigned) 54 | run: pnpm tauri build ${{ matrix.args }} 55 | env: 56 | CI: true 57 | 58 | - name: Upload unsigned app bundle 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: app-bundle-${{ matrix.arch }} 62 | path: | 63 | src-tauri/target/*/release/bundle/macos/*.app 64 | retention-days: 1 65 | 66 | # Sign and notarize the app 67 | sign: 68 | needs: build 69 | permissions: 70 | contents: write 71 | strategy: 72 | fail-fast: false 73 | matrix: 74 | include: 75 | - arch: 'aarch64' 76 | target: 'aarch64-apple-darwin' 77 | - arch: 'x64' 78 | target: 'x86_64-apple-darwin' 79 | 80 | runs-on: macos-latest 81 | steps: 82 | - name: Checkout repository 83 | uses: actions/checkout@v4 84 | 85 | - name: Download unsigned app bundle 86 | uses: actions/download-artifact@v4 87 | with: 88 | name: app-bundle-${{ matrix.arch }} 89 | path: bundle 90 | 91 | - name: Find app bundle 92 | id: find-app 93 | run: | 94 | APP_PATH=$(find bundle -name "*.app" -type d | head -1) 95 | echo "app_path=$APP_PATH" >> $GITHUB_OUTPUT 96 | echo "Found app at: $APP_PATH" 97 | 98 | - name: Fix executable permissions 99 | run: | 100 | chmod +x "${{ steps.find-app.outputs.app_path }}/Contents/MacOS/"* 101 | 102 | - name: Import Code-Signing Certificates 103 | uses: Apple-Actions/import-codesign-certs@v2 104 | with: 105 | p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} 106 | p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 107 | 108 | - name: List signing identities 109 | run: | 110 | echo "=== Available signing identities ===" 111 | security find-identity -v -p codesigning 112 | echo "" 113 | echo "=== Looking for identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} ===" 114 | 115 | - name: Sign the app 116 | env: 117 | APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} 118 | run: | 119 | echo "Signing app at: ${{ steps.find-app.outputs.app_path }}" 120 | codesign --deep --force --options runtime \ 121 | --sign "$APPLE_SIGNING_IDENTITY" \ 122 | "${{ steps.find-app.outputs.app_path }}" 123 | 124 | echo "Verifying signature..." 125 | codesign --verify --deep --strict "${{ steps.find-app.outputs.app_path }}" 126 | 127 | - name: Notarize the app 128 | env: 129 | APPLE_ID: ${{ secrets.APPLE_ID }} 130 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} 131 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 132 | run: | 133 | # Create a zip for notarization 134 | ditto -c -k --keepParent "${{ steps.find-app.outputs.app_path }}" app.zip 135 | 136 | # Submit for notarization 137 | xcrun notarytool submit app.zip \ 138 | --apple-id "$APPLE_ID" \ 139 | --password "$APPLE_PASSWORD" \ 140 | --team-id "$APPLE_TEAM_ID" \ 141 | --wait 142 | 143 | # Staple the notarization ticket 144 | xcrun stapler staple "${{ steps.find-app.outputs.app_path }}" 145 | 146 | - name: Create DMG 147 | run: | 148 | # Install create-dmg 149 | brew install create-dmg 150 | 151 | # Create DMG 152 | create-dmg \ 153 | --volname "ovim" \ 154 | --window-pos 200 120 \ 155 | --window-size 600 400 \ 156 | --icon-size 100 \ 157 | --app-drop-link 425 178 \ 158 | --icon "ovim.app" 175 178 \ 159 | "ovim_${{ matrix.arch }}.dmg" \ 160 | "${{ steps.find-app.outputs.app_path }}" 161 | 162 | - name: Sign DMG 163 | env: 164 | APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} 165 | run: | 166 | codesign --force --sign "$APPLE_SIGNING_IDENTITY" "ovim_${{ matrix.arch }}.dmg" 167 | 168 | - name: Upload DMG to release 169 | env: 170 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 171 | run: | 172 | gh release upload "${{ github.ref_name }}" "ovim_${{ matrix.arch }}.dmg" --clobber --repo "${{ github.repository }}" 173 | 174 | # Update Homebrew tap with new SHA256 checksums 175 | update-homebrew: 176 | needs: [sign] 177 | runs-on: ubuntu-latest 178 | permissions: 179 | contents: read 180 | steps: 181 | - name: Download release artifacts and calculate checksums 182 | env: 183 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 184 | run: | 185 | # Download GUI artifacts (DMG) 186 | curl -sL "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ovim_aarch64.dmg" -o dmg-arm.dmg 187 | curl -sL "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ovim_x64.dmg" -o dmg-intel.dmg 188 | 189 | # Calculate GUI checksums 190 | echo "SHA256_DMG_ARM=$(sha256sum dmg-arm.dmg | cut -d' ' -f1)" >> $GITHUB_ENV 191 | echo "SHA256_DMG_INTEL=$(sha256sum dmg-intel.dmg | cut -d' ' -f1)" >> $GITHUB_ENV 192 | 193 | - name: Checkout homebrew-tap 194 | uses: actions/checkout@v4 195 | with: 196 | repository: tonisives/homebrew-tap 197 | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 198 | path: homebrew-tap 199 | 200 | - name: Update cask 201 | run: | 202 | cd homebrew-tap 203 | 204 | # Update Cask with arch-specific checksums 205 | awk -v arm="$SHA256_DMG_ARM" -v intel="$SHA256_DMG_INTEL" ' 206 | /^cask/ { in_cask=1 } 207 | /sha256.*:no_check/ && in_cask { 208 | print " sha256 arm: \"" arm "\", intel: \"" intel "\"" 209 | next 210 | } 211 | /sha256 arm:/ && in_cask { 212 | print " sha256 arm: \"" arm "\", intel: \"" intel "\"" 213 | next 214 | } 215 | { print } 216 | ' Casks/ovim.rb > Casks/ovim.rb.tmp && mv Casks/ovim.rb.tmp Casks/ovim.rb 217 | 218 | - name: Commit and push 219 | run: | 220 | cd homebrew-tap 221 | git config user.name "github-actions[bot]" 222 | git config user.email "github-actions[bot]@users.noreply.github.com" 223 | git add Casks/ovim.rb 224 | git diff --staged --quiet || git commit -m "Update ovim to ${{ github.ref_name }}" 225 | git push 226 | -------------------------------------------------------------------------------- /src-tauri/src/keyboard/keycode.rs: -------------------------------------------------------------------------------- 1 | /// macOS virtual keycodes 2 | /// Reference: https://developer.apple.com/documentation/carbon/1430449-virtual_key_codes 3 | 4 | /// Macro to define keycodes with all their properties in one place. 5 | /// Format: (Variant, raw_code, name, display_name, optional_char, optional_digit) 6 | macro_rules! define_keycodes { 7 | ( 8 | $( 9 | $variant:ident = ($code:expr, $name:expr, $display:expr $(, char: $char:expr)? $(, digit: $digit:expr)?) 10 | ),* $(,)? 11 | ) => { 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 13 | #[repr(u16)] 14 | #[allow(dead_code)] 15 | pub enum KeyCode { 16 | $($variant = $code),* 17 | } 18 | 19 | impl KeyCode { 20 | pub fn from_raw(code: u16) -> Option { 21 | match code { 22 | $($code => Some(Self::$variant),)* 23 | _ => None, 24 | } 25 | } 26 | 27 | pub fn as_raw(&self) -> u16 { 28 | *self as u16 29 | } 30 | 31 | /// Convert keycode to a snake_case string name (for settings storage) 32 | pub fn to_name(&self) -> &'static str { 33 | match self { 34 | $(Self::$variant => $name,)* 35 | } 36 | } 37 | 38 | /// Convert keycode to a human-readable display name 39 | pub fn to_display_name(&self) -> &'static str { 40 | match self { 41 | $(Self::$variant => $display,)* 42 | } 43 | } 44 | 45 | /// Parse a key name string to KeyCode 46 | pub fn from_name(name: &str) -> Option { 47 | match name.to_lowercase().as_str() { 48 | $($name => Some(Self::$variant),)* 49 | _ => None, 50 | } 51 | } 52 | 53 | /// Convert keycode to character (for r{char} replacement) 54 | pub fn to_char(self) -> Option { 55 | match self { 56 | $($(Self::$variant => Some($char),)?)* 57 | _ => None, 58 | } 59 | } 60 | 61 | /// Convert a numeric keycode to its digit value 62 | pub fn to_digit(self) -> Option { 63 | match self { 64 | $($(Self::$variant => Some($digit),)?)* 65 | _ => None, 66 | } 67 | } 68 | } 69 | }; 70 | } 71 | 72 | define_keycodes! { 73 | // Letters 74 | A = (0x00, "a", "A", char: 'a'), 75 | S = (0x01, "s", "S", char: 's'), 76 | D = (0x02, "d", "D", char: 'd'), 77 | F = (0x03, "f", "F", char: 'f'), 78 | H = (0x04, "h", "H", char: 'h'), 79 | G = (0x05, "g", "G", char: 'g'), 80 | Z = (0x06, "z", "Z", char: 'z'), 81 | X = (0x07, "x", "X", char: 'x'), 82 | C = (0x08, "c", "C", char: 'c'), 83 | V = (0x09, "v", "V", char: 'v'), 84 | B = (0x0B, "b", "B", char: 'b'), 85 | Q = (0x0C, "q", "Q", char: 'q'), 86 | W = (0x0D, "w", "W", char: 'w'), 87 | E = (0x0E, "e", "E", char: 'e'), 88 | R = (0x0F, "r", "R", char: 'r'), 89 | Y = (0x10, "y", "Y", char: 'y'), 90 | T = (0x11, "t", "T", char: 't'), 91 | O = (0x1F, "o", "O", char: 'o'), 92 | U = (0x20, "u", "U", char: 'u'), 93 | I = (0x22, "i", "I", char: 'i'), 94 | P = (0x23, "p", "P", char: 'p'), 95 | L = (0x25, "l", "L", char: 'l'), 96 | J = (0x26, "j", "J", char: 'j'), 97 | K = (0x28, "k", "K", char: 'k'), 98 | N = (0x2D, "n", "N", char: 'n'), 99 | M = (0x2E, "m", "M", char: 'm'), 100 | 101 | // Numbers 102 | Num1 = (0x12, "1", "1", char: '1', digit: 1), 103 | Num2 = (0x13, "2", "2", char: '2', digit: 2), 104 | Num3 = (0x14, "3", "3", char: '3', digit: 3), 105 | Num4 = (0x15, "4", "4", char: '4', digit: 4), 106 | Num5 = (0x17, "5", "5", char: '5', digit: 5), 107 | Num6 = (0x16, "6", "6", char: '6', digit: 6), 108 | Num7 = (0x1A, "7", "7", char: '7', digit: 7), 109 | Num8 = (0x1C, "8", "8", char: '8', digit: 8), 110 | Num9 = (0x19, "9", "9", char: '9', digit: 9), 111 | Num0 = (0x1D, "0", "0", char: '0', digit: 0), 112 | 113 | // Special keys 114 | Return = (0x24, "return", "Return"), 115 | Tab = (0x30, "tab", "Tab"), 116 | Space = (0x31, "space", "Space", char: ' '), 117 | Delete = (0x33, "delete", "Delete"), 118 | Escape = (0x35, "escape", "Escape"), 119 | Command = (0x37, "command", "Command"), 120 | Shift = (0x38, "shift", "Shift"), 121 | CapsLock = (0x39, "caps_lock", "Caps Lock"), 122 | Option = (0x3A, "option", "Option"), 123 | Control = (0x3B, "control", "Control"), 124 | RightShift = (0x3C, "right_shift", "Right Shift"), 125 | RightOption = (0x3D, "right_option", "Right Option"), 126 | RightControl = (0x3E, "right_control", "Right Control"), 127 | Function = (0x3F, "function", "Function"), 128 | 129 | // Arrow keys 130 | Left = (0x7B, "left", "Left Arrow"), 131 | Right = (0x7C, "right", "Right Arrow"), 132 | Down = (0x7D, "down", "Down Arrow"), 133 | Up = (0x7E, "up", "Up Arrow"), 134 | 135 | // Function keys 136 | F1 = (0x7A, "f1", "F1"), 137 | F2 = (0x78, "f2", "F2"), 138 | F3 = (0x63, "f3", "F3"), 139 | F4 = (0x76, "f4", "F4"), 140 | F5 = (0x60, "f5", "F5"), 141 | F6 = (0x61, "f6", "F6"), 142 | F7 = (0x62, "f7", "F7"), 143 | F8 = (0x64, "f8", "F8"), 144 | F9 = (0x65, "f9", "F9"), 145 | F10 = (0x6D, "f10", "F10"), 146 | F11 = (0x67, "f11", "F11"), 147 | F12 = (0x6F, "f12", "F12"), 148 | 149 | // Navigation 150 | Home = (0x73, "home", "Home"), 151 | End = (0x77, "end", "End"), 152 | PageUp = (0x74, "page_up", "Page Up"), 153 | PageDown = (0x79, "page_down", "Page Down"), 154 | ForwardDelete = (0x75, "forward_delete", "Forward Delete"), 155 | 156 | // Punctuation 157 | Equal = (0x18, "equal", "="), 158 | Minus = (0x1B, "minus", "-"), 159 | LeftBracket = (0x21, "left_bracket", "["), 160 | RightBracket = (0x1E, "right_bracket", "]"), 161 | Quote = (0x27, "quote", "'"), 162 | Semicolon = (0x29, "semicolon", ";"), 163 | Backslash = (0x2A, "backslash", "\\"), 164 | Comma = (0x2B, "comma", ","), 165 | Slash = (0x2C, "slash", "/"), 166 | Period = (0x2F, "period", "."), 167 | Grave = (0x32, "grave", "`"), 168 | } 169 | 170 | /// Modifier flags matching CGEventFlags 171 | #[derive(Debug, Clone, Copy, Default)] 172 | pub struct Modifiers { 173 | pub shift: bool, 174 | pub control: bool, 175 | pub option: bool, 176 | pub command: bool, 177 | pub caps_lock: bool, 178 | } 179 | 180 | impl Modifiers { 181 | const SHIFT_MASK: u64 = 0x00020000; 182 | const CONTROL_MASK: u64 = 0x00040000; 183 | const OPTION_MASK: u64 = 0x00080000; 184 | const COMMAND_MASK: u64 = 0x00100000; 185 | const CAPS_LOCK_MASK: u64 = 0x00010000; 186 | 187 | pub fn from_cg_flags(flags: u64) -> Self { 188 | Self { 189 | shift: flags & Self::SHIFT_MASK != 0, 190 | control: flags & Self::CONTROL_MASK != 0, 191 | option: flags & Self::OPTION_MASK != 0, 192 | command: flags & Self::COMMAND_MASK != 0, 193 | caps_lock: flags & Self::CAPS_LOCK_MASK != 0, 194 | } 195 | } 196 | 197 | pub fn to_cg_flags(self) -> u64 { 198 | let mut flags = 0u64; 199 | if self.shift { 200 | flags |= Self::SHIFT_MASK; 201 | } 202 | if self.control { 203 | flags |= Self::CONTROL_MASK; 204 | } 205 | if self.option { 206 | flags |= Self::OPTION_MASK; 207 | } 208 | if self.command { 209 | flags |= Self::COMMAND_MASK; 210 | } 211 | if self.caps_lock { 212 | flags |= Self::CAPS_LOCK_MASK; 213 | } 214 | flags 215 | } 216 | } 217 | 218 | /// A key event with code and modifiers 219 | #[derive(Debug, Clone, Copy)] 220 | pub struct KeyEvent { 221 | pub code: u16, 222 | pub modifiers: Modifiers, 223 | pub is_key_down: bool, 224 | } 225 | 226 | impl KeyEvent { 227 | pub fn keycode(&self) -> Option { 228 | KeyCode::from_raw(self.code) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src-tauri/src/keyboard/capture.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{AtomicBool, Ordering}; 2 | use std::sync::{Arc, Mutex}; 3 | use std::thread; 4 | use std::time::Duration; 5 | 6 | use core_foundation::runloop::{kCFRunLoopDefaultMode, CFRunLoop}; 7 | use core_graphics::event::{ 8 | CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, 9 | CGEventTapProxy, CGEventType, EventField, CallbackResult, 10 | }; 11 | 12 | use super::inject::INJECTED_EVENT_MARKER; 13 | use super::keycode::{KeyEvent, Modifiers}; 14 | 15 | pub type KeyEventCallback = Box Option + Send + 'static>; 16 | 17 | /// Helper to compare CGEventType (which doesn't implement PartialEq) 18 | fn is_event_type(event_type: CGEventType, expected: CGEventType) -> bool { 19 | (event_type as u32) == (expected as u32) 20 | } 21 | 22 | /// Keyboard capture using CGEventTap 23 | pub struct KeyboardCapture { 24 | callback: Arc>>, 25 | running: Arc>, 26 | } 27 | 28 | impl KeyboardCapture { 29 | pub fn new() -> Self { 30 | Self { 31 | callback: Arc::new(Mutex::new(None)), 32 | running: Arc::new(Mutex::new(false)), 33 | } 34 | } 35 | 36 | /// Set the callback for key events 37 | /// Return Some(event) to pass through (possibly modified) 38 | /// Return None to suppress the event 39 | pub fn set_callback(&self, callback: F) 40 | where 41 | F: Fn(KeyEvent) -> Option + Send + 'static, 42 | { 43 | let mut cb = self.callback.lock().unwrap(); 44 | *cb = Some(Box::new(callback)); 45 | } 46 | 47 | /// Start capturing keyboard events 48 | /// This spawns a new thread with its own run loop 49 | pub fn start(&self) -> Result<(), String> { 50 | let mut running = self.running.lock().unwrap(); 51 | if *running { 52 | return Ok(()); 53 | } 54 | *running = true; 55 | drop(running); 56 | 57 | let callback = Arc::clone(&self.callback); 58 | let running_flag = Arc::clone(&self.running); 59 | 60 | // Flag to signal that tap needs re-enabling 61 | let needs_reenable = Arc::new(AtomicBool::new(false)); 62 | let needs_reenable_for_callback = Arc::clone(&needs_reenable); 63 | 64 | thread::spawn(move || { 65 | // Create the event tap - use HID tap location for reliable key suppression 66 | let tap = CGEventTap::new( 67 | CGEventTapLocation::HID, 68 | CGEventTapPlacement::HeadInsertEventTap, 69 | CGEventTapOptions::Default, 70 | vec![ 71 | CGEventType::KeyDown, 72 | CGEventType::KeyUp, 73 | CGEventType::FlagsChanged, 74 | ], 75 | move |_proxy: CGEventTapProxy, event_type: CGEventType, event| -> CallbackResult { 76 | // Handle tap disabled by timeout - signal re-enable 77 | if is_event_type(event_type, CGEventType::TapDisabledByTimeout) { 78 | log::warn!("CGEventTap was disabled by timeout, signaling re-enable..."); 79 | needs_reenable_for_callback.store(true, Ordering::SeqCst); 80 | return CallbackResult::Keep; 81 | } 82 | 83 | // Handle tap disabled by user - also re-enable 84 | if is_event_type(event_type, CGEventType::TapDisabledByUserInput) { 85 | log::warn!("CGEventTap was disabled by user input, signaling re-enable..."); 86 | needs_reenable_for_callback.store(true, Ordering::SeqCst); 87 | return CallbackResult::Keep; 88 | } 89 | 90 | // Skip events we injected ourselves 91 | let user_data = event.get_integer_value_field(EventField::EVENT_SOURCE_USER_DATA); 92 | if user_data == INJECTED_EVENT_MARKER { 93 | log::trace!("Skipping injected event"); 94 | return CallbackResult::Keep; 95 | } 96 | 97 | // Skip FlagsChanged events (modifier key changes) - pass through 98 | if is_event_type(event_type, CGEventType::FlagsChanged) { 99 | return CallbackResult::Keep; 100 | } 101 | 102 | // Get key code and flags 103 | let keycode = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16; 104 | log::trace!("Key event: keycode={}, type={:?}", keycode, event_type); 105 | let flags = event.get_flags(); 106 | let is_key_down = is_event_type(event_type, CGEventType::KeyDown); 107 | 108 | let key_event = KeyEvent { 109 | code: keycode, 110 | modifiers: Modifiers::from_cg_flags(flags.bits()), 111 | is_key_down, 112 | }; 113 | 114 | // Call user callback 115 | let cb_lock = callback.lock().unwrap(); 116 | if let Some(ref cb) = *cb_lock { 117 | match cb(key_event) { 118 | Some(_modified_event) => { 119 | // Pass through 120 | log::trace!("capture: passing through keycode={}", keycode); 121 | CallbackResult::Keep 122 | } 123 | None => { 124 | // Suppress the event - use Drop to return null_ptr 125 | log::trace!("capture: SUPPRESSING keycode={}", keycode); 126 | CallbackResult::Drop 127 | } 128 | } 129 | } else { 130 | // No callback set, pass through 131 | log::trace!("capture: no callback, passing through keycode={}", keycode); 132 | CallbackResult::Keep 133 | } 134 | }, 135 | ); 136 | 137 | match tap { 138 | Ok(tap) => { 139 | // Create run loop source and add to run loop 140 | let loop_source = tap 141 | .mach_port() 142 | .create_runloop_source(0) 143 | .expect("Failed to create run loop source"); 144 | 145 | let run_loop = CFRunLoop::get_current(); 146 | unsafe { 147 | run_loop.add_source(&loop_source, kCFRunLoopDefaultMode); 148 | } 149 | 150 | // Enable the tap 151 | tap.enable(); 152 | 153 | log::info!("CGEventTap started successfully"); 154 | 155 | // Run the loop 156 | while *running_flag.lock().unwrap() { 157 | // Check if tap needs re-enabling 158 | if needs_reenable.swap(false, Ordering::SeqCst) { 159 | log::info!("Re-enabling CGEventTap..."); 160 | tap.enable(); 161 | } 162 | 163 | CFRunLoop::run_in_mode( 164 | unsafe { kCFRunLoopDefaultMode }, 165 | Duration::from_millis(100), 166 | false, 167 | ); 168 | } 169 | 170 | log::info!("CGEventTap stopped"); 171 | } 172 | Err(()) => { 173 | log::error!( 174 | "Failed to create CGEventTap. Make sure Input Monitoring permission is granted." 175 | ); 176 | *running_flag.lock().unwrap() = false; 177 | } 178 | } 179 | }); 180 | 181 | Ok(()) 182 | } 183 | 184 | /// Stop capturing keyboard events 185 | pub fn stop(&self) { 186 | let mut running = self.running.lock().unwrap(); 187 | *running = false; 188 | } 189 | 190 | /// Check if currently capturing 191 | pub fn is_running(&self) -> bool { 192 | *self.running.lock().unwrap() 193 | } 194 | } 195 | 196 | impl Default for KeyboardCapture { 197 | fn default() -> Self { 198 | Self::new() 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src-tauri/src/keyboard_handler.rs: -------------------------------------------------------------------------------- 1 | //! Keyboard event handler for vim mode processing 2 | 3 | use std::sync::{Arc, Mutex}; 4 | use std::thread; 5 | 6 | use crate::commands::{RecordedKey, RecordedModifiers}; 7 | use crate::config::Settings; 8 | use crate::keyboard::{KeyCode, KeyEvent}; 9 | use crate::nvim_edit::{self, EditSessionManager}; 10 | use crate::vim::{ProcessResult, VimAction, VimMode, VimState}; 11 | 12 | #[cfg(target_os = "macos")] 13 | use objc::{class, msg_send, sel, sel_impl}; 14 | 15 | /// Execute a VimAction on a separate thread with a small delay 16 | fn execute_action_async(action: VimAction) { 17 | thread::spawn(move || { 18 | thread::sleep(std::time::Duration::from_micros(500)); 19 | if let Err(e) = action.execute() { 20 | log::error!("Failed to execute vim action: {}", e); 21 | } 22 | }); 23 | } 24 | 25 | /// Get the bundle identifier of the frontmost (currently focused) application 26 | #[cfg(target_os = "macos")] 27 | fn get_frontmost_app_bundle_id() -> Option { 28 | unsafe { 29 | let workspace: *mut objc::runtime::Object = 30 | msg_send![class!(NSWorkspace), sharedWorkspace]; 31 | if workspace.is_null() { 32 | return None; 33 | } 34 | let app: *mut objc::runtime::Object = msg_send![workspace, frontmostApplication]; 35 | if app.is_null() { 36 | return None; 37 | } 38 | let bundle_id: *mut objc::runtime::Object = msg_send![app, bundleIdentifier]; 39 | if bundle_id.is_null() { 40 | return None; 41 | } 42 | let utf8: *const std::os::raw::c_char = msg_send![bundle_id, UTF8String]; 43 | if utf8.is_null() { 44 | return None; 45 | } 46 | Some( 47 | std::ffi::CStr::from_ptr(utf8) 48 | .to_string_lossy() 49 | .into_owned(), 50 | ) 51 | } 52 | } 53 | 54 | /// Check if the frontmost app is in the ignored apps list. 55 | fn is_frontmost_app_ignored(ignored_apps: &[String]) -> bool { 56 | if ignored_apps.is_empty() { 57 | return false; 58 | } 59 | #[cfg(target_os = "macos")] 60 | { 61 | if let Some(bundle_id) = get_frontmost_app_bundle_id() { 62 | return ignored_apps.iter().any(|id| id == &bundle_id); 63 | } 64 | } 65 | false 66 | } 67 | 68 | /// Create the keyboard callback that processes key events 69 | pub fn create_keyboard_callback( 70 | vim_state: Arc>, 71 | settings: Arc>, 72 | record_key_tx: Arc>>>, 73 | edit_session_manager: Arc, 74 | ) -> impl Fn(KeyEvent) -> Option + Send + 'static { 75 | move |event| { 76 | // Check if we're recording a key (only on key down) 77 | if event.is_key_down { 78 | let mut record_tx = record_key_tx.lock().unwrap(); 79 | if let Some(tx) = record_tx.take() { 80 | if let Some(keycode) = event.keycode() { 81 | let recorded = RecordedKey { 82 | name: keycode.to_name().to_string(), 83 | display_name: keycode.to_display_name().to_string(), 84 | modifiers: RecordedModifiers { 85 | shift: event.modifiers.shift, 86 | control: event.modifiers.control, 87 | option: event.modifiers.option, 88 | command: event.modifiers.command, 89 | }, 90 | }; 91 | let _ = tx.send(recorded); 92 | return None; 93 | } 94 | } 95 | } 96 | 97 | // Check if this is the configured nvim edit shortcut 98 | if event.is_key_down { 99 | let settings_guard = settings.lock().unwrap(); 100 | let nvim_settings = &settings_guard.nvim_edit; 101 | 102 | if nvim_settings.enabled { 103 | let nvim_key = KeyCode::from_name(&nvim_settings.shortcut_key); 104 | let mods = &nvim_settings.shortcut_modifiers; 105 | 106 | let modifiers_match = event.modifiers.shift == mods.shift 107 | && event.modifiers.control == mods.control 108 | && event.modifiers.option == mods.option 109 | && event.modifiers.command == mods.command; 110 | 111 | if let Some(configured_key) = nvim_key { 112 | if event.keycode() == Some(configured_key) && modifiers_match { 113 | let nvim_settings_clone = nvim_settings.clone(); 114 | drop(settings_guard); 115 | 116 | let manager = Arc::clone(&edit_session_manager); 117 | thread::spawn(move || { 118 | if let Err(e) = 119 | nvim_edit::trigger_nvim_edit(manager, nvim_settings_clone) 120 | { 121 | log::error!("Failed to trigger nvim edit: {}", e); 122 | } 123 | }); 124 | 125 | return None; 126 | } 127 | } 128 | } 129 | } 130 | 131 | // Check if this is the configured vim key with matching modifiers 132 | if event.is_key_down { 133 | let settings_guard = settings.lock().unwrap(); 134 | 135 | if !settings_guard.enabled { 136 | return Some(event); 137 | } 138 | 139 | let vim_key = KeyCode::from_name(&settings_guard.vim_key); 140 | let mods = &settings_guard.vim_key_modifiers; 141 | 142 | let modifiers_match = event.modifiers.shift == mods.shift 143 | && event.modifiers.control == mods.control 144 | && event.modifiers.option == mods.option 145 | && event.modifiers.command == mods.command; 146 | 147 | if let Some(configured_key) = vim_key { 148 | if event.keycode() == Some(configured_key) && modifiers_match { 149 | let ignored_apps = settings_guard.ignored_apps.clone(); 150 | drop(settings_guard); 151 | 152 | let current_mode = vim_state.lock().unwrap().mode(); 153 | if current_mode == VimMode::Insert { 154 | if is_frontmost_app_ignored(&ignored_apps) { 155 | log::debug!("Vim key: ignored app, passing through"); 156 | return Some(event); 157 | } 158 | } 159 | 160 | let result = { 161 | let mut state = vim_state.lock().unwrap(); 162 | state.handle_vim_key() 163 | }; 164 | 165 | return match result { 166 | ProcessResult::ModeChanged(_mode, action) => { 167 | log::debug!("Vim key: ModeChanged"); 168 | if let Some(action) = action { 169 | execute_action_async(action); 170 | } 171 | None 172 | } 173 | _ => None, 174 | }; 175 | } 176 | } 177 | } 178 | 179 | // Check if vim mode is disabled for non-key-down events 180 | { 181 | let settings_guard = settings.lock().unwrap(); 182 | if !settings_guard.enabled { 183 | return Some(event); 184 | } 185 | } 186 | 187 | let result = { 188 | let mut state = vim_state.lock().unwrap(); 189 | state.process_key(event) 190 | }; 191 | 192 | match result { 193 | ProcessResult::Suppress => { 194 | log::debug!("Suppress: keycode={}", event.code); 195 | None 196 | } 197 | ProcessResult::SuppressWithAction(ref action) => { 198 | log::debug!( 199 | "SuppressWithAction: keycode={}, action={:?}", 200 | event.code, 201 | action 202 | ); 203 | execute_action_async(action.clone()); 204 | None 205 | } 206 | ProcessResult::PassThrough => { 207 | log::debug!("PassThrough: keycode={}", event.code); 208 | Some(event) 209 | } 210 | ProcessResult::ModeChanged(_mode, action) => { 211 | log::debug!("ModeChanged: keycode={}", event.code); 212 | if let Some(action) = action { 213 | execute_action_async(action); 214 | } 215 | None 216 | } 217 | } 218 | } 219 | } 220 | --------------------------------------------------------------------------------