diff --git a/Cargo.lock b/Cargo.lock index 2adc493e..1bee6631 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,6 +1316,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.83" @@ -1421,6 +1436,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.4.0" @@ -1591,6 +1619,31 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot 0.12.1", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -2359,6 +2412,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + [[package]] name = "inflections" version = "1.1.1" @@ -2654,6 +2713,15 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2c024b41519440580066ba82aab04092b333e09066a5eb86c7c4890df31f22" +dependencies = [ + "hashbrown", +] + [[package]] name = "mach2" version = "0.4.2" @@ -3416,6 +3484,26 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8a99fddc9f0ba0a85884b8d14e3592853e787d581ca1816c91349b10e4eeab" +[[package]] +name = "ratatui" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb12f8fbf6c62614b0d56eb352af54f6a22410c3b079eb53ee93c7b97dd31d8" +dependencies = [ + "bitflags 2.4.2", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -3647,6 +3735,36 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -3698,6 +3816,16 @@ dependencies = [ "bitflags 2.4.2", ] +[[package]] +name = "stability" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3715,6 +3843,9 @@ name = "strum" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" @@ -4800,6 +4931,16 @@ dependencies = [ "yarnspinner_core", ] +[[package]] +name = "yarnspinner_without_bevy_examples" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", + "yarnspinner", +] + [[package]] name = "yoke" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index a221a898..bdf3337a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/codegen", "demo", "examples/bevy_yarnspinner", + "examples/yarnspinner_without_bevy", ] # Source: https://github.com/bevyengine/bevy/blob/main/examples/README.md#1-tweak-your-cargotoml diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ff612b0a..983f23af 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,8 +19,10 @@ mod yarn_value; pub mod prelude { //! Types and functions used all throughout the runtime and compiler. + #[cfg(any(feature = "bevy", feature = "serde"))] + pub use crate::feature_gates::*; + pub use crate::{ - feature_gates::*, generated::{ instruction::OpCode, operand::Value as OperandValue, Header, Instruction, InvalidOpCodeError, Node, Operand, Program, diff --git a/examples/bevy_yarnspinner/Cargo.toml b/examples/bevy_yarnspinner/Cargo.toml index e7cf9a52..e71a1736 100644 --- a/examples/bevy_yarnspinner/Cargo.toml +++ b/examples/bevy_yarnspinner/Cargo.toml @@ -13,3 +13,19 @@ publish = false bevy = { version = "0.13", features = ["file_watcher"] } bevy_yarnspinner = { path = "../../crates/bevy_plugin", version = "0.2" } bevy_yarnspinner_example_dialogue_view = { path = "../../crates/example_dialogue_view", version = "0.2" } + +[[bin]] +name = "access_variables" +doc = false + +[[bin]] +name = "custom_command" +doc = false + +[[bin]] +name = "custom_function" +doc = false + +[[bin]] +name = "hello_world" +doc = false \ No newline at end of file diff --git a/examples/yarnspinner_without_bevy/Cargo.toml b/examples/yarnspinner_without_bevy/Cargo.toml new file mode 100644 index 00000000..c2e2e0f8 --- /dev/null +++ b/examples/yarnspinner_without_bevy/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "yarnspinner_without_bevy_examples" +version = "0.1.0" +edition = "2021" +repository = "https://github.com/YarnSpinnerTool/YarnSpinner-Rust" +homepage = "https://docs.yarnspinner.dev/" +keywords = ["gamedev", "dialog", "yarn", "bevy"] +categories = ["game-development", "compilers"] +authors = ["Joe Clay <27cupsofcoffee@gmail.com>"] +publish = false + +[dependencies] +crossterm = "0.27" +ratatui = "0.26" +anyhow = "1.0" +yarnspinner = { path = "../../crates/yarnspinner", version = "0.2" } + +[[bin]] +name = "access_variables" +doc = false + +[[bin]] +name = "custom_command" +doc = false + +[[bin]] +name = "custom_function" +doc = false + +[[bin]] +name = "hello_world" +doc = false \ No newline at end of file diff --git a/examples/yarnspinner_without_bevy/assets/dialogue/access_variables.yarn b/examples/yarnspinner_without_bevy/assets/dialogue/access_variables.yarn new file mode 100644 index 00000000..5eca6af3 --- /dev/null +++ b/examples/yarnspinner_without_bevy/assets/dialogue/access_variables.yarn @@ -0,0 +1,6 @@ +title: AccessVariables +--- +Setting $foo to 1 after you continue this line +<> +$foo is now {$foo}. This information should also be printed in your console once the UI exits. +=== diff --git a/examples/yarnspinner_without_bevy/assets/dialogue/custom_command.yarn b/examples/yarnspinner_without_bevy/assets/dialogue/custom_command.yarn new file mode 100644 index 00000000..a37bfee9 --- /dev/null +++ b/examples/yarnspinner_without_bevy/assets/dialogue/custom_command.yarn @@ -0,0 +1,8 @@ +title: CustomCommand +--- +When a command is encountered, a 'Command' DialogueEvent will be triggered. +You can handle this in your game code to do anything you'd like. +For example, here's a command that changes the background of the terminal! +<> +Ooh, pretty. +=== diff --git a/examples/yarnspinner_without_bevy/assets/dialogue/custom_function.yarn b/examples/yarnspinner_without_bevy/assets/dialogue/custom_function.yarn new file mode 100644 index 00000000..a08a8d9e --- /dev/null +++ b/examples/yarnspinner_without_bevy/assets/dialogue/custom_function.yarn @@ -0,0 +1,6 @@ +title: CustomFunction +--- +Rust functions can be registered into your dialogue's library to make them runnable via Yarn. +Let's take a look at a custom function now! +pow(2,3) = {pow(2,3)} +=== diff --git a/examples/yarnspinner_without_bevy/assets/dialogue/hello_world.yarn b/examples/yarnspinner_without_bevy/assets/dialogue/hello_world.yarn new file mode 100644 index 00000000..33dd6e4b --- /dev/null +++ b/examples/yarnspinner_without_bevy/assets/dialogue/hello_world.yarn @@ -0,0 +1,34 @@ +title: HelloWorld +--- +Hello World! To continue the dialogue, press the enter key. You can also press Q to quit. +These are options. You can select one by clicking on it or pressing the corresponding number on your keyboard. +-> Some cool option +-> Some other cool option +Now we'll jump to another node! +<> + +=== + +title: AnotherNode +--- +Now, a character will talk. Notice how the upper left corner of the dialogue will show their name. +Hohenheim: Hi, I'm Jan Hohenheim, creator of Yarn Spinner for Rust. I hope you enjoy using it! +Let's set a condition. Do you prefer dogs or cats? +-> Dogs + <> +-> Cats + <> +-> Turtles + I, uuuh... okay, why not. + <> +Now let's print the result of the condition. Your preference is... +(Drum roll) +<> +Dogs! Arf Arf! +<> +Cats! (Can't say I agree, but you do you) +<> +Turtles! Solid choice. +<> +Et voilĂ ! That was all. Thanks for checking out Yarn Spinner for Rust! Continuing from the last node will exit the dialogue. +=== diff --git a/examples/yarnspinner_without_bevy/src/bin/access_variables.rs b/examples/yarnspinner_without_bevy/src/bin/access_variables.rs new file mode 100644 index 00000000..9627c786 --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/bin/access_variables.rs @@ -0,0 +1,24 @@ +use yarnspinner::core::YarnValue; +use yarnspinner_without_bevy_examples::TuiDialogueRunner; + +fn main() -> anyhow::Result<()> { + // See lib.rs for more details on how this works! + let mut runner = + TuiDialogueRunner::new("./assets/dialogue/access_variables.yarn", "AccessVariables")?; + + runner.set_variable("$foo", YarnValue::Number(0.0))?; + + println!( + "Value of $foo before dialogue: {:?}", + runner.get_variable("$foo")? + ); + + runner.run()?; + + println!( + "Value of $foo after dialogue: {:?}", + runner.get_variable("$foo")? + ); + + Ok(()) +} diff --git a/examples/yarnspinner_without_bevy/src/bin/custom_command.rs b/examples/yarnspinner_without_bevy/src/bin/custom_command.rs new file mode 100644 index 00000000..9698330f --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/bin/custom_command.rs @@ -0,0 +1,6 @@ +use yarnspinner_without_bevy_examples::TuiDialogueRunner; + +fn main() -> anyhow::Result<()> { + // See lib.rs for more details on how this works! + TuiDialogueRunner::new("./assets/dialogue/custom_command.yarn", "CustomCommand")?.run() +} diff --git a/examples/yarnspinner_without_bevy/src/bin/custom_function.rs b/examples/yarnspinner_without_bevy/src/bin/custom_function.rs new file mode 100644 index 00000000..0a5fd8cb --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/bin/custom_function.rs @@ -0,0 +1,16 @@ +use yarnspinner_without_bevy_examples::TuiDialogueRunner; + +fn main() -> anyhow::Result<()> { + // See lib.rs for more details on how this works! + let mut runner = + TuiDialogueRunner::new("./assets/dialogue/custom_function.yarn", "CustomFunction")?; + + runner.add_function("pow", pow); + runner.run()?; + + Ok(()) +} + +fn pow(base: f32, exponent: f32) -> f32 { + base.powf(exponent) +} diff --git a/examples/yarnspinner_without_bevy/src/bin/hello_world.rs b/examples/yarnspinner_without_bevy/src/bin/hello_world.rs new file mode 100644 index 00000000..668c0794 --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/bin/hello_world.rs @@ -0,0 +1,6 @@ +use yarnspinner_without_bevy_examples::TuiDialogueRunner; + +fn main() -> anyhow::Result<()> { + // See lib.rs for more details on how this works! + TuiDialogueRunner::new("./assets/dialogue/hello_world.yarn", "HelloWorld")?.run() +} diff --git a/examples/yarnspinner_without_bevy/src/lib.rs b/examples/yarnspinner_without_bevy/src/lib.rs new file mode 100644 index 00000000..57719f6f --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/lib.rs @@ -0,0 +1,315 @@ +//! This example implements a minimal Yarn dialogue runner, which outputs to the terminal using ratatui. +//! Not all features are covered here (e.g. localization), but it should give you an idea of how the +//! yarnspinner crate can be used without a pre-made engine integration. +//! +//! The implementation is annotated with various comments explaining what each API does - start at +//! TuiDialogueRunner::new, and follow along from there! + +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; + +use anyhow::{anyhow, Context}; +use crossterm::event::KeyCode; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Stylize}; +use yarnspinner::compiler::Compiler; +use yarnspinner::core::{IntoYarnValueFromNonYarnValue, LineId, YarnFn, YarnValue}; +use yarnspinner::runtime::{ + Dialogue, DialogueEvent, Line, MemoryVariableStorage, StringTableTextProvider, +}; + +use self::terminal::Terminal; +use self::widgets::{ContinueView, LineView, OptionsView, OptionsViewState}; + +pub mod terminal; +pub mod widgets; + +enum Status { + ReadyToContinue, + WaitingForContinue, + WaitingForOptions(OptionsViewState), +} + +pub struct TuiDialogueRunner { + dialogue: Dialogue, + metadata: HashMap>, + status: Status, + last_line: Option, + background_color: Color, +} + +impl TuiDialogueRunner { + pub fn new( + source_path: impl AsRef, + start_node: &str, + ) -> anyhow::Result { + // Before we can run our dialogue, we need to compile it. + // + // In a real game, you might want to consider doing this as part of your asset + // pipeline, rather than at runtime.. + let compilation = Compiler::new().read_file(source_path).compile()?; + + // One of the outputs of compiling is a string table, containing all of the + // text (and associated metadata) for our dialogue. + let mut base_language_string_table = HashMap::new(); + let mut metadata = HashMap::new(); + + for (k, v) in compilation.string_table { + base_language_string_table.insert(k.clone(), v.text); + metadata.insert(k, v.metadata); + } + + // In order to create a Dialogue object, we need two things. + // + // First, an implementation of TextProvider, which is used to fetch the text + // that gets displayed in your game. + // + // The yarnspinner crate provides a simple implementation of this called + // StringTableTextProvider, which supports storing the base language + // (whatever you wrote the original text in), and one active localization. + // For this example, we'll just stick to the base language. + let mut text_provider = StringTableTextProvider::new(); + text_provider.extend_base_language(base_language_string_table); + + // Second, an implementation of VariableStorage, which is where any variables + // defined in your dialogue will get stored. + // + // The yarnspinner crate provides a simple implementation of this called + // MemoryVariableStorage, which (as the name suggests!) stores the variables + // in memory. + let variable_storage = MemoryVariableStorage::new(); + + // Now we can create the Dialogue! Note that both parameters need to be boxed. + let mut dialogue = Dialogue::new(Box::new(variable_storage), Box::new(text_provider)); + + // Finally, we need to actually give the Dialogue (which is empty to begin with) our + // compiled program, and tell it which node to start running from. + // + // To see how we actually drive this at runtime, scroll down to `fn update`! + dialogue.add_program(compilation.program.context("no program compiled")?); + dialogue.set_node(start_node)?; + + Ok(TuiDialogueRunner { + dialogue, + metadata, + status: Status::ReadyToContinue, + last_line: None, + background_color: Color::Black, + }) + } + + pub fn run(&mut self) -> anyhow::Result<()> { + terminal::set_panic_hook(); + let mut terminal = terminal::init()?; + + let result = self.run_inner(&mut terminal); + + // We should always *try* to clean up the terminal before we exit, even if an + // error was thrown. + let _ = terminal::restore(); + + result + } + + fn run_inner(&mut self, terminal: &mut Terminal) -> anyhow::Result<()> { + loop { + let should_exit = self.update()?; + + if should_exit { + return Ok(()); + } + + self.draw(terminal)?; + } + } + + fn update(&mut self) -> anyhow::Result { + // This function is where we actually make use of the Dialogue to decide what + // gets shown to the player. We're using a TUI, but the approach would be + // similar using a game engine too. + // + // First, let's check for any input from the player. + if let Some(key) = terminal::poll_input()? { + match key { + // If they press Q, we'll exit the game loop. + KeyCode::Char('q') => return Ok(true), + + // If they press Enter, we should check if the UI is currently waiting for + // them to continue or select an option. If so, we can signal to the + // dialogue that we're ready to continue running. + KeyCode::Enter => match &mut self.status { + Status::WaitingForContinue => { + self.status = Status::ReadyToContinue; + } + + Status::WaitingForOptions(state) => { + if let Some(id) = state.selected() { + // If the dialogue is waiting for an option to be selected, we + // must set the selected option before trying to continue + // running, or we'll get an error. We'll see where this + // ID comes from later! + self.dialogue.set_selected_option(id)?; + + self.status = Status::ReadyToContinue; + } + } + + _ => {} + }, + + // If they press Up or Down, and the UI is currently waiting for an option + // to be selected, we should move the cursor. + KeyCode::Up => { + if let Status::WaitingForOptions(state) = &mut self.status { + state.move_cursor_up(); + } + } + + KeyCode::Down => { + if let Status::WaitingForOptions(state) = &mut self.status { + state.move_cursor_down(); + } + } + + _ => {} + } + } + + // Now that we're done handling the player's input, we can check if we're ready to + // continue running the dialogue. + if let Status::ReadyToContinue = &self.status { + // If so, call `Dialogue::continue_` to get a list of dialogue events to handle. + let events = self.dialogue.continue_()?; + + for event in events { + match event { + // A 'Line' event will be triggered whenever a new line of dialogue is ready + // to be presented to the player. + DialogueEvent::Line(line) => { + // If this is the last line before displaying a list of options, + // we don't need to wait for the player to hit 'continue'. + let last_line_before_options = self + .metadata + .get(&line.id) + .map(|m| m.iter().any(|x| x == "lastline")) + .unwrap_or(false); + + if !last_line_before_options { + self.status = Status::WaitingForContinue; + } + + // Now we can store the new line, for our 'draw' method to render. + self.last_line = Some(line); + } + + // An 'Options' event will be triggered whenever a list of options needs + // to be presented to the player. + // + // As mentioned previously, we cannot run `continue_` again until an option + // has been selected. + DialogueEvent::Options(options) => { + self.status = Status::WaitingForOptions(OptionsViewState::new(options)); + } + + // A 'Command' event will be triggered whenever a custom command is encountered + // in the dialogue. + // + // The command is provided to you in the form of a name, and a list of `YarnValue` + // parameters. It's up to you to handle parsing/validating these in whatever + // way is appropriate for your game. + DialogueEvent::Command(command) => match command.name.as_str() { + "set_background" => match command.parameters.first() { + Some(YarnValue::String(s)) => { + self.background_color = Color::from_str(s)?; + } + + _ => { + return Err(anyhow!("invalid parameters: {:?}", command.parameters)) + } + }, + + _ => { + return Err(anyhow!("unknown command: {}", command.name)); + } + }, + + // A 'DialogueComplete' event will be triggered whenever we reach the end of a node + // without jumping somewhere else. To continue running, you must call `set_node` on + // the dialogue (as we did in the constructor). + DialogueEvent::DialogueComplete => { + return Ok(true); + } + + // There are several other events that can be triggered (such as NodeStart, or + // LineHints), which may be useful depending on what you're trying to build. + // See the docs for more info on these! + _ => {} + } + } + } + + // And with that, we're done until the next update! + // + // From here, you may want to look at `get_variable` and 'set_variable' to learn how + // to access your dialogue's state, or 'add_function' to learn how to add custom + // functions to Yarn. + Ok(false) + } + + fn draw(&mut self, terminal: &mut Terminal) -> anyhow::Result<()> { + terminal.draw(|f| { + let layout = Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]); + let [output_area, options_area] = layout.areas(f.size()); + + if let Some(line) = &self.last_line { + f.render_widget(LineView::new(line).bg(self.background_color), output_area); + } + + match &mut self.status { + Status::WaitingForContinue => f.render_widget( + ContinueView::default().bg(self.background_color), + options_area, + ), + + Status::WaitingForOptions(state) => f.render_stateful_widget( + OptionsView::default().bg(self.background_color), + options_area, + state, + ), + + _ => {} + } + })?; + + Ok(()) + } + + pub fn get_variable(&self, name: &str) -> anyhow::Result { + let value = self.dialogue.variable_storage().get(name)?; + + Ok(value) + } + + pub fn set_variable( + &mut self, + name: impl Into, + value: YarnValue, + ) -> anyhow::Result<()> { + self.dialogue + .variable_storage_mut() + .set(name.into(), value)?; + + Ok(()) + } + + pub fn add_function(&mut self, name: impl Into>, function: F) + where + Marker: 'static, + F: YarnFn + 'static + Clone, + F::Out: IntoYarnValueFromNonYarnValue + 'static + Clone, + { + self.dialogue.library_mut().add_function(name, function); + } +} diff --git a/examples/yarnspinner_without_bevy/src/terminal.rs b/examples/yarnspinner_without_bevy/src/terminal.rs new file mode 100644 index 00000000..fcbc5dae --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/terminal.rs @@ -0,0 +1,45 @@ +//! This module is here for using ratatui to interact with the terminal and +//! crossterm to listen to input. It does not contain any code specific to yarnspinner + +use std::io::Stdout; + +use crossterm::event::{Event, KeyCode, KeyEventKind}; +use ratatui::backend::CrosstermBackend; + +pub type Terminal = ratatui::Terminal>; + +pub fn init() -> anyhow::Result { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stdout(), crossterm::terminal::EnterAlternateScreen)?; + + let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?; + + Ok(terminal) +} + +pub fn restore() -> anyhow::Result<()> { + crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen)?; + crossterm::terminal::disable_raw_mode()?; + + Ok(()) +} + +pub fn set_panic_hook() { + let default_hook = std::panic::take_hook(); + + std::panic::set_hook(Box::new(move |info| { + let _ = restore(); + + default_hook(info) + })); +} + +pub fn poll_input() -> anyhow::Result> { + if let Event::Key(key) = crossterm::event::read()? { + if key.kind == KeyEventKind::Press { + return Ok(Some(key.code)); + } + } + + Ok(None) +} diff --git a/examples/yarnspinner_without_bevy/src/widgets.rs b/examples/yarnspinner_without_bevy/src/widgets.rs new file mode 100644 index 00000000..223f6b9d --- /dev/null +++ b/examples/yarnspinner_without_bevy/src/widgets.rs @@ -0,0 +1,151 @@ +//! This module is here for using ratatui to interact with the terminal and +//! does not contain any code specific to yarnspinner + +use ratatui::prelude::{Buffer, Rect}; +use ratatui::style::{Modifier, Style, Styled}; +use ratatui::widgets::{Block, Borders, List, ListState, Paragraph, StatefulWidget, Widget, Wrap}; + +use yarnspinner::runtime::{DialogueOption, Line, OptionId}; + +pub struct LineView<'a> { + line: &'a Line, + style: Style, +} + +impl<'a> LineView<'a> { + pub fn new(line: &'a Line) -> LineView<'a> { + LineView { + line, + style: Style::new(), + } + } +} + +impl<'a> Widget for LineView<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.text_without_character_name().as_str()) + .style(self.style) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .title(self.line.character_name().unwrap_or_default()) + .borders(Borders::ALL), + ) + .render(area, buf); + } +} + +impl<'a> Styled for LineView<'a> { + type Item = LineView<'a>; + + fn style(&self) -> Style { + self.style + } + + fn set_style>(mut self, style: S) -> Self::Item { + self.style = style.into(); + self + } +} + +pub struct OptionsViewState { + items: Vec, + list_state: ListState, +} + +impl OptionsViewState { + pub fn new(items: Vec) -> OptionsViewState { + OptionsViewState { + items, + list_state: ListState::default().with_selected(Some(0)), + } + } + + pub fn selected(&self) -> Option { + self.list_state.selected().map(|i| self.items[i].id) + } + + pub fn move_cursor_up(&mut self) { + let current_index = self.list_state.selected().unwrap_or(0); + + let new_index = if current_index == 0 { + self.items.len() - 1 + } else { + current_index - 1 + }; + + self.list_state.select(Some(new_index)); + } + + pub fn move_cursor_down(&mut self) { + let current_index = self.list_state.selected().unwrap_or(0); + + self.list_state + .select(Some((current_index + 1) % self.items.len())); + } +} + +#[derive(Default)] +pub struct OptionsView { + style: Style, +} + +impl StatefulWidget for OptionsView { + type State = OptionsViewState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let widget = List::new(state.items.iter().map(|o| o.line.text.clone())) + .style(self.style) + .block(Block::default().title("Options").borders(Borders::ALL)) + .highlight_style(Style::new().add_modifier(Modifier::REVERSED)); + + StatefulWidget::render(widget, area, buf, &mut state.list_state) + } +} + +impl Styled for OptionsView { + type Item = OptionsView; + + fn style(&self) -> Style { + self.style + } + + fn set_style>(mut self, style: S) -> Self::Item { + self.style = style.into(); + self + } +} + +#[derive(Default)] +pub struct ContinueView { + style: Style, +} + +impl Widget for ContinueView { + fn render(self, area: Rect, buf: &mut Buffer) { + let widget = List::new(["Continue"]) + .style(self.style) + .block(Block::default().title("Options").borders(Borders::ALL)) + .highlight_style(Style::new().add_modifier(Modifier::REVERSED)); + + StatefulWidget::render( + widget, + area, + buf, + &mut ListState::default().with_selected(Some(0)), + ) + } +} + +impl Styled for ContinueView { + type Item = ContinueView; + + fn style(&self) -> Style { + self.style + } + + fn set_style>(mut self, style: S) -> Self::Item { + self.style = style.into(); + self + } +}