beady-eye 0.13.0

A tree of work in flight: bead graphs annotated with the live agents working them
Documentation
//! The bands the screen is divided into, and which line of the forest a row
//! of one of them is showing.

use ratatui::layout::Rect;

use crate::view::tail;

/// The three bands of the screen, top to bottom.
///
/// Named rather than returned from `draw` because the tail is drawn by
/// whoever holds one, and `draw` is handed a forest and no tail. Both sides
/// ask here instead of agreeing a number twice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Regions {
    pub forest: Rect,
    pub tail: Rect,
    pub keys: Rect,
}

/// Divide the screen between the forest, the tail and the key bar.
///
/// The tail gives up its rows before the forest gives up any, and the forest
/// is never left with none: a `bdi` with no tree on screen is not showing the
/// thing it exists to show.
pub fn regions(area: Rect) -> Regions {
    let mut rows = area.height;
    let keys = if rows >= 2 { 1 } else { 0 };
    rows -= keys;
    let tail = (tail::LINES + 1).min(rows.saturating_sub(1) / 2);
    let forest = rows - tail;

    Regions {
        forest: Rect {
            height: forest,
            ..area
        },
        tail: Rect {
            y: area.y + forest,
            height: tail,
            ..area
        },
        keys: Rect {
            y: area.y + forest + tail,
            height: keys,
            ..area
        },
    }
}

