concord 2.6.0

A terminal user interface client for Discord
use crate::discord::ApplicationCommandIdentity;
use crate::tui::text_input::TextInputState;

use super::completions::{EmojiCompletion, MentionCompletion};

#[derive(Clone, Debug)]
pub(in crate::tui::state) struct ComposerInputSnapshot {
    pub(super) input: TextInputState,
    pub(super) mention_completions: Vec<MentionCompletion>,
    pub(super) emoji_completions: Vec<EmojiCompletion>,
    pub(super) selected_command_identity: Option<ApplicationCommandIdentity>,
}

impl ComposerInputSnapshot {
    fn from_value(value: String) -> Self {
        let mut input = TextInputState::default();
        input.set_value(value);
        Self {
            input,
            mention_completions: Vec::new(),
            emoji_completions: Vec::new(),
            selected_command_identity: None,
        }
    }

    pub(in crate::tui::state) fn value(&self) -> &str {
        self.input.value()
    }

    pub(in crate::tui::state) fn into_translation_plan(
        self,
        request_id: u64,
    ) -> Result<ComposerTranslationPlan, &'static str> {
        if self.input.value().trim_start().starts_with('/') {
            return Err("slash command drafts cannot be translated");
        }

        ComposerTranslationPlan::new(self, request_id)
    }

    fn matches_translation_source(&self, other: &Self) -> bool {
        self.input.value() == other.input.value()
            && self.mention_completions == other.mention_completions
            && self.emoji_completions == other.emoji_completions
            && self.selected_command_identity == other.selected_command_identity
    }
}

#[derive(Debug)]
pub(in crate::tui::state) struct ComposerTranslationPlan {
    source: ComposerInputSnapshot,
    provider_text: String,
    tokens: Vec<ComposerTranslationToken>,
}

#[derive(Debug)]
struct ComposerTranslationToken {
    placeholder: String,
    visible_text: String,
    kind: ComposerTranslationTokenKind,
}

#[derive(Debug)]
enum ComposerTranslationTokenKind {
    Mention(MentionCompletion),
    Emoji(EmojiCompletion),
}

#[derive(Debug)]
struct ComposerTranslationSourceRange {
    byte_start: usize,
    byte_end: usize,
    kind: ComposerTranslationTokenKind,
}

impl ComposerTranslationPlan {
    fn new(source: ComposerInputSnapshot, request_id: u64) -> Result<Self, &'static str> {
        let input = source.input.value();
        let mut ranges = source
            .mention_completions
            .iter()
            .copied()
            .map(|completion| ComposerTranslationSourceRange {
                byte_start: completion.byte_start,
                byte_end: completion.byte_end,
                kind: ComposerTranslationTokenKind::Mention(completion),
            })
            .chain(source.emoji_completions.iter().cloned().map(|completion| {
                ComposerTranslationSourceRange {
                    byte_start: completion.byte_start,
                    byte_end: completion.byte_end,
                    kind: ComposerTranslationTokenKind::Emoji(completion),
                }
            }))
            .collect::<Vec<_>>();
        ranges.sort_by_key(|range| range.byte_start);

        let mut provider_text = String::with_capacity(input.len());
        let mut tokens = Vec::with_capacity(ranges.len());
        let mut source_cursor = 0;
        for (index, range) in ranges.into_iter().enumerate() {
            if range.byte_start < source_cursor
                || range.byte_start >= range.byte_end
                || range.byte_end > input.len()
                || !input.is_char_boundary(range.byte_start)
                || !input.is_char_boundary(range.byte_end)
            {
                return Err("composer contains invalid semantic ranges");
            }

            let placeholder = format!("\u{e000}CONCORD_{request_id}_{index}\u{e001}");
            if input.contains(&placeholder) {
                return Err("composer contains a reserved translation placeholder");
            }
            provider_text.push_str(&input[source_cursor..range.byte_start]);
            provider_text.push_str(&placeholder);
            tokens.push(ComposerTranslationToken {
                placeholder,
                visible_text: input[range.byte_start..range.byte_end].to_owned(),
                kind: range.kind,
            });
            source_cursor = range.byte_end;
        }
        provider_text.push_str(&input[source_cursor..]);

        Ok(Self {
            source,
            provider_text,
            tokens,
        })
    }

    pub(in crate::tui::state) fn source_text(&self) -> &str {
        self.source.value()
    }

    pub(in crate::tui::state) fn provider_text(&self) -> &str {
        &self.provider_text
    }

    pub(in crate::tui::state) fn matches_source(&self, current: &ComposerInputSnapshot) -> bool {
        self.source.matches_translation_source(current)
    }

    pub(in crate::tui::state) fn translated_snapshot(
        &self,
        translated_text: &str,
    ) -> Result<ComposerInputSnapshot, &'static str> {
        let mut occurrences = Vec::with_capacity(self.tokens.len());
        for token in &self.tokens {
            let mut matches = translated_text.match_indices(&token.placeholder);
            let Some((byte_start, _)) = matches.next() else {
                return Err("translation provider changed a protected mention or emoji");
            };
            if matches.next().is_some() {
                return Err("translation provider duplicated a protected mention or emoji");
            }
            occurrences.push((byte_start, token));
        }
        occurrences.sort_by_key(|(byte_start, _)| *byte_start);

        let mut restored = String::with_capacity(translated_text.len());
        let mut mention_completions = Vec::new();
        let mut emoji_completions = Vec::new();
        let mut translated_cursor = 0;
        for (byte_start, token) in occurrences {
            if byte_start < translated_cursor {
                return Err("translation provider returned overlapping protected values");
            }
            let byte_end = byte_start.saturating_add(token.placeholder.len());
            restored.push_str(&translated_text[translated_cursor..byte_start]);
            let restored_start = restored.len();
            restored.push_str(&token.visible_text);
            let restored_end = restored.len();
            match &token.kind {
                ComposerTranslationTokenKind::Mention(completion) => {
                    mention_completions.push(MentionCompletion {
                        byte_start: restored_start,
                        byte_end: restored_end,
                        target: completion.target,
                    });
                }
                ComposerTranslationTokenKind::Emoji(completion) => {
                    emoji_completions.push(EmojiCompletion {
                        byte_start: restored_start,
                        byte_end: restored_end,
                        replacement: completion.replacement.clone(),
                        custom_image_url: completion.custom_image_url.clone(),
                    });
                }
            }
            translated_cursor = byte_end;
        }
        restored.push_str(&translated_text[translated_cursor..]);

        let mut snapshot = ComposerInputSnapshot::from_value(restored);
        snapshot.mention_completions = mention_completions;
        snapshot.emoji_completions = emoji_completions;
        Ok(snapshot)
    }
}