presenterm 0.16.1

A terminal slideshow presentation tool
use crate::{
    code::{
        execute::{LanguageSnippetExecutor, ProcessStatus, PtySnippetContext},
        snippet::{PtyArgs, Snippet},
    },
    markdown::{
        elements::{Line, Text},
        text_style::{Color, TextStyle},
    },
    render::{
        operation::{
            AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,
            RenderOperation,
        },
        properties::WindowSize,
    },
    theme::{Alignment, PtyOutputBlockStyle},
};
use portable_pty::{MasterPty, PtySize, native_pty_system};
use std::{
    fmt, io, iter, mem,
    sync::{Arc, Mutex},
    thread,
};
use unicode_width::UnicodeWidthStr;

const DEFAULT_COLUMNS: u16 = 80;
const DEFAULT_ROWS: u16 = 24;

#[derive(Default, Debug)]
enum State {
    #[default]
    Initial,
    Running {
        pty: PtyMaster,
        dirty: bool,
    },
    ProcessTerminated(ProcessStatus),
    Done(ProcessStatus),
}

struct Inner {
    snippet: Snippet,
    executor: LanguageSnippetExecutor,
    parser: vt100::Parser,
    expected_size: WindowSize,
    actual_size: WindowSize,
    update_size: bool,
    standby: bool,
    policy: RenderAsyncStartPolicy,
    state: State,
}

impl fmt::Debug for Inner {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Inner")
            .field("snippet", &self.snippet)
            .field("executor", &self.executor)
            .field("expected_size", &self.expected_size)
            .field("actual_size", &self.actual_size)
            .field("update_size", &self.update_size)
            .field("standby", &self.standby)
            .field("parser", &"...")
            .field("policy", &self.policy)
            .field("state", &"...")
            .finish()
    }
}

#[derive(Debug)]
pub(crate) struct PtySnippetOutputOperation {
    handle: PtySnippetHandle,
    style: PtyOutputBlockStyle,
    font_size: u8,
}

impl PtySnippetOutputOperation {
    pub(crate) fn new(handle: PtySnippetHandle, style: PtyOutputBlockStyle, font_size: u8) -> Self {
        Self { handle, style, font_size }
    }

    fn standby_row(&self, row: u16, dimensions: &WindowSize) -> Line {
        let lines = self.style.standby.as_lines();
        let start_index = (dimensions.rows / 2).saturating_sub(lines.len() as u16 / 2);
        if row < start_index || row >= start_index + lines.len() as u16 {
            Text::new("", TextStyle::default().size(self.font_size)).into()
        } else {
            let index = (row - start_index) as usize;
            let padding = usize::from(dimensions.columns / 2).saturating_sub(lines[index].width() / 2);
            let line: String = iter::repeat_n(' ', padding).chain(lines[index].chars()).collect();
            Text::new(line, self.style.style.size(self.font_size)).into()
        }
    }
}

impl AsRenderOperations for PtySnippetOutputOperation {
    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
        let mut inner = self.handle.0.lock().unwrap();
        let dimensions = dimensions
            .shrink_rows(dimensions.rows - dimensions.rows / self.font_size as u16)
            .shrink_columns(dimensions.columns - dimensions.columns / self.font_size as u16);

        if inner.update_size && inner.expected_size != dimensions && dimensions.rows > 0 {
            inner.expected_size = dimensions;
            inner.parser.screen_mut().set_size(dimensions.rows, dimensions.columns);
        }
        if matches!(inner.state, State::Initial) {
            let mut operations = Vec::new();
            if inner.standby {
                let dimensions = inner.expected_size;
                for row in 0..dimensions.rows {
                    let line = self.standby_row(row, &dimensions);
                    operations.extend([
                        RenderOperation::RenderBlockLine(BlockLine {
                            prefix: "".into(),
                            right_padding_length: 0,
                            repeat_prefix_on_wrap: false,
                            text: line.into(),
                            block_length: dimensions.columns,
                            block_color: self.style.style.colors.background,
                            alignment: Alignment::Center {
                                minimum_margin: Default::default(),
                                minimum_size: Default::default(),
                            },
                        }),
                        RenderOperation::RenderLineBreak,
                    ]);
                }
            }
            return operations;
        }

