tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use std::io::{self, Write};

use crossterm::event::{
    DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
    EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
    PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::style::Print;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};

use super::native_terminal::{
    DisableAutoWrap, DisableMouseAllMotion, EnableAutoWrap, SurfaceTerminalPolicy,
};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum TerminalOp {
    EnableRawMode,
    DisableRawMode,
    EnterAlternateScreen,
    LeaveAlternateScreen,
    EnableMouseCapture,
    DisableMouseCapture,
    EnableBracketedPaste,
    DisableBracketedPaste,
    EnableFocusChange,
    DisableFocusChange,
    DisableAutoWrap,
    EnableAutoWrap,
    ClearScreen,
    PushKeyboardEnhancement,
    PopKeyboardEnhancement,
    Flush,
}

impl TerminalOp {
    fn rollback(self) -> Option<Self> {
        match self {
            Self::EnableRawMode => Some(Self::DisableRawMode),
            Self::DisableRawMode => Some(Self::EnableRawMode),
            Self::EnterAlternateScreen => Some(Self::LeaveAlternateScreen),
            Self::LeaveAlternateScreen => Some(Self::EnterAlternateScreen),
            Self::EnableMouseCapture => Some(Self::DisableMouseCapture),
            Self::DisableMouseCapture => Some(Self::EnableMouseCapture),
            Self::EnableBracketedPaste => Some(Self::DisableBracketedPaste),
            Self::DisableBracketedPaste => Some(Self::EnableBracketedPaste),
            Self::EnableFocusChange => Some(Self::DisableFocusChange),
            Self::DisableFocusChange => Some(Self::EnableFocusChange),
            Self::DisableAutoWrap => Some(Self::EnableAutoWrap),
            Self::EnableAutoWrap => Some(Self::DisableAutoWrap),
            Self::PushKeyboardEnhancement => Some(Self::PopKeyboardEnhancement),
            Self::PopKeyboardEnhancement => Some(Self::PushKeyboardEnhancement),
            Self::ClearScreen | Self::Flush => None,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct TerminalTransitionPlan {
    ops: Vec<TerminalOp>,
}

impl TerminalTransitionPlan {
    fn new(ops: Vec<TerminalOp>) -> Self {
        Self { ops }
    }

    pub(crate) fn ops(&self) -> &[TerminalOp] {
        &self.ops
    }
}

pub(crate) fn enter_plan(
    policy: SurfaceTerminalPolicy,
    mouse_enabled: bool,
    keyboard_enhancement: bool,
) -> TerminalTransitionPlan {
    let mut ops = vec![TerminalOp::EnableRawMode];
    if policy.uses_alternate_screen {
        ops.push(TerminalOp::EnterAlternateScreen);
    }
    if mouse_enabled {
        ops.push(TerminalOp::EnableMouseCapture);
    }
    ops.push(TerminalOp::EnableBracketedPaste);
    if policy.disable_auto_wrap {
        ops.push(TerminalOp::DisableAutoWrap);
    }
    ops.push(TerminalOp::EnableFocusChange);
    if policy.clear_on_start {
        ops.push(TerminalOp::ClearScreen);
        ops.push(TerminalOp::Flush);
    }
    if keyboard_enhancement {
        ops.push(TerminalOp::PushKeyboardEnhancement);
    }
    TerminalTransitionPlan::new(ops)
}

pub(crate) fn exit_plan(
    policy: SurfaceTerminalPolicy,
    keyboard_enhancement: bool,
) -> TerminalTransitionPlan {
    let mut ops = vec![TerminalOp::DisableRawMode];
    if keyboard_enhancement {
        ops.push(TerminalOp::PopKeyboardEnhancement);
    }
    if policy.disable_auto_wrap {
        ops.push(TerminalOp::EnableAutoWrap);
    }
    if policy.uses_alternate_screen {
        ops.push(TerminalOp::LeaveAlternateScreen);
    }
    ops.push(TerminalOp::DisableBracketedPaste);
    ops.push(TerminalOp::DisableMouseCapture);
    ops.push(TerminalOp::DisableFocusChange);
    TerminalTransitionPlan::new(ops)
}

pub(crate) fn suspend_plan(policy: SurfaceTerminalPolicy) -> TerminalTransitionPlan {
    let mut ops = vec![TerminalOp::DisableRawMode];
    if policy.disable_auto_wrap {
        ops.push(TerminalOp::EnableAutoWrap);
    }
    if policy.uses_alternate_screen {
        ops.push(TerminalOp::LeaveAlternateScreen);
    }
    ops.push(TerminalOp::DisableBracketedPaste);
    ops.push(TerminalOp::DisableMouseCapture);
    ops.push(TerminalOp::DisableFocusChange);
    ops.push(TerminalOp::Flush);
    TerminalTransitionPlan::new(ops)
}

pub(crate) fn resume_plan(
    policy: SurfaceTerminalPolicy,
    mouse_enabled: bool,
) -> TerminalTransitionPlan {
    let mut ops = vec![TerminalOp::EnableRawMode];
    if policy.uses_alternate_screen {
        ops.push(TerminalOp::EnterAlternateScreen);
    }
    if mouse_enabled {
        ops.push(TerminalOp::EnableMouseCapture);
    }
    ops.push(TerminalOp::EnableBracketedPaste);
    if policy.disable_auto_wrap {
        ops.push(TerminalOp::DisableAutoWrap);
    }
    ops.push(TerminalOp::EnableFocusChange);
    ops.push(TerminalOp::Flush);
    TerminalTransitionPlan::new(ops)
}

pub(crate) trait TerminalTransitionExecutor {
    fn execute_op(&mut self, op: TerminalOp) -> io::Result<()>;
}

pub(crate) fn execute_plan<E: TerminalTransitionExecutor>(
    executor: &mut E,
    plan: &TerminalTransitionPlan,
) -> io::Result<()> {
    for &op in plan.ops() {
        executor.execute_op(op)?;
    }
    Ok(())
}

pub(crate) fn execute_plan_with_rollback<E: TerminalTransitionExecutor>(
    executor: &mut E,
    plan: &TerminalTransitionPlan,
) -> io::Result<()> {
    let mut applied = Vec::new();
    for &op in plan.ops() {
        if let Err(err) = executor.execute_op(op) {
            rollback_applied(executor, &applied);
            return Err(err);
        }
        applied.push(op);
    }
    Ok(())
}

fn rollback_applied<E: TerminalTransitionExecutor>(executor: &mut E, applied: &[TerminalOp]) {
    for op in applied.iter().rev().filter_map(|op| op.rollback()) {
        let _ = executor.execute_op(op);
    }
}

pub(crate) struct CrosstermTransitionExecutor<W> {
    writer: W,
}

impl<W> CrosstermTransitionExecutor<W> {
    pub(crate) fn new(writer: W) -> Self {
        Self { writer }
    }

    pub(crate) fn into_inner(self) -> W {
        self.writer
    }
}

impl<W: Write> TerminalTransitionExecutor for CrosstermTransitionExecutor<W> {
    fn execute_op(&mut self, op: TerminalOp) -> io::Result<()> {
        let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
            | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
        match op {
            TerminalOp::EnableRawMode => enable_raw_mode(),
            TerminalOp::DisableRawMode => disable_raw_mode(),
            TerminalOp::EnterAlternateScreen => execute!(self.writer, EnterAlternateScreen),
            TerminalOp::LeaveAlternateScreen => execute!(self.writer, LeaveAlternateScreen),
            TerminalOp::EnableMouseCapture => execute!(self.writer, EnableMouseCapture),
            TerminalOp::DisableMouseCapture => {
                execute!(self.writer, DisableMouseAllMotion, DisableMouseCapture)
            }
            TerminalOp::EnableBracketedPaste => execute!(self.writer, EnableBracketedPaste),
            TerminalOp::DisableBracketedPaste => execute!(self.writer, DisableBracketedPaste),
            TerminalOp::EnableFocusChange => execute!(self.writer, EnableFocusChange),
            TerminalOp::DisableFocusChange => execute!(self.writer, DisableFocusChange),
            TerminalOp::DisableAutoWrap => execute!(self.writer, DisableAutoWrap),
            TerminalOp::EnableAutoWrap => execute!(self.writer, EnableAutoWrap),
            TerminalOp::ClearScreen => execute!(self.writer, Print("\x1b[2J\x1b[H")),
            TerminalOp::PushKeyboardEnhancement => {
                execute!(self.writer, PushKeyboardEnhancementFlags(flags))
            }
            TerminalOp::PopKeyboardEnhancement => {
                execute!(self.writer, PopKeyboardEnhancementFlags)
            }
            TerminalOp::Flush => self.writer.flush(),
        }
    }
}

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

    #[derive(Default)]
    struct MockExecutor {
        ops: Vec<TerminalOp>,
        fail_on: Option<TerminalOp>,
    }

    impl TerminalTransitionExecutor for MockExecutor {
        fn execute_op(&mut self, op: TerminalOp) -> io::Result<()> {
            if self.fail_on == Some(op) {
                return Err(io::Error::other("planned failure"));
            }
            self.ops.push(op);
            Ok(())
        }
    }

    fn fullscreen_policy() -> SurfaceTerminalPolicy {
        SurfaceTerminalPolicy {
            uses_alternate_screen: true,
            disable_auto_wrap: false,
            clear_on_start: true,
        }
    }

    #[test]
    fn handoff_resume_plan_does_not_push_keyboard_enhancement() {
        let plan = resume_plan(fullscreen_policy(), true);
        assert_eq!(
            plan.ops(),
            &[
                TerminalOp::EnableRawMode,
                TerminalOp::EnterAlternateScreen,
                TerminalOp::EnableMouseCapture,
                TerminalOp::EnableBracketedPaste,
                TerminalOp::EnableFocusChange,
                TerminalOp::Flush,
            ]
        );
    }

    #[test]
    fn enter_plan_includes_keyboard_enhancement_when_requested() {
        let plan = enter_plan(fullscreen_policy(), true, true);
        assert_eq!(
            plan.ops().last(),
            Some(&TerminalOp::PushKeyboardEnhancement)
        );
    }

    #[test]
    fn enter_failure_rolls_back_applied_terminal_modes() {
        let plan = enter_plan(fullscreen_policy(), true, true);
        let mut executor = MockExecutor {
            fail_on: Some(TerminalOp::PushKeyboardEnhancement),
            ..MockExecutor::default()
        };

        let err = execute_plan_with_rollback(&mut executor, &plan).unwrap_err();

        assert_eq!(err.kind(), io::ErrorKind::Other);
        assert_eq!(
            executor.ops,
            vec![
                TerminalOp::EnableRawMode,
                TerminalOp::EnterAlternateScreen,
                TerminalOp::EnableMouseCapture,
                TerminalOp::EnableBracketedPaste,
                TerminalOp::EnableFocusChange,
                TerminalOp::ClearScreen,
                TerminalOp::Flush,
                TerminalOp::DisableFocusChange,
                TerminalOp::DisableBracketedPaste,
                TerminalOp::DisableMouseCapture,
                TerminalOp::LeaveAlternateScreen,
                TerminalOp::DisableRawMode,
            ]
        );
    }

    #[test]
    fn resume_failure_rolls_back_without_keyboard_enhancement() {
        let plan = resume_plan(fullscreen_policy(), true);
        let mut executor = MockExecutor {
            fail_on: Some(TerminalOp::EnableFocusChange),
            ..MockExecutor::default()
        };

        let err = execute_plan_with_rollback(&mut executor, &plan).unwrap_err();

        assert_eq!(err.kind(), io::ErrorKind::Other);
        assert_eq!(
            executor.ops,
            vec![
                TerminalOp::EnableRawMode,
                TerminalOp::EnterAlternateScreen,
                TerminalOp::EnableMouseCapture,
                TerminalOp::EnableBracketedPaste,
                TerminalOp::DisableBracketedPaste,
                TerminalOp::DisableMouseCapture,
                TerminalOp::LeaveAlternateScreen,
                TerminalOp::DisableRawMode,
            ]
        );
    }
}