ascii_hangman_webapp/
lib.rs

1//! This is the wasm web-gui.
2//! This file is only compiled for the wasm32 target.
3
4#![cfg(target_arch = "wasm32")]
5#![recursion_limit = "512"]
6
7use ascii_hangman_backend::game::State;
8use ascii_hangman_backend::Backend;
9use ascii_hangman_backend::HangmanBackend;
10use ascii_hangman_backend::{AUTHOR, CONF_TEMPLATE, CONF_TEMPLATE_SHORT, TITLE, VERSION};
11use wasm_bindgen::prelude::*;
12use yew::events::KeyboardEvent;
13use yew::prelude::*;
14use yew::services::reader::{File, FileData, ReaderService, ReaderTask};
15// Disable debugging code.
16//use yew::services::ConsoleService;
17use yew::services::DialogService;
18use yew::{html, Component, ComponentLink, Html, InputData, ShouldRender};
19
20#[derive(Debug)]
21pub enum Scene {
22    Playground(Backend),
23    ConfigureGame,
24    GameOver,
25}
26
27pub struct GuiState {
28    config_text: String,
29    guess: String,
30}
31
32pub struct Model {
33    link: ComponentLink<Self>,
34    // Disable debugging code.
35    //console: ConsoleService,
36    filereader_tasks: Vec<ReaderTask>,
37    scene: Scene,
38    state: GuiState,
39}
40
41#[derive(Debug)]
42pub enum Msg {
43    SwitchTo(Scene),
44    ConfigTextDelete,
45    ConfigTextUpdate(String),
46    ConfigReady,
47    Files(Vec<File>),
48    Loaded(FileData),
49    UpdateGuess(String),
50    Guess,
51    Nope,
52    NextRound,
53}
54
55impl Component for Model {
56    type Message = Msg;
57    type Properties = ();
58
59    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
60        let state = GuiState {
61            config_text: String::from(CONF_TEMPLATE_SHORT),
62            guess: String::new(),
63        };
64
65        Model {
66            link,
67            // Disable debugging code.
68            //console: ConsoleService::new(),
69            filereader_tasks: vec![],
70            scene: Scene::ConfigureGame,
71            state,
72        }
73    }
74
75    fn update(&mut self, msg: Self::Message) -> ShouldRender {
76        let mut new_scene = None;
77        match &mut self.scene {
78            Scene::Playground(ref mut app) => match msg {
79                Msg::SwitchTo(Scene::ConfigureGame) => {
80                    new_scene = Some(Scene::ConfigureGame);
81                }
82                Msg::SwitchTo(Scene::GameOver) => {
83                    new_scene = Some(Scene::GameOver);
84                }
85                Msg::UpdateGuess(val) => {
86                    self.state.guess = val.chars().rev().take(1).collect();
87                    // Disable debugging code.
88                    //self.console.debug(&self.state.guess);
89                }
90                Msg::Guess => {
91                    app.process_user_input(&self.state.guess);
92                    self.state.guess = String::new();
93                }
94                Msg::Nope => {}
95                unexpected => {
96                    panic!(
97                        "Unexpected message when configurations list shown: {:?}",
98                        unexpected
99                    );
100                }
101            },
102            Scene::ConfigureGame => match msg {
103                Msg::ConfigTextUpdate(val) => {
104                    self.state.config_text = val;
105                }
106                Msg::ConfigTextDelete => {
107                    self.state.config_text = CONF_TEMPLATE_SHORT.to_string();
108                }
109                Msg::SwitchTo(Scene::Playground(app)) => {
110                    new_scene = Some(Scene::Playground(app));
111                }
112
113                Msg::SwitchTo(Scene::GameOver) => {
114                    if DialogService::confirm("Do you really want to quit this game?") {
115                        new_scene = Some(Scene::GameOver);
116                    }
117                }
118
119                Msg::Loaded(file) => {
120                    if let Ok(s) = std::str::from_utf8(&file.content) {
121                        self.state.config_text.push_str(s);
122                    } else {
123                        DialogService::alert(&format!("Can not read text file: {}", file.name));
124                    }
125                }
126                Msg::Files(files) => {
127                    for file in files.into_iter() {
128                        let task = {
129                            let callback = self.link.callback(Msg::Loaded);
130                            ReaderService::read_file(file, callback).unwrap()
131                        };
132                        self.filereader_tasks.push(task);
133                    }
134                }
135                Msg::ConfigReady => {
136                    match Backend::new(self.state.config_text.as_str()) {
137                        Ok(app) => {
138                            self.link
139                                .send_message(Msg::SwitchTo(Scene::Playground(app)));
140                        }
141                        Err(e) => {
142                            DialogService::alert(&format!("Can not parse configuration:\n {}", e))
143                        }
144                    };
145                }
146                unexpected => {
147                    panic!(
148                        "Unexpected message during new config editing: {:?}",
149                        unexpected
150                    );
151                }
152            },
153            Scene::GameOver => match msg {
154                Msg::SwitchTo(Scene::ConfigureGame) => {
155                    new_scene = Some(Scene::ConfigureGame);
156                }
157                unexpected => {
158                    panic!("Unexpected message for settings scene: {:?}", unexpected);
159                }
160            },
161        }
162        if let Some(new_scene) = new_scene.take() {
163            self.scene = new_scene;
164        }
165        true
166    }
167
168    fn change(&mut self, _: Self::Properties) -> ShouldRender {
169        false
170    }
171
172    fn view(&self) -> Html {
173        let header = move || -> Html {
174            html! { <h1> {TITLE} </h1> }
175        };
176        let footer = move || -> Html {
177            html! { <footer class="footer">
178            <a href="../ascii-hangman--manual.html"> {"User Manual"} </a>
179            {", "} <a href="../#distribution"> {"Desktop Version"} </a>
180            {", "} <a href="../"> {"Documentation"} </a>
181            {", "} <a href="https://github.com/getreu/ascii-hangman"> {"Source Code"} </a>
182            {", Version "} {VERSION.unwrap()} {", "} {AUTHOR}  </footer> }
183        };
184        match self.scene {
185            Scene::ConfigureGame => html! { <>
186                {header()}
187                <div class="ascii-hangman-wasm">
188                    <div> {"Enter your secrets here:"}</div>
189                    <div>
190                    <textarea class="conf-text"
191                        placeholder=CONF_TEMPLATE
192                        cols=80
193                        rows=25
194                        value=self.state.config_text.clone()
195                        oninput=self.link.callback(|e: InputData| Msg::ConfigTextUpdate(e.value)) />
196                    </div>
197                    <div class="upload-container"> { "or load secrets from files: "}
198                        <label class="upload-link" for="upload">{"Upload Files ..."}</label>
199                        <input class="custom-file-input" type="file" id="upload" multiple=true onchange=self.link.callback(move |value| {
200                                let mut result = Vec::new();
201                                if let ChangeData::Files(files) = value {
202                                    let files = js_sys::try_iter(&files)
203                                        .unwrap()
204                                        .unwrap()
205                                        .into_iter()
206                                        .map(|v| File::from(v.unwrap()));
207                                    result.extend(files);
208                                }
209                                Msg::Files(result)
210                            })/>
211                    </div>
212
213                    <button disabled=self.state.config_text.is_empty()
214                            onclick=self.link.callback(|_| Msg::ConfigTextDelete)>{ "Delete Secrets" }</button>
215                    <button disabled=self.state.config_text.is_empty()
216                            onclick=self.link.callback(|_| Msg::ConfigReady)>{ "Start Game" }</button>
217                </div>
218                {footer()}
219                </>
220            },
221            Scene::Playground(ref app) => {
222                let secret = app.render_secret();
223                let (cols, rows) = dimensions(&secret);
224                let secret = secret.trim_end_matches("\n").to_string();
225                let image = app.render_image();
226                let image = image.trim_end_matches("\n").to_string();
227                html! { <>
228                    {header()}
229                    <div class="ascii-hangman-wasm">
230                            <textarea class="image"
231                                placeholder="Image"
232                                cols=format!("{}", &app.get_image_dimension().0)
233                                rows=format!("{}", &app.get_image_dimension().1)
234                                value=image
235                                readonly=true
236                            />
237                        <table class="game-status">
238                        <tr>
239                        <th>
240                            { app.render_game_lifes() } { " " }
241                        </th>
242                        <th>
243                            { app.render_game_last_guess() }
244                        </th>
245                        </tr>
246                        </table>
247                            <textarea class="secret"
248                                cols=format!("{}", cols+1)
249                                rows=format!("{}", rows)
250                                value=secret
251                                readonly=true
252                            />
253                        <div class="instructions">
254                            { app.render_instructions() }
255                            <input class="guess"
256                                type="text"
257                                autofocus=true
258                                size=1
259                                value=self.state.guess.clone()
260                                oninput=self.link.callback(|e: InputData| Msg::UpdateGuess(e.value))
261                                onkeypress=self.link.callback(|e: KeyboardEvent| {
262                                   if e.key() == "Enter" { Msg::Guess } else { Msg::Nope }
263                                }) />
264
265                        </div>
266                        <button disabled={app.get_state() == State::Ongoing || app.get_state() == State::VictoryGameOver}
267                                onclick=self.link.callback(|_| Msg::Guess)>{ "Continue Game" }</button>
268                        <button disabled={app.get_state() != State::VictoryGameOver}
269                                onclick=self.link.callback(|_| Msg::SwitchTo(Scene::ConfigureGame))>{ "Reset Game" }</button>
270                        <button disabled={app.get_state() != State::VictoryGameOver}
271                                onclick=self.link.callback(|_| Msg::SwitchTo(Scene::GameOver))>{ "End Game" }</button>
272                    </div>
273                    {footer()}
274                    </>
275                }
276            }
277            Scene::GameOver => html! { <>
278                {header()}
279                <div> {"You can now close this window."}</div>
280                <div> <label>{ "Bye bye!" } </label>
281                <p/>
282                    <button onclick=self.link.callback(|_| Msg::SwitchTo(Scene::ConfigureGame))>{ "No, Continue Playing" }</button>
283                </div>
284                {footer()}
285                </>
286            },
287        }
288    }
289}
290
291/// Returns the columns and lines of the smallest
292/// grid that can display this multi-line string `s`.
293pub fn dimensions(s: &str) -> (usize, usize) {
294    let mut row = 0;
295    let mut col = 0;
296    for l in s.lines() {
297        let c = l.chars().count();
298        if c > col {
299            col = c;
300        };
301        row += 1;
302    }
303    (col, row)
304}
305
306#[wasm_bindgen(start)]
307pub fn run_app() {
308    App::<Model>::new().mount_to_body();
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use secret::Secret;
315    #[test]
316    fn test_dimensions() {
317        let secret = Secret::new("Lorem ipsum dolor sit amet, consectetur adipiscing\
318         elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\
319         quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure\
320         dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur\
321         sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est\
322         laborum.");
323
324        //secret.disclose_all();
325
326        assert_eq!(dimensions(format!("{}", secret).as_str()), (68, 22));
327        //assert_eq!(format!("{}", secret), String::new());
328    }
329}