use std::collections::HashSet;
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
use crate::config::{Config, Peaks, TabKind};
use crate::wirehose::state::CaptureEligibility;
use crate::wirehose::{
media_class, CommandSender, Event as PipewireEvent, PeakProcessor,
PipewireError, StateEvent,
};
use anyhow::{anyhow, Result};
use ratatui::{
layout::Flex,
prelude::{Buffer, Constraint, Direction, Layout, Position, Rect},
text::{Line, Span},
widgets::{Clear, StatefulWidget, Widget},
DefaultTerminal, Frame,
};
use crossterm::event::{
Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseButton, MouseEvent,
MouseEventKind,
};
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use crate::device_kind::DeviceKind;
use crate::event::Event;
use crate::help::{HelpWidget, HelpWidgetState};
use crate::object_list::{ObjectList, ObjectListWidget};
use crate::view::{self, ListKind, View};
use crate::wirehose::{state::State, ObjectId};
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, PartialOrd)]
pub enum Action {
Help,
Exit,
MoveUp,
MoveDown,
ToggleMute,
SetRelativeVolume(f32),
SetDefault,
ActivateDropdown,
CloseDropdown,
TabLeft,
TabRight,
SelectTab(usize),
SetAbsoluteVolume(f32),
#[serde(skip_deserializing)]
SelectObject(ObjectId),
#[serde(skip_deserializing)]
SetTarget(view::Target),
Nothing,
}
impl std::fmt::Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::SelectTab(tab) => write!(f, "Select {tab} tab"),
Action::MoveUp => write!(f, "Move cursor up"),
Action::MoveDown => write!(f, "Move cursor down"),
Action::TabLeft => write!(f, "Select previous tab"),
Action::TabRight => write!(f, "Select next tab"),
Action::CloseDropdown => write!(f, "Close menu"),
Action::ActivateDropdown => write!(f, "Open menu"),
Action::SelectObject(object_id) => {
write!(f, "Select object {object_id:?}")
}
Action::SetTarget(_) => write!(f, "Set target"),
Action::ToggleMute => write!(f, "Toggle mute"),
Action::SetAbsoluteVolume(vol) => {
write!(f, "Set volume to {}%", Self::format_percentage(*vol))
}
Action::SetRelativeVolume(vol) => {
Self::format_relative_volume(f, *vol)
}
Action::SetDefault => write!(f, "Set default"),
Action::Help => write!(f, "Show/hide help"),
Action::Exit => write!(f, "Exit wiremix"),
Action::Nothing => write!(f, "Nothing"),
}
}
}
impl Action {
fn format_percentage(vol: f32) -> u16 {
(vol * 100.0).trunc() as u16
}
fn format_relative_volume(
f: &mut std::fmt::Formatter<'_>,
vol: f32,
) -> std::fmt::Result {
match vol {
0.01 => write!(f, "Increment volume"),
-0.01 => write!(f, "Decrement volume"),
v if v >= 0.0 => {
write!(f, "Increase volume by {}%", Self::format_percentage(v))
}
v => {
write!(f, "Decrease volume by {}%", Self::format_percentage(-v))
}
}
}
}
struct Tab {
title: String,
list: ObjectList,
}
impl Tab {
fn new(title: String, list: ObjectList) -> Self {
Self { title, list }
}
}
impl From<TabKind> for Tab {
fn from(tab_kind: TabKind) -> Tab {
match tab_kind {
TabKind::Playback => Tab::new(
String::from("Playback"),
ObjectList::new(ListKind::Node(view::NodeKind::Playback), None),
),
TabKind::Recording => Tab::new(
String::from("Recording"),
ObjectList::new(
ListKind::Node(view::NodeKind::Recording),
None,
),
),
TabKind::Output => Tab::new(
String::from("Output Devices"),
ObjectList::new(
ListKind::Node(view::NodeKind::Output),
Some(DeviceKind::Sink),
),
),
TabKind::Input => Tab::new(
String::from("Input Devices"),
ObjectList::new(
ListKind::Node(view::NodeKind::Input),
Some(DeviceKind::Source),
),
),
TabKind::Configuration => Tab::new(
String::from("Configuration"),
ObjectList::new(ListKind::Device, None),
),
}
}
}
pub type MouseArea =
(Rect, SmallVec<[MouseEventKind; 4]>, SmallVec<[Action; 4]>);
pub struct App<'a> {
exit: bool,
wirehose: &'a dyn CommandSender,
rx: mpsc::Receiver<Event>,
error_message: Option<String>,
tabs: Vec<Tab>,
current_tab_index: usize,
mouse_areas: Vec<MouseArea>,
is_ready: bool,
state: State,
state_dirty: bool,
view: View<'a>,
config: Config,
drag_row: Option<u16>,
help_position: Option<u16>,
visible_objects: HashSet<ObjectId>,
peak_processor: Arc<dyn PeakProcessor>,
capturable_objects: HashSet<ObjectId>,
capturing_objects: HashSet<ObjectId>,
}
macro_rules! current_list {
($self:expr) => {
$self.tabs[$self.current_tab_index].list
};
}
impl<'a> App<'a> {
pub fn new(
wirehose: &'a dyn CommandSender,
rx: mpsc::Receiver<Event>,
config: Config,
) -> Self {
let tabs = config.tabs.iter().copied().map(Tab::from).collect();
let peak_processor = |new_peak, current_peak, samples, rate| {
let time_constant = 0.3;
let coef =
1.0 - (-(samples as f32) / (time_constant * rate as f32)).exp();
current_peak + (new_peak - current_peak) * coef
};
let state = State::default();
App {
exit: false,
wirehose,
rx,
error_message: None,
tabs,
current_tab_index: config.tab,
mouse_areas: Vec::new(),
is_ready: false,
state,
state_dirty: false,
view: View::new(wirehose),
config,
drag_row: None,
help_position: None,
visible_objects: HashSet::new(),
peak_processor: Arc::new(peak_processor),
capturable_objects: HashSet::new(),
capturing_objects: HashSet::new(),
}
}
pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> {
let _ = terminal.draw(|frame| {
frame.render_widget(Line::from("Initializing..."), frame.area());
});
while !self.exit && !self.is_ready {
let _ = self.handle_events(None);
}
let mut pacer = RenderPacer::new(self.config.fps);
let mut needs_render = true;
while !self.exit {
if self.state_dirty {
self.view = View::from(
self.wirehose,
&self.state,
&self.config.names,
&self.config.filters,
);
}
self.state_dirty = false;
let frame = terminal.get_frame();
current_list!(self).update(frame.area(), &self.view);
let new_visible_objects =
current_list!(self).visible_objects(&frame.area(), &self.view);
if new_visible_objects != self.visible_objects {
needs_render = true;
self.visible_objects = new_visible_objects;
self.update_capturing();
}
if needs_render && pacer.is_time_to_render() {
needs_render = false;
self.mouse_areas.clear();
terminal.draw(|frame| {
self.draw(frame);
})?;
}
needs_render |= self.handle_events(
needs_render.then_some(pacer.duration_until_next_frame()),
)?;
}
self.error_message.map_or(Ok(()), |s| Err(anyhow!(s)))
}
fn draw(&mut self, frame: &mut Frame) {
let widget = AppWidget {
current_tab_index: self.current_tab_index,
view: &self.view,
config: &self.config,
};
let mut widget_state = AppWidgetState {
mouse_areas: &mut self.mouse_areas,
tabs: &mut self.tabs,
help_position: &mut self.help_position,
};
frame.render_stateful_widget(widget, frame.area(), &mut widget_state);
}
fn exit(&mut self, error_message: Option<String>) {
self.exit = true;
self.error_message = error_message;
}
fn stop_capture(&mut self, object_id: ObjectId) {
self.capturing_objects.remove(&object_id);
self.wirehose.node_capture_stop(object_id);
}
fn start_capture(&mut self, object_id: ObjectId) {
if self.config.lazy_capture
&& !self.visible_objects.contains(&object_id)
{
return;
}
let Some(node) = self.state.nodes.get(&object_id) else {
return;
};
if self
.config
.filters
.iter()
.any(|condition| condition.matches(&self.state, node))
{
return;
}
let Some(object_serial) = node.props.object_serial() else {
return;
};
let capture_sink =
node.props
.media_class()
.as_ref()
.is_some_and(|media_class| {
media_class::is_sink(media_class)
|| media_class::is_source(media_class)
});
self.capturing_objects.insert(object_id);
self.wirehose.node_capture_start(
node.object_id,
*object_serial,
capture_sink,
Arc::clone(&node.peaks_dirty),
Some(Arc::clone(&self.peak_processor)),
);
}
fn update_capturing(&mut self) {
if !self.config.lazy_capture {
return;
}
let need_to_start: Vec<_> = self
.visible_objects
.intersection(&self.capturable_objects)
.copied()
.collect();
for object_id in need_to_start {
if !self.capturing_objects.contains(&object_id) {
self.start_capture(object_id);
}
}
let need_to_stop: Vec<_> = self
.capturing_objects
.difference(&self.visible_objects)
.copied()
.collect();
for object_id in need_to_stop {
self.stop_capture(object_id);
}
}
fn set_capture_eligibility(
&mut self,
capture_eligibility: CaptureEligibility,
) {
if self.config.peaks == Peaks::Off {
return;
}
match capture_eligibility {
CaptureEligibility::Eligible(object_id) => {
self.capturable_objects.insert(object_id);
if !self.capturing_objects.contains(&object_id) {
self.start_capture(object_id);
}
}
CaptureEligibility::NeedsRestart(object_id) => {
self.capturable_objects.insert(object_id);
self.start_capture(object_id);
}
CaptureEligibility::Ineligible(object_id) => {
self.capturable_objects.remove(&object_id);
self.stop_capture(object_id);
}
}
}
fn handle_events(&mut self, timeout: Option<Duration>) -> Result<bool> {
let mut were_events_handled = match timeout {
Some(timeout) => match self.rx.recv_timeout(timeout) {
Ok(event) => event.handle(self)?,
Err(mpsc::RecvTimeoutError::Timeout) => return Ok(false),
Err(e) => return Err(e.into()),
},
None => self.rx.recv()?.handle(self)?,
};
while let Ok(event) = self.rx.try_recv() {
were_events_handled |= event.handle(self)?;
}
Ok(were_events_handled)
}
}
struct RenderPacer {
frame_duration: Duration,
next_frame_time: Instant,
}
impl RenderPacer {
fn new(fps: Option<f32>) -> Self {
let frame_duration = fps.map_or(Default::default(), |fps| {
Duration::from_secs_f32(1.0 / fps)
});
Self {
frame_duration,
next_frame_time: Instant::now(),
}
}
fn is_time_to_render(&mut self) -> bool {
let now = Instant::now();
if now >= self.next_frame_time {
if now > self.next_frame_time + self.frame_duration {
self.next_frame_time = now + self.frame_duration;
} else {
self.next_frame_time += self.frame_duration;
}
return true;
}
false
}
fn duration_until_next_frame(&self) -> Duration {
self.next_frame_time
.saturating_duration_since(Instant::now())
}
}
trait Handle {
fn handle(self, app: &mut App) -> Result<bool>;
}
impl Handle for Event {
fn handle(self, app: &mut App) -> Result<bool> {
match self {
Event::Input(event) => event.handle(app),
Event::Pipewire(event) => event.handle(app),
}
}
}
impl Handle for crossterm::event::Event {
fn handle(self, app: &mut App) -> Result<bool> {
match self {
CrosstermEvent::Key(event) => event.handle(app),
CrosstermEvent::Mouse(event) => event.handle(app),
CrosstermEvent::Resize(..) => Ok(true),
_ => Ok(false),
}
}
}
impl Handle for KeyEvent {
fn handle(self, app: &mut App) -> Result<bool> {
if self.kind != KeyEventKind::Press {
return Ok(false);
}
if let Some(&action) = app.config.keybindings.get(&self) {
return action.handle(app);
}
Ok(false)
}
}
impl Handle for Action {
fn handle(self, app: &mut App) -> Result<bool> {
if let Some(ref mut help_position) = app.help_position {
match self {
Action::MoveDown => {
*help_position = help_position.saturating_add(1);
return Ok(true);
}
Action::MoveUp => {
*help_position = help_position.saturating_sub(1);
return Ok(true);
}
Action::ActivateDropdown
| Action::CloseDropdown
| Action::Help => {
app.help_position = None;
return Ok(true);
}
Action::Exit => {
app.exit(None);
return Ok(true);
}
_ => {
return Ok(false);
}
}
}
match self {
Action::SelectTab(index) => {
if index < app.tabs.len() {
app.current_tab_index = index;
}
}
Action::MoveDown => {
current_list!(app).down(&app.view);
}
Action::MoveUp => {
current_list!(app).up(&app.view);
}
Action::TabLeft => {
app.current_tab_index = app
.current_tab_index
.checked_sub(1)
.unwrap_or(app.tabs.len() - 1)
}
Action::TabRight => {
app.current_tab_index =
(app.current_tab_index + 1) % app.tabs.len()
}
Action::CloseDropdown => {
current_list!(app).dropdown_close();
}
Action::ActivateDropdown => {
current_list!(app).dropdown_activate(&app.view);
}
Action::SetTarget(target) => {
current_list!(app).set_target(&app.view, target);
}
Action::SelectObject(object_id) => {
app.tabs[app.current_tab_index].list.selected = Some(object_id)
}
Action::ToggleMute => {
current_list!(app).toggle_mute(&app.view);
}
Action::SetAbsoluteVolume(volume) => {
let max = app
.config
.enforce_max_volume
.then_some(app.config.max_volume_percent);
current_list!(app).set_absolute_volume(&app.view, volume, max);
return Ok(current_list!(app)
.set_absolute_volume(&app.view, volume, max));
}
Action::SetRelativeVolume(volume) => {
let max = (volume > 0.0 && app.config.enforce_max_volume)
.then_some(app.config.max_volume_percent);
return Ok(current_list!(app)
.set_relative_volume(&app.view, volume, max));
}
Action::SetDefault => {
current_list!(app).set_default(&app.view);
}
Action::Exit => {
app.exit(None);
}
Action::Nothing => {
return Ok(false);
}
Action::Help => {
app.help_position = Some(0);
}
}
Ok(true)
}
}
impl Handle for MouseEvent {
fn handle(self, app: &mut App) -> Result<bool> {
match self.kind {
MouseEventKind::Down(MouseButton::Left) => {
app.drag_row = Some(self.row)
}
MouseEventKind::Up(MouseButton::Left) => app.drag_row = None,
_ => {}
}
let actions = app
.mouse_areas
.iter()
.rev()
.find(|(rect, kinds, _)| {
rect.contains(Position {
x: self.column,
y: app.drag_row.unwrap_or(self.row),
}) && kinds.contains(&self.kind)
})
.map(|(_, _, action)| action.clone())
.into_iter()
.flatten();
let mut handled_action = false;
for action in actions {
handled_action = true;
let _ = action.handle(app);
}
Ok(handled_action)
}
}
impl Handle for PipewireEvent {
fn handle(self, app: &mut App) -> Result<bool> {
match self {
PipewireEvent::Ready => {
app.is_ready = true;
Ok(true)
}
PipewireEvent::Error(message) => message.handle(app),
PipewireEvent::State(event) => event.handle(app),
}
}
}
impl Handle for StateEvent {
fn handle(self, app: &mut App) -> Result<bool> {
app.state_dirty = !matches!(&self, StateEvent::NodePeaksDirty { .. });
let visible_affected = self
.affected_objects(&app.state)
.iter()
.any(|object| app.visible_objects.contains(object));
for capture_eligibility in app.state.update(self) {
app.set_capture_eligibility(capture_eligibility);
}
Ok(visible_affected)
}
}
impl Handle for PipewireError {
fn handle(self, app: &mut App) -> Result<bool> {
if cfg!(debug_assertions) {
app.exit(Some(self));
return Ok(true);
}
Ok(false)
}
}
pub struct AppWidget<'a, 'b> {
current_tab_index: usize,
view: &'a View<'b>,
config: &'a Config,
}
pub struct AppWidgetState<'a> {
mouse_areas: &'a mut Vec<MouseArea>,
tabs: &'a mut Vec<Tab>,
help_position: &'a mut Option<u16>,
}
impl<'a> StatefulWidget for AppWidget<'a, '_> {
type State = AppWidgetState<'a>;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let list_area = layout[0];
let menu_area = layout[1];
let constraints: Vec<_> = state
.tabs
.iter()
.map(|tab| Constraint::Length(tab.title.len() as u16 + 2))
.collect();
let menu_areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(menu_area);
for (i, tab) in state.tabs.iter().enumerate() {
let title_line = if i == self.current_tab_index {
Line::from(vec![
Span::styled(
&self.config.char_set.tab_marker_left,
self.config.theme.tab_marker,
),
Span::styled(&tab.title, self.config.theme.tab_selected),
Span::styled(
&self.config.char_set.tab_marker_right,
self.config.theme.tab_marker,
),
])
} else {
Line::from(Span::styled(
format!(" {} ", tab.title),
self.config.theme.tab,
))
};
title_line.render(menu_areas[i], buf);
state.mouse_areas.push((
menu_areas[i],
smallvec![MouseEventKind::Down(MouseButton::Left)],
smallvec![Action::SelectTab(i)],
));
}
let mut widget = ObjectListWidget {
object_list: &mut state.tabs[self.current_tab_index].list,
view: self.view,
config: self.config,
};
widget.render(list_area, buf, state.mouse_areas);
if let Some(ref mut help_position) = state.help_position {
state.mouse_areas.clear();
state.mouse_areas.push((
area,
smallvec![MouseEventKind::Down(MouseButton::Left)],
smallvec![Action::Help],
));
let width: u16 = self
.config
.help
.widths
.iter()
.fold(HelpWidget::base_width(), |acc, &x| acc.saturating_add(x))
.try_into()
.unwrap_or(u16::MAX);
let [help_area] = Layout::horizontal([Constraint::Max(width)])
.flex(Flex::Center)
.areas(list_area);
let height: u16 = self
.config
.help
.rows
.len()
.saturating_add(2)
.try_into()
.unwrap_or(u16::MAX)
.min(((help_area.height as f32) * 0.90) as u16);
let [help_area] = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.areas(help_area);
Clear.render(help_area, buf);
HelpWidget {
config: self.config,
}
.render(
help_area,
buf,
&mut HelpWidgetState {
mouse_areas: state.mouse_areas,
help_position,
},
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mock;
use crate::wirehose::PropertyStore;
use std::cell::RefCell;
use std::collections::VecDeque;
use std::sync::Arc;
fn fixture<'a>(wirehose: &'a mock::WirehoseHandle<'a>) -> App<'a> {
let (_, event_rx) = mpsc::channel();
let config = Config {
remote: None,
fps: None,
mouse: false,
peaks: Default::default(),
char_set: Default::default(),
theme: Default::default(),
max_volume_percent: Default::default(),
enforce_max_volume: Default::default(),
keybindings: Default::default(),
help: Default::default(),
names: Default::default(),
tab: 0,
tabs: vec![TabKind::Playback],
lazy_capture: Default::default(),
filters: Default::default(),
};
let mut app = App::new(wirehose, event_rx, config);
let object_id = ObjectId::from_raw_id(0);
let mut props = PropertyStore::default();
props.set_node_description(String::from("Test node"));
props.set_media_class(String::from("Stream/Output/Audio"));
props.set_media_name(String::from("Media name"));
props.set_node_name(String::from("Node name"));
props.set_object_serial(0);
let props = props;
let events = vec![
StateEvent::NodeProperties { object_id, props },
StateEvent::NodePositions {
object_id,
positions: vec![0, 1],
},
StateEvent::NodeStreamStarted {
object_id,
rate: 44100,
peaks: Arc::new([0.0.into(), 0.0.into()]),
},
StateEvent::NodeVolumes {
object_id,
volumes: vec![1.0, 1.0],
},
StateEvent::NodeMute {
object_id,
mute: false,
},
];
for event in events {
event.handle(&mut app).unwrap();
}
app.view =
View::from(wirehose, &app.state, &app.config.names, &Vec::new());
Action::SelectObject(object_id).handle(&mut app).unwrap();
app
}
fn add_capturable_node(app: &mut App<'_>, object_id: ObjectId) {
let mut props = PropertyStore::default();
props.set_node_description(String::from("Test node"));
props.set_media_class(String::from("Stream/Output/Audio"));
props.set_object_serial(u32::from(object_id) as u64);
StateEvent::NodeProperties { object_id, props }
.handle(app)
.unwrap();
}
#[test]
fn select_tab_bounds() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
let _ = Action::SelectTab(app.tabs.len()).handle(&mut app);
assert!(app.current_tab_index < app.tabs.len());
}
#[test]
fn key_modifiers() {
use crossterm::event::{KeyCode, KeyModifiers};
use std::collections::HashMap;
let wirehose = mock::WirehoseHandle::default();
let (_, event_rx) = mpsc::channel();
let x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let ctrl_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
let keybindings = HashMap::from([
(x, Action::SelectTab(2)),
(ctrl_x, Action::SelectTab(4)),
]);
let config = Config {
remote: None,
fps: None,
mouse: false,
peaks: Default::default(),
char_set: Default::default(),
theme: Default::default(),
max_volume_percent: Default::default(),
enforce_max_volume: Default::default(),
keybindings,
help: Default::default(),
names: Default::default(),
tab: 0,
tabs: vec![
TabKind::Playback,
TabKind::Recording,
TabKind::Output,
TabKind::Input,
TabKind::Configuration,
],
lazy_capture: Default::default(),
filters: Default::default(),
};
let mut app = App::new(&wirehose, event_rx, config);
let _ = x.handle(&mut app);
assert_eq!(app.current_tab_index, 2);
let _ = ctrl_x.handle(&mut app);
assert_eq!(app.current_tab_index, 4);
let _ = x.handle(&mut app);
assert_eq!(app.current_tab_index, 2);
}
#[test]
fn help_underflow() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
assert!(Action::Help.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(0));
assert!(Action::MoveUp.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(0));
}
#[test]
fn help_up_down() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
assert!(Action::Help.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(0));
assert!(Action::MoveDown.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(1));
assert!(Action::MoveUp.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(0));
}
#[test]
fn help_toggle() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
assert!(Action::Help.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(0));
assert!(Action::Help.handle(&mut app).unwrap());
assert!(app.help_position.is_none());
}
#[test]
fn help_ignore_other_actions() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
assert!(Action::SetDefault.handle(&mut app).unwrap());
assert!(Action::Help.handle(&mut app).unwrap());
assert_eq!(app.help_position, Some(0));
assert!(!Action::SetDefault.handle(&mut app).unwrap());
}
#[test]
fn volume_limit_not_enforcing() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
app.config.max_volume_percent = 100.0;
app.config.enforce_max_volume = false;
assert!(Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());
assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
}
#[test]
fn volume_limit_at_max() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
app.config.max_volume_percent = 100.0;
app.config.enforce_max_volume = true;
assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());
assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(1.00).handle(&mut app).unwrap());
}
#[test]
fn volume_limit_above_max() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
app.config.max_volume_percent = 95.0;
app.config.enforce_max_volume = true;
assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());
assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(0.95).handle(&mut app).unwrap());
}
#[test]
fn volume_limit_below_max() {
let wirehose = mock::WirehoseHandle::default();
let mut app = fixture(&wirehose);
app.config.max_volume_percent = 105.0;
app.config.enforce_max_volume = true;
assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());
assert!(Action::SetRelativeVolume(0.05).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(1.05).handle(&mut app).unwrap());
assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
}
#[test]
fn update_capturing_noop_when_lazy_disabled() {
let commands = RefCell::new(VecDeque::new());
let wirehose = mock::WirehoseHandle::with_commands(&commands);
let (_, event_rx) = mpsc::channel();
let config = Config::from_toml_str("lazy_capture = false");
let mut app = App::new(&wirehose, event_rx, config);
let id = ObjectId::from_raw_id(1);
add_capturable_node(&mut app, id);
app.capturable_objects.insert(id);
app.visible_objects.insert(id);
commands.borrow_mut().clear();
app.update_capturing();
assert!(commands.borrow().is_empty());
}
#[test]
fn update_capturing_starts_visible_capturable() {
let commands = RefCell::new(VecDeque::new());
let wirehose = mock::WirehoseHandle::with_commands(&commands);
let (_, event_rx) = mpsc::channel();
let config = Config::from_toml_str("lazy_capture = true");
let mut app = App::new(&wirehose, event_rx, config);
let id = ObjectId::from_raw_id(1);
add_capturable_node(&mut app, id);
app.capturable_objects.insert(id);
app.visible_objects.insert(id);
commands.borrow_mut().clear();
app.update_capturing();
assert_eq!(
commands.borrow_mut().pop_front(),
Some(mock::MockCommand::NodeCaptureStart(id))
);
assert!(app.capturing_objects.contains(&id));
}
#[test]
fn update_capturing_stops_invisible() {
let commands = RefCell::new(VecDeque::new());
let wirehose = mock::WirehoseHandle::with_commands(&commands);
let (_, event_rx) = mpsc::channel();
let config = Config::from_toml_str("lazy_capture = true");
let mut app = App::new(&wirehose, event_rx, config);
let id = ObjectId::from_raw_id(1);
add_capturable_node(&mut app, id);
app.capturable_objects.insert(id);
app.capturing_objects.insert(id);
commands.borrow_mut().clear();
app.update_capturing();
assert_eq!(
commands.borrow_mut().pop_front(),
Some(mock::MockCommand::NodeCaptureStop(id))
);
assert!(!app.capturing_objects.contains(&id));
}
#[test]
fn set_capture_eligibility_eligible_starts_capture() {
let commands = RefCell::new(VecDeque::new());
let wirehose = mock::WirehoseHandle::with_commands(&commands);
let (_, event_rx) = mpsc::channel();
let config = Config::from_toml_str("lazy_capture = false");
let mut app = App::new(&wirehose, event_rx, config);
let id = ObjectId::from_raw_id(1);
add_capturable_node(&mut app, id);
app.capturing_objects.clear();
app.capturable_objects.clear();
commands.borrow_mut().clear();
app.set_capture_eligibility(CaptureEligibility::Eligible(id));
assert!(app.capturable_objects.contains(&id));
assert_eq!(
commands.borrow_mut().pop_front(),
Some(mock::MockCommand::NodeCaptureStart(id))
);
}
#[test]
fn set_capture_eligibility_eligible_skips_invisible_when_lazy() {
let commands = RefCell::new(VecDeque::new());
let wirehose = mock::WirehoseHandle::with_commands(&commands);
let (_, event_rx) = mpsc::channel();
let config = Config::from_toml_str("lazy_capture = true");
let mut app = App::new(&wirehose, event_rx, config);
let id = ObjectId::from_raw_id(1);
add_capturable_node(&mut app, id);
commands.borrow_mut().clear();
app.set_capture_eligibility(CaptureEligibility::Eligible(id));
assert!(app.capturable_objects.contains(&id));
assert!(commands.borrow().is_empty());
assert!(!app.capturing_objects.contains(&id));
}
#[test]
fn set_capture_eligibility_ineligible_stops_capture() {
let commands = RefCell::new(VecDeque::new());
let wirehose = mock::WirehoseHandle::with_commands(&commands);
let (_, event_rx) = mpsc::channel();
let config = Config::from_toml_str("lazy_capture = false");
let mut app = App::new(&wirehose, event_rx, config);
let id = ObjectId::from_raw_id(1);
add_capturable_node(&mut app, id);
app.capturable_objects.insert(id);
app.capturing_objects.insert(id);
commands.borrow_mut().clear();
app.set_capture_eligibility(CaptureEligibility::Ineligible(id));
assert!(!app.capturable_objects.contains(&id));
assert!(!app.capturing_objects.contains(&id));
assert_eq!(
commands.borrow_mut().pop_front(),
Some(mock::MockCommand::NodeCaptureStop(id))
);
}
}