Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.0] — 2026-04-18

Stage 8 — **UI Automation integration**. The long-standing "smart-copy"
limitation from 0.2.0 is gone for apps that expose a UIA `TextPattern`
(Notepad on Windows 11, WordPad, Word, Chrome/Edge web inputs, many
Electron apps). The "convert selection" hotkey now sees the *actual*
selection state via UIA, so an unselected current line can no longer
be misinterpreted as a selection. Word conversion also got smarter:
"word" is now a whitespace-bounded run everywhere, which means
`прибор ↔ ghb,jh` and similar punctuation-crossing cycles finally
round-trip correctly.

### Added
- **New `[general] use_uia` toggle in settings**, labelled
"Use UI Automation for reading selections". Default on. Caption:
"(uncheck if switching behaves oddly in some program)". Disabling
it reverts to the 0.2.0 clipboard-based flow globally.
- **"About..." menu entry** (added in 0.2.0 branch, finalised in 0.3.0)
— shows version, build timestamp, copyright, and offers to open the
repository on GitHub via `ShellExecuteW`.
- **UI Automation path** (`src/uia.rs`) — thread-local COM init, safe
wrappers for `IUIAutomation`, `IUIAutomationElement`,
`IUIAutomationTextPattern`, `IUIAutomationTextRange`.
- **Clickable repo URL** in About via `MB_YESNO` + `ShellExecuteW`.
- **GitHub Actions CI** (`cargo fmt --check`, `cargo clippy -D warnings`,
`cargo build --release` on a Windows runner) and a **release
workflow** that builds and publishes the EXE on any `v*` tag.
- **LICENSE** (MIT) and **CONTRIBUTING.md** for the public-release
preparation.

### Changed
- **Word detection is now whitespace-delimited, not punctuation-delimited.**
UIA's `TextUnit_Word` splits at commas/periods; in Notepad+Win11 and
Word it also includes the trailing space. Both cause cyclic
word-conversion to misbehave. We replace that with: expand to
`TextUnit_Line`, locate the caret column via prefix-range text, walk
outward in Rust until whitespace, then shrink the line range to
exactly that span. The clipboard fallback (Notepad++ and other apps
without UIA) does the same trick with `Shift+Home` + `Shift+Right ×
N`. Result: `прибор → ghb,jh → прибор` cycles correctly everywhere.
- **`perform_conversion`** tries UIA's `GetSelection` first, falls back
to clipboard+`Ctrl+C` when UIA isn't available or returns nothing.
- **`perform_word_conversion`** tries UIA's line-based whitespace
expansion first, falls back to `Shift+Home`-based selection in the
clipboard path.

### Infrastructure
- Repository published to GitHub: https://github.com/zergius-eggstream/lightswitch
- Branch ruleset on `main`: block deletion, block force-push. Admin bypass.
- Existing 0.1.0 and 0.2.0 releases attached to their tags with binaries.

## [0.2.0] — 2026-04-18

A significant rewrite of the core conversion engine plus two new user-visible
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lightswitch"
version = "0.2.0"
version = "0.3.0"
edition = "2024"
description = "Lightweight keyboard layout switcher with text conversion for Windows"
license = "MIT"
Expand Down Expand Up @@ -28,6 +28,8 @@ features = [
"Win32_UI_TextServices", # GetForegroundWindow thread layout
"Win32_UI_Controls", # DRAWITEMSTRUCT, ODS_FOCUS
"Win32_UI_Controls_Dialogs", # ChooseColorW
"Win32_UI_Accessibility", # IUIAutomation / TextPattern (Stage 8)
"Win32_System_Com", # CoInitializeEx / CoUninitialize / CoCreateInstance
]

[dependencies]
Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ pub struct GeneralConfig {
pub autostart: bool,
#[serde(default = "default_autostart_scope")]
pub autostart_scope: String,
/// If true, try reading the user's selection via UI Automation first,
/// falling back to the old clipboard+Ctrl+C path when UIA isn't available.
/// Disable if a particular program misbehaves with UIA-based reads.
#[serde(default = "default_use_uia")]
pub use_uia: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -41,6 +46,10 @@ fn default_autostart_scope() -> String {
"user".to_string()
}

fn default_use_uia() -> bool {
true
}

fn default_conversion_hotkey() -> String {
"Pause".to_string()
}
Expand All @@ -54,6 +63,7 @@ impl Default for GeneralConfig {
Self {
autostart: false,
autostart_scope: default_autostart_scope(),
use_uia: default_use_uia(),
}
}
}
Expand Down
119 changes: 91 additions & 28 deletions src/conversion.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::layouts::{self, HklId};
use crate::{clipboard, input, log, tables};
use crate::{clipboard, input, log, tables, uia};

