Codex Handbook
tui/src/pets/preview.rs 164 lines
//! Shared preview-state model for the `/pets` side pane.//!//! The preview pane is intentionally small and stateful: the selection popup//! renders it synchronously, while async preview loading updates this state//! from outside the widget tree. Keeping the state in a mutex-backed object lets//! the picker remember the last preview area for out-of-band image rendering//! without requiring the rest of the popup machinery to know about pet images.use std::sync::Arc;use std::sync::Mutex;use ratatui::buffer::Buffer;use ratatui::layout::Alignment;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::widgets::Paragraph;use ratatui::widgets::Widget;use crate::render::renderable::Renderable;#[derive(Debug, Clone, Default)]pub(crate) struct PetPickerPreviewState {    inner: Arc<Mutex<PetPickerPreviewInner>>,}impl PetPickerPreviewState {    /// Return a renderable wrapper for the picker side pane.    ///    /// The wrapper is cheap to clone and intentionally shares interior state    /// with the controller so selection-change callbacks can update the visible    /// loading/error/ready state without rebuilding the popup.    pub(crate) fn renderable(&self) -> PetPickerPreviewRenderable {        PetPickerPreviewRenderable {            inner: Arc::clone(&self.inner),        }    }    pub(crate) fn set_loading(&self) {        self.update(|inner| {            inner.status = PetPickerPreviewStatus::Loading;        });    }    pub(crate) fn set_disabled(&self) {        self.update(|inner| {            inner.status = PetPickerPreviewStatus::Disabled;        });    }    pub(crate) fn set_ready(&self) {        self.update(|inner| {            inner.status = PetPickerPreviewStatus::Ready;        });    }    pub(crate) fn set_error(&self, message: String) {        self.update(|inner| {            inner.status = PetPickerPreviewStatus::Error { message };        });    }    pub(crate) fn clear(&self) {        self.update(|inner| {            inner.status = PetPickerPreviewStatus::Hidden;            inner.last_area = None;        });    }    pub(crate) fn area(&self) -> Option<Rect> {        self.inner.lock().ok().and_then(|inner| inner.last_area)    }    fn update(&self, f: impl FnOnce(&mut PetPickerPreviewInner)) {        if let Ok(mut inner) = self.inner.lock() {            f(&mut inner);        }    }}#[derive(Debug, Default)]struct PetPickerPreviewInner {    status: PetPickerPreviewStatus,    last_area: Option<Rect>,}#[derive(Debug, Default)]enum PetPickerPreviewStatus {    #[default]    Hidden,    Loading,    Disabled,    Ready,    Error {        message: String,    },}pub(crate) struct PetPickerPreviewRenderable {    inner: Arc<Mutex<PetPickerPreviewInner>>,}impl Renderable for PetPickerPreviewRenderable {    fn render(&self, area: Rect, buf: &mut Buffer) {        let (title, body) = {            let Ok(mut inner) = self.inner.lock() else {                return;            };            inner.last_area = Some(area);            match &inner.status {                PetPickerPreviewStatus::Hidden => return,                PetPickerPreviewStatus::Loading => ("Loading preview...", None),                PetPickerPreviewStatus::Disabled => (                    "Terminal pets disabled",                    Some("No pet will be shown.".to_string()),                ),                PetPickerPreviewStatus::Ready => return,                PetPickerPreviewStatus::Error { message } => {                    ("Preview unavailable", Some(message.clone()))                }            }        };        let text_height = if body.is_some() { 2 } else { 1 };        let text_area = centered_text_area(area, text_height);        let mut lines = vec![Line::from(title.bold())];        if let Some(body) = body {            lines.push(Line::from(body.dim()));        }        Paragraph::new(lines)            .alignment(Alignment::Center)            .render(text_area, buf);    }    fn desired_height(&self, _width: u16) -> u16 {        4    }}fn centered_text_area(area: Rect, height: u16) -> Rect {    let height = height.min(area.height);    let y = area.y + area.height.saturating_sub(height) / 2;    Rect::new(area.x, y, area.width, height)}#[cfg(test)]mod tests {    use super::*;    #[test]    fn centered_text_area_centers_vertically() {        assert_eq!(            centered_text_area(                Rect::new(                    /*x*/ 5, /*y*/ 10, /*width*/ 20, /*height*/ 8                ),                /*height*/ 2            ),            Rect::new(                /*x*/ 5, /*y*/ 13, /*width*/ 20, /*height*/ 2            )        );    }}