diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c12a3..dc055b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 3ea0b1a..56aca24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,7 +26,7 @@ dependencies = [ [[package]] name = "lightswitch" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "toml 0.8.23", diff --git a/Cargo.toml b/Cargo.toml index 35578d1..41d10f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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] diff --git a/src/config.rs b/src/config.rs index 10f424f..26bd4dc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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)] @@ -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() } @@ -54,6 +63,7 @@ impl Default for GeneralConfig { Self { autostart: false, autostart_scope: default_autostart_scope(), + use_uia: default_use_uia(), } } } diff --git a/src/conversion.rs b/src/conversion.rs index b2b8670..4265db4 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -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. @@ -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)); @@ -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 = 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; diff --git a/src/input.rs b/src/input.rs index e2ec5ae..999f6ed 100644 --- a/src/input.rs +++ b/src/input.rs @@ -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 = 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 = 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. @@ -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 = 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. diff --git a/src/main.rs b/src/main.rs index bfc0d5d..fc0384e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod layouts; mod logger; mod tables; mod ui; +mod uia; use config::Config; use std::sync::Mutex; @@ -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), diff --git a/src/ui.rs b/src/ui.rs index 7ce5cf1..7c2010e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -19,6 +19,7 @@ use windows::core::w; const ID_SAVE: u16 = 2001; const ID_CANCEL: u16 = 2002; const ID_AUTOSTART: u16 = 2003; +const ID_USE_UIA: u16 = 2004; const ID_CONVERSION_HOTKEY: u16 = 2100; const ID_WORD_HOTKEY: u16 = 2101; const ID_LAYOUT_HOTKEY_BASE: u16 = 3000; @@ -82,7 +83,9 @@ fn create_settings_window() { RegisterClassW(&wc); let num_layouts = installed.len(); - let window_height = 190 + (num_layouts as i32 * 30) + 60; + // Base = 190 (title + conversion section) + 60 (autostart + buttons) + // + 46 (UIA checkbox + caption) + per-layout rows. + let window_height = 190 + (num_layouts as i32 * 30) + 60 + 46; let hwnd = CreateWindowExW( Default::default(), @@ -175,7 +178,30 @@ fn create_settings_window() { config.general.autostart, font.0, ); - y += 35; + y += 25; + + create_checkbox( + hwnd, + "Use UI Automation for reading selections", + 20, + y, + 330, + 20, + ID_USE_UIA, + config.general.use_uia, + font.0, + ); + y += 18; + create_label( + hwnd, + "(uncheck if switching behaves oddly in some program)", + 20, + y, + 370, + 16, + font.0, + ); + y += 28; create_button(hwnd, "Save", 200, y, 90, 28, ID_SAVE, font.0); create_button(hwnd, "Cancel", 300, y, 90, 28, ID_CANCEL, font.0); @@ -815,6 +841,10 @@ fn save_settings(hwnd: HWND) { let checked = unsafe { SendMessageW(autostart_hwnd, BM_GETCHECK_MSG, None, None) }; s.config.general.autostart = checked.0 == BST_CHECKED_VAL as isize; + let use_uia_hwnd = unsafe { GetDlgItem(Some(hwnd), ID_USE_UIA as i32) }.unwrap(); + let use_uia_checked = unsafe { SendMessageW(use_uia_hwnd, BM_GETCHECK_MSG, None, None) }; + s.config.general.use_uia = use_uia_checked.0 == BST_CHECKED_VAL as isize; + match s.config.save() { Ok(_) => { eprintln!("[settings] Config saved to {:?}", Config::path()); diff --git a/src/uia.rs b/src/uia.rs new file mode 100644 index 0000000..5082325 --- /dev/null +++ b/src/uia.rs @@ -0,0 +1,236 @@ +//! UI Automation (UIA) integration — reads the user's selected text directly +//! from the focused control, bypassing the clipboard-based flow entirely. +//! +//! This module wraps just enough of the COM API surface to: +//! +//! - Get the currently focused `IUIAutomationElement`. +//! - Query its `IUIAutomationTextPattern`, if the element exposes one. +//! - Read the current selection as a `String`, or expand around the caret to +//! the surrounding word and read that. +//! +//! Everything here is opt-in: if [`init`] hasn't been called, or if the +//! focused element doesn't implement `TextPattern`, the helpers return +//! `None` and the caller falls back to clipboard + `SendInput`. +//! +//! COM lifecycle note: we call `CoInitializeEx(COINIT_APARTMENTTHREADED)` +//! on the thread that first calls [`init`] (the main message loop thread +//! in practice) and leave it initialized for the process lifetime. The OS +//! tears down COM when the process exits, so an explicit `CoUninitialize` +//! isn't strictly necessary. + +use std::cell::RefCell; + +use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, +}; +use windows::Win32::UI::Accessibility::{ + CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTextPattern, + IUIAutomationTextRange, TextPatternRangeEndpoint_End, TextPatternRangeEndpoint_Start, + TextUnit_Character, TextUnit_Line, UIA_TextPatternId, +}; +use windows::core::Interface; + +// UIA / COM objects are apartment-threaded — they must be accessed from the +// same thread that created them. The main message-loop thread is where we +// initialize COM, and it's also where hotkey-triggered conversions run +// (via `WM_APP_HOTKEY` → `wnd_proc`), so a thread-local fits naturally. +thread_local! { + static AUTOMATION: RefCell> = const { RefCell::new(None) }; +} + +/// Runtime toggle — mirrors `config.general.use_uia`. Updated by main/settings. +/// When `false`, all helpers short-circuit and return `None`. +static USE_UIA: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +pub fn set_enabled(enabled: bool) { + USE_UIA.store(enabled, std::sync::atomic::Ordering::Relaxed); +} + +pub fn is_enabled() -> bool { + USE_UIA.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Initializes COM (apartment-threaded) and creates the `IUIAutomation` root +/// on the *current* thread. Must be called from the same thread that will +/// later call the read helpers (in our case, the main message-loop thread). +/// Safe to call more than once — subsequent calls are no-ops on the same +/// thread. Returns `false` if init fails; helpers then always return `None`. +pub fn init() -> bool { + AUTOMATION.with(|cell| { + if cell.borrow().is_some() { + return true; + } + unsafe { + // COINIT_APARTMENTTHREADED is what UIA expects for a GUI thread. + // Returns S_FALSE if COM was already initialized on this thread, + // which is fine — `.is_err()` only matches actual failure. + let hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if hr.is_err() { + crate::logger::log(&format!("[uia] CoInitializeEx failed: {hr:?}")); + return false; + } + match CoCreateInstance::<_, IUIAutomation>(&CUIAutomation, None, CLSCTX_INPROC_SERVER) { + Ok(a) => { + *cell.borrow_mut() = Some(a); + crate::logger::log("[uia] initialized"); + true + } + Err(e) => { + crate::logger::log(&format!("[uia] CoCreateInstance failed: {e:?}")); + false + } + } + } + }) +} + +/// Returns the current focused UIA element, or `None` if UIA is disabled / +/// uninitialized / the system has no focused element. +fn focused_element() -> Option { + if !is_enabled() { + return None; + } + AUTOMATION.with(|cell| { + let borrow = cell.borrow(); + let automation = borrow.as_ref()?; + unsafe { automation.GetFocusedElement().ok() } + }) +} + +/// Tries to get the `TextPattern` from the given element. +fn text_pattern(element: &IUIAutomationElement) -> Option { + unsafe { + let pattern = element.GetCurrentPattern(UIA_TextPatternId).ok()?; + pattern.cast::().ok() + } +} + +/// Reads the text of a UIA text range, up to a generous cap. +fn range_text(range: &IUIAutomationTextRange) -> Option { + unsafe { + // -1 means "no limit". UIA clamps internally. + let bstr = range.GetText(-1).ok()?; + let s = bstr.to_string(); + Some(s) + } +} + +/// Reads the currently selected text from the focused element, if any. +/// Returns `None` in any of these cases: +/// - UIA is disabled (config or init failed) +/// - No focused element / no TextPattern support +/// - Nothing is selected +/// - The selection is empty (`""`) +pub fn get_selected_text() -> Option { + let element = focused_element()?; + let pattern = text_pattern(&element)?; + + unsafe { + let selection = pattern.GetSelection().ok()?; + let count = selection.Length().ok()?; + if count == 0 { + return None; + } + // Real multi-range selections are rare; first range is enough for our needs. + let range = selection.GetElement(0).ok()?; + let text = range_text(&range)?; + if text.is_empty() { None } else { Some(text) } + } +} + +/// Selects and returns the "word" surrounding the caret, where "word" is +/// defined as a maximal run of **non-whitespace** characters. This is +/// intentionally different from UIA's `TextUnit_Word`, because UIA's +/// definition: +/// +/// 1. Splits on punctuation — so `ghb,jh` is two UIA-words (`ghb` and +/// `jh`), which breaks cyclic conversion: the second hotkey press +/// only re-converts one half. +/// 2. In some apps (Notepad Win11, Word) includes the **trailing +/// whitespace** in the word range, so after pasting the replacement +/// the caret lands past the space — the next cycle then selects the +/// next word, not the one we just converted. +/// +/// Our definition is whitespace-delimited: we grab the enclosing line +/// via UIA, locate the caret column by reading the prefix text from +/// line-start to caret, and in Rust walk outward from that column until +/// we hit whitespace on each side. Then we move the range endpoints to +/// exactly that span. Result: `ghb,jh` is one unit, trailing spaces are +/// excluded, cycling works. +/// +/// Returns `None` if UIA isn't available, the focused element doesn't +/// implement `TextPattern`, or the caret is on whitespace. +pub fn select_word_at_caret() -> Option { + let element = focused_element()?; + let pattern = text_pattern(&element)?; + + unsafe { + let selection = pattern.GetSelection().ok()?; + if selection.Length().ok()? == 0 { + return None; + } + let caret_range = selection.GetElement(0).ok()?; + + // Enclosing line: gives us the text surrounding the caret. + let line_range = caret_range.Clone().ok()?; + line_range.ExpandToEnclosingUnit(TextUnit_Line).ok()?; + let line_text = range_text(&line_range)?; + + // Caret column (in characters) within the line: build a range from + // line-start to caret-start, read its text, count the chars. + let prefix_range = caret_range.Clone().ok()?; + prefix_range + .MoveEndpointByRange( + TextPatternRangeEndpoint_Start, + &line_range, + TextPatternRangeEndpoint_Start, + ) + .ok()?; + let caret_col = range_text(&prefix_range)?.chars().count(); + + // Walk outward until whitespace / line boundary. + let chars: Vec = line_text.chars().collect(); + let mut start_col = caret_col; + let mut end_col = caret_col; + while start_col > 0 && !chars[start_col - 1].is_whitespace() { + start_col -= 1; + } + while end_col < chars.len() && !chars[end_col].is_whitespace() { + end_col += 1; + } + if start_col == end_col { + // Caret sits on whitespace — nothing to convert. + return None; + } + + // Contract the line range to [start_col, end_col) in the line. + let word_range = line_range.Clone().ok()?; + if start_col > 0 { + word_range + .MoveEndpointByUnit( + TextPatternRangeEndpoint_Start, + TextUnit_Character, + start_col as i32, + ) + .ok()?; + } + let tail = chars.len() - end_col; + if tail > 0 { + word_range + .MoveEndpointByUnit( + TextPatternRangeEndpoint_End, + TextUnit_Character, + -(tail as i32), + ) + .ok()?; + } + + word_range.Select().ok()?; + let word_text = range_text(&word_range)?; + if word_text.trim().is_empty() { + None + } else { + Some(word_text) + } + } +}