/// Attempts cyclic conversion: detects source layout from text,
/// falls back to current keyboard layout for ambiguous text.
Expand Down Expand Up @@ -39,26 +39,42 @@ pub fn convert_cyclic(
/// of the current line. Workaround: always make a real selection. Proper
/// fix planned via UI Automation (Stage 8).
pub fn perform_conversion() {
let saved_clipboard = clipboard::get_text();
clipboard::set_text("");
std::thread::sleep(std::time::Duration::from_millis(30));
// 1. Try UIA first — it reports the real selection without touching the
// clipboard, which avoids the "smart copy" bug in Notepad Win11, VS
// Code, and other modern editors.
let uia_text = uia::get_selected_text();
if let Some(text) = &uia_text {
log!("[uia] read selection: {} chars", text.len());
}

input::send_copy();
std::thread::sleep(std::time::Duration::from_millis(80));
let saved_clipboard = clipboard::get_text();

let text = clipboard::get_text().unwrap_or_default();
if text.is_empty() {
restore_clipboard(saved_clipboard);
return;
}
let text = match uia_text {
Some(t) => t,
None => {
// 2. Fallback: clipboard + Ctrl+C. This hits the smart-copy
// issue in editors that copy the current line on empty
// selection — documented limitation.
clipboard::set_text("");
std::thread::sleep(std::time::Duration::from_millis(30));
input::send_copy();
std::thread::sleep(std::time::Duration::from_millis(80));
let t = clipboard::get_text().unwrap_or_default();
if t.is_empty() {
restore_clipboard(saved_clipboard);
return;
}
t
}
};

let Some(converted_len) = convert_and_paste(&text) else {
restore_clipboard(saved_clipboard);
return;
};

// Selection-based conversion: re-select the pasted text so the user can
// press the hotkey again to cycle through layouts without re-selecting.
// Re-select the pasted text so the user can cycle through layouts
// with repeated hotkey presses.
input::send_select_n_left(converted_len);
std::thread::sleep(std::time::Duration::from_millis(30));

Expand All @@ -69,24 +85,71 @@ pub fn perform_conversion() {
/// Performs single-word conversion: selects the word to the left of the cursor
/// (Ctrl+Shift+Left), converts it, pastes back.
pub fn perform_word_conversion() {
let saved_clipboard = clipboard::get_text();
clipboard::set_text("");
std::thread::sleep(std::time::Duration::from_millis(30));

input::send_select_word_left();
std::thread::sleep(std::time::Duration::from_millis(50));
// UIA path: expand around the caret to a Word unit, Select() it.
// After this the selection is the word; the rest of the flow is the
// same as the selection-based conversion — we just don't re-select
// at the end, so the cursor lands after the pasted text.
let uia_text = uia::select_word_at_caret();
if let Some(text) = &uia_text {
log!("[uia] selected word: {} chars", text.len());
}

input::send_copy();
std::thread::sleep(std::time::Duration::from_millis(80));
let saved_clipboard = clipboard::get_text();

let text = clipboard::get_text().unwrap_or_default();
if text.is_empty() {
restore_clipboard(saved_clipboard);
return;
}
let text = match uia_text {
Some(t) => t,
None => {
// Fallback for apps without UIA TextPattern (e.g. Notepad++).
// Select from cursor to line start, find the last whitespace
// from the right, then shrink the selection from the left so
// only the contiguous non-whitespace run ending at the cursor
// remains selected. This mirrors the UIA path's definition of
// "word" (maximum non-whitespace run) rather than the OS's
// Ctrl+Shift+Left, which breaks at punctuation.
clipboard::set_text("");
std::thread::sleep(std::time::Duration::from_millis(30));

input::send_select_to_line_start();
std::thread::sleep(std::time::Duration::from_millis(50));
input::send_copy();
std::thread::sleep(std::time::Duration::from_millis(80));

let selected = clipboard::get_text().unwrap_or_default();
if selected.is_empty() {
restore_clipboard(saved_clipboard);
return;
}

let chars: Vec<char> = selected.chars().collect();
let mut word_start = chars.len();
while word_start > 0 && !chars[word_start - 1].is_whitespace() {
word_start -= 1;
}
if word_start == chars.len() {
// Caret sits on whitespace or at line start — nothing to do.
restore_clipboard(saved_clipboard);
return;
}

let word: String = chars[word_start..].iter().collect();
log!(
"[fallback] word: {} chars (shrinking {} from left)",
word.len(),
word_start
);

// Shrink selection from left so only the word remains selected.
// This matters for the subsequent Ctrl+V: it replaces the
// selected text, and we want it to replace exactly the word.
input::send_select_n_right(word_start);
std::thread::sleep(std::time::Duration::from_millis(30));

word
}
};

// Word conversion doesn't re-select the pasted text — cursor stays at the
// end, matching the pre-conversion state (no selection was active before).
// Word conversion doesn't re-select the pasted text — cursor stays at
// the end, matching the pre-conversion state.
if convert_and_paste(&text).is_none() {
restore_clipboard(saved_clipboard);
return;
Expand Down
79 changes: 55 additions & 24 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,61 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
VK_INSERT, VK_LEFT, VK_NEXT, VK_PRIOR, VK_RIGHT, VK_SHIFT, VK_UP, VK_V,
};

/// Sends Shift+Home — selects from cursor to line start. Used by the
/// fallback word-conversion path when UIA isn't available.
pub fn send_select_to_line_start() {
let shift_held = hooks::user_holds_shift();
let ctrl_held = hooks::user_holds_ctrl();

let mut inputs: Vec<INPUT> = Vec::with_capacity(6);
// Ctrl+Shift+Home jumps to document start; we only want line start.
if ctrl_held {
inputs.push(key_up(VK_CONTROL));
}
if !shift_held {
inputs.push(key_down(VK_SHIFT));
}
inputs.push(key_down(VK_HOME));
inputs.push(key_up(VK_HOME));
if !shift_held {
inputs.push(key_up(VK_SHIFT));
}
if ctrl_held {
inputs.push(key_down(VK_CONTROL));
}
dispatch(&inputs);
}

/// Sends Shift+Right × count. When the current selection anchor is on the
/// right (as after Shift+Home or Shift+Left), this shrinks the selection
/// from the left by `count` characters.
pub fn send_select_n_right(count: usize) {
if count == 0 {
return;
}
let ctrl_held = hooks::user_holds_ctrl();
let shift_held = hooks::user_holds_shift();

let mut inputs: Vec<INPUT> = Vec::with_capacity(count * 2 + 4);
if ctrl_held {
inputs.push(key_up(VK_CONTROL));
}
if !shift_held {
inputs.push(key_down(VK_SHIFT));
}
for _ in 0..count {
inputs.push(key_down(VK_RIGHT));
inputs.push(key_up(VK_RIGHT));
}
if !shift_held {
inputs.push(key_up(VK_SHIFT));
}
if ctrl_held {
inputs.push(key_down(VK_CONTROL));
}
dispatch(&inputs);
}

/// Returns true if the given virtual key is an "extended key" in Win32 terms.
/// Extended keys (arrows, navigation) need the KEYEVENTF_EXTENDEDKEY flag
/// in SendInput, otherwise they get interpreted as numpad equivalents.
Expand Down Expand Up @@ -98,30 +153,6 @@ pub fn send_paste() {
send_ctrl_key(VK_V);
}

/// Simulates Ctrl+Shift+Left — selects the word to the left of the cursor.
/// Preserves the user's physical Ctrl/Shift state.
pub fn send_select_word_left() {
let ctrl_held = hooks::user_holds_ctrl();
let shift_held = hooks::user_holds_shift();

let mut inputs: Vec<INPUT> = Vec::with_capacity(6);
if !ctrl_held {
inputs.push(key_down(VK_CONTROL));
}
if !shift_held {
inputs.push(key_down(VK_SHIFT));
}
inputs.push(key_down(VK_LEFT));
inputs.push(key_up(VK_LEFT));
if !shift_held {
inputs.push(key_up(VK_SHIFT));
}
if !ctrl_held {
inputs.push(key_up(VK_CONTROL));
}
dispatch(&inputs);
}

/// Selects N characters to the left of the cursor by sending Shift+Left × N.
/// Temporarily releases Ctrl if held — otherwise Shift+Left with Ctrl held
/// becomes Ctrl+Shift+Left (word-step), not what we want here.
Expand Down
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod layouts;
mod logger;
mod tables;
mod ui;
mod uia;

use config::Config;
use std::sync::Mutex;
Expand Down Expand Up @@ -70,6 +71,14 @@ fn main() {
hotkeys::set_bindings(bindings);
colors::set_overrides(config.to_color_overrides());

// Initialize UIA on the main thread (required for apartment threading).
// The setting is applied even if init fails — helpers short-circuit when
// `is_enabled()` is false or no automation object was created.
uia::set_enabled(config.general.use_uia);
if config.general.use_uia {
uia::init();
}

match hooks::install_hook() {
Ok(_) => log!("Keyboard hook installed"),
Err(e) => log!("Failed to install hook: {}", e),
Expand Down
Loading
Loading