monothink 0.5.1

A totally non-agentic coding assistant
/*
 * This file is part of monothink.
 *
 * monothink is free software: you can redistribute it and/or modify it under the terms
 * of the GNU General Public License as published by the Free Software Foundation,
 * either version 3 of the License, or (at your option) any later version.
 *
 * monothink is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Wavething.
 * If not, see <https://www.gnu.org/licenses/>.
 */

use crate::{
    common::conversation::Inline,
    ui::{
        md_renderer::{InlineFragment, RenderConfig, fragments_to_lines, render_inline},
        style::TextStyle,
    },
};
use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Layout, Rect},
    text::Span,
    widgets::{Clear, StatefulWidget, Widget},
};
use std::cmp::{max, min};

const MINIMAL_COLUMN_WIDTH: usize = 20;
const MINIMAL_SPACE: usize = 1;
const NUM_ROWS: usize = 12;

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Suggestion {
    pub raw: String,
    pub name: Vec<Inline>,
    pub hint: Vec<Inline>,
}

#[derive(Debug, Default)]
pub struct SuggestionState {
    filter: String,
    candidates: Vec<Suggestion>,
    selected: Option<usize>,
}

impl SuggestionState {
    pub fn new(mut candidates: Vec<Suggestion>) -> Self {
        candidates.sort();
        SuggestionState {
            filter: String::new(),
            candidates,
            selected: None,
        }
    }

    pub fn filter(&mut self, text: &str) {
        self.filter = text.to_string();
        self.selected = None;
    }

    pub fn matches(&self) -> Vec<Suggestion> {
        self.candidates
            .iter()
            .filter(|candidate| {
                let name = inlines_as_string(&candidate.name);
                name.contains(&self.filter)
            })
            .cloned()
            .collect()
    }

    pub fn next_match(&mut self) -> Option<String> {
        let matches = self.matches();
        if matches.is_empty() {
            return None;
        }

        if self.selected.is_none() {
            self.selected = Some(matches.len() - 1);
        }

        if let Some(mut selected) = self.selected {
            if selected >= matches.len() - 1 {
                selected = 0;
            } else {
                selected += 1;
            }
            self.selected = Some(selected);

            Some(matches[selected].raw.clone())
        } else {
            None
        }
    }

    pub fn previous_match(&mut self) -> Option<String> {
        let matches = self.matches();
        if matches.is_empty() {
            return None;
        }

        if self.selected.is_none() {
            self.selected = Some(0);
        }

        if let Some(mut selected) = self.selected {
            if selected == 0 {
                selected = matches.len() - 1;
            } else {
                selected -= 1;
            }
            self.selected = Some(selected);

            Some(matches[selected].raw.clone())
        } else {
            None
        }
    }
}

#[derive(Debug)]
pub struct SuggestionPopup {
    style: TextStyle,
    hl_style: TextStyle,
}

impl StatefulWidget for SuggestionPopup {
    type State = SuggestionState;
    fn render(self, area: Rect, buf: &mut Buffer, state: &mut <Self as StatefulWidget>::State) {
        let mut config = RenderConfig::new().with_style(self.style);
        let mut hl_config = RenderConfig::new().with_style(self.hl_style);
        let suggestions = state.matches();

        let max_name_width = suggestions
            .iter()
            .map(|suggestion| inlines_width(&suggestion.name, &config))
            .fold(0, max)
            + MINIMAL_SPACE;

        let max_hint_width = suggestions
            .iter()
            .map(|suggestion| inlines_width(&suggestion.hint, &config))
            .fold(0, max)
            + 2; // for parentheses

        let required_column_width = max(MINIMAL_COLUMN_WIDTH, max_name_width + max_hint_width);
        let num_columns = max(1, area.width as usize / required_column_width);
        let column_width = area.width as usize / num_columns;

        // @todo replace parts of the hint in the middle with " ... " if area width would be exceeded

        config = RenderConfig {
            width: column_width as u16,
            ..config
        };
        hl_config = RenderConfig {
            width: column_width as u16,
            ..hl_config
        };
        let num_rows = min(NUM_ROWS, suggestions.len().div_ceil(num_columns));
        let mut popup_area = area;
        popup_area.y += area.y + area.height - num_rows as u16;
        popup_area.height = num_rows as u16;
        let column_constraints = vec![Constraint::Length(column_width as u16); num_columns];
        let columns = Layout::horizontal(column_constraints).split(popup_area);

        // @todo highlight match (pos == selected)
        Clear.render(popup_area, buf);
        suggestions
            .iter()
            .enumerate()
            .take(num_columns * num_rows)
            .for_each(|(pos, suggestion)| {
                let current_config = if let Some(selected) = state.selected
                    && pos == selected
                {
                    &hl_config
                } else {
                    &config
                };
                let mut fragments: Vec<InlineFragment> = suggestion
                    .name
                    .iter()
                    .flat_map(|inline| render_inline(inline, current_config))
                    .filter(|fragment| match *fragment {
                        InlineFragment::Text(_) => true,
                        InlineFragment::LineBreak => false, // @todo log a warning
                    })
                    .collect();
                fragments.push(InlineFragment::Text(Span::styled(
                    " ".repeat(max_name_width - inlines_width(&suggestion.name, current_config)),
                    current_config.style.clone(),
                )));
                fragments.push(InlineFragment::Text(Span::styled(
                    "(",
                    current_config.style.clone(),
                )));
                fragments.extend(
                    suggestion
                        .hint
                        .iter()
                        .flat_map(|inline| render_inline(inline, current_config))
                        .filter(|fragment| match *fragment {
                            InlineFragment::Text(_) => true,
                            InlineFragment::LineBreak => false, // @todo log a warning
                        }),
                );
                fragments.push(InlineFragment::Text(Span::styled(
                    ")",
                    current_config.style.clone(),
                )));
                let lines = fragments_to_lines(&fragments);
                let line = lines
                    .first()
                    .expect("Rendering a suggestion should result in one line");
                // render
                let col_pos = pos / num_rows;
                let row_pos = pos % num_rows;
                let mut suggestion_area = columns[col_pos];
                suggestion_area.y += row_pos as u16;
                suggestion_area.height = 1;
                line.render(suggestion_area, buf);
            })
    }
}

impl SuggestionPopup {
    pub fn new(style: TextStyle, hl_style: TextStyle) -> Self {
        SuggestionPopup { style, hl_style }
    }

    //     fn render_to_line(suggestion: Suggestion, config: &RenderConfig) -> Line<'static> {
    //         // @todo
    //     }
}

fn inlines_width(inlines: &[Inline], config: &RenderConfig) -> usize {
    inlines
        .iter()
        .flat_map(|inline| render_inline(inline, config))
        .filter_map(|fragment| match fragment {
            InlineFragment::Text(txt) => Some(txt.width()),
            InlineFragment::LineBreak => None,
        })
        .sum()
}

// @todo this should be close to md_renderer to ensure consistency
fn inlines_as_string(inlines: &[Inline]) -> String {
    inlines
        .iter()
        .map(|inline| match inline {
            Inline::Code(text) => text.to_string(),
            Inline::Emphasis(inlines) => inlines_as_string(inlines),
            Inline::StrongEmphasis(inlines) => inlines_as_string(inlines),
            Inline::Link { url, title, label } => "".to_string(), // @todo implement
            Inline::Image(description) => "".to_string(),         // @todo implement
            Inline::LineBreak => "\n".to_string(),
            Inline::Text(text) => text.to_string(),
            Inline::Unsupported(_) => "".to_string(),
        })
        .collect()
}