livesplit_core/component/title/
mod.rs

1//! Provides the Title Component and relevant types for using it. The Title
2//! Component is a component that shows the name of the game and the category
3//! that is being run. Additionally, the game icon, the attempt count, and the
4//! total number of finished runs can be shown.
5
6use crate::{
7    platform::prelude::*,
8    settings::{
9        Alignment, CachedImageId, Color, Field, Gradient, Image, ImageData, SettingsDescription,
10        Value,
11    },
12    Timer, TimerPhase,
13};
14use core::fmt::Write;
15use livesplit_title_abbreviations::{abbreviate as abbreviate_title, abbreviate_category};
16use serde::{Deserialize, Serialize};
17use smallstr::SmallString;
18
19#[cfg(test)]
20mod tests;
21
22/// The Title Component is a component that shows the name of the game and the
23/// category that is being run. Additionally, the game icon, the attempt count,
24/// and the total number of finished runs can be shown.
25#[derive(Default, Clone)]
26pub struct Component {
27    icon_id: CachedImageId,
28    settings: Settings,
29}
30
31/// The Settings for this component.
32#[derive(Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct Settings {
35    /// The background shown behind the component.
36    pub background: Gradient,
37    /// The color of the title text. If `None` is specified, the color is taken
38    /// from the layout.
39    pub text_color: Option<Color>,
40    /// Specifies whether the game name should be part of the title that is
41    /// being shown.
42    pub show_game_name: bool,
43    /// Specifies whether the category name should be part of the title that is
44    /// being shown.
45    pub show_category_name: bool,
46    /// Specifies whether the amount of successfully finished attempts should be
47    /// shown.
48    pub show_finished_runs_count: bool,
49    /// Specifies whether the total amount of attempts should be shown.
50    pub show_attempt_count: bool,
51    /// Specifies the alignment of the title.
52    pub text_alignment: Alignment,
53    /// Specifies if the title should be shown as a single line, instead of
54    /// being separated into one line for the game name and one for the category
55    /// name.
56    pub display_as_single_line: bool,
57    /// Specifies whether the game's icon should be shown, in case there is a
58    /// game icon stored in the Run.
59    pub display_game_icon: bool,
60    /// The category name can be extended by additional information. This
61    /// extends it by the game's region, if it is provided by the run's
62    /// metadata.
63    pub show_region: bool,
64    /// The category name can be extended by additional information. This
65    /// extends it by the platform the game is being played on, if it is
66    /// provided by the run's metadata.
67    pub show_platform: bool,
68    /// The category name can be extended by additional information. This
69    /// extends it by additional variables provided by the run's metadata.
70    pub show_variables: bool,
71}
72
73/// The state object describes the information to visualize for this component.
74#[derive(Default, Serialize, Deserialize)]
75pub struct State {
76    /// The background shown behind the component.
77    pub background: Gradient,
78    /// The color of the text. If `None` is specified, the color is taken from
79    /// the layout.
80    pub text_color: Option<Color>,
81    /// The game's icon encoded as the raw file bytes. This value is only
82    /// specified whenever the icon changes. If you explicitly want to query
83    /// this value, remount the component. The buffer itself may be empty. This
84    /// indicates that there is no icon.
85    pub icon_change: Option<ImageData>,
86    /// The first title line to show. This is either the game's name, or a
87    /// combination of the game's name and the category. This is a list of all
88    /// the possible abbreviations. It contains at least one element and the
89    /// last element is the unabbreviated value.
90    pub line1: Vec<Box<str>>,
91    /// By default the category name is shown on the second line. Based on the
92    /// settings, it can however instead be shown in a single line together with
93    /// the game name. This is a list of all the possible abbreviations. If this
94    /// is empty, only a single line is supposed to be shown. If it contains at
95    /// least one element, the last element is the unabbreviated value.
96    pub line2: Vec<Box<str>>,
97    /// Specifies whether the title should centered or aligned to the left
98    /// instead.
99    pub is_centered: bool,
100    /// The amount of successfully finished attempts. If `None` is specified,
101    /// the amount of successfully finished attempts isn't supposed to be shown.
102    pub finished_runs: Option<u32>,
103    /// The amount of total attempts. If `None` is specified, the amount of
104    /// total attempts isn't supposed to be shown.
105    pub attempts: Option<u32>,
106}
107
108impl Default for Settings {
109    fn default() -> Self {
110        Self {
111            background: Gradient::Vertical(
112                Color::hsla(0.0, 0.0, 1.0, 0.13),
113                Color::hsla(0.0, 0.0, 1.0, 0.0),
114            ),
115            text_color: None,
116            show_game_name: true,
117            show_category_name: true,
118            show_finished_runs_count: false,
119            show_attempt_count: true,
120            text_alignment: Alignment::Auto,
121            display_as_single_line: false,
122            display_game_icon: true,
123            show_region: false,
124            show_platform: false,
125            show_variables: true,
126        }
127    }
128}
129
130#[cfg(feature = "std")]
131impl State {
132    /// Encodes the state object's information as JSON.
133    pub fn write_json<W>(&self, writer: W) -> serde_json::Result<()>
134    where
135        W: std::io::Write,
136    {
137        serde_json::to_writer(writer, self)
138    }
139}
140
141impl Component {
142    /// Creates a new Title Component.
143    pub fn new() -> Self {
144        Default::default()
145    }
146
147    /// Creates a new Title Component with the given settings.
148    pub fn with_settings(settings: Settings) -> Self {
149        Self {
150            settings,
151            ..Default::default()
152        }
153    }
154
155    /// Accesses the settings of the component.
156    pub const fn settings(&self) -> &Settings {
157        &self.settings
158    }
159
160    /// Grants mutable access to the settings of the component.
161    pub fn settings_mut(&mut self) -> &mut Settings {
162        &mut self.settings
163    }
164
165    /// Accesses the name of the component.
166    pub const fn name(&self) -> &'static str {
167        "Title"
168    }
169
170    /// Updates the component's state based on the timer provided.
171    pub fn update_state(&mut self, state: &mut State, timer: &Timer) {
172        let run = timer.run();
173
174        let finished_runs = if self.settings.show_finished_runs_count {
175            let mut count = timer
176                .run()
177                .attempt_history()
178                .iter()
179                .filter(|a| a.time().real_time.is_some())
180                .count() as u32;
181
182            if timer.current_phase() == TimerPhase::Ended {
183                count += 1;
184            }
185
186            Some(count)
187        } else {
188            None
189        };
190
191        let attempts = if self.settings.show_attempt_count {
192            Some(run.attempt_count())
193        } else {
194            None
195        };
196
197        let game_icon = Some(run.game_icon()).filter(|_| self.settings.display_game_icon);
198        let icon_change = self.icon_id.update_with(game_icon).map(Into::into);
199
200        let is_centered = match self.settings.text_alignment {
201            Alignment::Center => true,
202            Alignment::Left => false,
203            Alignment::Auto => game_icon.map_or(true, Image::is_empty),
204        };
205
206        let game_name = if self.settings.show_game_name {
207            run.game_name()
208        } else {
209            ""
210        };
211
212        let mut full_category_name = SmallString::<[u8; 1024]>::new();
213        let _ = write!(
214            full_category_name,
215            "{}",
216            run.extended_category_name(
217                self.settings.show_region,
218                self.settings.show_platform,
219                self.settings.show_variables,
220            ),
221        );
222
223        let full_category_name = if self.settings.show_category_name {
224            &full_category_name
225        } else {
226            ""
227        };
228
229        match (!game_name.is_empty(), !full_category_name.is_empty()) {
230            (true, true) => {
231                if self.settings.display_as_single_line {
232                    let unchanged = catch! {
233                        let mut rem = &**state.line1.last()?;
234
235                        let Some(rest) = rem.strip_prefix(game_name) else {
236                            return None;
237                        };
238                        rem = rest;
239
240                        if !game_name.is_empty() && !full_category_name.is_empty() {
241                            let Some(rest) = rem.strip_prefix(" - ") else {
242                                return None;
243                            };
244                            rem = rest;
245                        }
246
247                        if rem != full_category_name {
248                            return None;
249                        }
250                    };
251                    if unchanged.is_none() {
252                        let abbrevs = &mut state.line1;
253                        abbrevs.clear();
254
255                        let mut abbrev = String::new();
256                        let game_abbrevs = abbreviate_title(game_name);
257                        let category_abbrevs = abbreviate_category(full_category_name);
258
259                        if !full_category_name.is_empty() {
260                            for game_abbrev in game_abbrevs.iter() {
261                                abbrev.clear();
262                                abbrev.push_str(game_abbrev);
263                                if !game_abbrev.is_empty() {
264                                    abbrev.push_str(" - ");
265                                }
266                                abbrev.push_str(full_category_name);
267                                abbrevs.push(abbrev.as_str().into());
268                            }
269                        }
270                        // This assumes the last element is the unabbreviated value, which
271                        // can only be the case if the `game_abbrevs` also has the
272                        // unabbreviated game name as its last element.
273                        let swap_index = abbrevs.len().checked_sub(1);
274
275                        if let Some(shortest_game_name) =
276                            game_abbrevs.iter().min_by_key(|a| a.len())
277                        {
278                            abbrev.clear();
279                            abbrev.push_str(shortest_game_name);
280                            let game_len = abbrev.len();
281                            for category_abbrev in category_abbrevs.iter() {
282                                if !shortest_game_name.is_empty() && !category_abbrev.is_empty() {
283                                    abbrev.push_str(" - ");
284                                }
285                                abbrev.push_str(category_abbrev);
286                                abbrevs.push(abbrev.as_str().into());
287                                abbrev.drain(game_len..);
288                            }
289                        }
290
291                        // We want to ensure the "unabbreviated value" is at the end. This
292                        // is something we guarantee at least at the moment.
293                        if let Some(swap_index) = swap_index {
294                            let last_element_idx = abbrevs.len() - 1;
295                            abbrevs.swap(swap_index, last_element_idx);
296                        }
297                    }
298                    state.line2.clear();
299                } else {
300                    if state.line1.last().map_or(true, |g| game_name != &**g) {
301                        state.line1.clear();
302                        state.line1.extend(abbreviate_title(game_name));
303                    }
304                    if state
305                        .line2
306                        .last()
307                        .map_or(true, |c| full_category_name != &**c)
308                    {
309                        state.line2.clear();
310                        state.line2.extend(abbreviate_category(full_category_name));
311                    }
312                }
313            }
314            (true, false) => {
315                if state.line1.last().map_or(true, |g| game_name != &**g) {
316                    state.line1.clear();
317                    state.line1.extend(abbreviate_title(game_name));
318                }
319                state.line2.clear();
320            }
321            (false, true) => {
322                if state
323                    .line1
324                    .last()
325                    .map_or(true, |c| full_category_name != &**c)
326                {
327                    state.line1.clear();
328                    state.line1.extend(abbreviate_category(full_category_name));
329                }
330                state.line2.clear();
331            }
332            (false, false) => {
333                state.line1.clear();
334                state.line1.push("Untitled".into());
335                state.line2.clear();
336            }
337        }
338
339        state.background = self.settings.background;
340        state.text_color = self.settings.text_color;
341        state.icon_change = icon_change;
342        state.finished_runs = finished_runs;
343        state.attempts = attempts;
344        state.is_centered = is_centered;
345    }
346
347    /// Calculates the component's state based on the timer provided.
348    pub fn state(&mut self, timer: &Timer) -> State {
349        let mut state = Default::default();
350        self.update_state(&mut state, timer);
351        state
352    }
353
354    /// Remounts the component as if it was freshly initialized. The game icon
355    /// shown by this component is only provided in the state objects whenever
356    /// the icon changes or whenever the component's state is first queried.
357    /// Remounting returns the game icon again, whenever its state is queried
358    /// the next time.
359    pub fn remount(&mut self) {
360        self.icon_id.reset();
361    }
362
363    /// Accesses a generic description of the settings available for this
364    /// component and their current values.
365    pub fn settings_description(&self) -> SettingsDescription {
366        SettingsDescription::with_fields(vec![
367            Field::new("Background".into(), self.settings.background.into()),
368            Field::new("Text Color".into(), self.settings.text_color.into()),
369            Field::new("Show Game Name".into(), self.settings.show_game_name.into()),
370            Field::new(
371                "Show Category Name".into(),
372                self.settings.show_category_name.into(),
373            ),
374            Field::new(
375                "Show Finished Runs Count".into(),
376                self.settings.show_finished_runs_count.into(),
377            ),
378            Field::new(
379                "Show Attempt Count".into(),
380                self.settings.show_attempt_count.into(),
381            ),
382            Field::new("Text Alignment".into(), self.settings.text_alignment.into()),
383            Field::new(
384                "Display Text as Single Line".into(),
385                self.settings.display_as_single_line.into(),
386            ),
387            Field::new(
388                "Display Game Icon".into(),
389                self.settings.display_game_icon.into(),
390            ),
391            Field::new("Show Region".into(), self.settings.show_region.into()),
392            Field::new("Show Platform".into(), self.settings.show_platform.into()),
393            Field::new("Show Variables".into(), self.settings.show_variables.into()),
394        ])
395    }
396
397    /// Sets a setting's value by its index to the given value.
398    ///
399    /// # Panics
400    ///
401    /// This panics if the type of the value to be set is not compatible with
402    /// the type of the setting's value. A panic can also occur if the index of
403    /// the setting provided is out of bounds.
404    pub fn set_value(&mut self, index: usize, value: Value) {
405        match index {
406            0 => self.settings.background = value.into(),
407            1 => self.settings.text_color = value.into(),
408            2 => self.settings.show_game_name = value.into(),
409            3 => self.settings.show_category_name = value.into(),
410            4 => self.settings.show_finished_runs_count = value.into(),
411            5 => self.settings.show_attempt_count = value.into(),
412            6 => self.settings.text_alignment = value.into(),
413            7 => self.settings.display_as_single_line = value.into(),
414            8 => self.settings.display_game_icon = value.into(),
415            9 => self.settings.show_region = value.into(),
416            10 => self.settings.show_platform = value.into(),
417            11 => self.settings.show_variables = value.into(),
418            _ => panic!("Unsupported Setting Index"),
419        }
420    }
421}