dua-cli 2.44.0

A tool to conveniently learn about the disk usage of directories, fast!
Documentation
use crossbeam::channel::Receiver;
pub use crossterm::event::{Event, KeyCode};
use std::sync::{
    Arc,
    atomic::{AtomicBool, Ordering},
};

/// The latest known focus state of the terminal running the interactive UI.
///
/// The application state keeps one clone while another is moved to the input
/// thread. That thread records focus events as soon as they are read, including
/// while the main event loop is busy with synchronous work or its bounded input
/// queue is full. Focus events are consumed by the tracker instead of occupying
/// queue capacity. Completion handlers then consult this state to avoid sending
/// notifications while the terminal is focused.
#[derive(Clone)]
pub struct TerminalFocus(Arc<AtomicBool>);

impl Default for TerminalFocus {
    fn default() -> Self {
        Self(Arc::new(AtomicBool::new(true)))
    }
}

impl TerminalFocus {
    pub fn is_focussed(&self) -> bool {
        self.0.load(Ordering::Relaxed)
    }

    pub fn observe(&self, event: &Event) {
        let value = match event {
            Event::FocusGained => true,
            Event::FocusLost => false,
            _ => return,
        };
        self.0.store(value, Ordering::Relaxed);
    }
}

/// Returns whether the input thread should continue reading events.
///
/// Return `false` if the channel was disconnected, so forwarding should stop.
fn forward_event(
    sender: &crossbeam::channel::Sender<Event>,
    focus: &TerminalFocus,
    event: Event,
) -> bool {
    focus.observe(&event);
    if matches!(event, Event::FocusGained | Event::FocusLost) {
        return true;
    }
    match sender.try_send(event) {
        Ok(()) | Err(crossbeam::channel::TrySendError::Full(_)) => true,
        Err(crossbeam::channel::TrySendError::Disconnected(_)) => false,
    }
}

pub fn input_channel(focus: TerminalFocus) -> Receiver<Event> {
    // Keep reading while the event loop performs synchronous deletion or trash work so the
    // shared focus state stays current, without allowing user input to grow without bound.
    let (key_send, key_receive) = crossbeam::channel::bounded(32);
    std::thread::spawn(move || -> Result<(), std::io::Error> {
        loop {
            let event = loop {
                match crossterm::event::read() {
                    Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
                    result => break result?,
                }
            };
            if !forward_event(&key_send, &focus, event) {
                break;
            }
        }
        Ok(())
    });
    key_receive
}

/// Return a receiver for a configured-key expression, or a legacy sequence of character keys.
pub fn input_channel_from_keys(input: &str) -> Result<Receiver<Event>, String> {
    let (key_send, key_receive) = crossbeam::channel::unbounded();
    let events = if input.is_empty() {
        Vec::new()
    } else {
        match input.parse::<dua::KeyBinding>() {
            Ok(binding) => vec![Event::Key(binding.to_event())],
            Err(err) if input.contains('+') => return Err(err),
            Err(_) => input
                .chars()
                .map(|character| Event::Key(KeyCode::Char(character).into()))
                .collect(),
        }
    };
    for event in events {
        if key_send.send(event).is_err() {
            break;
        }
    }
    Ok(key_receive)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cloned_focus_tracker_observes_changes_immediately() {
        let app_focus = TerminalFocus::default();
        let input_focus = app_focus.clone();

        input_focus.observe(&Event::FocusLost);
        assert!(!app_focus.is_focussed());
        input_focus.observe(&Event::FocusGained);
        assert!(app_focus.is_focussed());
    }

    #[test]
    fn focus_changes_bypass_a_full_bounded_event_queue() {
        let focus = TerminalFocus::default();
        let (sender, receiver) = crossbeam::channel::bounded(1);
        assert!(forward_event(
            &sender,
            &focus,
            Event::Key(KeyCode::Char('a').into()),
        ));
        assert!(forward_event(&sender, &focus, Event::FocusLost));
        assert!(forward_event(&sender, &focus, Event::FocusGained));
        assert!(focus.is_focussed());
        assert!(matches!(receiver.try_recv(), Ok(Event::Key(_))));
        assert!(receiver.try_recv().is_err());
    }

    #[test]
    fn once_input_accepts_configured_key_syntax_and_legacy_sequences() {
        use crossterm::event::{KeyEvent, KeyModifiers};

        let modified = input_channel_from_keys("ctrl+r").unwrap();
        assert_eq!(
            modified.recv().unwrap(),
            Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL))
        );

        let named = input_channel_from_keys("page-down").unwrap();
        assert_eq!(named.recv().unwrap(), Event::Key(KeyCode::PageDown.into()));

        let legacy = input_channel_from_keys("jk").unwrap();
        assert_eq!(
            legacy.recv().unwrap(),
            Event::Key(KeyCode::Char('j').into())
        );
        assert_eq!(
            legacy.recv().unwrap(),
            Event::Key(KeyCode::Char('k').into())
        );
    }
}