1
0

add long-press + notif based mode switching

oooooh boy, this is looking pretty slick. I'm kind-of impressed I was
able to throw this together in ~2 days of post-work hacking (though I
guess they were some pretty late-nights...)

There's really only one feature left that I _need_ to implement, which
is the on-disk persistence for selected mode. That shouldn't be too
tricky though...
This commit is contained in:
Daniel Prilik
2020-10-30 23:39:12 -04:00
parent e6fa6845fe
commit d14a92dfe5
11 changed files with 280 additions and 63 deletions

View File

@@ -18,12 +18,13 @@ You've been warned :eyes:
## Overview
Consists of two components:
`surface-dial-daemon` is a background daemon which recieves raw events and translates them to various actions.
- `surface-dial-daemon` - A background daemon which recieves raw events and translates them to various actions.
- `surface-dial-cli` - Controller to configure daemon functionality (e.g: change operating modes)
Aside from haptic feedback, the daemon also uses FreeDesktop notifications to provide visual feedback when performing various actions.
It would be cool to create some sort of GUI overlay (similar to the Windows one), though that's a bit out of scope at the moment.
![](notif-demo.gif)
It would be cool to create some sort of GUI overlay (similar to the Windows one), though that's out of scope at the moment.
## Functionality
@@ -33,12 +34,12 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one)
- [x] Media Controls
- [x] D-Pad (emulated left, right, and space key)
- [x] Scrolling / Zooming
- [ ] \(meta\) Specify modes via config file(s)
- [ ] Dynamically switch between operating modes
- _currently requires re-compiling the daemon_
- [ ] Using some-sort of on-device mechanism (e.g: long-press)
- [ ] Using `surface-dial-cli` application
- [ ] \(meta\) custom modes specified via config file(s)
- [x] Dynamically switch between operating modes
- [x] Using some-sort of on-device mechanism (e.g: long-press)
- [ ] Context-sensitive (based on currently open application)
- [ ] Mode Persistence
- _At the moment, whenever the dial disconnects, the daemon is re-launched, which resets the active mode to the default one. It would be good to have some on-disk persistence to remember the last selected mode._
- [x] Haptic Feedback
- https://docs.microsoft.com/en-us/windows-hardware/design/component-guidelines/radial-controller-protocol-implementation
- https://www.usb.org/sites/default/files/hutrr63b_-_haptics_page_redline_0.pdf
@@ -46,6 +47,7 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one)
- _This was tricky to figure out, but in the end, it was surprisingly straightforward! Big thanks to [Geo](https://www.linkedin.com/in/geo-palakunnel-57718245/) for pointing me in the right direction!_
- [x] Desktop Notifications
- [x] On Launch
- [x] When switching between modes
- [x] When switching between sub-modes (e.g: scroll/zoom)
Feel free to contribute new features!
@@ -88,10 +90,6 @@ cargo build -p surface-dial-daemon && sudo target/debug/surface-dial-daemon
Note the use of `sudo`, as `surface-dial-daemon` requires permission to access files under `/dev/input/` and `/dev/uinput`.
## Using `surface-dial-cli`
TODO (the controller cli doesn't exist yet lol)
## Installation
As you might have noticed, the daemon dies whenever the Surface Dial disconnects (which happens after a brief period of inactivity).

BIN
notif-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -2,6 +2,7 @@ use notify_rust::error::Result as NotifyResult;
use notify_rust::{Hint, Notification, NotificationHandle, Timeout};
pub fn action_notification(msg: &str, icon: &str) -> NotifyResult<NotificationHandle> {
eprintln!("sending notification: {}", msg);
Notification::new()
.hint(Hint::Transient(true))
.hint(Hint::Category("device".into()))

View File

@@ -3,7 +3,7 @@ use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::Duration;
use crate::controller::ControlMode;
use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics;
use crate::fake_input::FakeInput;
use crate::DynResult;
@@ -123,6 +123,13 @@ impl DPad {
}
impl ControlMode for DPad {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Paddle",
icon: "input-gaming",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(3600))?;
Ok(())

View File

@@ -1,4 +1,4 @@
use crate::controller::ControlMode;
use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics;
use crate::fake_input::FakeInput;
use crate::DynResult;
@@ -18,6 +18,13 @@ impl Media {
}
impl ControlMode for Media {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Media",
icon: "applications-multimedia",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(36))?;
Ok(())

View File

@@ -1,16 +1,15 @@
use crate::controller::ControlMode;
use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics;
use crate::DynResult;
pub struct Null {}
impl Null {
pub fn new() -> Null {
Null {}
impl ControlMode for () {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "null",
icon: "",
}
}
impl ControlMode for Null {
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(0))?;
Ok(())

View File

@@ -1,5 +1,5 @@
use crate::common::action_notification;
use crate::controller::ControlMode;
use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics;
use crate::fake_input::{FakeInput, ScrollStep};
use crate::DynResult;
@@ -26,6 +26,13 @@ const ZOOM_SENSITIVITY: u16 = 36;
const SCROLL_SENSITIVITY: u16 = 90;
impl ControlMode for ScrollZoom {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Scroll/Zoom",
icon: "input-mouse",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?;
Ok(())
@@ -43,7 +50,7 @@ impl ControlMode for ScrollZoom {
action_notification("Zoom Mode", "zoom-in")?;
haptics.set_mode(false, Some(ZOOM_SENSITIVITY))?;
} else {
action_notification("ScrollZoom Mode", "input-mouse")?;
action_notification("Scroll Mode", "input-mouse")?;
haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?;
}

View File

@@ -1,4 +1,4 @@
use crate::controller::ControlMode;
use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics;
use crate::fake_input::FakeInput;
use crate::DynResult;
@@ -18,8 +18,15 @@ impl Volume {
}
impl ControlMode for Volume {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Volume",
icon: "audio-volume-high",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(36 * 2))?;
haptics.set_mode(true, Some(36 * 2))?;
Ok(())
}

View File

@@ -1,47 +1,191 @@
use crate::DynResult;
use std::sync::{Arc, Mutex};
use crate::dial_device::{DialDevice, DialEventKind, DialHaptics};
use crate::DynResult;
pub mod controls;
pub struct ControlModeMeta {
name: &'static str,
icon: &'static str,
}
pub trait ControlMode {
fn meta(&self) -> ControlModeMeta;
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()>;
fn on_end(&mut self, _haptics: &DialHaptics) -> DynResult<()> {
Ok(())
}
fn on_btn_press(&mut self, haptics: &DialHaptics) -> DynResult<()>;
fn on_btn_release(&mut self, haptics: &DialHaptics) -> DynResult<()>;
fn on_dial(&mut self, haptics: &DialHaptics, delta: i32) -> DynResult<()>;
}
enum ActiveMode {
Normal(usize),
Meta,
}
pub struct DialController {
device: DialDevice,
mode: Box<dyn ControlMode>,
modes: Vec<Box<dyn ControlMode>>,
active_mode: ActiveMode,
new_mode: Arc<Mutex<Option<usize>>>,
meta_mode: Box<dyn ControlMode>, // always MetaMode
}
impl DialController {
pub fn new(device: DialDevice, default_mode: Box<dyn ControlMode>) -> DialController {
DialController {
mode: default_mode,
pub fn new(device: DialDevice, modes: Vec<Box<dyn ControlMode>>) -> DialController {
let metas = modes.iter().map(|m| m.meta()).collect();
let new_mode = Arc::new(Mutex::new(None));
DialController {
device,
modes,
active_mode: ActiveMode::Normal(0),
new_mode: new_mode.clone(),
meta_mode: Box::new(MetaMode::new(new_mode, 0, metas)),
}
}
pub fn run(&mut self) -> DynResult<()> {
let haptics = self.device.haptics();
self.mode.on_start(haptics)?;
self.modes[0].on_start(self.device.haptics())?;
loop {
let evt = self.device.next_event()?;
let haptics = self.device.haptics();
if let Some(new_mode) = self.new_mode.lock().unwrap().take() {
self.active_mode = ActiveMode::Normal(new_mode);
self.modes[new_mode].on_start(haptics)?;
}
let mode = match self.active_mode {
ActiveMode::Normal(idx) => &mut self.modes[idx],
ActiveMode::Meta => &mut self.meta_mode,
};
// TODO: press and hold (+ rotate?) to switch between modes
match evt.kind {
DialEventKind::Ignored => {}
DialEventKind::ButtonPress => self.mode.on_btn_press(haptics)?,
DialEventKind::ButtonRelease => self.mode.on_btn_release(haptics)?,
DialEventKind::Dial(delta) => self.mode.on_dial(haptics, delta)?,
DialEventKind::ButtonPress => mode.on_btn_press(haptics)?,
DialEventKind::ButtonRelease => mode.on_btn_release(haptics)?,
DialEventKind::Dial(delta) => mode.on_dial(haptics, delta)?,
DialEventKind::ButtonLongPress => {
eprintln!("long press!");
if !matches!(self.active_mode, ActiveMode::Meta) {
mode.on_end(haptics)?;
self.active_mode = ActiveMode::Meta;
self.meta_mode.on_start(haptics)?;
}
}
}
}
}
}
/// A mode for switching between modes.
struct MetaMode {
// constant
metas: Vec<ControlModeMeta>,
// stateful (across invocations)
current_mode: usize,
new_mode: Arc<Mutex<Option<usize>>>,
// reset in on_start
first_release: bool,
notif: Option<notify_rust::NotificationHandle>,
}
impl MetaMode {
fn new(
new_mode: Arc<Mutex<Option<usize>>>,
current_mode: usize,
metas: Vec<ControlModeMeta>,
) -> MetaMode {
MetaMode {
metas,
current_mode,
new_mode,
first_release: true,
notif: None,
}
}
}
impl ControlMode for MetaMode {
fn meta(&self) -> ControlModeMeta {
unreachable!() // meta mode never queries itself
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
use notify_rust::*;
self.notif = Some(
Notification::new()
.hint(Hint::Resident(true))
.hint(Hint::Category("device".into()))
.timeout(Timeout::Never)
.summary("Surface Dial")
.body(&format!(
"Entered Meta Mode (From Mode: {})",
self.metas[self.current_mode].name
))
.icon("emblem-system")
.show()?,
);
haptics.buzz(1)?;
self.first_release = true;
haptics.set_mode(true, Some(36))?;
Ok(())
}
fn on_btn_press(&mut self, _haptics: &DialHaptics) -> DynResult<()> {
Ok(())
}
fn on_btn_release(&mut self, haptics: &DialHaptics) -> DynResult<()> {
if self.first_release {
self.first_release = false;
} else {
*self.new_mode.lock().unwrap() = Some(self.current_mode);
haptics.buzz(1)?;
self.notif.take().unwrap().close();
}
Ok(())
}
fn on_dial(&mut self, _haptics: &DialHaptics, delta: i32) -> DynResult<()> {
if delta > 0 {
self.current_mode += 1;
} else {
self.current_mode -= 1;
}
self.current_mode %= self.metas.len();
let mode_meta = &self.metas[self.current_mode];
if let Some(ref mut notification) = self.notif {
notification
.body(&format!("New Mode: {}", mode_meta.name))
.icon(mode_meta.icon);
notification.update();
}
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
use std::fs;
use std::sync::mpsc;
use std::time::Duration;
use evdev_rs::{Device, InputEvent};
@@ -7,10 +8,11 @@ use hidapi::{HidApi, HidDevice};
use crate::error::Error;
pub struct DialDevice {
// TODO: explore what the control channel can be used for...
_control: Device,
axis: Device,
long_press_timeout: Duration,
haptics: DialHaptics,
events: mpsc::Receiver<DialEvent>,
possible_long_press: bool,
}
#[derive(Debug)]
@@ -25,10 +27,11 @@ pub enum DialEventKind {
ButtonPress,
ButtonRelease,
Dial(i32),
ButtonLongPress,
}
impl DialDevice {
pub fn new() -> Result<DialDevice, crate::Error> {
pub fn new(long_press_timeout: Duration) -> Result<DialDevice, crate::Error> {
let mut control = None;
let mut axis = None;
@@ -62,24 +65,66 @@ impl DialDevice {
}
}
// TODO: explore what the control channel can be used for...
let _control = control.ok_or(Error::MissingDial)?;
let axis = axis.ok_or(Error::MissingDial)?;
let (events_tx, events_rx) = mpsc::channel();
// TODO: interleave control events with regular events
std::thread::spawn({
let events = events_tx;
move || {
loop {
let (_axis_status, axis_evt) = axis
.next_event(evdev_rs::ReadFlag::NORMAL)
.expect("Error::Evdev");
// assert!(matches!(axis_status, ReadStatus::Success));
let event = DialEvent::from_raw_evt(axis_evt.clone())
.expect("Error::UnexpectedEvt(axis_evt)");
events.send(event).expect("failed to send axis event");
}
}
});
Ok(DialDevice {
_control: control.ok_or(Error::MissingDial)?,
axis: axis.ok_or(Error::MissingDial)?,
long_press_timeout,
events: events_rx,
haptics: DialHaptics::new()?,
possible_long_press: false,
})
}
pub fn next_event(&self) -> Result<DialEvent, Error> {
// TODO: figure out how to interleave control events into the same event stream.
pub fn next_event(&mut self) -> Result<DialEvent, Error> {
let evt = if self.possible_long_press {
self.events.recv_timeout(self.long_press_timeout)
} else {
self.events
.recv()
.map_err(|_| mpsc::RecvTimeoutError::Disconnected)
};
let (_axis_status, axis_evt) = self
.axis
.next_event(evdev_rs::ReadFlag::NORMAL)
.map_err(Error::Evdev)?;
// assert!(matches!(axis_status, ReadStatus::Success));
let event =
DialEvent::from_raw_evt(axis_evt.clone()).ok_or(Error::UnexpectedEvt(axis_evt))?;
let event = match evt {
Ok(event) => {
match event.kind {
DialEventKind::ButtonPress => self.possible_long_press = true,
DialEventKind::ButtonRelease => self.possible_long_press = false,
_ => {}
}
event
}
Err(mpsc::RecvTimeoutError::Timeout) => {
self.possible_long_press = false;
DialEvent {
time: Duration::from_secs(0), // this could be improved...
kind: DialEventKind::ButtonLongPress,
}
}
Err(_e) => panic!("Could not recv event"),
};
Ok(event)
}

View File

@@ -24,7 +24,7 @@ fn main() {
fn true_main() -> DynResult<()> {
println!("Started.");
let dial = DialDevice::new()?;
let dial = DialDevice::new(std::time::Duration::from_millis(750))?;
println!("Found the dial.");
std::thread::spawn(move || {
@@ -34,7 +34,7 @@ fn true_main() -> DynResult<()> {
.timeout(Timeout::Never)
.summary("Surface Dial")
.body("Active!")
.icon("input-mouse")
.icon("media-optical") // it should be vaguely circular :P
.show()
.expect("failed to send notification");
@@ -46,13 +46,15 @@ fn true_main() -> DynResult<()> {
}
});
// let default_mode = Box::new(controller::controls::Null::new());
let default_mode = Box::new(controller::controls::ScrollZoom::new());
// let default_mode = Box::new(controller::controls::Volume::new());
// let default_mode = Box::new(controller::controls::Media::new());
// let default_mode = Box::new(controller::controls::DPad::new());
let mut controller = DialController::new(dial, default_mode);
let mut controller = DialController::new(
dial,
vec![
Box::new(controller::controls::ScrollZoom::new()),
Box::new(controller::controls::Volume::new()),
Box::new(controller::controls::Media::new()),
Box::new(controller::controls::DPad::new()),
],
);
controller.run()
}