Skip to content
Open
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
170 changes: 169 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ pub struct App<'a> {
drag_row: Option<u16>,
/// Position in help text (None if not showing help)
help_position: Option<u16>,
/// Last left-click info for double-click detection: (timestamp, column, row)
last_click: Option<(Instant, u16, u16)>,
}

macro_rules! current_list {
Expand Down Expand Up @@ -279,6 +281,7 @@ impl<'a> App<'a> {
config,
drag_row: None,
help_position: None,
last_click: None,
}
}

Expand Down Expand Up @@ -560,6 +563,28 @@ impl Handle for Action {

impl Handle for MouseEvent {
fn handle(self, app: &mut App) -> Result<bool> {
// Double-click detection for left button
let is_double_click = if let MouseEventKind::Down(MouseButton::Left) = self.kind {
let now = Instant::now();
let double_click = if let Some((last_time, last_col, last_row)) = app.last_click {
// Check if within 400ms and same position (within 2 pixels tolerance)
now.duration_since(last_time) < Duration::from_millis(400)
&& (self.column as i16 - last_col as i16).abs() <= 2
&& (self.row as i16 - last_row as i16).abs() <= 2
} else {
false
};
// Update last click tracking (reset on double-click to prevent triple-click)
app.last_click = if double_click {
None
} else {
Some((now, self.column, self.row))
};
double_click
} else {
false
};

match self.kind {
MouseEventKind::Down(MouseButton::Left) => {
app.drag_row = Some(self.row)
Expand All @@ -568,6 +593,14 @@ impl Handle for MouseEvent {
_ => {}
}

// Determine which event kind to look up actions for
let lookup_kind = if is_double_click && app.config.double_click_select {
// On double-click, use right-click actions (SetDefault)
MouseEventKind::Down(MouseButton::Right)
} else {
self.kind
};

let actions = app
.mouse_areas
.iter()
Expand All @@ -576,7 +609,7 @@ impl Handle for MouseEvent {
rect.contains(Position {
x: self.column,
y: app.drag_row.unwrap_or(self.row),
}) && kinds.contains(&self.kind)
}) && kinds.contains(&lookup_kind)
})
.map(|(_, _, action)| action.clone())
.into_iter()
Expand Down Expand Up @@ -785,6 +818,7 @@ mod tests {
remote: None,
fps: None,
mouse: false,
double_click_select: true,
peaks: Default::default(),
char_set: Default::default(),
theme: Default::default(),
Expand Down Expand Up @@ -869,6 +903,7 @@ mod tests {
remote: None,
fps: None,
mouse: false,
double_click_select: false,
peaks: Default::default(),
char_set: Default::default(),
theme: Default::default(),
Expand Down Expand Up @@ -1044,4 +1079,137 @@ mod tests {
assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
}

// Double-click detection tests
mod double_click {
use super::*;
use std::thread;

fn make_mouse_event(column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}

#[test]
fn single_click_stores_position() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);

assert!(app.last_click.is_none());

let event = make_mouse_event(10, 5);
let _ = event.handle(&mut app);

assert!(app.last_click.is_some());
let (_, col, row) = app.last_click.unwrap();
assert_eq!(col, 10);
assert_eq!(row, 5);
}

#[test]
fn double_click_within_threshold_resets_tracking() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);

// First click
let event1 = make_mouse_event(10, 5);
let _ = event1.handle(&mut app);
assert!(app.last_click.is_some());

// Second click at same position (simulates double-click)
let event2 = make_mouse_event(10, 5);
let _ = event2.handle(&mut app);

// After double-click, last_click should be reset to None
// to prevent triple-click triggering another double-click
assert!(app.last_click.is_none());
}

#[test]
fn clicks_outside_time_threshold_not_double_click() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);

// First click
let event1 = make_mouse_event(10, 5);
let _ = event1.handle(&mut app);

// Wait longer than 400ms threshold
thread::sleep(Duration::from_millis(450));

// Second click at same position
let event2 = make_mouse_event(10, 5);
let _ = event2.handle(&mut app);

// Should NOT be detected as double-click, so last_click is updated
// (not reset to None like it would be after a real double-click)
assert!(app.last_click.is_some());
}

#[test]
fn clicks_outside_position_threshold_not_double_click() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);

