use std::cell::RefCell;
use std::rc::Rc;
use crate::reactive::{Scope, Signal};
use crate::ui::KeyChord;
use super::keys::{hold_gesture_label, use_key_state, KeyFidelity, KeyState};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum CaptureState {
#[default]
Idle,
Held,
Latched,
}
impl CaptureState {
pub fn is_talking(self) -> bool {
!matches!(self, CaptureState::Idle)
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum StopReason {
Released,
FocusLost,
Suspended,
Cancelled,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum PttMode {
Hold,
Latch,
}
type StartSlot = Rc<RefCell<Option<Box<dyn FnMut()>>>>;
type StopSlot = Rc<RefCell<Option<Box<dyn FnMut(StopReason)>>>>;
#[derive(Clone)]
pub struct PushToTalk {
chord: KeyChord,
keys: KeyState,
state: Signal<CaptureState>,
prev_pressed: Rc<std::cell::Cell<bool>>,
on_start: StartSlot,
on_stop: StopSlot,
}
impl PushToTalk {
pub fn bind(cx: Scope, chord: KeyChord) -> PushToTalk {
let keys = use_key_state(cx);
let state = cx.signal(CaptureState::default());
let on_start: StartSlot = Rc::new(RefCell::new(None));
let on_stop: StopSlot = Rc::new(RefCell::new(None));
let ptt = PushToTalk {
chord,
keys,
state,
prev_pressed: Rc::new(std::cell::Cell::new(false)),
on_start,
on_stop,
};
let machine = ptt.clone();
let mut primed = false;
cx.effect_labeled("push-to-talk", move || {
machine.step(primed);
primed = true;
});
ptt
}
fn step(&self, primed: bool) {
let fidelity = self.keys.fidelity();
let focus_cleared = self.keys.focus_cleared();
let suspend_cleared = self.keys.suspend_cleared();
let pressed_now = self.keys.pressed_chord(self.chord);
let released = self.keys.released(self.chord.key);
let down = self.keys.is_down(self.chord.key);
let pressed = primed && pressed_now && !self.prev_pressed.get();
self.prev_pressed.set(pressed_now);
if !primed {
return;
}
let current = self.state.get_untracked();
if focus_cleared && current.is_talking() {
self.stop(StopReason::FocusLost);
return;
}
if suspend_cleared && current.is_talking() {
self.stop(StopReason::Suspended);
return;
}
match fidelity {
KeyFidelity::Full => {
if current == CaptureState::Idle && pressed {
self.start(CaptureState::Held);
}
match self.state.get_untracked() {
CaptureState::Held if released || !down => {
self.stop(StopReason::Released);
}
CaptureState::Latched if pressed => {
self.stop(StopReason::Released);
}
_ => {}
}
}
KeyFidelity::Degraded => {
if pressed {
match current {
CaptureState::Idle => self.start(CaptureState::Latched),
_ => self.stop(StopReason::Released),
}
}
}
}
}
fn start(&self, next: CaptureState) {
self.state.set(next);
let cb = self.on_start.borrow_mut().take();
if let Some(mut f) = cb {
f();
let mut slot = self.on_start.borrow_mut();
if slot.is_none() {
*slot = Some(f);
}
}
}
fn stop(&self, reason: StopReason) {
self.state.set(CaptureState::Idle);
let cb = self.on_stop.borrow_mut().take();
if let Some(mut f) = cb {
f(reason);
let mut slot = self.on_stop.borrow_mut();
if slot.is_none() {
*slot = Some(f);
}
}
}
pub fn on_start(self, f: impl FnMut() + 'static) -> PushToTalk {
*self.on_start.borrow_mut() = Some(Box::new(f));
self
}
pub fn on_stop(self, f: impl FnMut(StopReason) + 'static) -> PushToTalk {
*self.on_stop.borrow_mut() = Some(Box::new(f));
self
}
pub fn state(&self) -> Signal<CaptureState> {
self.state
}
pub fn is_talking(&self) -> bool {
self.state.get().is_talking()
}
pub fn mode(&self) -> PttMode {
match self.keys.fidelity() {
KeyFidelity::Full => PttMode::Hold,
KeyFidelity::Degraded => PttMode::Latch,
}
}
pub fn gesture_label(&self) -> String {
hold_gesture_label(self.keys.fidelity(), self.chord)
}
pub fn chord(&self) -> KeyChord {
self.chord
}
pub fn cancel(&self) {
if self.state.get_untracked().is_talking() {
self.stop(StopReason::Cancelled);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::keys;
use crate::input::{KeyCode, KeyEvent as InKeyEvent, KeyEventKind};
use crate::reactive::{create_root, flush_effects};
use crate::ui::Key;
fn press(code: KeyCode) -> InKeyEvent {
InKeyEvent::plain(code)
}
fn release(code: KeyCode) -> InKeyEvent {
InKeyEvent::plain(code).with_kind(KeyEventKind::Release)
}
type Log = Rc<RefCell<Vec<String>>>;
fn bind_logged(cx: Scope, chord: KeyChord) -> (PushToTalk, Log) {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let l1 = log.clone();
let l2 = log.clone();
let ptt = PushToTalk::bind(cx, chord)
.on_start(move || l1.borrow_mut().push("start".into()))
.on_stop(move |r| l2.borrow_mut().push(format!("stop:{r:?}")));
(ptt, log)
}
fn reset_keys(full: bool) {
let _ = keys::key_state();
keys::begin_turn();
keys::publish_fidelity(full);
keys::begin_turn();
}
#[test]
fn hold_mode_start_on_press_stop_on_release() {
reset_keys(true);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
assert_eq!(ptt.mode(), PttMode::Hold);
assert_eq!(ptt.gesture_label(), "hold Space");
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Held);
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')).with_kind(KeyEventKind::Repeat));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Held);
keys::begin_turn();
keys::on_key_event(&release(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(
log.borrow().as_slice(),
["start", "stop:Released"],
"one start, one stop"
);
}
#[test]
fn hold_mode_release_with_stale_mods_still_stops() {
reset_keys(true);
let (_root, (ptt, log)) = create_root(|cx| bind_logged(cx, KeyChord::ctrl(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&InKeyEvent::new(
KeyCode::Char(' '),
crate::input::Mods::CTRL,
));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Held);
keys::begin_turn();
keys::on_key_event(&release(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().last().unwrap(), "stop:Released");
}
#[test]
fn tap_in_one_turn_fires_start_then_stop() {
reset_keys(true);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
keys::on_key_event(&release(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().as_slice(), ["start", "stop:Released"]);
}
#[test]
fn focus_lost_stops_with_the_privacy_reason_and_never_restarts() {
reset_keys(true);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
keys::begin_turn();
keys::on_focus_lost();
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().last().unwrap(), "stop:FocusLost");
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')).with_kind(KeyEventKind::Repeat));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().len(), 2, "no third event");
}
#[test]
fn latch_mode_toggles_and_labels_truthfully() {
reset_keys(false);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
assert_eq!(ptt.mode(), PttMode::Latch);
assert_eq!(ptt.gesture_label(), "press Space to start/stop");
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Latched);
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().as_slice(), ["start", "stop:Released"]);
}
#[test]
fn latch_mode_stops_on_focus_loss_too() {
reset_keys(false);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert!(ptt.is_talking());
keys::begin_turn();
keys::on_focus_lost();
flush_effects();
assert!(!ptt.state().get_untracked().is_talking());
assert_eq!(log.borrow().last().unwrap(), "stop:FocusLost");
}
#[test]
fn suspend_stops_a_hold_with_the_truthful_reason() {
reset_keys(true);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Held);
keys::begin_turn();
keys::on_suspend();
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().last().unwrap(), "stop:Suspended");
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')).with_kind(KeyEventKind::Repeat));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().len(), 2, "no third event");
}
#[test]
fn suspend_stops_a_latch_on_a_degraded_wire_too() {
reset_keys(false);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Latched);
keys::begin_turn();
keys::on_suspend();
flush_effects();
assert!(!ptt.state().get_untracked().is_talking());
assert_eq!(log.borrow().last().unwrap(), "stop:Suspended");
}
#[test]
fn cancel_is_programmatic_and_idempotent() {
reset_keys(false);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
ptt.cancel(); assert!(log.borrow().is_empty());
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
ptt.cancel();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().last().unwrap(), "stop:Cancelled");
ptt.cancel(); assert_eq!(log.borrow().len(), 2);
}
#[test]
fn fidelity_upgrade_mid_latch_keeps_capture_until_toggled_off() {
reset_keys(false);
let (_root, (ptt, log)) =
create_root(|cx| bind_logged(cx, KeyChord::plain(Key::Char(' '))));
flush_effects();
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Latched);
keys::publish_fidelity(true); flush_effects();
assert_eq!(ptt.mode(), PttMode::Hold, "label flips live");
assert!(
ptt.is_talking(),
"an upgrade must not silently kill a running capture"
);
keys::begin_turn();
keys::on_key_event(&press(KeyCode::Char(' ')));
flush_effects();
assert_eq!(ptt.state().get_untracked(), CaptureState::Idle);
assert_eq!(log.borrow().last().unwrap(), "stop:Released");
keys::publish_fidelity(false); }
}