bevy_brink/transcript.rs
1//! Auto-rendered per-flow transcript component.
2//!
3//! Attach a [`BrinkTranscript<M>`] to a fulfilled flow entity (alongside
4//! the usual [`BrinkFlow`](crate::BrinkFlow), [`BrinkProgram`](crate::BrinkProgram),
5//! [`BrinkLocale`](crate::BrinkLocale)) and the plugin will keep it
6//! in sync with `flow.inner.transcript()` — re-rendering whenever
7//! the flow grows, the locale handle changes, or the line-tables
8//! asset content changes (hot-reload).
9//!
10//! This is purely a convenience: consumers can read structural output
11//! parts directly via `flow.inner.transcript()` and call
12//! `brink_runtime::transcript::render_transcript` themselves.
13
14use std::marker::PhantomData;
15
16use bevy_asset::{AssetEvent, Assets};
17use bevy_ecs::change_detection::{DetectChanges, Ref};
18use bevy_ecs::component::Component;
19use bevy_ecs::message::MessageReader;
20use bevy_ecs::system::{Query, Res};
21
22use crate::asset::{BrinkProgram, LineTablesAsset, ProgramAsset};
23use crate::flow::BrinkFlow;
24use crate::line_tables::BrinkLocale;
25
26/// Cached, locale-resolved view of a flow's transcript.
27///
28/// Inserted by the consumer (opt-in) on a flow entity. The plugin's
29/// `refresh_transcripts<M>` system re-renders `lines` when:
30///
31/// - The flow's `transcript_len()` differs from `cached_len` (the flow
32/// has produced new output since the last refresh).
33/// - The `BrinkLocale<M>` handle on this entity changed (locale swap).
34/// - An `AssetEvent::Modified<LineTablesAsset>` fired since last refresh
35/// (the current locale's content was hot-reloaded).
36///
37/// Each entry in `lines` is `(text, tags)` for one resolved output line,
38/// as produced by [`brink_runtime::transcript::render_transcript`].
39#[derive(Component)]
40pub struct BrinkTranscript<M: Send + Sync + 'static = ()> {
41 pub lines: Vec<(String, Vec<String>)>,
42 cached_len: usize,
43 _marker: PhantomData<fn() -> M>,
44}
45
46impl<M: Send + Sync + 'static> Default for BrinkTranscript<M> {
47 fn default() -> Self {
48 Self {
49 lines: Vec::new(),
50 cached_len: 0,
51 _marker: PhantomData,
52 }
53 }
54}
55
56impl<M: Send + Sync + 'static> BrinkTranscript<M> {
57 /// Concatenate every line's text with `\n` between entries.
58 /// `render_transcript` returns lines stripped of their trailing
59 /// newline, so we re-insert one between consecutive entries.
60 #[must_use]
61 pub fn text(&self) -> String {
62 // Capacity hint: sum of line lengths + (n-1) separators.
63 let n = self.lines.len();
64 let total: usize = self.lines.iter().map(|(s, _)| s.len()).sum();
65 let mut out = String::with_capacity(total + n.saturating_sub(1));
66 for (i, (text, _)) in self.lines.iter().enumerate() {
67 if i > 0 {
68 out.push('\n');
69 }
70 out.push_str(text);
71 }
72 out
73 }
74}
75
76/// Plugin-managed system: re-render `BrinkTranscript<M>` for any flow
77/// entity whose transcript has grown, whose locale handle changed, or
78/// whose locale's `LineTablesAsset` content was hot-reloaded.
79#[expect(
80 clippy::needless_pass_by_value,
81 clippy::type_complexity,
82 reason = "bevy systems take Res/Query by value and have complex query tuples"
83)]
84pub fn refresh_transcripts<M: Send + Sync + 'static>(
85 mut events: MessageReader<AssetEvent<LineTablesAsset>>,
86 mut flows: Query<(
87 &BrinkFlow<M>,
88 &BrinkProgram<M>,
89 Ref<BrinkLocale<M>>,
90 &mut BrinkTranscript<M>,
91 )>,
92 programs: Res<Assets<ProgramAsset>>,
93 line_tables: Res<Assets<LineTablesAsset>>,
94) {
95 // If any LineTablesAsset content changed this tick, every flow
96 // potentially needs a re-render against the new tables. We don't
97 // route by handle — there's typically one locale per marker.
98 let any_locale_modified = events
99 .read()
100 .any(|ev| matches!(ev, AssetEvent::Modified { .. }));
101
102 for (flow, program_h, locale_h, mut transcript) in &mut flows {
103 let current_len = flow.inner.transcript_len();
104 let locale_changed = locale_h.is_changed();
105 let needs_refresh =
106 current_len != transcript.cached_len || locale_changed || any_locale_modified;
107 if !needs_refresh {
108 continue;
109 }
110
111 let Some(program_asset) = programs.get(&program_h.handle) else {
112 continue;
113 };
114 let Some(lt_asset) = line_tables.get(&locale_h.handle) else {
115 continue;
116 };
117
118 transcript.lines = brink_runtime::transcript::render_transcript(
119 flow.inner.transcript(),
120 &program_asset.program,
121 <_asset.tables,
122 None,
123 flow.inner.fragments(),
124 );
125 transcript.cached_len = current_len;
126 }
127}