ghosttea 0.1.0

A native Ghostty-powered terminal runtime for Electron applications
use std::{
    collections::BTreeMap,
    sync::{
        Arc, Mutex,
        atomic::{AtomicU64, Ordering},
    },
};

use anyhow::{Result, bail};
use ghosttea_text::{FontStyle, GlyphDefinition, StyleSpan, TextEngine};
use ghosttea_vt::{CellStyle, TerminalCell};
use tokio::sync::broadcast;
use uuid::Uuid;

use crate::{
    frame::{FrameCursor, TextSnapshot, encode_text_snapshot},
    session::SessionSummary,
    tunnel_protocol::{LogicalCellStyle, LogicalTerminalSnapshot},
};

pub struct RemoteReplica {
    summary: Mutex<SessionSummary>,
    sequence: AtomicU64,
    latest: Mutex<Option<LogicalTerminalSnapshot>>,
    frames: broadcast::Sender<Vec<u8>>,
    text_engine: Arc<Mutex<TextEngine>>,
}

impl RemoteReplica {
    pub fn new(
        title: String,
        cwd: Option<String>,
        cols: u16,
        rows: u16,
        frames: broadcast::Sender<Vec<u8>>,
        text_engine: Arc<Mutex<TextEngine>>,
    ) -> Arc<Self> {
        let id = Uuid::new_v4();
        let bytes = *id.as_bytes();
        let handle = u64::from_le_bytes(bytes[..8].try_into().unwrap());
        Arc::new(Self {
            summary: Mutex::new(SessionSummary {
                id: id.to_string(),
                handle: handle.to_string(),
                executable: "remote-terminal".into(),
                cols,
                rows,
                exited: false,
                title: Some(title),
                cwd,
                bell_count: 0,
            }),
            sequence: AtomicU64::new(0),
            latest: Mutex::new(None),
            frames,
            text_engine,
        })
    }

    pub fn summary(&self) -> SessionSummary {
        self.summary.lock().unwrap().clone()
    }

    pub fn publish(&self, snapshot: LogicalTerminalSnapshot) -> Result<()> {
        *self.latest.lock().unwrap() = Some(snapshot.clone());
        if snapshot.rows.len() > u16::MAX as usize {
            bail!("remote terminal snapshot has too many rows");
        }
        let rows = snapshot
            .rows
            .iter()
            .map(|row| row.text.clone())
            .collect::<Vec<_>>();
        let cells = snapshot
            .rows
            .iter()
            .map(|row| {
                row.cells
                    .iter()
                    .map(|cell| TerminalCell {
                        column: cell.column,
                        span: cell.span,
                        text: cell.text.clone(),
                        style: cell_style(cell.style),
                    })
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();
        let mut engine = self.text_engine.lock().unwrap();
        let mut definitions = BTreeMap::<u32, GlyphDefinition>::new();
        let mut shaped_rows = Vec::with_capacity(rows.len());
        for (text, row_cells) in rows.iter().zip(&cells) {
            let mut byte_offset = 0;
            let spans = row_cells
                .iter()
                .filter_map(|cell| {
                    if byte_offset >= text.len() {
                        return None;
                    }
                    let byte_start = byte_offset;
                    byte_offset = (byte_offset + cell.text.len()).min(text.len());
                    Some(StyleSpan {
                        byte_start,
                        byte_end: byte_offset,
                        style: FontStyle {
                            bold: cell.style.bold,
                            italic: cell.style.italic,
                        },
                    })
                })
                .collect::<Vec<_>>();
            let shaped = engine.shape_styled_row(text, &spans)?;
            for definition in &shaped.definitions {
                definitions
                    .entry(definition.id)
                    .or_insert_with(|| definition.clone());
            }
            shaped_rows.push(shaped);
        }
        drop(engine);

        let row_count = u16::try_from(rows.len())?;
        let updated_rows = (0..row_count).collect::<Vec<_>>();
        let cursor = FrameCursor {
            x: snapshot.cursor.x,
            y: snapshot.cursor.y,
            visible: snapshot.cursor.visible,
            style: snapshot.cursor.style,
            blinking: snapshot.cursor.blinking,
        };
        let mut summary = self.summary.lock().unwrap();
        summary.cols = snapshot.cols;
        summary.rows = row_count;
        summary.title = snapshot.title.clone().or_else(|| summary.title.clone());
        summary.cwd = snapshot.cwd.clone();
        let handle = summary.handle.parse::<u64>()?;
        drop(summary);
        let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1;
        let definitions = definitions.into_values().collect::<Vec<_>>();
        let frame = encode_text_snapshot(TextSnapshot {
            session_handle: handle,
            session_epoch: snapshot.session_epoch,
            layout_epoch: snapshot.layout_epoch,
            sequence,
            revision: snapshot.terminal_revision,
            cols: snapshot.cols,
            rows: &rows,
            shaped_rows: &shaped_rows,
            cells: &cells,
            updated_rows: &updated_rows,
            full_snapshot: true,
            mouse_tracking: snapshot.mouse_tracking,
            new_glyph_definitions: &definitions,
            clipboard: None,
            cursor: &cursor,
        })?;
        let _ = self.frames.send(frame);
        Ok(())
    }

    pub fn refresh(&self) -> Result<()> {
        let snapshot =
            self.latest.lock().unwrap().clone().ok_or_else(|| {
                anyhow::anyhow!("remote terminal has not published a snapshot yet")
            })?;
        self.publish(snapshot)
    }
}

fn cell_style(style: LogicalCellStyle) -> CellStyle {
    CellStyle {
        bold: style.bold,
        italic: style.italic,
        faint: style.faint,
        inverse: style.inverse,
        invisible: style.invisible,
        strikethrough: style.strikethrough,
        underline: style.underline,
        foreground: style.foreground,
        background: style.background,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tunnel_protocol::{LogicalCell, LogicalCursor, LogicalRow};

    #[test]
    fn logical_snapshot_is_shaped_into_a_local_full_frame() {
        let (frames, mut receiver) = broadcast::channel(2);
        let engine = Arc::new(Mutex::new(TextEngine::discover().unwrap()));
        let replica = RemoteReplica::new("remote".into(), None, 20, 1, frames, engine);
        replica
            .publish(LogicalTerminalSnapshot {
                session_epoch: 7,
                layout_epoch: 3,
                terminal_revision: 11,
                cols: 20,
                rows: vec![LogicalRow {
                    text: "hello".into(),
                    cells: vec![LogicalCell {
                        column: 0,
                        span: 5,
                        text: "hello".into(),
                        style: LogicalCellStyle::default(),
                    }],
                }],
                cursor: LogicalCursor {
                    x: 5,
                    y: 0,
                    visible: true,
                    style: 0,
                    blinking: true,
                },
                mouse_tracking: false,
                title: Some("remote title".into()),
                cwd: Some("/remote".into()),
            })
            .unwrap();
        let frame = receiver.try_recv().unwrap();
        assert_eq!(u64::from_le_bytes(frame[24..32].try_into().unwrap()), 7);
        assert_eq!(u64::from_le_bytes(frame[32..40].try_into().unwrap()), 3);
        assert_eq!(replica.summary().title.as_deref(), Some("remote title"));
    }
}