        let screen = inner.parser.screen();
        let (rows, columns) = screen.size();
        let mut operations = vec![];
        let cursor_position = if screen.hide_cursor() { None } else { Some(screen.cursor_position()) };

        for row in 0..rows {
            let mut line = Vec::new();
            let mut current_text = String::new();
            let mut current_style = TextStyle::default();
            for column in 0..columns {
                let cell = screen.cell(row, column).expect("no cell");
                let mut style = TextStyle::from(cell).size(self.font_size);
                if style.colors.foreground.is_none() {
                    style.colors.foreground = self.style.style.colors.foreground;
                }
                if style.colors.background.is_none() {
                    style.colors.background = self.style.style.colors.background;
                }
                let (contents, style) = match cursor_position == Some((row, column)) {
                    true => {
                        let contents = cell.contents();
                        if contents.is_empty() {
                            (self.style.cursor.symbol.as_str(), style)
                        } else {
                            (contents, self.style.cursor.highlight_style)
                        }
                    }
                    false => (cell.contents(), style),
                };
                if current_style != style && !current_text.is_empty() {
                    line.push(Text::new(mem::take(&mut current_text), current_style));
                }
                current_style = style;
                if contents.is_empty() {
                    current_text.push(' ');
                } else {
                    current_text.push_str(contents);
                }
            }
            if !current_text.is_empty() {
                line.push(Text::new(current_text, current_style));
            }
            operations.extend([
                RenderOperation::RenderBlockLine(BlockLine {
                    prefix: "".into(),
                    right_padding_length: 0,
                    repeat_prefix_on_wrap: false,
                    text: line.into(),
                    block_length: columns,
                    block_color: None,
                    alignment: Alignment::Center {
                        minimum_margin: Default::default(),
                        minimum_size: Default::default(),
                    },
                }),
                RenderOperation::RenderLineBreak,
            ]);
        }
        operations
    }
}

impl RenderAsync for PtySnippetOutputOperation {
    fn pollable(&self) -> Box<dyn Pollable> {
        Box::new(OperationPollable { handle: self.handle.clone() })
    }
}

#[derive(Debug)]
struct OperationPollable {
    handle: PtySnippetHandle,
}

impl OperationPollable {
    fn spawn(ctx: PtySnippetContext, dimensions: WindowSize, handle: PtySnippetHandle) -> anyhow::Result<PtyMaster> {
        let pty_system = native_pty_system();
        let pty_size = PtySize {
            rows: dimensions.rows,
            cols: dimensions.columns,
            pixel_width: dimensions.pixels_per_column() as u16,
            pixel_height: dimensions.pixels_per_row() as u16,
        };
        let pair = pty_system.openpty(pty_size)?;
        pair.slave.spawn_command(ctx.command.clone())?;
        PtyMaster::new(pair.master, handle, ctx)
    }
}

impl Pollable for OperationPollable {
    fn poll(&mut self) -> PollableState {
        let mut inner = self.handle.0.lock().unwrap();
        let expected_size = inner.expected_size;
        let actual_size = inner.actual_size;
        inner.actual_size = expected_size;
        match &mut inner.state {
            State::Initial => match inner.executor.pty_execution_context(&inner.snippet) {
                Ok(ctx) => match Self::spawn(ctx, expected_size, self.handle.clone()) {
                    Ok(pty) => {
                        inner.state = State::Running { pty, dirty: true };
                        PollableState::Modified
                    }
                    Err(e) => {
                        inner.state = State::Done(ProcessStatus::Failure);
                        PollableState::Failed { error: format!("failed to run script: {e}") }
                    }
                },
                Err(e) => {
                    inner.state = State::Done(ProcessStatus::Failure);
                    PollableState::Failed { error: format!("failed to run script: {e}") }
                }
            },
            State::Running { dirty, pty } => {
                if actual_size != expected_size {
                    let size = PtySize {
                        rows: expected_size.rows,
                        cols: expected_size.columns,
                        pixel_width: 0,
                        pixel_height: 0,
                    };
                    let _ = pty._master.resize(size);
                }

                if mem::take(dirty) { PollableState::Modified } else { PollableState::Unmodified }
            }
            State::ProcessTerminated(status) => {
                inner.state = State::Done(*status);
                PollableState::Modified
            }
            _ => PollableState::Unmodified,
        }
    }
}

