alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Backend-neutral editor synchronization adapter.

use crate::{
    buffer::BufferEdit,
    text_stream::{TextByteStream, TextStreamError},
    vim::VimCursor,
};

/// Snapshot of the editor state last pushed into the visual backend.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EditorBackendSnapshot {
    /// Last `TextByteStream` revision synchronized into the backend.
    synced_revision: u64,
    /// Text currently known by the backend.
    text: String,
}

impl EditorBackendSnapshot {
    /// Creates a backend snapshot from the authoritative stream.
    #[must_use]
    pub fn from_stream(stream: &TextByteStream) -> Self {
        Self {
            synced_revision: stream.revision(),
            text: stream.as_str().to_owned(),
        }
    }

    /// Returns the last synchronized text revision.
    #[must_use]
    pub const fn synced_revision(&self) -> u64 {
        self.synced_revision
    }

    /// Returns the backend text snapshot.
    #[must_use]
    pub fn text(&self) -> &str {
        &self.text
    }
}

/// Adapter contract for editor rendering/editing backends.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EditorBackendAdapter {
    /// Current backend snapshot.
    snapshot: EditorBackendSnapshot,
}

impl EditorBackendAdapter {
    /// Initializes an adapter from the authoritative text stream.
    #[must_use]
    pub fn from_stream(stream: &TextByteStream) -> Self {
        Self {
            snapshot: EditorBackendSnapshot::from_stream(stream),
        }
    }

    /// Reads the current backend snapshot.
    #[must_use]
    pub const fn snapshot(&self) -> &EditorBackendSnapshot {
        &self.snapshot
    }

    /// Pushes authoritative text into the backend snapshot when the stream changed.
    pub fn sync_from_stream(&mut self, stream: &TextByteStream) -> EditorSyncReport {
        if self.snapshot.synced_revision == stream.revision()
            && self.snapshot.text == stream.as_str()
        {
            return EditorSyncReport::Unchanged;
        }

        self.snapshot = EditorBackendSnapshot::from_stream(stream);
        EditorSyncReport::SyncedFromStream {
            revision: stream.revision(),
        }
    }

    /// Applies backend text to the authoritative stream, guarding against sync feedback.
    ///
    /// # Errors
    ///
    /// Returns [`TextStreamError`] if the resulting buffer edit is invalid.
    pub fn apply_backend_text(
        &mut self,
        backend_text: impl Into<String>,
        stream: &mut TextByteStream,
        cursor: &mut VimCursor,
    ) -> Result<EditorSyncReport, TextStreamError> {
        let backend_text = backend_text.into();

        if self.snapshot.synced_revision == stream.revision()
            && self.snapshot.text == backend_text
            && stream.as_str() == backend_text
        {
            return Ok(EditorSyncReport::Unchanged);
        }

        if stream.as_str() == backend_text {
            self.snapshot = EditorBackendSnapshot::from_stream(stream);
            return Ok(EditorSyncReport::Unchanged);
        }

        let report = BufferEdit::SetAll(backend_text).apply_with_cursor(stream, cursor)?;
        self.snapshot = EditorBackendSnapshot::from_stream(stream);

        Ok(EditorSyncReport::AppliedBackendEdit {
            previous_revision: report.previous_revision,
            current_revision: report.current_revision,
        })
    }
}

/// Result of one editor/backend synchronization step.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EditorSyncReport {
    /// No synchronization was needed.
    Unchanged,
    /// The visual backend was refreshed from the authoritative stream.
    SyncedFromStream {
        /// Revision pushed into the backend.
        revision: u64,
    },
    /// Backend text was applied to the authoritative stream.
    AppliedBackendEdit {
        /// Revision before applying backend text.
        previous_revision: u64,
        /// Revision after applying backend text.
        current_revision: u64,
    },
}

#[cfg(test)]
mod tests {
    use super::{EditorBackendAdapter, EditorSyncReport};
    use crate::{buffer::BufferEdit, text_stream::TextByteStream, vim::VimCursor};

    #[test]
    fn initializes_from_stream() {
        let stream = TextByteStream::new("hello");
        let adapter = EditorBackendAdapter::from_stream(&stream);

        assert_eq!(adapter.snapshot().text(), "hello");
        assert_eq!(adapter.snapshot().synced_revision(), 0);
    }

    #[test]
    fn sync_from_stream_updates_changed_revision() {
        let mut stream = TextByteStream::new("hello");
        let mut adapter = EditorBackendAdapter::from_stream(&stream);
        let _report = BufferEdit::Insert {
            byte_index: 5,
            text: String::from(" world"),
        }
        .apply(&mut stream)
        .expect("insert should succeed");

        assert_eq!(
            adapter.sync_from_stream(&stream),
            EditorSyncReport::SyncedFromStream { revision: 1 }
        );
        assert_eq!(adapter.snapshot().text(), "hello world");
    }

    #[test]
    fn backend_text_applies_to_stream_once() {
        let mut stream = TextByteStream::new("hello");
        let mut cursor = VimCursor::new();
        let mut adapter = EditorBackendAdapter::from_stream(&stream);

        assert_eq!(
            adapter
                .apply_backend_text("hello!", &mut stream, &mut cursor)
                .expect("backend edit should succeed"),
            EditorSyncReport::AppliedBackendEdit {
                previous_revision: 0,
                current_revision: 1
            }
        );
        assert_eq!(stream.as_str(), "hello!");

        assert_eq!(
            adapter
                .apply_backend_text("hello!", &mut stream, &mut cursor)
                .expect("repeat sync should succeed"),
            EditorSyncReport::Unchanged
        );
        assert_eq!(stream.revision(), 1);
    }
}