diff --git a/src/app.rs b/src/app.rs index 58d5697..06667ce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,12 +7,12 @@ use crate::compositor::LayoutEvent; use crate::domain::CaptureState; use crate::infrastructure::audio::SoundPackLoader; use crate::input::{ - InputControlCommand, KeyEvent, KeyListener, LayoutManager, ListenerConfig, ListenerHandle, + InputControlCommand, KeyEvent, KeyListener, LayoutManager, ListenerConfig, ListenerHandle, MouseEvent, }; use crate::tray::{TrayAction, TrayHandle}; use crate::ui::{ - create_bubble_window, create_launcher_window, create_settings_window, create_window, - setup_drag, show_launcher, show_settings, BubbleDisplayWidget, DisplayMode, KeyDisplayWidget, + create_bubble_window, create_launcher_window, create_mouse_cursor_window, create_settings_window, create_window, + setup_drag, show_launcher, show_settings, BubbleDisplayWidget, DisplayMode, KeyDisplayWidget, MouseCursorWidget, }; use anyhow::Result; use async_channel::{bounded, Receiver}; @@ -40,6 +40,7 @@ struct RuntimeState { mode: Option, routing_engine: RoutingEngine, keystroke_window: Option, + mouse_cursor_window: Option, bubble_window: Option, launcher_window: Option, settings_window: Option, @@ -271,6 +272,9 @@ fn close_mode_windows(state: &Rc>) { if let Some(window) = s.keystroke_window.take() { window.close(); } + if let Some(window) = s.mouse_cursor_window.take() { + window.close(); + } if let Some(window) = s.bubble_window.take() { window.close(); } @@ -473,6 +477,7 @@ fn start_keystroke_mode( } let window = create_window(app, &config)?; + let mouse_window = create_mouse_cursor_window(app, &config)?; if config.keystroke_draggable { let controllers = setup_drag(&window); @@ -484,9 +489,13 @@ fn start_keystroke_mode( config.display_timeout_ms, ))); + let mouse_display = Rc::new(RefCell::new(MouseCursorWidget::new())); + window.set_child(Some(display.borrow().widget())); + mouse_window.set_child(Some(mouse_display.borrow().widget())); - let (sender, receiver) = bounded::(1024); + let (key_sender, key_receiver) = bounded::(1024); + let (mouse_sender, mouse_receiver) = bounded::(256); let listener_config = ListenerConfig { all_keyboards: config.all_keyboards, @@ -494,7 +503,7 @@ fn start_keystroke_mode( }; let audio_dispatcher = state.borrow().audio_dispatcher.clone(); - let listener = KeyListener::new(sender, listener_config, audio_dispatcher); + let listener = KeyListener::new(key_sender, mouse_sender, listener_config, audio_dispatcher); let handle = listener.start()?; let mode_active = Arc::new(AtomicBool::new(true)); @@ -503,6 +512,7 @@ fn start_keystroke_mode( s.mode_active = Some(Arc::clone(&mode_active)); s.listener_handle = Some(handle); s.keystroke_window = Some(window.clone()); + s.mouse_cursor_window = Some(mouse_window.clone()); } let active_key_loop = Arc::clone(&mode_active); @@ -513,7 +523,7 @@ fn start_keystroke_mode( let config_service_clone = config_service.clone(); glib::MainContext::default().spawn_local(async move { - while let Ok(event) = receiver.recv().await { + while let Ok(event) = key_receiver.recv().await { if !active_key_loop.load(Ordering::SeqCst) { break; } @@ -564,6 +574,35 @@ fn start_keystroke_mode( debug!("Keystroke event loop terminated"); }); + let active = Arc::clone(&mode_active); + let state_clone = Rc::clone(state); + let mouse_display_clone = Rc::clone(&mouse_display); + + glib::MainContext::default().spawn_local(async move { + while let Ok(event) = mouse_receiver.recv().await { + if !active.load(Ordering::SeqCst) { + break; + } + + let (capture, _) = state_clone.borrow().routing_engine.get_states(); + if capture == CaptureState::Paused { + continue; + } + + match event { + MouseEvent::Moved { .. } => { + // Cursor position tracking not possible on Wayland + // Mouse bubble window stays anchored near keystroke display + } + _ => { + let mut display = mouse_display_clone.borrow_mut(); + display.handle_event(&event); + } + } + } + debug!("Mouse event loop terminated"); + }); + let mut rx = config_service.subscribe(); let display_c = display.clone(); let window_c = window.clone(); @@ -638,7 +677,12 @@ fn start_keystroke_mode( let state_clone = Rc::clone(state); setup_keystroke_cleanup_timer(display.clone(), window.clone(), state_clone, active); + let active = Arc::clone(&mode_active); + let state_clone = Rc::clone(state); + setup_mouse_cleanup_timer(mouse_display.clone(), mouse_window.clone(), state_clone, active); + window.present(); + mouse_window.present(); Ok(()) } @@ -724,7 +768,8 @@ fn start_bubble_mode( }); } - let (sender, receiver) = bounded::(1024); + let (key_sender, key_receiver) = bounded::(1024); + let (mouse_sender, _mouse_receiver) = bounded::(256); let listener_config = ListenerConfig { all_keyboards: config.all_keyboards, @@ -732,7 +777,7 @@ fn start_bubble_mode( }; let audio_dispatcher = state.borrow().audio_dispatcher.clone(); - let listener = KeyListener::new(sender, listener_config, audio_dispatcher); + let listener = KeyListener::new(key_sender, mouse_sender, listener_config, audio_dispatcher); let handle = listener.start()?; { @@ -763,7 +808,7 @@ fn start_bubble_mode( let app = app_clone.clone(); let config_service = config_service_clone.clone(); - while let Ok(event) = receiver.recv().await { + while let Ok(event) = key_receiver.recv().await { if !active.load(Ordering::SeqCst) { break; } @@ -975,6 +1020,42 @@ fn setup_keystroke_cleanup_timer( }); } +fn setup_mouse_cleanup_timer( + display: Rc>, + window: ApplicationWindow, + state: Rc>, + mode_active: Arc, +) { + glib::timeout_add_local(Duration::from_millis(100), move || { + if !mode_active.load(Ordering::SeqCst) { + return ControlFlow::Break; + } + + if state.borrow().routing_engine.get_states().0 == CaptureState::Paused { + return ControlFlow::Continue; + } + + let mut display = display.borrow_mut(); + display.remove_expired(); + + if display.has_buttons() { + window.remove_css_class("fading-out"); + window.set_visible(true); + } else if !window.has_css_class("fading-out") { + window.add_css_class("fading-out"); + + let w = window.clone(); + glib::timeout_add_local_once(Duration::from_millis(200), move || { + if w.has_css_class("fading-out") { + w.set_visible(false); + } + }); + } + + ControlFlow::Continue + }); +} + fn setup_bubble_cleanup_timer( display: Rc>, window: ApplicationWindow, diff --git a/src/domain/config.rs b/src/domain/config.rs index d36d809..ef8f32a 100644 --- a/src/domain/config.rs +++ b/src/domain/config.rs @@ -93,6 +93,24 @@ impl Position { ], } } + + /// Returns which vertical edge is anchored and whether offset should be positive or negative + /// to move the window UP (for positioning mouse bubbles above keystroke display) + #[must_use] + pub fn vertical_offset_direction(self) -> Option<(Edge, i32)> { + match self { + // Top positions: anchored to TOP, subtract to move UP + Position::TopLeft | Position::TopCenter | Position::TopRight => { + Some((Edge::Top, -1)) + } + // Bottom positions: anchored to BOTTOM, add to move UP + Position::BottomLeft | Position::BottomCenter | Position::BottomRight => { + Some((Edge::Bottom, 1)) + } + // Center positions: not vertically anchored, can't easily offset + _ => None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/input/device.rs b/src/input/device.rs index 668710b..b2953e9 100644 --- a/src/input/device.rs +++ b/src/input/device.rs @@ -17,6 +17,19 @@ impl KeyboardDevice { } } +#[derive(Debug, Clone)] +pub struct MouseDevice { + pub path: PathBuf, + + pub name: String, +} + +impl MouseDevice { + pub fn open(&self) -> Result { + Device::open(&self.path).with_context(|| format!("Failed to open device: {:?}", self.path)) + } +} + pub fn discover_keyboards() -> Result> { let mut keyboards = Vec::new(); let input_dir = PathBuf::from("/dev/input"); @@ -58,6 +71,47 @@ pub fn discover_keyboards() -> Result> { Ok(keyboards) } +pub fn discover_mice() -> Result> { + let mut mice = Vec::new(); + let input_dir = PathBuf::from("/dev/input"); + + let entries = fs::read_dir(&input_dir) + .with_context(|| format!("Failed to read directory: {:?}", input_dir))?; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + + if !file_name.starts_with("event") { + continue; + } + + match Device::open(&path) { + Ok(device) => { + if is_mouse(&device) { + let name = device.name().unwrap_or("Unknown Mouse").to_string(); + + info!("Found mouse: {} at {:?}", name, path); + + mice.push(MouseDevice { path, name }); + } + } + Err(e) => { + debug!("Could not open {:?}: {}", path, e); + } + } + } + + if mice.is_empty() { + warn!("No mouse devices found. Ensure you are in the 'input' group."); + } + + Ok(mice) +} + fn is_keyboard(device: &Device) -> bool { let supported = device.supported_events(); if !supported.contains(evdev::EventType::KEY) { @@ -75,6 +129,24 @@ fn is_keyboard(device: &Device) -> bool { false } +fn is_mouse(device: &Device) -> bool { + let supported = device.supported_events(); + + if !supported.contains(evdev::EventType::KEY) { + return false; + } + + let has_mouse_buttons = device.supported_keys().map_or(false, |keys| { + keys.contains(evdev::Key::BTN_LEFT) + || keys.contains(evdev::Key::BTN_RIGHT) + || keys.contains(evdev::Key::BTN_MIDDLE) + }); + + let has_rel_events = supported.contains(evdev::EventType::RELATIVE); + + has_mouse_buttons && has_rel_events +} + fn is_virtual(name: &str) -> bool { name.as_bytes() .windows(7) diff --git a/src/input/listener.rs b/src/input/listener.rs index 1310735..30a07eb 100644 --- a/src/input/listener.rs +++ b/src/input/listener.rs @@ -1,5 +1,5 @@ use crate::application::audio_dispatcher::AudioDispatcher; -use crate::input::device::{discover_keyboards, KeyboardDevice}; +use crate::input::device::{discover_keyboards, discover_mice, KeyboardDevice, MouseDevice}; use crate::input::keymap::KeyDisplay; use anyhow::{Context, Result}; use async_channel::{Receiver, Sender, TrySendError}; @@ -20,6 +20,51 @@ pub enum KeyEvent { AllReleased, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MouseButton { + Left, + Right, + Middle, + Side, + Extra, + WheelUp, + WheelDown, +} + +impl MouseButton { + pub fn from_key(key: Key) -> Option { + match key { + Key::BTN_LEFT => Some(MouseButton::Left), + Key::BTN_RIGHT => Some(MouseButton::Right), + Key::BTN_MIDDLE => Some(MouseButton::Middle), + Key::BTN_SIDE => Some(MouseButton::Side), + Key::BTN_EXTRA => Some(MouseButton::Extra), + _ => None, + } + } + + #[allow(dead_code)] + pub fn display_name(&self) -> &'static str { + match self { + MouseButton::Left => "L", + MouseButton::Right => "R", + MouseButton::Middle => "M", + MouseButton::Side => "B4", + MouseButton::Extra => "B5", + MouseButton::WheelUp => "⟰", + MouseButton::WheelDown => "⟱", + } + } +} + +#[derive(Debug, Clone)] +pub enum MouseEvent { + Pressed(MouseButton), + Released(MouseButton), + Moved { x: i32, y: i32 }, + Scroll { delta: i32 }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputControlCommand { Grab, @@ -30,6 +75,7 @@ pub enum InputControlCommand { pub struct ListenerConfig { pub all_keyboards: bool, pub ignored_keys: HashSet, + pub listen_mouse: bool, } impl Default for ListenerConfig { @@ -37,6 +83,7 @@ impl Default for ListenerConfig { Self { all_keyboards: true, ignored_keys: HashSet::new(), + listen_mouse: true, } } } @@ -63,7 +110,8 @@ impl Drop for ListenerHandle { } pub struct KeyListener { - sender: Sender, + key_sender: Sender, + mouse_sender: Sender, running: Arc, config: ListenerConfig, audio_dispatcher: Option, @@ -72,12 +120,14 @@ pub struct KeyListener { impl KeyListener { #[must_use] pub fn new( - sender: Sender, + key_sender: Sender, + mouse_sender: Sender, config: ListenerConfig, audio_dispatcher: Option, ) -> Self { Self { - sender, + key_sender, + mouse_sender, running: Arc::new(AtomicBool::new(false)), config, audio_dispatcher, @@ -102,7 +152,7 @@ impl KeyListener { let mut control_txs = Vec::new(); for keyboard in devices_to_use { - let sender = self.sender.clone(); + let sender = self.key_sender.clone(); let running = Arc::clone(&self.running); let ignored_keys = self.config.ignored_keys.clone(); let audio_dispatcher = self.audio_dispatcher.clone(); @@ -111,7 +161,7 @@ impl KeyListener { control_txs.push(control_tx); thread::spawn(move || { - if let Err(e) = listen_to_device( + if let Err(e) = listen_to_keyboard( keyboard, sender, running, @@ -124,6 +174,26 @@ impl KeyListener { }); } + if self.config.listen_mouse { + match discover_mice() { + Ok(mice) => { + for mouse in mice { + let sender = self.mouse_sender.clone(); + let running = Arc::clone(&self.running); + + thread::spawn(move || { + if let Err(e) = listen_to_mouse(mouse, sender, running) { + error!("Mouse listener error: {}", e); + } + }); + } + } + Err(e) => { + warn!("Failed to discover mice: {}", e); + } + } + } + Ok(ListenerHandle { running: self.running.clone(), control_txs, @@ -131,7 +201,7 @@ impl KeyListener { } } -fn listen_to_device( +fn listen_to_keyboard( keyboard: KeyboardDevice, sender: Sender, running: Arc, @@ -321,6 +391,160 @@ fn process_events( Ok(()) } +fn listen_to_mouse( + mouse: MouseDevice, + sender: Sender, + running: Arc, +) -> Result<()> { + let mut device = mouse.open()?; + info!("Listening to mouse: {}", mouse.name); + + let raw_fd = device.as_raw_fd(); + + let borrowed_fd = unsafe { BorrowedFd::borrow_raw(raw_fd) }; + let mut poll_fds = [PollFd::new(borrowed_fd, PollFlags::POLLIN)]; + let mut pressed_buttons = HashSet::new(); + + while running.load(Ordering::SeqCst) { + let poll_result = poll(&mut poll_fds, PollTimeout::from(100_u16)); + + match poll_result { + Ok(_n) => { + if let Err(e) = + process_mouse_events(&mut device, &sender, &mut pressed_buttons) + { + if e.to_string().contains("Channel closed") { + info!("Channel closed, stopping listener for {}", mouse.name); + break; + } + warn!("Error processing mouse events: {}", e); + } + } + Err(e) => { + error!("Poll error: {}", e); + break; + } + } + } + + info!("Stopped listening to mouse: {}", mouse.name); + Ok(()) +} + +fn process_mouse_events( + device: &mut Device, + sender: &Sender, + pressed_buttons: &mut HashSet, +) -> Result<()> { + let events = device.fetch_events().context("Failed to fetch mouse events")?; + + for event in events { + match event.kind() { + InputEventKind::Key(key) => { + if let Some(button) = MouseButton::from_key(key) { + let mouse_event = match event.value() { + 1 => { + trace!("Mouse button pressed: {:?}", button); + pressed_buttons.insert(button); + MouseEvent::Pressed(button) + } + 0 => { + trace!("Mouse button released: {:?}", button); + pressed_buttons.remove(&button); + MouseEvent::Released(button) + } + _ => continue, + }; + + if let Err(e) = sender.try_send(mouse_event) { + match e { + TrySendError::Full(_) => warn!("Mouse channel full, dropping event"), + TrySendError::Closed(_) => { + return Err(anyhow::anyhow!("Mouse channel closed")); + } + } + } + } + } + InputEventKind::RelAxis(axis) => { + match axis { + evdev::RelativeAxisType::REL_X => { + let delta = event.value(); + if delta != 0 { + if let Err(e) = sender.try_send(MouseEvent::Moved { x: delta, y: 0 }) { + match e { + TrySendError::Full(_) => {} + TrySendError::Closed(_) => { + return Err(anyhow::anyhow!("Mouse channel closed")); + } + } + } + } + } + evdev::RelativeAxisType::REL_Y => { + let delta = event.value(); + if delta != 0 { + if let Err(e) = sender.try_send(MouseEvent::Moved { x: 0, y: delta }) { + match e { + TrySendError::Full(_) => {} + TrySendError::Closed(_) => { + return Err(anyhow::anyhow!("Mouse channel closed")); + } + } + } + } + } + evdev::RelativeAxisType::REL_WHEEL => { + let delta = event.value(); + if delta != 0 { + trace!("Mouse wheel scroll: {}", delta); + if let Err(e) = sender.try_send(MouseEvent::Scroll { delta }) { + match e { + TrySendError::Full(_) => {} + TrySendError::Closed(_) => { + return Err(anyhow::anyhow!("Mouse channel closed")); + } + } + } + } + } + _ => {} + } + } + _ => {} + } + } + + if !pressed_buttons.is_empty() { + if let Ok(actual_state) = device.get_key_state() { + let stuck_buttons: Vec = pressed_buttons + .iter() + .filter(|b| { + let key = match b { + MouseButton::Left => Key::BTN_LEFT, + MouseButton::Right => Key::BTN_RIGHT, + MouseButton::Middle => Key::BTN_MIDDLE, + MouseButton::Side => Key::BTN_SIDE, + MouseButton::Extra => Key::BTN_EXTRA, + // Wheel events are not buttons, skip them + MouseButton::WheelUp | MouseButton::WheelDown => return false, + }; + !actual_state.contains(key) + }) + .cloned() + .collect(); + + for button in stuck_buttons { + trace!("Detected stuck mouse button released: {:?}", button); + pressed_buttons.remove(&button); + let _ = sender.try_send(MouseEvent::Released(button)); + } + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -331,6 +555,26 @@ mod tests { let config = ListenerConfig::default(); assert!(config.all_keyboards); assert!(config.ignored_keys.is_empty()); + assert!(config.listen_mouse); + } + + #[test] + fn test_mouse_button_from_key() { + assert_eq!(MouseButton::from_key(Key::BTN_LEFT), Some(MouseButton::Left)); + assert_eq!(MouseButton::from_key(Key::BTN_RIGHT), Some(MouseButton::Right)); + assert_eq!(MouseButton::from_key(Key::BTN_MIDDLE), Some(MouseButton::Middle)); + assert_eq!(MouseButton::from_key(Key::BTN_SIDE), Some(MouseButton::Side)); + assert_eq!(MouseButton::from_key(Key::BTN_EXTRA), Some(MouseButton::Extra)); + assert_eq!(MouseButton::from_key(Key::KEY_A), None); + } + + #[test] + fn test_mouse_button_display_name() { + assert_eq!(MouseButton::Left.display_name(), "L"); + assert_eq!(MouseButton::Right.display_name(), "R"); + assert_eq!(MouseButton::Middle.display_name(), "M"); + assert_eq!(MouseButton::Side.display_name(), "B4"); + assert_eq!(MouseButton::Extra.display_name(), "B5"); } #[test] diff --git a/src/input/mod.rs b/src/input/mod.rs index c3ece80..0ad0c2f 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -6,5 +6,5 @@ pub mod xkb; pub use keymap::{is_modifier, is_super_key, KeyDisplay}; pub use layout::LayoutManager; -pub use listener::{InputControlCommand, KeyEvent, KeyListener, ListenerConfig, ListenerHandle}; +pub use listener::{InputControlCommand, KeyEvent, KeyListener, ListenerConfig, ListenerHandle, MouseButton, MouseEvent}; pub use xkb::XkbState; diff --git a/src/ui/display.rs b/src/ui/display.rs index 3f0c74e..19849b3 100644 --- a/src/ui/display.rs +++ b/src/ui/display.rs @@ -46,6 +46,7 @@ impl KeyDisplayWidget { .build(); container.add_css_class("keystroke-container"); + container.set_margin_top(25); let logo_texture = Self::load_logo_texture(); @@ -117,9 +118,14 @@ impl KeyDisplayWidget { if is_modifier(key.key) { self.held_modifiers.insert(key.key); + // Show modifiers when held alone + self.update_modifier_display(); return; } + // Remove any modifier-only display since we're now showing a combo + self.remove_modifier_only_display(); + let current_combo = self.get_current_combo(key.key); if let Some(existing) = self .displayed_keys @@ -226,6 +232,8 @@ impl KeyDisplayWidget { if is_modifier(key.key) { self.held_modifiers.remove(&key.key); + // Update modifier display when a modifier is released + self.update_modifier_display(); } for displayed in self.displayed_keys.iter_mut() { @@ -287,4 +295,50 @@ impl KeyDisplayWidget { pub fn has_keys(&self) -> bool { !self.displayed_keys.is_empty() } + + fn update_modifier_display(&mut self) { + // Remove existing modifier-only display + self.remove_modifier_only_display(); + + // If we have held modifiers and no non-modifier keys are held, show them + if !self.held_modifiers.is_empty() && self.held_keys.iter().all(|k| is_modifier(*k)) { + let mut modifier_combo: Vec = self.held_modifiers.iter().copied().collect(); + modifier_combo.sort_by_key(|k| match *k { + Key::KEY_LEFTCTRL | Key::KEY_RIGHTCTRL => 0, + Key::KEY_LEFTALT | Key::KEY_RIGHTALT => 1, + Key::KEY_LEFTSHIFT | Key::KEY_RIGHTSHIFT => 2, + Key::KEY_LEFTMETA | Key::KEY_RIGHTMETA => 3, + _ => 4, + }); + + let widget = self.create_combo_widget(&modifier_combo); + self.container.append(&widget); + + let displayed = DisplayedKey { + keys: modifier_combo, + last_active: Instant::now(), + is_held: true, + widget, + }; + + self.displayed_keys.push_back(displayed); + } + } + + fn remove_modifier_only_display(&mut self) { + // Remove any display entry that contains only modifiers + let modifier_only_indices: Vec = self + .displayed_keys + .iter() + .enumerate() + .filter(|(_, dk)| dk.keys.iter().all(|k| is_modifier(*k))) + .map(|(i, _)| i) + .collect(); + + for &i in modifier_only_indices.iter().rev() { + if let Some(removed) = self.displayed_keys.remove(i) { + self.container.remove(&removed.widget); + } + } + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5f44cc4..5275a9f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod display; pub mod drag; pub mod icon_renderer; pub mod launcher; +pub mod mouse_cursor; pub mod settings; pub mod window; @@ -11,5 +12,6 @@ pub use display::KeyDisplayWidget; pub use drag::setup_drag; pub use icon_renderer::{render_tray_icon, IconTheme}; pub use launcher::{create_launcher_window, show_launcher, DisplayMode}; +pub use mouse_cursor::MouseCursorWidget; pub use settings::{create_settings_window, show_settings}; -pub use window::{create_bubble_window, create_window, update_css_provider}; +pub use window::{create_bubble_window, create_mouse_cursor_window, create_window, update_css_provider, update_mouse_window_position}; diff --git a/src/ui/mouse_cursor.rs b/src/ui/mouse_cursor.rs new file mode 100644 index 0000000..8371e12 --- /dev/null +++ b/src/ui/mouse_cursor.rs @@ -0,0 +1,162 @@ +use crate::input::MouseButton; +use gtk4::prelude::*; +use gtk4::{Box as GtkBox, Label, Orientation}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +const MOUSE_DISPLAY_TIMEOUT_MS: u64 = 2000; + +#[derive(Debug)] +struct DisplayedButton { + _button: MouseButton, + widget: gtk4::Widget, + last_active: Instant, + is_held: bool, +} + +pub struct MouseCursorWidget { + container: GtkBox, + displayed_buttons: HashMap, + display_duration: Duration, +} + +impl MouseCursorWidget { + pub fn new() -> Self { + let container = GtkBox::builder() + .orientation(Orientation::Horizontal) + .spacing(4) + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .build(); + + container.add_css_class("mouse-cursor-container"); + + Self { + container, + displayed_buttons: HashMap::new(), + display_duration: Duration::from_millis(MOUSE_DISPLAY_TIMEOUT_MS), + } + } + + pub fn widget(&self) -> &GtkBox { + &self.container + } + + pub fn handle_event(&mut self, event: &crate::input::MouseEvent) { + match event { + crate::input::MouseEvent::Pressed(button) => self.add_button(*button), + crate::input::MouseEvent::Released(button) => self.remove_button(*button), + crate::input::MouseEvent::Scroll { delta } => { + // Show wheel scroll as a brief button press + let button = if *delta > 0 { + crate::input::MouseButton::WheelUp + } else { + crate::input::MouseButton::WheelDown + }; + // Add as "held" button, remove_expired will clean it up + self.add_scroll_button(button); + } + _ => {} + } + } + + fn add_scroll_button(&mut self, button: MouseButton) { + // Remove existing if any + if let Some(existing) = self.displayed_buttons.remove(&button) { + self.container.remove(&existing.widget); + } + + let widget = self.create_button_widget(button); + self.container.append(&widget); + + let displayed = DisplayedButton { + _button: button, + widget: widget.upcast(), + last_active: Instant::now(), + is_held: false, // Not held, will be removed by remove_expired quickly + }; + + self.displayed_buttons.insert(button, displayed); + } + + fn add_button(&mut self, button: MouseButton) { + if let Some(existing) = self.displayed_buttons.get_mut(&button) { + existing.last_active = Instant::now(); + existing.is_held = true; + existing.widget.remove_css_class("fading"); + return; + } + + let widget = self.create_button_widget(button); + self.container.append(&widget); + + let displayed = DisplayedButton { + _button: button, + widget: widget.upcast(), + last_active: Instant::now(), + is_held: true, + }; + + self.displayed_buttons.insert(button, displayed); + } + + fn remove_button(&mut self, button: MouseButton) { + if let Some(displayed) = self.displayed_buttons.get_mut(&button) { + displayed.is_held = false; + displayed.last_active = Instant::now(); + displayed.widget.add_css_class("fading"); + } + } + + fn create_button_widget(&self, button: MouseButton) -> GtkBox { + let button_box = GtkBox::builder() + .orientation(Orientation::Horizontal) + .spacing(0) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + button_box.add_css_class("mouse-button-bubble"); + + let symbol = match button { + MouseButton::Left => "L", + MouseButton::Right => "R", + MouseButton::Middle => "M", + MouseButton::Side => "4", + MouseButton::Extra => "5", + MouseButton::WheelUp => "⟰", + MouseButton::WheelDown => "⟱", + }; + + let label = Label::builder() + .label(symbol) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .hexpand(true) + .build(); + button_box.append(&label); + + button_box + } + + pub fn remove_expired(&mut self) { + let now = Instant::now(); + let display_duration = self.display_duration; + + let expired: Vec = self + .displayed_buttons + .iter() + .filter(|(_, d)| !d.is_held && now.duration_since(d.last_active) > display_duration) + .map(|(b, _)| *b) + .collect(); + + for button in expired { + if let Some(removed) = self.displayed_buttons.remove(&button) { + self.container.remove(&removed.widget); + } + } + } + + pub fn has_buttons(&self) -> bool { + !self.displayed_buttons.is_empty() + } +} diff --git a/src/ui/window.rs b/src/ui/window.rs index f6a903b..e9c9069 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -151,6 +151,72 @@ pub fn create_bubble_window(app: &Application, config: &Config) -> Result Result { + let window = ApplicationWindow::builder() + .application(app) + .decorated(false) + .resizable(false) + .build(); + + window.init_layer_shell(); + + window.set_layer(Layer::Overlay); + + window.set_namespace("hibiki-mouse"); + + window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::None); + + // Anchor to same position as keystroke window + for (edge, anchor) in config.position.layer_shell_edges() { + window.set_anchor(edge, anchor); + } + + // Offset mouse bubbles 70px UP from keystroke window based on anchor position + let vertical_offset = config.position.vertical_offset_direction(); + let top_margin = if let Some((Edge::Top, dir)) = vertical_offset { + config.margin + (dir * 70) + } else { + config.margin + }; + let bottom_margin = if let Some((Edge::Bottom, dir)) = vertical_offset { + config.margin + (dir * 70) + } else { + config.margin + }; + + window.set_margin(Edge::Top, top_margin); + window.set_margin(Edge::Bottom, bottom_margin); + window.set_margin(Edge::Left, config.margin); + window.set_margin(Edge::Right, config.margin); + + window.set_exclusive_zone(0); + + window.add_css_class("mouse-cursor-window"); + + info!("Created mouse cursor window"); + + Ok(window) +} + +pub fn update_mouse_window_position(window: &ApplicationWindow, x: i32, y: i32, offset_x: i32, offset_y: i32) { + let display = gtk4::prelude::WidgetExt::display(window); + + if let Some(surface) = window.surface() { + if let Some(monitor) = display.monitor_at_surface(&surface) { + let geometry = monitor.geometry(); + + let rel_x = x - geometry.x(); + let rel_y = y - geometry.y(); + + let margin_left = rel_x + offset_x; + let margin_top = rel_y + offset_y; + + window.set_margin(Edge::Left, margin_left.max(0)); + window.set_margin(Edge::Top, margin_top.max(0)); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/style/overlay.css b/style/overlay.css index f336bbb..63d9e92 100644 --- a/style/overlay.css +++ b/style/overlay.css @@ -40,6 +40,49 @@ .keystroke-key.fading {{ }} +/* Mouse button bubble styles */ +.mouse-bubble-container {{ + padding: 0; + margin-right: 6px; +}} + +.mouse-button-bubble {{ + background-color: alpha(@accent_bg_color, {keystroke_opacity}); + color: @accent_fg_color; + border-radius: {keystroke_border_radius}; + padding: 10px 16px; + margin: 4px; + font-weight: bold; + font-family: "{keystroke_font_family}"; + font-size: {keystroke_font_size}em; + border: 1px solid @borders; + min-width: 32px; + min-height: 18px; +}} + +.mouse-button-bubble label {{ + margin: 0; + padding: 0; +}} + +.mouse-button-bubble.fading {{ +}} + +/* Mouse cursor window styles */ +.mouse-cursor-window {{ + background-color: transparent; + padding: 0; + border: none; +}} + +.mouse-cursor-container {{ + padding: 0; +}} + +.keystroke-key-container {{ + padding: 4px; +}} + .combo-separator {{ color: inherit; font-weight: normal;