Skip to content

Commit 529399d

Browse files
fix: isolate text buffers by input context
1 parent ef2177e commit 529399d

14 files changed

Lines changed: 404 additions & 33 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lay"
3-
version = "0.1.202"
3+
version = "0.1.203"
44
edition = "2021"
55
description = "Alpha Double Shift RU/EN layout rescue for Linux desktops"
66
license = "MIT"

extension/lay@radislabus-star.github.io/lay-impl.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ const STATS_PATH = GLib.get_home_dir() + '/.local/share/lay/stats.json';
2222
const RECENT_ACTIONS_PATH = GLib.get_home_dir() + '/.local/share/lay/recent_actions.jsonl';
2323
const PROJECT_DIR = GLib.get_home_dir() + '/projects/lay';
2424
const UPDATE_LOG_PATH = GLib.get_home_dir() + '/.local/state/lay/update.log';
25-
const APP_VERSION = '0.1.202';
25+
const APP_VERSION = '0.1.203';
2626
const APP_DESCRIPTION = 'Alpha RU/EN layout helper: double Shift и помощь при наборе';
27-
const APP_RELEASE_DATE = '2026-05-31';
27+
const APP_RELEASE_DATE = '2026-06-01';
2828
const APP_LICENSE = 'MIT';
2929
const APP_URL = 'https://github.com/radislabus-star/lay-public';
3030
const APP_PLATFORM = 'Linux desktops: GNOME, KDE, Wayland, X11';

extension/lay@radislabus-star.github.io/metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"uuid": "lay@radislabus-star.github.io",
33
"name": "Lay Layout Switcher",
4-
"version": 202,
5-
"version-name": "0.1.202",
4+
"version": 203,
5+
"version-name": "0.1.203",
66
"description": "Alpha GNOME Shell extension для lay-daemon. Показывает EN/RU в трее, переключает раскладку по клику. Минимальный DBus bridge для layout activation и fallback text insert.",
77
"shell-version": ["45", "46", "47", "50"],
88
"url": "https://github.com/radislabus-star/lay-public"

src/bin/lay_daemon.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ mod boundary_runtime;
118118
#[path = "lay_daemon/typing_key_runtime.rs"]
119119
mod typing_key_runtime;
120120

121+
#[path = "lay_daemon/text_context_runtime.rs"]
122+
mod text_context_runtime;
123+
121124
#[path = "lay_daemon/buffer_filter_runtime.rs"]
122125
mod buffer_filter_runtime;
123126
use buffer_filter_runtime::*;

src/bin/lay_daemon/daemon_runtime.rs

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use evdev::{uinput::VirtualDevice, Device, EventType, InputEvent, KeyCode};
22
use lay::config::LayConfig;
33
use lay::keyboard::is_typing_key;
44
use std::os::fd::AsRawFd;
5+
use std::sync::atomic::{AtomicU64, Ordering};
56
use std::sync::{Arc, Mutex};
67

