terrazzo-terminal 0.2.9

A simple web-based terminal emulator built on Terrazzo.
use std::cell::RefCell;
use std::rc::Rc;

use terrazzo::autoclone;
use terrazzo::html;
use terrazzo::prelude::*;
use terrazzo::template;
use terrazzo::widgets::element_capture::ElementCapture;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsValue;
use web_sys::HtmlTextAreaElement;
use web_sys::KeyboardEvent;

use self::diagnostics::warn;
use crate::assets::icons;
use crate::frontend::speech_recognition;

terrazzo_css::import_style!(pub style, "input_overlay.scss");

struct SpeechRecognitionHandle {
    recognition: JsValue,
    _on_result: Closure<dyn FnMut(JsValue)>,
    _on_end: Closure<dyn FnMut()>,
    _on_error: Closure<dyn FnMut(JsValue)>,
}

#[html]
pub fn input_overlay(send: Ptr<dyn Fn(String)>, focus_target: Ptr<dyn Fn()>) -> XElement {
    let is_open = XSignal::new("input-overlay-open", false);
    let is_recording = XSignal::new("input-overlay-recording", false);
    let value = XSignal::new("input-overlay-value", XString::default());
    let textarea: ElementCapture<HtmlTextAreaElement> = Default::default();
    let speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>> = Default::default();

    div(
        class = style::INPUT_OVERLAY,
        class %= overlay_class(is_open.clone()),
        #[cfg(not(feature = "client-prod"))]
        class = "input-overlay",
        div(
            class = style::INPUT_OVERLAY_BUTTONS,
            state_button(
                is_open.clone(),
                is_recording.clone(),
                value.clone(),
                textarea.clone(),
                speech_recognition.clone(),
            ),
            send_button(
                send.clone(),
                focus_target.clone(),
                is_open.clone(),
                is_recording.clone(),
                value.clone(),
                textarea.clone(),
                speech_recognition.clone(),
            ),
        ),
        compose_textarea(
            textarea.clone(),
            value.clone(),
            is_open.clone(),
            is_recording,
            send,
            focus_target,
            speech_recognition,
        ),
    )
}

#[template(wrap = true)]
fn overlay_class(#[signal] is_open: bool) -> XAttributeValue {
    is_open.then_some(style::ACTIVE)
}

#[autoclone]
#[html]
#[template(tag = textarea)]
fn compose_textarea(
    textarea: ElementCapture<HtmlTextAreaElement>,
    value: XSignal<XString>,
    is_open: XSignal<bool>,
    is_recording: XSignal<bool>,
    send: Ptr<dyn Fn(String)>,
    focus_target: Ptr<dyn Fn()>,
    speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>>,
) -> XElement {
    tag(
        before_render = textarea.capture(),
        class = style::INPUT_OVERLAY_TEXTAREA,
        #[cfg(not(feature = "client-prod"))]
        class = "input-overlay-textarea",
        input = move |_| {
            autoclone!(value, textarea);
            let new_value = textarea
                .try_with(|textarea| textarea.value())
                .unwrap_or_default();
            value.set(new_value);
        },
        keydown = move |event: KeyboardEvent| {
            autoclone!(
                send,
                focus_target,
                is_open,
                is_recording,
                value,
                textarea,
                speech_recognition
            );
            event.stop_propagation();
            match event.key().as_str() {
                "Escape" => is_open.set(false),
                "Enter" if event.ctrl_key() || event.meta_key() => {
                    event.prevent_default();
                    send_value(
                        send.clone(),
                        focus_target.clone(),
                        MutableSignal::from(&is_open),
                        is_recording.clone(),
                        value.clone(),
                        textarea.clone(),
                        speech_recognition.clone(),
                    );
                }
                _ => {}
            }
        },
    )
}

#[html]
#[template(tag = img)]
fn send_button(
    send: Ptr<dyn Fn(String)>,
    focus_target: Ptr<dyn Fn()>,
    is_open: XSignal<bool>,
    is_recording: XSignal<bool>,
    value: XSignal<XString>,
    textarea: ElementCapture<HtmlTextAreaElement>,
    speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>>,
) -> XElement {
    return tag(
        class = style::INPUT_OVERLAY_SEND,
        class %= send_button_class(value.clone()),
        #[cfg(not(feature = "client-prod"))]
        class = "input-overlay-send",
        src = icons::send_fill(),
        title = "Send input",
        click = move |_| {
            send_value(
                send.clone(),
                focus_target.clone(),
                MutableSignal::from(&is_open),
                is_recording.clone(),
                value.clone(),
                textarea.clone(),
                speech_recognition.clone(),
            )
        },
    );

    #[template(wrap = true)]
    fn send_button_class(#[signal] value: XString) -> XAttributeValue {
        (!value.is_empty()).then_some(style::ACTIVE)
    }
}

