lean-tui 1.1.1

Standalone TUI infoview for Lean 4 theorem prover
use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Layout, Rect},
    style::Style,
    text::Line,
    widgets::{Block, Clear, Widget},
    Frame,
};

use crate::{
    message::ClickTarget,
    model::{ActiveWorker, Model, Overlay, WorkerConnection},
    theme::Theme,
    widgets::{
        connection_hint::ConnectionHint, error_banner::ErrorBanner, help_menu::HelpMenu,
        input_pane::InputPane, legend::LegendPopup, output_pane::OutputPane,
        render_helpers::render_error, status_bar::StatusBar, worker_selector::WorkerSelector,
    },
};

fn file_name_of(uri: &str) -> &str {
    uri.rsplit('/').next().unwrap_or(uri)
}

pub fn view(model: &Model, frame: &mut Frame) -> Vec<ClickTarget> {
    let area = frame.area();
    let buf = frame.buffer_mut();
    let [main_area, status_area] =
        Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);

    let click_targets = draw_main(model, main_area, buf);
    StatusBar {
        mode_keybindings: model.display_mode.keybindings(),
    }
    .render(status_area, buf);
    draw_overlay(model, area, main_area, buf);
    click_targets
}

fn draw_overlay(model: &Model, area: Rect, main_area: Rect, buf: &mut Buffer) {
    match &model.overlay {
        Some(Overlay::Help) => HelpMenu {
            mode_keybindings: model.display_mode.keybindings(),
        }
        .render(area, buf),
        Some(Overlay::Legend) => LegendPopup {
            sections: model.display_mode.legend_sections(model.dag()),
        }
        .render(area, buf),
        Some(Overlay::WorkerSelector {
            highlighted,
            rescanning,
        }) => WorkerSelector {
            workers: &model.known_workers,
            highlighted: *highlighted,
            rescanning: *rescanning,
            port_range: model.port_range.clone(),
        }
        .render(area, buf),
        Some(Overlay::Error { message }) => {
            let banner = ErrorBanner::new(message);
            let height = banner
                .required_height(main_area.width)
                .min(main_area.height / 2);
            let overlay_area = Rect::new(main_area.x, main_area.y, main_area.width, height);
            Clear.render(overlay_area, buf);
            banner.render(overlay_area, buf);
        }
        None => {}
    }
}

fn connection_hint(model: &Model) -> Option<ConnectionHint<'_>> {
    let port_min = *model.port_range.start();
    let port_max = *model.port_range.end();
    match &model.connection {
        WorkerConnection::Searching => {
            Some(ConnectionHint::SearchingWorkers { port_min, port_max })
        }
        WorkerConnection::Stopped { file_uri, .. } => Some(ConnectionHint::WorkerStopped {
            file_name: file_name_of(file_uri),
            port_min,
            port_max,
        }),
        WorkerConnection::Active(worker) if worker.dag.is_some() || worker.error.is_some() => None,
        WorkerConnection::Active(worker) => match &worker.cursor {
            None => Some(ConnectionHint::AwaitingCursor {
                file_name: worker.file_uri.as_deref().map(file_name_of),
                editor_name: worker.editor_name.as_deref(),
                other_worker_count: model.known_workers.len().saturating_sub(1),
            }),
            Some(cursor) if worker.awaiting_graph => Some(ConnectionHint::AwaitingGraph {
                file_name: file_name_of(&cursor.uri),
                line_number: cursor.position.line + 1,
            }),
            Some(_) => None,
        },
    }
}

fn block_title(worker: &ActiveWorker) -> String {
    match (&worker.definition, &worker.cursor) {
        (Some(definition), Some(cursor)) => {
            let filename = cursor.filename().unwrap_or("?");
            definition.kind.as_ref().map_or_else(
                || format!(" {} ({}) ", definition.name, filename),
                |kind| format!(" {kind} {} ({}) ", definition.name, filename),
            )
        }
        (None, Some(cursor)) => format!("lean-tui ({}) ", cursor.filename().unwrap_or("?")),
        (_, None) => "lean-tui".to_string(),
    }
}

fn position_info(follow_mode: bool, worker: &ActiveWorker) -> String {
    let follow = if follow_mode { "follow" } else { "manual" };
    worker.cursor.as_ref().map_or_else(
        || format!(" [{follow}] "),
        |cursor| {
            format!(
                " {}:{} ({}) [{}] ",
                cursor.position.line + 1,
                cursor.position.character + 1,
                cursor.method,
                follow
            )
        },
    )
}

fn draw_main(model: &Model, area: Rect, buf: &mut Buffer) -> Vec<ClickTarget> {
    match (connection_hint(model), &model.connection) {
        (Some(hint), _) => {
            hint.render(area, buf);
            Vec::new()
        }
        (None, WorkerConnection::Active(worker)) => draw_active_worker(model, worker, area, buf),
        (None, _) => Vec::new(),
    }
}

fn draw_active_worker(
    model: &Model,
    worker: &ActiveWorker,
    area: Rect,
    buf: &mut Buffer,
) -> Vec<ClickTarget> {
    let block = Block::bordered()
        .title(block_title(worker))
        .title_top(Line::from(format!(" {} ", model.display_mode.name())).right_aligned())
        .title_bottom(Line::from(position_info(model.follow_mode, worker)).right_aligned())
        .border_style(Style::new().fg(Theme::FRAME_BORDER));

    let inner = block.inner(area);
    block.render(area, buf);

    let dag = &worker.dag;
    let max_pane_height = inner.height / 4;
    let input_height = if model.show_input {
        (InputPane::required_height(dag.block_input(), inner.width) + 2).min(max_pane_height)
    } else {
        0
    };
    let output_height = if model.show_output {
        (OutputPane::required_height(dag.block_output(), inner.width) + 2).min(max_pane_height)
    } else {
        0
    };
    let [input_area, mode_area, output_area] = Layout::vertical([
        Constraint::Length(input_height),
        Constraint::Fill(1),
        Constraint::Length(output_height),
    ])
    .areas(inner);

    if model.show_input {
        InputPane {
            bindings: dag.block_input(),
        }
        .render(input_area, buf);
    }
    let error = worker
        .error
        .clone()
        .or_else(|| model.display_mode.incompatibility_error(dag));
    let mode_area = error.map_or(mode_area, |error| render_error(&error, mode_area, buf));
    let click_targets = model
        .display_mode
        .draw(dag, model.timing_display, mode_area, buf);
    if model.show_output {
        OutputPane {
            block_output: dag.block_output(),
        }
        .render(output_area, buf);
    }
    click_targets
}

#[cfg(test)]
mod tests {
    use insta::assert_snapshot;

    use crate::scenario_replay::{key, render_scenario, ReplayedMessages};

    #[test]
    fn searching_without_messages() {
        assert_snapshot!(render_scenario(
            "swap",
            &ReplayedMessages::First { count: 0 },
            &[]
        ));
    }

    #[test]
    fn awaiting_graph_after_cursor() {
        assert_snapshot!(render_scenario(
            "swap",
            &ReplayedMessages::First { count: 2 },
            &[]
        ));
    }

    #[test]
    fn swap_input_output_panes() {
        assert_snapshot!(render_scenario(
            "swap",
            &ReplayedMessages::All,
            &[key('i'), key('o')]
        ));
    }

    #[test]
    fn swap_help_menu() {
        assert_snapshot!(render_scenario("swap", &ReplayedMessages::All, &[key('?')]));
    }

    #[test]
    fn swap_worker_selector() {
        assert_snapshot!(render_scenario("swap", &ReplayedMessages::All, &[key('f')]));
    }
}