1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
//! Adapted from <https://github.com/YarnSpinnerTool/YarnSpinner/blob/da39c7195107d8211f21c263e4084f773b84eaff/YarnSpinner/Dialogue.cs>, which we split off into multiple files
//!
//! ## Implementation notes
//! Introduced `LineId` newtype for better type safety
use crate::markup::{
CHARACTER_ATTRIBUTE, CHARACTER_ATTRIBUTE_NAME_PROPERTY, MarkupAttribute, MarkupValue,
};
use crate::prelude::*;
/// A line of dialogue, sent from the [`Dialogue`] to the game.
///
/// A [`Line`] is automatically produced follows:
/// - A localized text was fetched through the [`TextProvider`] registered in the [`Dialogue`].
/// - Any expressions found in the text are evaluated
/// - The text is parsed for markup
///
/// You do not create instances of this struct yourself. They are created by the [`Dialogue`] during program execution.
///
/// ## See also
/// [`DialogueEvent::Line`]
///
/// ## Implementation Notes
///
/// `MarkupParseResult` and `ExpandSubstitutions` were merged into this because we don't require consumers to manually fetch from string tables.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "bevy", derive(Reflect))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "bevy", reflect(Debug, PartialEq))]
#[cfg_attr(
all(feature = "bevy", feature = "serde"),
reflect(Serialize, Deserialize)
)]
pub struct Line {
/// The ID of the line in the string table.
pub id: LineId,
/// The original text, with all parsed markers removed.
pub text: String,
/// The list of [`MarkupAttribute`] in this parse result.
pub attributes: Vec<MarkupAttribute>,
}
impl Line {
/// Gets the first attribute with the specified name, if present.
///
/// ## Implementation note
///
/// Originally named `TryGetAttributeWithName`
pub fn attribute(&self, name: &str) -> Option<&MarkupAttribute> {
self.attributes.iter().find(|attr| attr.name == name)
}
/// The name of the character, if present.
/// ## Examples
/// When there is a name:
/// ```rust
/// # use bevy_platform::collections::HashMap;
/// # use yarnspinner_core::prelude::*;
/// # use yarnspinner_runtime::markup::*;
/// # use yarnspinner_runtime::prelude::*;
/// # let line = Line {
/// # id: "line".into(),
/// # text: "Alice: Hello! How are you today?".to_owned(),
/// # attributes: vec![MarkupAttribute {
/// # name: "character".to_owned(),
/// # position: 0,
/// # length: 7,
/// # properties: HashMap::from([("name".to_owned(), "Alice".into())]),
/// # source_position: 0,
/// # }],
/// # };
/// assert_eq!("Alice: Hello! How are you today?", line.text);
/// assert_eq!(Some("Alice"), line.character_name());
/// ```
///
/// When there is no name:
/// ```rust
/// # use bevy_platform::collections::HashMap;
/// # use yarnspinner_core::prelude::*;
/// # use yarnspinner_runtime::markup::*;
/// # use yarnspinner_runtime::prelude::*;
/// # let line = Line {
/// # id: "line".into(),
/// # text: "Great, thanks".to_owned(),
/// # attributes: vec![],
/// # };
/// assert_eq!("Great, thanks", line.text);
/// assert!(line.character_name().is_none());
pub fn character_name(&self) -> Option<&str> {
if let Some(attribute) = self.attribute(CHARACTER_ATTRIBUTE)
&& let Some(name) = attribute.property(CHARACTER_ATTRIBUTE_NAME_PROPERTY)
{
let MarkupValue::String(name) = name else {
bug!("Attribute \"character\" has a \"name\" property, but it is not a string.");
};
return Some(name.as_str());
}
None
}
/// The underlying text for this line, with any `character` attribute removed.
///
/// ## Examples
/// When there is a name:
/// ```rust
/// # use bevy_platform::collections::HashMap;
/// # use yarnspinner_core::prelude::*;
/// # use yarnspinner_runtime::markup::*;
/// # use yarnspinner_runtime::prelude::*;
/// # let line = Line {
/// # id: "line".into(),
/// # text: "Alice: Hello! How are you today?".to_owned(),
/// # attributes: vec![MarkupAttribute {
/// # name: "character".to_owned(),
/// # position: 0,
/// # length: 7,
/// # properties: HashMap::from([("name".to_owned(), "Alice".into())]),
/// # source_position: 0,
/// # }],
/// # };
/// assert_eq!("Alice: Hello! How are you today?", line.text);
/// assert_eq!("Hello! How are you today?", &line.text_without_character_name());
/// ```
///
/// When there is no name:
/// ```rust
/// # use bevy_platform::collections::HashMap;
/// # use yarnspinner_core::prelude::*;
/// # use yarnspinner_runtime::markup::*;
/// # use yarnspinner_runtime::prelude::*;
/// # let line = Line {
/// # id: "line".into(),
/// # text: "Great, thanks".to_owned(),
/// # attributes: vec![],
/// # };
/// assert_eq!("Great, thanks", line.text);
/// assert_eq!("Great, thanks", &line.text_without_character_name());
pub fn text_without_character_name(&self) -> String {
if let Some(attribute) = self.attribute(CHARACTER_ATTRIBUTE) {
self.delete_range(attribute).text
} else {
self.text.to_owned()
}
}
/// Returns the substring of [`Line::text`] covered by the passed `attribute`s [`MarkupAttribute::position`] and [`MarkupAttribute::length`] fields.
pub fn text_for_attribute(&self, attribute: &MarkupAttribute) -> &str {
assert!(
self.text.len() >= attribute.position + attribute.length,
"Attribute \"{attribute}\" represents a range not representable by this text: \"{}\". \
Does this MarkupAttribute belong to this MarkupParseResult?",
self.text
);
&self.text[attribute.position..attribute.position + attribute.length]
}
/// Deletes an attribute from this markup.
/// This method deletes the range of text covered by `attribute_to_delete`,
/// and updates the other attributes in this markup as follows:
///
/// - Attributes that start and end before the deleted attribute are
/// unmodified.
/// - Attributes that start before the deleted attribute and end inside it
/// are truncated to remove the part overlapping the deleted attribute.
/// - Attributes that have the same position and length as the deleted
/// attribute are deleted, if they apply to any text.
/// - Attributes that start and end within the deleted attribute are deleted.
/// - Attributes that start within the deleted attribute, and end outside
/// it, have their start truncated to remove the part overlapping the
/// deleted attribute.
/// - Attributes that start after the deleted attribute have their start
/// point adjusted to account for the deleted text.
///
/// This method does not modify the current object. A new [`Line`] is returned.
///
/// ## Panics
/// Panics if `attribute_to_delete` is not an attribute of this [`Line::attribute`].
pub fn delete_range(&self, attribute_to_delete: &MarkupAttribute) -> Self {
if !self
.attributes
.iter()
.any(|attr| attr == attribute_to_delete)
{
panic!("Attribute to delete is not an attribute of this line");
}
// Address the trivial case: if the attribute has a zero
// length, just create a new markup that doesn't include it.
// The plain text is left unmodified, because this attribute
// didn't apply to any text.
if attribute_to_delete.length == 0 {
let attributes = self
.attributes
.iter()
.filter(|attr| *attr != attribute_to_delete)
.cloned()
.collect();
return Line {
id: self.id.clone(),
text: self.text.to_string(),
attributes,
};
}
let deletion_start = attribute_to_delete.position;
let deletion_end = attribute_to_delete.position + attribute_to_delete.length;
let edited_substring = {
let mut text = self.text.to_string();
text.replace_range(deletion_start..deletion_end, "");
text
};
let attributes = self
.attributes
.iter()
// This is the attribute we're deleting. Don't include it.
.filter(|attr| *attr != attribute_to_delete)
.filter_map(|attribute| {
let mut attribute = attribute.clone();
let start = attribute.position;
let end = attribute.position + attribute.length;
if start <= deletion_start {
// The attribute starts before start point of the item
// we're deleting.
if end <= deletion_start {
// This attribute is entirely before the item we're
// deleting, and will be unmodified.
} else if end <= deletion_end {
// This attribute starts before the item we're
// deleting, and ends inside it. The Position
// doesn't need to change, but its Length is
// trimmed so that it ends where the deleted
// attribute begins.
let original_length = attribute.length;
attribute.length = deletion_start.saturating_sub(start);
if original_length > 0 && attribute.length == 0 {
// The attribute's length has been reduced to
// zero. All of the contents it previous had
// have been removed, so we will remove the
// attribute itself.
return None;
}
} else {
// This attribute starts before the item we're
// deleting, and ends after it. Its length is
// edited to remove the length of the item we're
// deleting.
attribute.length =
attribute.length.saturating_sub(attribute_to_delete.length);
}
} else if start >= deletion_end {
// The item begins after the item we're deleting. Its
// length isn't changing. We just need to offset its
// start position.
attribute.position = start.saturating_sub(attribute_to_delete.length);
} else if start >= deletion_start && end <= deletion_end {
// The item is entirely within the item we're deleting.
// It will be deleted too - we'll skip including it in
// the updated attributes list.
return None;
} else if start >= deletion_start && end > deletion_end {
// The item starts within the item we're deleting, and
// ends outside it. We'll adjust the start point so
// that it begins at the point where this item and the
// item we're deleting stop overlapping.
let overlap = deletion_end - start;
let new_start = deletion_start;
let new_length = attribute.length - overlap;
attribute.position = new_start;
attribute.length = new_length;
}
Some(attribute)
})
.collect();
Line {
id: self.id.clone(),
text: edited_substring,
attributes,
}
}
}