/// Which line the forest draws on one row of the screen, where it draws one.
///
/// The inverse of the skip-and-take in `draw`, and here beside it rather than
/// beside the click that asks the question: the two are one agreement about
/// where a line goes, and the failure they can have is drifting apart. Both
/// are handed `from` rather than working one out, so there is one viewport
/// and not two answers about it.
///
/// The column is not asked for. Every band spans the width of the screen, so
/// a row is the whole of what a pointer names.
pub fn line_at(forest: Rect, from: usize, lines: usize, row: u16) -> Option<usize> {
    let within = row.checked_sub(forest.y)? as usize;
    if within >= forest.height as usize {
        return None;
    }

    let at = from + within;
    (at < lines).then_some(at)
}

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

    use crate::model::snapshot::ProviderState;
    use crate::view::draw::tests::*;
    use crate::view::{Action, Motion, Notch};

    /// How far a notch is told to go here, which the config settles for a
    /// reader and neither this file nor the forest decides.
    const A_NOTCH: usize = 3;

    // ---- the bands of the screen -----------------------------------------

    #[test]
    fn a_full_screen_gives_the_forest_most_of_it_the_tail_a_look_and_the_keys_a_row() {
        let bands = regions(Rect::new(0, 0, 80, 24));

        assert_eq!(bands.forest, Rect::new(0, 0, 80, 16));
        assert_eq!(
            bands.tail,
            Rect::new(0, 16, 80, 7),
            "six lines of pane, under the rule that names it"
        );
        assert_eq!(bands.keys, Rect::new(0, 23, 80, 1));
    }

    /// The tail yields first, because the forest is the thing this tool is
    /// for and a screen showing no tree is showing nothing.
    #[test]
    fn a_short_screen_takes_the_rows_from_the_tail_and_not_from_the_forest() {
        let bands = regions(Rect::new(0, 0, 80, 10));

        assert_eq!(bands.forest.height, 5);
        assert_eq!(bands.tail.height, 4);
        assert_eq!(bands.keys.height, 1);
    }

    #[test]
    fn the_forest_keeps_a_row_however_little_room_there_is() {
        for height in 1..=8 {
            let bands = regions(Rect::new(0, 0, 80, height));
            assert!(bands.forest.height >= 1, "{height} rows: {bands:?}");
        }
    }

    /// The two smallest screens that still show something, pinned so the rule
    /// that produces them cannot be simplified into one that does not.
    #[test]
    fn the_smallest_screens_spend_their_rows_on_the_forest_first() {
        assert_eq!(
            regions(Rect::new(0, 0, 80, 2)),
            Regions {
                forest: Rect::new(0, 0, 80, 1),
                tail: Rect::new(0, 1, 80, 0),
                keys: Rect::new(0, 1, 80, 1),
            }
        );
        assert_eq!(
            regions(Rect::new(0, 0, 80, 1)),
            Regions {
                forest: Rect::new(0, 0, 80, 1),
                tail: Rect::new(0, 1, 80, 0),
                keys: Rect::new(0, 1, 80, 0),
            }
        );
    }

    /// The three bands are the screen: a gap between them would draw whatever
    /// the last frame left there, and an overlap would draw two things at once.
    #[test]
    fn the_three_bands_tile_the_screen_exactly() {
        for height in 0..40 {
            let area = Rect::new(3, 7, 80, height);
            let bands = regions(area);

            assert_eq!(bands.forest.y, area.y, "{height}");
            assert_eq!(
                bands.tail.y,
                bands.forest.y + bands.forest.height,
                "{height}"
            );
            assert_eq!(bands.keys.y, bands.tail.y + bands.tail.height, "{height}");
            assert_eq!(
                bands.forest.height + bands.tail.height + bands.keys.height,
                area.height,
                "{height}"
            );
        }
    }

    // ---- the line a screen row shows --------------------------------------

    /// The inverse held against the drawing rather than against itself. The
    /// fixture's rows are the header and then `bead number 1` upward, so what
    /// is on a row says which line was drawn there, and a forest taller than
    /// its band is scrolled far enough that an off-by-one in either direction
    /// shows.
    ///
    /// Both ways of scrolling it, because they are what the inverse and the
    /// drawing could disagree about: a motion moves the selection and the
    /// view after it, and a notch moves the view alone. An inverse still
    /// deriving the offset from the selection agrees with the drawing on the
    /// first and is wrong by the whole scroll on the second.
    #[test]
    fn every_row_of_the_forest_names_the_line_drawn_on_it() {
        for wheeled in 0..3 {
            let (width, height) = (60, 24);
            let band = regions(Rect::new(0, 0, width, height)).forest;
            let mut forest = opened(&snapshot(
                vec![grove(40)],
                Vec::new(),
                ProviderState::Answering,
            ));
            forest.fit(band.height as usize);
            forest.apply(Action::Move(Motion::HalfScreenDown));
            for _ in 0..wheeled {
                forest.scrolled(Notch::Down, A_NOTCH);
            }

            let frame = frame_of(&forest, width, height).rows();
            let lines = forest.lines().len();

            for row in band.y..band.y + band.height {
                let at =
                    line_at(band, forest.from(), lines, row).expect("the band is full of lines");
                let shown = match at {
                    0 => "summit-works".to_string(),
                    1 => "lift the ground station".to_string(),
                    at => format!("bead number {}", at - 1),
                };
                assert!(
                    frame[row as usize].contains(&shown),
                    "after {wheeled} notches, row {row} shows {:?}, not line {at}",
                    frame[row as usize]
                );
            }
        }
    }

    /// The rows under the last line of a short forest are blank, and a click
    /// on blank is a click on nothing.
    #[test]
    fn a_row_past_the_last_line_names_none() {
        let band = Rect::new(0, 0, 60, 16);

        assert_eq!(line_at(band, 0, 3, 2), Some(2));
        for row in 3..16 {
            assert_eq!(line_at(band, 0, 3, row), None, "row {row}");
        }
    }

    /// The tail and the key row are drawn by someone else and hold nothing
    /// the selection can sit on.
    #[test]
    fn a_row_outside_the_forest_band_names_none() {
        let bands = regions(Rect::new(0, 0, 60, 24));
        let (from, lines) = (0, 100);

        for row in [bands.tail.y, bands.tail.y + 3, bands.keys.y] {
            assert_eq!(line_at(bands.forest, from, lines, row), None, "row {row}");
        }
    }

    /// A band that starts partway down the screen is the only kind the forest
    /// ever gets when something is drawn above it, and a row measured from
    /// the top of the screen rather than the top of the band would be wrong
    /// by exactly that offset.
    #[test]
    fn a_row_above_the_forest_band_names_none() {
        let band = Rect::new(0, 4, 60, 8);

        assert_eq!(line_at(band, 0, 100, 4), Some(0));
        for row in 0..4 {
            assert_eq!(line_at(band, 0, 100, row), None, "row {row}");
        }
    }

    /// A band scrolled away from the top names the lines it is showing, and
    /// not the ones the forest starts with.
    #[test]
    fn a_scrolled_band_names_the_lines_it_is_showing() {
        let band = Rect::new(0, 0, 60, 8);

        assert_eq!(line_at(band, 12, 100, 0), Some(12));
        assert_eq!(line_at(band, 12, 100, 7), Some(19));
        assert_eq!(line_at(band, 12, 100, 8), None);
    }
}