pub(crate) struct PtyMaster {
    _master: Box<dyn MasterPty>,
    _ctx: PtySnippetContext,
}

impl PtyMaster {
    fn new(master: Box<dyn MasterPty>, handle: PtySnippetHandle, ctx: PtySnippetContext) -> anyhow::Result<Self> {
        let reader = master.try_clone_reader()?;
        thread::spawn(|| process_output(reader, handle));
        Ok(Self { _master: master, _ctx: ctx })
    }
}

impl fmt::Debug for PtyMaster {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PtyMaster").field("master", &"...").finish()
    }
}

fn process_output(mut reader: Box<dyn io::Read>, handle: PtySnippetHandle) {
    let mut input_buffer = [0; 1024];
    let status = loop {
        let Ok(bytes_read) = reader.read(&mut input_buffer) else {
            break ProcessStatus::Failure;
        };
        if bytes_read == 0 {
            break ProcessStatus::Success;
        }
        let bytes = &input_buffer[..bytes_read];
        let mut inner = handle.0.lock().unwrap();
        inner.parser.process(bytes);
        if let State::Running { dirty, .. } = &mut inner.state {
            *dirty = true;
        };
    };
    handle.0.lock().unwrap().state = State::ProcessTerminated(status);
}

impl From<&vt100::Cell> for TextStyle {
    fn from(cell: &vt100::Cell) -> Self {
        let mut style = TextStyle::default();
        if cell.bold() {
            style = style.bold();
        }
        if cell.italic() {
            style = style.italics();
        }
        if cell.underline() {
            style = style.underlined();
        }
        style.colors.foreground = parse_color(cell.fgcolor());
        style.colors.background = parse_color(cell.bgcolor());
        style
    }
}

fn parse_color(color: vt100::Color) -> Option<Color> {
    match color {
        vt100::Color::Default => None,
        vt100::Color::Idx(value) => Color::from_8bit(value),
        vt100::Color::Rgb(r, g, b) => Some(Color::Rgb { r, g, b }),
    }
}

#[derive(Debug, Clone)]
pub(crate) struct PtySnippetHandle(Arc<Mutex<Inner>>);

impl PtySnippetHandle {
    pub(crate) fn new(
        snippet: Snippet,
        executor: LanguageSnippetExecutor,
        policy: RenderAsyncStartPolicy,
        args: PtyArgs,
    ) -> Self {
        let expected_size = WindowSize {
            columns: args.columns.unwrap_or(DEFAULT_COLUMNS),
            rows: args.rows.unwrap_or(DEFAULT_ROWS),
            height: 0,
            width: 0,
        };
        let update_size = args.columns.is_none() || args.rows.is_none();
        let parser = vt100::Parser::new(expected_size.rows, expected_size.columns, 1000);
        let inner = Inner {
            snippet,
            executor,
            parser,
            expected_size,
            actual_size: expected_size,
            update_size,
            standby: args.standby,
            state: Default::default(),
            policy,
        };
        Self(Arc::new(Mutex::new(inner)))
    }

    pub(crate) fn snippet(&self) -> Snippet {
        self.0.lock().unwrap().snippet.clone()
    }

    pub(crate) fn process_status(&self) -> Option<ProcessStatus> {
        match &self.0.lock().unwrap().state {
            State::Initial => None,
            State::Running { .. } => Some(ProcessStatus::Running),
            State::ProcessTerminated(status) | State::Done(status) => Some(*status),
        }
    }
}

#[derive(Debug)]
pub(crate) struct RunPtySnippetTrigger(PtySnippetHandle);

impl RunPtySnippetTrigger {
    pub(crate) fn new(handle: PtySnippetHandle) -> Self {
        Self(handle)
    }
}

impl AsRenderOperations for RunPtySnippetTrigger {
    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
        vec![]
    }
}

impl RenderAsync for RunPtySnippetTrigger {
    fn pollable(&self) -> Box<dyn Pollable> {
        Box::new(OperationPollable { handle: self.0.clone() })
    }

    fn start_policy(&self) -> RenderAsyncStartPolicy {
        self.0.0.lock().unwrap().policy
    }
}