terrazzo-terminal 0.2.8

A simple web-based terminal emulator built on Terrazzo.
#![cfg(feature = "client")]

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use terrazzo::autoclone;
use terrazzo::html;
use terrazzo::prelude::*;
use terrazzo::template;
use terrazzo::widgets::sleep::SleepError;
use terrazzo::widgets::sleep::sleep;
use terrazzo::widgets::tabs::TabDescriptor;
use terrazzo::widgets::tabs::TabsDescriptor;
use terrazzo::widgets::tabs::TabsState;
use wasm_bindgen_futures::JsFuture;
use wasm_bindgen_futures::spawn_local;
use web_sys::ClipboardEvent;
use web_sys::DragEvent;
use web_sys::KeyboardEvent;
use web_sys::MouseEvent;

use self::diagnostics::warn;
use super::api::Conversion;
use super::api::Conversions;
use crate::assets::icons;
use crate::converter::api::Language;

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

impl TabsDescriptor for Conversions {
    type State = ConversionsState;
    type TabDescriptor = Conversion;

    fn tab_descriptors(&self) -> &[Self::TabDescriptor] {
        &self.conversions
    }

    fn after_titles(&self, state: &Self::State) -> impl IntoIterator<Item = impl Into<XNode>> {
        Some(copy(state))
    }
}

#[html]
pub fn copy(state: &ConversionsState) -> XElement {
    let label: XSignal<Label> = XSignal::new("copy-label", Label::default());
    copy_impl(label, state.selected.clone())
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Label {
    #[default]
    Ready,
    Copying,
    Copied,
    Failed,
}

impl Label {
    #[html]
    #[template(tag = div)]
    fn show(#[signal] this: Label) -> XElement {
        match this {
            Label::Ready => div(img(class = style::COPY_ICON, src = icons::copy()), "Copy"),
            Label::Copying => div("Copying"),
            Label::Copied => div(img(class = style::COPY_ICON, src = icons::done()), "Copied"),
            Label::Failed => div("Failed"),
        }
    }
}

#[autoclone]
#[html]
#[template(tag = div)]
fn copy_impl(label: XSignal<Label>, #[signal] selected: Option<Conversion>) -> XElement {
    let Some(selected) = selected else {
        return tag(style::visibility = "hidden", style::display = "none");
    };
    tag(
        Label::show(label.clone()),
        class = style::LABEL,
        click = move |_ev: MouseEvent| {
            let window = web_sys::window().or_throw("window");
            let clipboard = window.navigator().clipboard();
            let promise = clipboard.write_text(&selected.content);
            let future = JsFuture::from(promise);
            spawn_local(async move {
                autoclone!(label);
                label.set(Label::Copying);
                match future.await {
                    Ok(ok) => {
                        label.set(Label::Copied);
                        diagnostics::info!("Copied into clipboard {ok:?}");
                    }
                    Err(err) => {
                        label.set(Label::Failed);
                        warn!("Failed to clipboard copy {err:?}");
                    }
                }
                spawn_local(async move {
                    let sleep: Result<(), SleepError> = sleep(Duration::from_millis(700)).await;
                    sleep.or_else_throw(|error| format!("Failed to sleep: {error:?}"));
                    label.set(Label::default())
                });
            });
        },
    )
}

#[derive(Clone)]
pub struct ConversionsState {
    selected: XSignal<Option<Conversion>>,
    selected_tabs: Arc<HashMap<Language, XSignal<bool>>>,
}

impl ConversionsState {
    #[autoclone]
    pub fn new(conversions: &Conversions, preferred_language: XSignal<Option<Language>>) -> Self {
        let current_preferred_language = preferred_language.get_value_untracked();
        let selected = XSignal::new(
            "conversion-selected",
            current_preferred_language
                .or_else(|| conversions.conversions.first().map(|c| c.language.clone()))
                .and_then(|current_preferred_language| {
                    conversions
                        .conversions
                        .iter()
                        .find(|conversion| conversion.language == current_preferred_language)
                        .cloned()
                }),
        );
        let selected_tabs = conversions
            .conversions
            .iter()
            .map(|conversion| {
                let this = conversion.clone();
                let language = this.language.clone();
                let is_selected = selected.derive(
                    format!("selected-{language}"),
                    move |conversion| {
                        autoclone!(language);
                        conversion
                            .as_ref()
                            .map(|c: &Conversion| c.language == language)
                            .unwrap_or(false)
                    },
                    move |_, selected| {
                        autoclone!(preferred_language);
                        selected.then(|| {
                            preferred_language.set(this.language.clone());
                            Some(this.clone())
                        })
                    },
                );
                (language, is_selected)
            })
            .collect::<HashMap<_, _>>()
            .into();
        Self {
            selected,
            selected_tabs,
        }
    }
}

impl TabsState for ConversionsState {
    type TabDescriptor = Conversion;
    fn move_tab(&self, _after_tab: Option<Self::TabDescriptor>, _moved_tab_key: String) {}
}

impl TabDescriptor for Conversion {
    type State = ConversionsState;

    fn key(&self) -> XString {
        self.language.name.clone().into()
    }

    #[html]
    fn title(&self, _state: &Self::State) -> impl Into<XNode> {
        let language = self.language.name.clone();
        terrazzo::widgets::link::link(
            |_click| {},
            move || [span(class = super::ui::style::TITLE_SPAN, "{language}")],
        )
    }

    #[html]
    fn item(&self, _state: &Self::State) -> impl Into<XNode> {
        let content = &self.content;
        pre(
            #[cfg(not(feature = "client-prod"))]
            class = "converter-output",
            "{content}",
            contenteditable = "true",
            keydown = |ev: KeyboardEvent| {
                struct KeyEvent<'t> {
                    alt_key: bool,
                    shift_key: bool,
                    ctrl_key: bool,
                    meta_key: bool,
                    key: &'t str,
                }
                match (KeyEvent {
                    alt_key: ev.alt_key(),
                    shift_key: ev.shift_key(),
                    ctrl_key: ev.ctrl_key(),
                    meta_key: ev.meta_key(),
                    key: &ev.key(),
                }) {
                    KeyEvent {
                        alt_key: false,
                        shift_key: false,
                        ctrl_key: true,
                        meta_key: false,
                        key: "c" | "a" | "f",
                    }
                    | KeyEvent {
                        alt_key: false,
                        shift_key: false,
                        ctrl_key: false,
                        meta_key: true,
                        key: "c" | "a" | "f",
                    }
                    | KeyEvent {
                        key: "ArrowDown" | "ArrowUp" | "ArrowLeft" | "ArrowRight",
                        ..
                    }
                    | KeyEvent {
                        key: "Home" | "End" | "PageUp" | "PageDown" | "Tab",
                        ..
                    } => {}
                    KeyEvent { .. } => ev.prevent_default(),
                }
            },
            drop = |ev: DragEvent| ev.prevent_default(),
            cut = |ev: ClipboardEvent| ev.prevent_default(),
            paste = |ev: ClipboardEvent| ev.prevent_default(),
        )
    }

    fn selected(&self, state: &Self::State) -> XSignal<bool> {
        state.selected_tabs.get(&self.language).unwrap().clone()
    }
}