├── 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 |
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 |
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 | |    |  |
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 | 
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 |
--------------------------------------------------------------------------------