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