use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::thread;
use ratatui::crossterm::event::{self, KeyEventKind, MouseButton, MouseEventKind};
use signal_hook::iterator::Signals;
use crate::app::{Asked, Wanted};
use crate::collect::agents::Agents;
use crate::collect::changes::{self, Reported, Socket};
use crate::collect::panes::{Aside, Panes};
use crate::view::{Notch, Notice};
use super::drive::Event;
use super::Collecting;
fn inbound(opened: Result<Socket, changes::Refused>) -> (Option<Socket>, Option<Notice>) {
match opened {
Ok(socket) => (Some(socket), None),
Err(refused) => {
eprintln!("bdi: {refused}");
(None, Some(said_at_the_foot(&refused)))
}
}
}
fn said_at_the_foot(refused: &changes::Refused) -> Notice {
match refused {
changes::Refused::AlreadyListening(_) => Notice::AnotherBdiHadTheInboundChannel,
changes::Refused::NoRuntimeDirectory
| changes::Refused::NotASocket(_)
| changes::Refused::NameOthersMayTake(_)
| changes::Refused::Unopenable(_, _) => Notice::NoInboundChannel,
}
}
pub(super) type Wired = (
Receiver<Event>,
Sender<Asked>,
Box<dyn Panes>,
Option<Socket>,
Vec<Notice>,
);
pub(super) fn wire(
reported: Reported,
agents: Arc<dyn Agents>,
listening_on: Option<PathBuf>,
collect: Collecting,
asked_to_stop: Signals,
) -> Wired {
let (to_the_loop, events) = mpsc::channel();
let (ask, asked) = mpsc::channel();
let panes: Box<dyn Panes> = Box::new(Aside::new(agents, to_the_loop.clone()));
let collecting = to_the_loop.clone();
thread::spawn(move || collector(collect, &asked, &collecting));
let typing = to_the_loop.clone();
thread::spawn(move || keys(&typing));
let stopping = to_the_loop.clone();
thread::spawn(move || signalled(asked_to_stop, &stopping));
let (changed, changes) = mpsc::channel();
let (socket, refused) = inbound(changes::listen(listening_on, &reported, changed.clone()));
thread::spawn(move || {
report(
&mut Inbound {
changes,
_open: changed,
},
&to_the_loop,
);
});
(events, ask, panes, socket, refused.into_iter().collect())
}
trait Changes: Send {
fn next(&mut self) -> Option<Wanted>;
}
struct Inbound {
changes: Receiver<String>,
_open: Sender<String>,
}
impl Changes for Inbound {
fn next(&mut self) -> Option<Wanted> {
self.changes.recv().ok().map(Wanted::Project)
}
}
fn report(source: &mut dyn Changes, to: &Sender<Event>) {
while let Some(wanted) = source.next() {
if to.send(Event::Changed(wanted)).is_err() {
return;
}
}
}
pub(super) fn collector(mut collect: Collecting, asked: &Receiver<Asked>, to: &Sender<Event>) {
while let Ok(asked) = asked.recv() {
let Some(snapshot) = collect(asked) else {
continue;
};
if to.send(Event::Collected(Box::new(snapshot))).is_err() {
return;
}
}
}
fn signalled(mut asked_to_stop: Signals, to: &Sender<Event>) {
if asked_to_stop.forever().next().is_some() {
let _ = to.send(Event::Signalled);
}
}
fn keys(to: &Sender<Event>) {
while let Ok(read) = event::read() {
let Some(event) = incoming(read) else {
continue;
};
if to.send(event).is_err() {
return;
}
}
}
fn incoming(read: event::Event) -> Option<Event> {
match read {
event::Event::Key(key) if key.kind == KeyEventKind::Press => Some(Event::Key(key)),
event::Event::Resize(..) => Some(Event::Resize),
event::Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => Some(Event::Clicked(mouse.row)),
MouseEventKind::ScrollUp => Some(Event::Scrolled(Notch::Up)),
MouseEventKind::ScrollDown => Some(Event::Scrolled(Notch::Down)),
_ => None,
},
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::fixtures::{arkham, A_MOMENT};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::time::Duration;
struct OnCue(Receiver<Wanted>);
impl Changes for OnCue {
fn next(&mut self) -> Option<Wanted> {
self.0.recv().ok()
}
}
#[test]
fn a_project_is_reported_by_whatever_reports_its_changes() {
let (to_the_loop, events) = mpsc::channel();
let (cue, cued) = mpsc::channel();
thread::spawn(move || report(&mut OnCue(cued), &to_the_loop));
assert!(
events.recv_timeout(Duration::from_millis(100)).is_err(),
"nothing was reported until the source said so"
);
cue.send(arkham()).expect("the source is listening");
assert_eq!(
events.recv_timeout(A_MOMENT).ok(),
Some(Event::Changed(arkham())),
"the loop was told which project moved, not just that something did"
);
}
#[test]
fn an_inbound_channel_with_no_writers_left_goes_quiet() {
let (to_the_loop, events) = mpsc::channel();
let (changed, changes) = mpsc::channel();
thread::spawn(move || {
report(
&mut Inbound {
changes,
_open: changed,
},
&to_the_loop,
);
});
assert!(events.recv_timeout(Duration::from_millis(100)).is_err());
}
#[test]
fn a_project_named_on_the_inbound_channel_is_reported_as_that_project() {
let (to_the_loop, events) = mpsc::channel();
let (changed, changes) = mpsc::channel();
let open = changed.clone();
thread::spawn(move || {
report(
&mut Inbound {
changes,
_open: open,
},
&to_the_loop,
);
});
changed
.send("arkham".to_string())
.expect("the source is listening");
assert_eq!(
events.recv_timeout(A_MOMENT).ok(),
Some(Event::Changed(arkham())),
"the loop was told which project a writer said had moved"
);
}
#[test]
fn a_reporter_ends_when_the_loop_stops_listening() {
let (to_the_loop, events) = mpsc::channel();
let (cue, cued) = mpsc::channel();
let reporter = thread::spawn(move || report(&mut OnCue(cued), &to_the_loop));
drop(events);
let _ = cue.send(arkham());
assert!(reporter.join().is_ok());
}
fn moused(kind: MouseEventKind, row: u16) -> event::Event {
event::Event::Mouse(event::MouseEvent {
kind,
column: 17,
row,
modifiers: KeyModifiers::NONE,
})
}
const ON_ROW: u16 = 9;
fn every_mouse_kind() -> Vec<(MouseEventKind, Option<Event>)> {
let every = vec![
(
MouseEventKind::Down(MouseButton::Left),
Some(Event::Clicked(ON_ROW)),
),
(MouseEventKind::Down(MouseButton::Right), None),
(MouseEventKind::Down(MouseButton::Middle), None),
(MouseEventKind::Up(MouseButton::Left), None),
(MouseEventKind::Up(MouseButton::Right), None),
(MouseEventKind::Up(MouseButton::Middle), None),
(MouseEventKind::Drag(MouseButton::Left), None),
(MouseEventKind::Drag(MouseButton::Right), None),
(MouseEventKind::Drag(MouseButton::Middle), None),
(MouseEventKind::Moved, None),
(MouseEventKind::ScrollUp, Some(Event::Scrolled(Notch::Up))),
(
MouseEventKind::ScrollDown,
Some(Event::Scrolled(Notch::Down)),
),
(MouseEventKind::ScrollLeft, None),
(MouseEventKind::ScrollRight, None),
];
for (kind, _) in &every {
match kind {
MouseEventKind::Down(button)
| MouseEventKind::Up(button)
| MouseEventKind::Drag(button) => match button {
MouseButton::Left | MouseButton::Right | MouseButton::Middle => (),
},
MouseEventKind::Moved
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => (),
}
}
every
}
#[test]
fn every_kind_of_mouse_report_is_answered_or_dropped_on_purpose() {
for (kind, wanted) in every_mouse_kind() {
assert_eq!(incoming(moused(kind, ON_ROW)), wanted, "{kind:?}");
}
}
#[test]
fn a_pointer_moving_over_the_screen_reaches_the_loop_not_at_all() {
let flood: Vec<Option<Event>> = (0..500)
.map(|row| incoming(moused(MouseEventKind::Moved, row % 24)))
.collect();
assert!(flood.iter().all(Option::is_none));
}
#[test]
fn a_click_is_read_as_the_row_it_landed_on() {
for row in [0, 9, 23, u16::MAX] {
assert_eq!(
incoming(moused(MouseEventKind::Down(MouseButton::Left), row)),
Some(Event::Clicked(row)),
"row {row}"
);
}
}
#[test]
fn a_key_release_a_focus_change_and_a_resize_are_read_as_they_were() {
let pressed = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let released = KeyEvent::new_with_kind(
KeyCode::Char('j'),
KeyModifiers::NONE,
KeyEventKind::Release,
);
assert_eq!(
incoming(event::Event::Key(pressed)),
Some(Event::Key(pressed))
);
assert_eq!(incoming(event::Event::Key(released)), None);
assert_eq!(incoming(event::Event::Resize(80, 24)), Some(Event::Resize));
assert_eq!(incoming(event::Event::FocusGained), None);
assert_eq!(incoming(event::Event::FocusLost), None);
}
fn a_socket_path(named: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("bdi-{named}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a directory to put the socket in");
dir.join("beady-eye").join("changes.sock")
}
#[test]
fn a_channel_that_would_not_open_leaves_a_notice_behind_it() {
let (socket, notice) = inbound(Err(changes::Refused::NoRuntimeDirectory));
assert!(socket.is_none());
assert_eq!(notice, Some(Notice::NoInboundChannel));
}
#[test]
fn a_socket_another_bdi_holds_is_said_to_be_that_rather_than_just_lost() {
let (socket, notice) = inbound(Err(changes::Refused::AlreadyListening(
std::path::PathBuf::from("/run/user/1000/beady-eye/changes.sock"),
)));
assert!(socket.is_none());
assert_eq!(notice, Some(Notice::AnotherBdiHadTheInboundChannel));
}
#[test]
fn a_channel_lost_to_nothing_anyone_can_close_offers_no_remedy() {
for refused in [
changes::Refused::NoRuntimeDirectory,
changes::Refused::Unopenable(
std::path::PathBuf::from("/run/user/1000/beady-eye/changes.sock"),
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
),
] {
let (_, notice) = inbound(Err(refused));
assert_eq!(notice, Some(Notice::NoInboundChannel));
}
}
#[test]
fn a_channel_that_opens_says_nothing() {
let (changed, _changes) = mpsc::channel();
let at = a_socket_path("inbound");
let (socket, notice) = inbound(changes::listen(
Some(at),
&Reported::watching(["arkham".to_string()]),
changed,
));
assert!(socket.is_some());
assert_eq!(notice, None);
}
#[test]
fn a_writer_on_the_socket_moves_the_project_it_named_on_the_loops_channel() {
let (to_the_loop, events) = mpsc::channel();
let (changed, changes) = mpsc::channel();
let at = a_socket_path("reported");
let _socket = changes::listen(
Some(at.clone()),
&Reported::watching(["arkham".to_string()]),
changed.clone(),
)
.expect("a socket of this test's own");
thread::spawn(move || {
report(
&mut Inbound {
changes,
_open: changed,
},
&to_the_loop,
);
});
let mut writer = UnixStream::connect(&at).expect("bdi is listening");
writeln!(writer, "arkham").expect("the channel takes a line");
assert_eq!(
events.recv_timeout(A_MOMENT).ok(),
Some(Event::Changed(arkham())),
"what a writer said on the socket reached the loop as a project to collect for"
);
}
}