fn send_value(
    send: Ptr<dyn Fn(String)>,
    focus_target: Ptr<dyn Fn()>,
    is_open: MutableSignal<bool>,
    is_recording: XSignal<bool>,
    value: XSignal<XString>,
    textarea: ElementCapture<HtmlTextAreaElement>,
    speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>>,
) {
    let data = textarea.try_with(|t| t.value()).unwrap_or_default();
    if data.is_empty() {
        return;
    }
    stop_recording(is_recording, speech_recognition);
    value.set(XString::default());
    textarea.try_with(|textarea| textarea.set_value(""));
    send(data);
    is_open.set(false);
    focus_target();
}

#[autoclone]
#[html]
fn state_button(
    is_open: XSignal<bool>,
    is_recording: XSignal<bool>,
    value: XSignal<XString>,
    textarea: ElementCapture<HtmlTextAreaElement>,
    speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>>,
) -> XElement {
    img(
        class = style::INPUT_OVERLAY_BUTTON,
        class %= state_button_class(is_open.clone(), is_recording.clone()),
        #[cfg(not(feature = "client-prod"))]
        class = "input-overlay-button",
        src %= state_button_icon(is_open.clone(), is_recording.clone()),
        title %= state_button_title(is_open.clone(), is_recording.clone()),
        click = move |_| {
            autoclone!(is_open, is_recording, value, textarea, speech_recognition);
            let textarea_hidden = textarea
                .try_with(|textarea| textarea.offset_parent().is_none())
                .unwrap_or(true);
            if !is_open.get_value_untracked() || textarea_hidden {
                is_open.force(true);
                if let Some(Err(error)) = textarea.try_with(|textarea| textarea.focus()) {
                    warn!("Failed to focus input overlay: {error:?}");
                }
                return;
            }
            if is_recording.get_value_untracked() {
                stop_recording(is_recording.clone(), speech_recognition.clone());
            } else {
                start_recording(
                    is_recording.clone(),
                    value.clone(),
                    textarea.clone(),
                    speech_recognition.clone(),
                );
            }
        },
    )
}

#[template(wrap = true)]
fn state_button_class(#[signal] is_open: bool, #[signal] is_recording: bool) -> XAttributeValue {
    (is_open || is_recording).then_some(style::ACTIVE)
}

#[template(wrap = true)]
fn state_button_icon(#[signal] is_open: bool, #[signal] is_recording: bool) -> XAttributeValue {
    if is_recording {
        icons::mic_fill()
    } else if is_open {
        icons::mic_mute_fill()
    } else {
        icons::paragraph()
    }
}

#[template(wrap = true)]
fn state_button_title(#[signal] is_open: bool, #[signal] is_recording: bool) -> XAttributeValue {
    if is_recording {
        "Stop dictation"
    } else if is_open {
        "Start dictation"
    } else {
        "Compose input"
    }
}

fn start_recording(
    is_recording: XSignal<bool>,
    value: XSignal<XString>,
    textarea: ElementCapture<HtmlTextAreaElement>,
    speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>>,
) {
    let original_value = value.get_value_untracked();
    let on_result: Closure<dyn FnMut(JsValue)> = Closure::new({
        let value = value.clone();
        let textarea = textarea.clone();
        move |transcript: JsValue| {
            let transcript = transcript.as_string().unwrap_or_default();
            let mut new_value = original_value.to_string();
            if new_value.is_empty() || new_value.ends_with(char::is_whitespace) {
                new_value += &transcript;
            } else {
                new_value += &format!(" {}", transcript);
            }
            value.set(new_value.clone());
            textarea.try_with(|textarea| textarea.set_value(&new_value));
        }
    });
    let on_end: Closure<dyn FnMut()> = Closure::new({
        let is_recording = is_recording.clone();
        move || is_recording.set(false)
    });
    let on_error: Closure<dyn FnMut(JsValue)> = Closure::new({
        let is_recording = is_recording.clone();
        move |error: JsValue| {
            warn!("Speech recognition failed: {error:?}");
            is_recording.set(false);
        }
    });
    let recognition = speech_recognition::create_speech_recognition(&on_result, &on_end, &on_error);
    if recognition.is_null() || recognition.is_undefined() {
        warn!("Speech recognition is not supported in this browser");
        return;
    }
    speech_recognition::start_speech_recognition(&recognition);
    is_recording.set(true);
    *speech_recognition.borrow_mut() = Some(SpeechRecognitionHandle {
        recognition,
        _on_result: on_result,
        _on_end: on_end,
        _on_error: on_error,
    });
}

fn stop_recording(
    is_recording: XSignal<bool>,
    speech_recognition: Rc<RefCell<Option<SpeechRecognitionHandle>>>,
) {
    if let Some(speech_recognition) = speech_recognition.borrow().as_ref() {
        speech_recognition::stop_speech_recognition(&speech_recognition.recognition);
    }
    is_recording.set(false);
}