// First click at (10, 5)
let event1 = make_mouse_event(10, 5);
let _ = event1.handle(&mut app);

// Second click at (20, 15) - far from first
let event2 = make_mouse_event(20, 15);
let _ = event2.handle(&mut app);

// Should NOT be detected as double-click
assert!(app.last_click.is_some());
let (_, col, row) = app.last_click.unwrap();
assert_eq!(col, 20);
assert_eq!(row, 15);
}

#[test]
fn clicks_within_position_tolerance_is_double_click() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);

// First click at (10, 5)
let event1 = make_mouse_event(10, 5);
let _ = event1.handle(&mut app);

// Second click at (11, 6) - within 2px tolerance
let event2 = make_mouse_event(11, 6);
let _ = event2.handle(&mut app);

// Should be detected as double-click (last_click reset)
assert!(app.last_click.is_none());
}

#[test]
fn after_double_click_next_click_starts_fresh() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);

// First click
let event1 = make_mouse_event(10, 5);
let _ = event1.handle(&mut app);

// Second click (double-click)
let event2 = make_mouse_event(10, 5);
let _ = event2.handle(&mut app);
assert!(app.last_click.is_none());

// Third click should start fresh tracking
let event3 = make_mouse_event(10, 5);
let _ = event3.handle(&mut app);
assert!(app.last_click.is_some());

// Fourth click should be a new double-click
let event4 = make_mouse_event(10, 5);
let _ = event4.handle(&mut app);
assert!(app.last_click.is_none());
}
}
}
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct Config {
pub remote: Option<String>,
pub fps: Option<f32>,
pub mouse: bool,
pub double_click_select: bool,
pub peaks: Peaks,
pub char_set: CharSet,
pub theme: Theme,
Expand All @@ -50,6 +51,8 @@ struct ConfigFile {
fps: Option<f32>,
#[serde(default = "default_mouse")]
mouse: bool,
#[serde(default = "default_double_click_select")]
double_click_select: bool,
#[serde(default = "default_peaks")]
peaks: Option<Peaks>,
#[serde(default = "default_char_set_name")]
Expand Down Expand Up @@ -195,6 +198,10 @@ fn default_mouse() -> bool {
true
}

fn default_double_click_select() -> bool {
false
}

fn default_peaks() -> Option<Peaks> {
Some(Peaks::default())
}
Expand Down Expand Up @@ -238,6 +245,10 @@ impl ConfigFile {
self.mouse = true;
}

if opt.double_click_select {
self.double_click_select = true;
}

if let Some(peaks) = &opt.peaks {
self.peaks = Some(peaks.clone());
}
Expand Down Expand Up @@ -304,6 +315,7 @@ impl TryFrom<ConfigFile> for Config {
remote: config_file.remote,
fps: config_file.fps,
mouse: config_file.mouse,
double_click_select: config_file.double_click_select,
peaks: config_file.peaks.unwrap_or_default(),
max_volume_percent: config_file
.max_volume_percent
Expand Down Expand Up @@ -378,6 +390,7 @@ pub mod strict {
remote: Option<String>,
fps: Option<f32>,
mouse: bool,
double_click_select: bool,
peaks: Option<Peaks>,
char_set: String,
theme: String,
Expand All @@ -399,6 +412,7 @@ pub mod strict {
remote: strict.remote,
fps: strict.fps,
mouse: strict.mouse,
double_click_select: strict.double_click_select,
peaks: strict.peaks,
char_set: strict.char_set,
theme: strict.theme,
Expand Down
6 changes: 6 additions & 0 deletions src/opt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ pub struct Opt {
#[clap(long, conflicts_with = "no_mouse", help = "Enable mouse support")]
pub mouse: bool,

#[clap(
long,
help = "Enable double-click to set default device"
)]
pub double_click_select: bool,

#[clap(
short = 'v',
long,
Expand Down
5 changes: 5 additions & 0 deletions wiremix.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
# Enable mouse support
mouse = true

# Enable double-click to set default device (disabled by default)
# When enabled, double-clicking on a device sets it as the default
# (same as right-click or pressing 'd')
double_click_select = false

# Peak meter mode
# "off" - no meters
# "mono" - mono meters
Expand Down
Loading