Skip to main content

bubbles/runtime/
event.rs

1//! [`DialogueEvent`], [`DialogueOption`], and [`MarkupSpan`] - the output types of the runner.
2
3/// How a [`DialogueEvent::Line`] should be treated for display or logging.
4///
5/// The runner sets this from trailing `#tag` metadata on the source line:
6/// `#debug` yields [`LineMode::Debug`], `#narration` yields [`LineMode::Narration`].
7/// If both are present, [`LineMode::Debug`] wins. All other lines use [`LineMode::Normal`].
8///
9/// Tags that only exist to set the mode remain in [`DialogueEvent::Line::tags`]; your
10/// host can ignore them once you branch on [`LineMode`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum LineMode {
13    /// Ordinary character or narrator line.
14    #[default]
15    Normal,
16    /// System or omniscient narration (subtitle style, VO bus, etc.).
17    Narration,
18    /// Developer or QA line you may want to hide in release builds.
19    Debug,
20}
21
22/// Derives [`LineMode`] from trailing `#tag` strings (without the `#` prefix).
23#[must_use]
24pub fn line_mode_from_tags(tags: &[String]) -> LineMode {
25    if tags.iter().any(|t| t == "debug") {
26        LineMode::Debug
27    } else if tags.iter().any(|t| t == "narration") {
28        LineMode::Narration
29    } else {
30        LineMode::Normal
31    }
32}
33
34/// Returns the group from a `#group:<name>` tag in `tags`, if any (first match wins).
35///
36/// Used for UI constraints (radio-button semantics, mutually exclusive option sets).
37/// The group tag itself remains in [`DialogueOption::tags`]; your UI can use both
38/// the `group` field for constraint logic and `tags` for styling/metadata.
39#[must_use]
40pub fn option_group_from_tags(tags: &[String]) -> Option<String> {
41    tags.iter()
42        .find_map(|t| t.strip_prefix("group:"))
43        .map(ToOwned::to_owned)
44        .filter(|s| !s.is_empty())
45}
46
47/// A resolved inline markup span: a named annotation over a byte range in the
48/// stripped display text.
49///
50/// Spans are produced at runtime, after expression substitution, so [`start`]
51/// and [`length`] are byte offsets into the final [`DialogueEvent::Line::text`]
52/// / [`DialogueOption::text`] string.
53///
54/// The runtime assigns no meaning to span names or properties. Your game
55/// decides what `[wave]`, `[color value=red]`, or `[pause /]` means and how
56/// to render it.
57///
58/// [`start`]: MarkupSpan::start
59/// [`length`]: MarkupSpan::length
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct MarkupSpan {
62    /// The markup tag name, e.g. `wave` for `[wave]text[/wave]`.
63    pub name: String,
64    /// Byte offset of the first character of the spanned text in the display string.
65    pub start: usize,
66    /// Byte length of the spanned text. Zero for self-closing tags.
67    pub length: usize,
68    /// Zero or more `(key, value)` pairs from the tag, e.g. `[("value", "red")]`
69    /// for `[color value=red]`.
70    pub properties: Vec<(String, String)>,
71}
72
73/// Returns the id from a `#line:<id>` tag in `tags`, if any (first match wins).
74///
75/// This matches the id passed to [`crate::LineProvider`]. Use it to key voice-over or analytics
76/// without re-parsing [`DialogueEvent::Line::tags`] or [`DialogueOption::tags`].
77#[must_use]
78pub fn line_id_from_tags(tags: &[String]) -> Option<String> {
79    tags.iter()
80        .find_map(|t| t.strip_prefix("line:"))
81        .map(str::to_owned)
82        .filter(|s| !s.is_empty())
83}
84
85/// An option presented to the player.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct DialogueOption {
88    /// Display text of the option (markup tags stripped, expressions evaluated).
89    pub text: String,
90    /// Whether this option is currently available (guards that evaluate to false make it unavailable).
91    pub available: bool,
92    /// If the option text was tagged with `#line:<id>`, the stable id (no `line:` prefix).
93    pub line_id: Option<String>,
94    /// Trailing `#tag` metadata.
95    pub tags: Vec<String>,
96    /// If the option was tagged with `#group:<name>`, the group name for UI constraints (radio buttons, etc.).
97    pub group: Option<String>,
98    /// Inline markup spans over [`text`](DialogueOption::text), in source order.
99    /// Empty when the option text contains no markup tags.
100    pub spans: Vec<MarkupSpan>,
101}
102
103/// Events emitted by [`crate::Runner`] one at a time via [`crate::Runner::next_event`].
104#[non_exhaustive]
105#[derive(Debug, Clone, PartialEq)]
106pub enum DialogueEvent {
107    /// A node has started executing.
108    NodeStarted(String),
109    /// A line of dialogue ready to display.
110    Line {
111        /// Optional speaker name.
112        speaker: Option<String>,
113        /// Display text with all `{expr}` fragments evaluated and markup tags stripped.
114        text: String,
115        /// If the line was tagged with `#line:<id>`, the stable id (no `line:` prefix).
116        line_id: Option<String>,
117        /// Trailing `#tag` metadata.
118        tags: Vec<String>,
119        /// Hint for filtering or routing (from `#narration` / `#debug` when present).
120        line_mode: LineMode,
121        /// Inline markup spans over [`text`](DialogueEvent::Line::text), in source order.
122        /// Empty when the line contains no markup tags.
123        spans: Vec<MarkupSpan>,
124    },
125    /// A set of options for the player to choose from.
126    Options(Vec<DialogueOption>),
127    /// A host command to execute.
128    Command {
129        /// Command name.
130        name: String,
131        /// Arguments with `{expr}` substituted.
132        args: Vec<String>,
133        /// Trailing tags.
134        tags: Vec<String>,
135    },
136    /// The current node has finished.
137    NodeComplete(String),
138    /// All dialogue has finished.
139    DialogueComplete,
140}
141
142#[cfg(test)]
143#[path = "event_tests.rs"]
144mod tests;