78
use super::boundary_runtime::{
@@ -16,17 +17,20 @@ use super::manual_trigger_runtime::{
1617
fire_expired_pending_multi_tap, handle_manual_trigger_event, ManualTriggerEventContext,
1718
PendingMultiTapTimeoutContext,
1819
};
20+
use super::text_context_runtime::should_advance_text_context;
1921
use super::trigger_dispatch::{is_single_trigger_id, trigger_key_from_config};
2022
use super::typing_key_runtime::{handle_typing_key_press, TypingKeyContext};
2123
use super::{
2224
active_enter_autocorrect_from_env, active_layout_backend, idle_wait_timeout, log,
23-
poll_focused_window_state, should_skip_buffer_input, wait_for_keyboard_event_or_timeout,
24-
DShiftState, ForceLayoutHotkeyContext, ShiftState, ENTER_AUTOCORRECT_EXPERIMENT_ENV,
25+
poll_focused_window_state, poll_focused_window_state_for_key_event, should_skip_buffer_input,
26+
wait_for_keyboard_event_or_timeout, DShiftState, ForceLayoutHotkeyContext, ShiftState,
27+
ENTER_AUTOCORRECT_EXPERIMENT_ENV,
2528
};
2629

2730
pub(super) fn listen_keyboard(
2831
device_path: std::path::PathBuf,
2932
virtual_kbd: Arc<Mutex<Option<VirtualDevice>>>,
33+
field_context_epoch: Arc<AtomicU64>,
3034
verbose: bool,
3135
cfg: LayConfig,
3236
) -> std::io::Result<()> {
@@ -139,7 +143,8 @@ pub(super) fn listen_keyboard(
139143
Err(e) => return Err(e),
140144
};
141145

142-
update_focus_state(&mut state);
146+
update_focus_state_for_key_batch(&events, &mut state);
147+
sync_field_context_epoch(&field_context_epoch, &mut state);
143148
if state.focus_ignored {
144149
state.shift_state = ShiftState::default();
145150
state.dshift_state = DShiftState::Idle;
@@ -169,6 +174,12 @@ pub(super) fn listen_keyboard(
169174

170175
// ─── modifier tracking ────────────────────────────
171176
state.shift_state.update(key, value);
177+
if should_advance_text_context(key, value, &state.shift_state) {
178+
let epoch = field_context_epoch.fetch_add(1, Ordering::Relaxed) + 1;
179+
if state.switch_field_context_epoch(epoch) && verbose {
180+
log("► text context changed: switched field buffer");
181+
}
182+
}
172183
if state.force_layout_hotkeys.handle_event(
173184
key,
174185
value,
@@ -339,11 +350,92 @@ pub(super) fn listen_keyboard(
339350
}
340351
}
341352

353+
pub(super) fn listen_pointer(
354+
device_path: std::path::PathBuf,
355+
field_context_epoch: Arc<AtomicU64>,
356+
verbose: bool,
357+
) -> std::io::Result<()> {
358+
let mut device = Device::open(&device_path)?;
359+
device.set_nonblocking(true)?;
360+
let device_fd = device.as_raw_fd();
361+
log(&format!(
362+
"► слушаю pointer: {device_path:?} имя={:?}",
363+
device.name().unwrap_or("?")
364+
));
365+
366+
loop {
367+
let fetched_events = {
368+
device
369+
.fetch_events()
370+
.map(|events| events.collect::<Vec<_>>())
371+
};
372+
let events: Vec<InputEvent> = match fetched_events {
373+
Ok(events) => events,
374+
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
375+
wait_for_keyboard_event_or_timeout(
376+
device_fd,
377+
std::time::Duration::from_millis(500),
378+
)?;
379+
continue;
380+
}
381+
Err(e) => return Err(e),
382+
};
383+
384+
for event in events {
385+
if event.event_type() != EventType::KEY || event.value() != 1 {
386+
continue;
387+
}
388+
let key = KeyCode::new(event.code());
389+
if matches!(
390+
key,
391+
KeyCode::BTN_LEFT
392+
| KeyCode::BTN_RIGHT
393+
| KeyCode::BTN_MIDDLE
394+
| KeyCode::BTN_SIDE
395+
| KeyCode::BTN_EXTRA
396+
| KeyCode::BTN_FORWARD
397+
| KeyCode::BTN_BACK
398+
| KeyCode::BTN_TASK
399+
) {
400+
let epoch = field_context_epoch.fetch_add(1, Ordering::Relaxed) + 1;
401+
if verbose {
402+
log(&format!("► pointer context changed: field epoch {epoch}"));
403+
}
404+
}
405+
}
406+
}
407+
}
408+
342409
fn update_focus_state(state: &mut DaemonLoopState) {
343-
let Some(focus) = poll_focused_window_state(&mut state.last_focus_ignore_poll) else {
344-
return;
410+
let focus = poll_focused_window_state(&mut state.last_focus_ignore_poll);
411+
apply_focus_state(state, focus);
412+
}
413+
414+
fn update_focus_state_for_key_batch(events: &[InputEvent], state: &mut DaemonLoopState) {
415+
let has_key_event = events
416+
.iter()
417+
.any(|event| event.event_type() == EventType::KEY);
418+
let focus = if has_key_event {
419+
poll_focused_window_state_for_key_event(&mut state.last_focus_ignore_poll)
420+
} else {
421+
poll_focused_window_state(&mut state.last_focus_ignore_poll)
345422
};
423+
apply_focus_state(state, focus);
424+
}
425+
426+
fn sync_field_context_epoch(field_context_epoch: &AtomicU64, state: &mut DaemonLoopState) {
427+
let epoch = field_context_epoch.load(Ordering::Relaxed);
428+
if state.switch_field_context_epoch(epoch) {
429+
log("► text context changed: switched field buffer");
430+
state.dshift_state = DShiftState::Idle;
431+
state.pending_multi_tap = None;
432+
}
433+
}
346434

435+
fn apply_focus_state(state: &mut DaemonLoopState, focus: Option<super::FocusedWindowState>) {
436+
let Some(focus) = focus else {
437+
return;
438+
};
347439
let identity_changed = state.switch_window_input_state(focus.identity);
348440
if identity_changed {
349441
log("► focused window changed: switched text tail buffer");

src/bin/lay_daemon/daemon_state.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ pub(super) struct DaemonLoopState {
3131
pub(super) events_since_word_start: u32,
3232
pub(super) pending_typing_assist_after_space: Option<PendingTypingAssist>,
3333
pub(super) focus_ignored: bool,
34+
pub(super) focused_window_identity: Option<String>,
35+
pub(super) field_context_epoch: u64,
3436
pub(super) active_window_identity: Option<String>,
3537
pub(super) window_states: HashMap<String, WindowInputState>,
3638
pub(super) ignore_current_token_until_space: bool,
@@ -44,6 +46,7 @@ pub(super) struct WindowInputState {
4446
ignore_current_token_until_space: bool,
4547
clear_on_next_typing: bool,
4648
suppress_next_typing_assist_after_manual_replay: bool,
49+
saved_at: Instant,
4750
}
4851

4952
impl WindowInputState {
@@ -56,6 +59,7 @@ impl WindowInputState {
5659
clear_on_next_typing: state.clear_on_next_typing,
5760
suppress_next_typing_assist_after_manual_replay: state
5861
.suppress_next_typing_assist_after_manual_replay,
62+
saved_at: Instant::now(),
5963
}
6064
}
6165

@@ -95,6 +99,8 @@ impl DaemonLoopState {
9599
events_since_word_start: 0,
96100
pending_typing_assist_after_space: None,
97101
focus_ignored: false,
102+
focused_window_identity: None,
103+
field_context_epoch: 0,
98104
active_window_identity: None,
99105
window_states: HashMap::new(),
100106
ignore_current_token_until_space: false,
@@ -103,12 +109,28 @@ impl DaemonLoopState {
103109
}
104110

105111
pub(super) fn switch_window_input_state(&mut self, identity: Option<String>) -> bool {
112+
self.focused_window_identity = identity;
113+
self.switch_text_context_state()
114+
}
115+
116+
pub(super) fn switch_field_context_epoch(&mut self, epoch: u64) -> bool {
117+
if self.field_context_epoch == epoch {
118+
return false;
119+
}
120+
self.field_context_epoch = epoch;
121+
self.switch_text_context_state()
122+
}
123+
124+
fn switch_text_context_state(&mut self) -> bool {
125+
let identity = self.active_text_context_identity();
106126
if self.active_window_identity == identity {
107127
return false;
108128
}
109129
if let Some(previous) = self.active_window_identity.take() {
110-
let previous_state = WindowInputState::take_from(self);
111-
self.window_states.insert(previous, previous_state);
130+
if self.should_save_current_text_context() {
131+
let previous_state = WindowInputState::take_from(self);
132+
self.window_states.insert(previous, previous_state);
133+
}
112134
}
113135

114136
if let Some(current) = identity.clone() {
@@ -124,6 +146,38 @@ impl DaemonLoopState {
124146
}
125147
}
126148
self.active_window_identity = identity;
149+
self.prune_window_states();
127150
true
128151
}
152+
153+
fn active_text_context_identity(&self) -> Option<String> {
154+
self.focused_window_identity
155+
.as_ref()
156+
.map(|window| format!("{window}:field:{}", self.field_context_epoch))
157+
}
158+
159+
fn should_save_current_text_context(&self) -> bool {
160+
!self.buffer.current_is_empty()
161+
|| self.events_since_word_start > 0
162+
|| self.pending_typing_assist_after_space.is_some()
163+
|| self.ignore_current_token_until_space
164+
|| self.clear_on_next_typing
165+
|| self.suppress_next_typing_assist_after_manual_replay
166+
}
167+
168+
fn prune_window_states(&mut self) {
169+
const MAX_SAVED_TEXT_CONTEXTS: usize = 50;
170+
171+
while self.window_states.len() > MAX_SAVED_TEXT_CONTEXTS {
172+
let Some(oldest_key) = self
173+
.window_states
174+
.iter()
175+
.min_by_key(|(_, state)| state.saved_at)
176+
.map(|(key, _)| key.clone())
177+
else {
178+
break;
179+
};
180+
self.window_states.remove(&oldest_key);
181+
}
182+
}
129183
}

0 commit comments

Comments
 (0)