molt_wasm/
lib.rs

1use gloo::{console::debug, timers::callback::Timeout};
2// re-export molt_forked
3use molt::prelude::*;
4pub use molt_forked as molt;
5use std::{mem, rc::Rc};
6use web_sys::HtmlTextAreaElement;
7use yew::prelude::*;
8use yew_icons::{Icon, IconId};
9
10pub struct Terminal {
11    input_div_ref: NodeRef,
12    hist_div_ref: NodeRef,
13    input: String,
14    input_tmp: String,
15    current_hist_idx: Option<usize>,
16}
17#[derive(Debug, PartialEq, Clone, Copy, Default)]
18pub enum RunState {
19    #[default]
20    Ok,
21    Err,
22    Uncompleted,
23}
24
25#[derive(Debug, Properties, PartialEq)]
26pub struct TerminalProp {
27    pub class: &'static str,
28    pub hist: Rc<Vec<(RunState, String, Html)>>,
29    // new input, last one is uncompleted
30    pub on_run_cmd: Callback<(String, bool)>,
31}
32
33pub enum TerminalMsg {
34    None,
35    UpdateInput(String),
36    // RunCmd,
37    KeyDown(Key),
38}
39
40pub enum Key {
41    Enter,
42    ArrowUp,
43    ArrowDown,
44}
45
46impl Terminal {
47    pub fn to_hist(
48        cmd_ctx: String,
49        outs: Vec<Result<Value, Exception>>,
50    ) -> (RunState, String, Html) {
51        let mut run_state = RunState::Ok;
52        let out_html = html! {
53            {for outs.iter().enumerate().map(
54                |(i,out)|{
55                    match out{
56                    Ok(s) => html!(<code class="stdout" style="margin:0px;white-space:pre-wrap;"> { s.to_string() }{if i==(outs.len()-1){html!()}else{html!(<br />)}}</code>),
57                    Err(s) => {
58                        run_state=RunState::Err;
59                        html!(<code class="stderr" style="margin:0px;white-space:pre-wrap;"> { s.error_info().to_string() }{if i==(outs.len()-1){html!()}else{html!(<br />)}}</code>)},
60                    }
61                }
62            )}
63        };
64        if run_state == RunState::Err {
65            if let Some(Err(e)) = outs.last() {
66                if e.is_uncompleted() {
67                    run_state = RunState::Uncompleted;
68                }
69            }
70        }
71        (run_state, cmd_ctx, out_html)
72    }
73    fn input_div_cursor_to_end(&mut self) {
74        if let Some(textarea) = self.input_div_ref.cast::<HtmlTextAreaElement>() {
75            let length = self.input.chars().count() as u32;
76            Timeout::new(5, move || {
77                _ = textarea.set_selection_range(length, length);
78            })
79            .forget();
80        }
81    }
82}
83
84impl Component for Terminal {
85    type Message = TerminalMsg;
86    type Properties = TerminalProp;
87
88    fn create(_ctx: &Context<Self>) -> Self {
89        Self {
90            hist_div_ref: NodeRef::default(),
91            input_div_ref: NodeRef::default(),
92            input: String::new(),
93            input_tmp: String::new(),
94            current_hist_idx: None,
95        }
96    }
97
98    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
99        match msg {
100            TerminalMsg::KeyDown(key) => match key {
101                Key::Enter => {
102                    let cmd = mem::take(&mut self.input);
103                    if let Some((RunState::Uncompleted, _, _)) = ctx.props().hist.last() {
104                        ctx.props().on_run_cmd.emit((cmd, true));
105                    } else {
106                        if !cmd.is_empty() {
107                            ctx.props().on_run_cmd.emit((cmd, false));
108                        }
109                    }
110                    if let Some(element) = self.hist_div_ref.cast::<web_sys::Element>() {
111                        Timeout::new(5 as u32, move || {
112                            element.set_scroll_top(element.scroll_height());
113                        })
114                        .forget();
115                    }
116                    self.current_hist_idx = None;
117                    self.input_tmp.clear();
118                    true
119                }
120                Key::ArrowUp => match self.current_hist_idx.as_mut() {
121                    Some(0) => false,
122                    Some(i) => {
123                        if *i == ctx.props().hist.len() {
124                            self.input_tmp = mem::take(&mut self.input);
125                        }
126                        *i -= 1;
127                        if let Some((_, hist_cmd, _)) = ctx.props().hist.get(*i) {
128                            self.input = hist_cmd.clone();
129                        }
130                        self.input_div_cursor_to_end();
131                        true
132                    }
133                    None => {
134                        let i = ctx.props().hist.len() - 1;
135                        self.current_hist_idx = Some(i);
136                        if let Some((_, hist_cmd, _)) = ctx.props().hist.get(i) {
137                            self.input_tmp = mem::take(&mut self.input);
138                            self.input = hist_cmd.clone();
139                        }
140                        self.input_div_cursor_to_end();
141                        true
142                    }
143                },
144                Key::ArrowDown => match self.current_hist_idx.as_mut() {
145                    Some(i) => {
146                        if *i == ctx.props().hist.len() {
147                            false
148                        } else if *i == ctx.props().hist.len() - 1 {
149                            *i += 1;
150                            self.input = mem::take(&mut self.input_tmp);
151                            true
152                        } else {
153                            *i += 1;
154                            if let Some((_, hist_cmd, _)) = ctx.props().hist.get(*i) {
155                                self.input = hist_cmd.clone();
156                            }
157                            true
158                        }
159                    }
160                    None => false,
161                },
162            },
163            TerminalMsg::UpdateInput(s) => {
164                self.input = s;
165                self.current_hist_idx = None;
166                self.input_tmp.clear();
167                if self.input == "\n" {
168                    self.input.clear();
169                }
170                true
171            }
172            TerminalMsg::None => false,
173        }
174    }
175
176    fn rendered(&mut self, _ctx: &Context<Self>, first_render: bool) {
177        if first_render {
178            // NOTICE: slip scroll animation
179            if let Some(element) = self.hist_div_ref.cast::<web_sys::Element>() {
180                let target_scroll_top = element.scroll_height();
181                let current_scroll_top = element.scroll_top();
182                let distance = target_scroll_top - current_scroll_top;
183                let steps = 40;
184                let step_duration = 20;
185                for step in 0..steps {
186                    let current_scroll_top = current_scroll_top + distance * step / steps;
187                    let element = element.clone();
188                    Timeout::new((step_duration * step) as u32, move || {
189                        element.set_scroll_top(current_scroll_top);
190                    })
191                    .forget();
192                }
193            }
194        }
195    }
196    fn view(&self, ctx: &Context<Self>) -> Html {
197        html! {
198          <div class={ctx.props().class}>
199            <ul ref={self.hist_div_ref.clone()}
200            class="history"
201            style="text-wrap:nowrap;margin:0px;overflow-y:auto;overflow-x:auto;">
202                { for ctx.props().hist.iter().map(|(run_state,cmd_ctx,out_html)|{
203                    let (icon_class,icon,last_line,out_html) = match run_state{
204                        RunState::Ok => ("stdout-icon",IconId::BootstrapCheckLg,html!(),out_html.clone()),
205                        RunState::Err => ("stderr-icon",IconId::FontAwesomeSolidXmark,html!(),out_html.clone()),
206                        RunState::Uncompleted => ("command",IconId::FontAwesomeSolidEllipsis,html!(
207                            <div style="display:flex;flex-wrap:nowrap;">
208                                <Icon class="command" icon_id={IconId::FontAwesomeSolidEllipsis} height={"10px".to_owned()} width={"15px".to_owned()}/>
209                            </div>
210                        ),html!()),
211                    };
212                    html!{
213                        <li style="padding:0px;margin:0px;list-style:none;white-space:nowrap;">
214                        <div style="display:flex;flex-wrap:nowrap;">
215                            <Icon class={icon_class} icon_id={icon} height={"10px".to_owned()} width={"15px".to_owned()}/>
216                            <code class="command" style="white-space: pre-wrap;">
217                                {cmd_ctx}
218                            </code>
219                        </div>
220                        {last_line}
221                        <div style="padding-left:15px">
222                            {out_html.clone()}
223                        </div>
224                        </li>
225                    }
226                })}
227            </ul>
228            <textarea
229                class="input"
230                style="width:100%;"
231                ref={self.input_div_ref.clone()}
232                value={self.input.clone()}
233                oninput={ctx.link().callback(|e: InputEvent| {
234                    let input: web_sys::HtmlInputElement = e.target_unchecked_into();
235                    TerminalMsg::UpdateInput(input.value())
236                })}
237                onkeydown={ctx.link().callback(|e: KeyboardEvent| {
238                    match e.key().as_str(){
239                    "Enter" => TerminalMsg::KeyDown(Key::Enter),
240                    "ArrowUp" => TerminalMsg::KeyDown(Key::ArrowUp),
241                    "ArrowDown" => TerminalMsg::KeyDown(Key::ArrowDown),
242                    _ => TerminalMsg::None,
243                    }
244                })}
245            ></textarea>
246          </div>
247        }
248    }
249}