chatty-rs 0.0.1-alpha1

A terminal-based chat client for OpenAI's GPT models.
Documentation
use std::time::{self, Duration};

use super::utils;
use crate::models::{NoticeKind, NoticeMessage};
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Style, Stylize},
    text::{Line, Span},
    widgets::{List, ListItem},
};
use ratatui_macros::span;
use unicode_width::UnicodeWidthStr;

struct MessageWrapper {
    value: NoticeMessage,
    created_at: chrono::DateTime<chrono::Utc>,
}

pub struct Notice {
    notices: Vec<MessageWrapper>,
    display_duration: time::Duration,
}

impl Notice {
    pub fn new(display_duration: time::Duration) -> Notice {
        Notice {
            display_duration,
            ..Default::default()
        }
    }

    pub fn add_message(&mut self, msg: NoticeMessage) {
        let now = chrono::Utc::now();
        self.notices.push(MessageWrapper {
            value: msg,
            created_at: now,
        });
    }

    pub fn info(&mut self, msg: impl Into<String>) {
        self.add_message(NoticeMessage::info(msg))
    }

    pub fn warning(&mut self, msg: impl Into<String>) {
        self.add_message(NoticeMessage::warning(msg))
    }

    pub fn error(&mut self, msg: impl Into<String>) {
        self.add_message(NoticeMessage::error(msg))
    }

    pub fn clear(&mut self) {
        self.notices.clear();
    }

    fn sync(&mut self) {
        let now = chrono::Utc::now();
        self.notices.retain(|msg| {
            let elapsed = now.signed_duration_since(msg.created_at);
            elapsed.num_milliseconds()
                < msg
                    .value
                    .duration()
                    .unwrap_or(self.display_duration)
                    .as_millis() as i64
        });
    }

    pub fn render(&mut self, f: &mut Frame, area: Rect) {
        self.sync();
        if self.notices.is_empty() {
            return;
        }

        let max_width = area.width as usize - 2;
        let max_height = area.height as usize - 2;

        let items = build_list_items(&self.notices, max_width, max_height);
        let list = List::new(items);
        f.render_widget(list, area);
    }
}

impl Default for Notice {
    fn default() -> Self {
        Self {
            notices: vec![],
            display_duration: Duration::from_secs(3),
        }
    }
}

fn build_list_items<'a>(
    notices: &[MessageWrapper],
    max_width: usize,
    max_height: usize,
) -> Vec<ListItem<'a>> {
    let mut items = vec![];
    let mut current_height = 0;

    for item in notices {
        let lines = build_bubble(item.value.message(), max_width, item.value.kind());

        current_height += lines.len();
        if current_height > max_height {
            break;
        }

        let item = ListItem::new(lines).style(Style::default());
        items.push(item);
    }
    items
}

fn build_bubble<'a>(message: &str, max_width: usize, notice_type: &NoticeKind) -> Vec<Line<'a>> {
    // build lines from message based on max_width
    let mut lines = vec![];

    let mut line = String::new();
    for word in message.replace('\n', " ").split(' ') {
        if line.width() + word.width() > max_width - 2 {
            lines.push(line.trim().to_string());
            line = String::new();
        }
        line.push_str(word);
        line.push(' ');
    }

    if !line.is_empty() {
        lines.push(line.trim().to_string());
    }

    wrap_bubble(lines, max_width, notice_type)
}

fn wrap_bubble<'a>(
    lines: Vec<String>,
    max_width: usize,
    notice_type: &NoticeKind,
) -> Vec<Line<'a>> {
    let border_color = notice_type.border_color();
    let text_color = notice_type.text_color();
    let top_bar = highlight_line(
        format!("{}", [""].repeat(max_width).join("")),
        border_color,
    );
    let bottom_bar = highlight_line(
        format!("{}", [""].repeat(max_width).join("")),
        border_color,
    );

    let mut wrapped_lines = vec![top_bar];
    for line in lines {
        let mut wrapped_spans = vec![highlight_span("".to_string(), border_color)];
        wrapped_spans.push(span!(line).fg(text_color));
        let fill = utils::repeat_from_substactions(" ", vec![max_width - 2, line.width()]);
        wrapped_spans.push(highlight_span(format!("{fill}"), border_color));
        wrapped_lines.push(Line::from(wrapped_spans));
    }

    wrapped_lines.push(bottom_bar);
    wrapped_lines
}

fn highlight_span<'a>(text: String, color: Color) -> Span<'a> {
    Span::styled(
        text,
        Style {
            fg: Some(color),
            ..Default::default()
        },
    )
}

fn highlight_line<'a>(text: String, color: Color) -> Line<'a> {
    Line::from(highlight_span(text, color))
}