1use std::collections::{BTreeMap, HashMap};
7use std::ops::Range;
8use std::time::{Duration, Instant};
9
10use bevy_ecs::change_detection::Tick;
11use bevy_ecs::prelude::*;
12use bevy_ecs::schedule::ScheduleLabel;
13use bevy_ecs::system::{SystemChangeTick, SystemParam};
14use crossterm::cursor::MoveTo;
15use ratatui::Frame;
16use ratatui::backend::Backend;
17use ratatui::buffer::{Buffer, Cell};
18use ratatui::layout::{Constraint, Layout, Rect};
19use ratatui::style::{Color, Style, Stylize};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Clear, List, ListState, Padding, Paragraph};
22
23use super::complete::Completion;
24use super::editor::{Caret, Editor};
25use super::input::InputContext;
26use super::keys::KeyFocus;
27use super::pane::{PaneState, TranscriptPane};
28use super::panel::{
29 self, PanelCanvas, PanelSpawns, Placement, RedrawArea, RequestRedraw, TerminalSize, TuiPanel,
30};
31use super::pick::{Choices, InlineStrip, Listing, PickerInput, Row};
32use super::renderers::{
33 AsideRenderer, CallDecorator, CallState, ContentRenderer, EntryRenderer, Looks,
34 MessageDecorator, MessageView, Renderers, Role, ToolCallView, ToolRenderer, UNNAMED_HOLD,
35};
36use super::status::{AppStatus, StatusItems, StatusLine};
37use super::terminal::{Tui, TuiTestScreen};
38use super::theme::Theme;
39use super::transcript::{
40 CallFacts, CallTree, ChildCalls, Inside, Look, Marks, Part, ShownAside, StreamingCalls,
41 Transcript, live_call, notice_lines, on_band, plain_lines,
42};
43use super::view::{
44 DrawnTranscript, Highlight, MarkIn, Notices, RowWrap, ShownNotice, TranscriptItem,
45 TranscriptSlot, TranscriptView, TuiView,
46};
47use super::wrap::wrap_all;
48use rig_core::conversation;
49use rig_core::message::{CallId, ToolCall};
50use rig_ecs::agent::{ActiveTurn, AgentId, Calls, Conversation, Partial};
51use rig_ecs::inbox::{DeliveryMode, Incoming};
52use rig_ecs::stage::{Effect, ForAgent, Holds, Outcome, Settled, Started, SupersededBy};
53use rig_ecs::tools::ToolRequest;
54use rig_ecs::turn::{ModelRequest, SentTo};
55use rig_harness::main_agent::ShownAgent;
56use rig_harness::pick::{PickPreview, Picker};
57use rig_harness::prelude::{CallOutput, ToolProgress};
58
59const COMPLETION_ROWS: usize = 8;
61
62const STREAM_FRAME: Duration = Duration::from_millis(33);
66
67#[derive(Default)]
70pub(crate) struct Paced {
71 drawn: Option<Instant>,
72 waiting: bool,
73}
74
75pub(crate) fn needs_redraw(
80 (view, notices, facts): (Res<TuiView>, Res<Notices>, Res<CallFacts>),
81 (focus, shown, screen): (Res<KeyFocus>, Res<ShownAgent>, Res<TerminalSize>),
82 changed: Query<
83 (),
84 Or<(
85 Changed<Conversation>,
86 Changed<ActiveTurn>,
87 Changed<Calls>,
88 Changed<StatusItems>,
89 Changed<TuiPanel>,
90 Changed<ToolRenderer>,
91 Changed<EntryRenderer>,
92 Changed<ContentRenderer>,
93 Changed<CallDecorator>,
94 Changed<AsideRenderer>,
95 Changed<MessageDecorator>,
96 )>,
97 >,
98 widgets: Query<
99 (),
100 Or<(
101 Changed<TranscriptPane>,
102 Changed<TranscriptView>,
103 Changed<PaneState>,
104 Changed<Editor>,
105 Changed<StatusLine>,
106 Changed<PickerInput>,
107 Changed<PickPreview>,
108 Changed<Picker>,
109 )>,
110 >,
111 partials: Query<(), Or<(Changed<Partial>, Changed<StreamingCalls>)>>,
112 messages: Query<(), (With<Incoming>, Or<(Added<Started>, Added<Outcome>)>)>,
113 calls: Query<
114 (),
115 (
116 With<ToolRequest>,
117 Or<(
118 Added<Started>,
119 Added<Outcome>,
120 Changed<Holds>,
121 Changed<ToolProgress>,
122 )>,
123 ),
124 >,
125 (app_status, test): (Option<Res<AppStatus>>, Option<Res<TuiTestScreen>>),
126 (mut requests, mut repaints): (MessageReader<RequestRedraw>, MessageReader<RedrawArea>),
127 mut paced: Local<Paced>,
128) -> bool {
129 let requested = requests.read().count() + repaints.read().count() > 0;
131 let changed = requested
132 || view.is_changed()
133 || notices.is_changed()
134 || facts.is_changed()
135 || shown.is_changed()
136 || !widgets.is_empty()
137 || focus.is_changed()
138 || screen.is_changed()
139 || app_status.is_some_and(|status| status.is_changed())
140 || test.is_some_and(|test| test.is_changed())
141 || !changed.is_empty()
142 || !messages.is_empty()
143 || !calls.is_empty();
144 paced.waiting |= !partials.is_empty();
145 let now = Instant::now();
146 let next = paced.drawn.map(|drawn| drawn + STREAM_FRAME);
147 if changed || (paced.waiting && next.is_none_or(|next| now >= next)) {
148 paced.drawn = Some(now);
149 paced.waiting = false;
150 return true;
151 }
152 false
153}
154
155pub(crate) fn redraw_on_remove(
157 _: On<
158 Remove<(
159 TuiPanel,
160 StatusItems,
161 Holds,
162 ToolProgress,
163 TranscriptPane,
164 Editor,
165 PickerInput,
166 )>,
167 >,
168 mut redraw: MessageWriter<RequestRedraw>,
169) {
170 redraw.write(RequestRedraw);
171}
172
173pub(crate) fn forget_looks(
175 _: On<
176 Remove<(
177 ToolRenderer,
178 EntryRenderer,
179 ContentRenderer,
180 AsideRenderer,
181 CallDecorator,
182 MessageDecorator,
183 )>,
184 >,
185 mut panes: Query<&mut PaneState>,
186 mut redraw: MessageWriter<RequestRedraw>,
187) {
188 for mut pane in &mut panes {
189 pane.bypass_change_detection().transcript.clear();
190 }
191 redraw.write(RequestRedraw);
192}
193
194#[derive(Resource, Default)]
196pub(crate) struct FrameLayout {
197 due: bool,
198 picker: Option<Rect>,
201}
202
203pub(crate) fn frame_due(frame: Res<FrameLayout>) -> bool {
207 frame.due
208}
209
210#[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)]
217pub(crate) struct LayOut;
218
219#[derive(Resource, Default)]
221pub(crate) struct LaidOutAt(Option<Tick>);
222
223pub(crate) fn lay_out_frame(world: &mut World) {
226 if world.try_run_schedule(LayOut).is_ok() {
227 let now = world.change_tick();
228 world.insert_resource(LaidOutAt(Some(now)));
229 }
230}
231
232pub(crate) fn lay_out_fitted(world: &mut World, panels: &mut QueryState<Ref<TuiPanel>>) {
236 let Some(at) = world.get_resource::<LaidOutAt>().and_then(|laid| laid.0) else {
237 return;
238 };
239 let now = world.read_change_tick();
240 let fitted = panels
241 .iter(world)
242 .any(|panel| panel.last_changed().is_newer_than(at, now));
243 if fitted {
244 world.try_run_schedule(LayOut).ok();
245 }
246}
247
248pub(crate) fn layout(
252 screens: (Option<Res<Tui>>, Option<Res<TuiTestScreen>>),
253 (view, previewed): (
254 Res<TuiView>,
255 Query<(Entity, &PickerInput), With<PickPreview>>,
256 ),
257 mut frame: ResMut<FrameLayout>,
258 mut panels: Query<(
259 Entity,
260 &TuiPanel,
261 &mut PanelCanvas,
262 Option<&Editor>,
263 Has<InlineStrip>,
264 )>,
265 (spawns, ticks): (PanelSpawns, SystemChangeTick),
266) {
267 let size = match screens {
268 (Some(tui), _) => tui.terminal.size().ok(),
269 (None, Some(test)) => Some(test.size()),
270 (None, None) => None,
271 };
272 let Some(size) = size else {
273 return;
274 };
275 let screen = Rect::new(0, 0, size.width, size.height);
276 let inline = u16::from(!view.inline.is_empty());
277 let mut panels: Vec<_> = panels.iter_mut().collect();
278 panels.sort_by_key(|(entity, ..)| panel::spawn_order(ticks.this_run(), &spawns, *entity));
279 let panels = panels
280 .into_iter()
281 .map(|(entity, panel, canvas, editor, strip)| {
282 let fits = editor.map(|editor| editor.fit_rows(screen.width));
283 (entity, panel, fits.or(strip.then_some(inline)), canvas)
284 });
285 let previewed = previewed.iter().find(|(_, state)| !state.in_input());
286 let boxed = Constraint::Percentage(90);
287 let picker = previewed.map(|(entity, _)| (entity, screen.centered(boxed, boxed)));
288 panel::lay_out(panels, screen, picker.as_slice());
289 *frame = FrameLayout {
290 due: true,
291 picker: picker.map(|(_, area)| area),
292 };
293}
294
295#[derive(SystemParam)]
297pub(crate) struct CallStates<'w, 's> {
298 calls: Query<
299 'w,
300 's,
301 (
302 &'static ToolRequest,
303 Option<&'static Holds>,
304 Has<Started>,
305 Has<Outcome>,
306 Option<&'static ToolProgress>,
307 ),
308 >,
309 names: Query<'w, 's, &'static Name>,
310 outputs: Query<
311 'w,
312 's,
313 (&'static ToolRequest, &'static CallOutput),
314 (With<Started>, Without<Outcome>),
315 >,
316}
317
318impl CallStates<'_, '_> {
319 pub(crate) fn of(
323 &self,
324 calls: impl IntoIterator<Item = Entity>,
325 ) -> BTreeMap<CallId, CallState> {
326 let found = calls
327 .into_iter()
328 .filter_map(|call| self.calls.get(call).ok());
329 found
330 .map(|(request, holds, started, answered, progress)| {
331 let state = match holds {
332 Some(holds) => {
333 CallState::Held(holds.iter().map(|hold| self.name(hold)).collect())
334 }
335 None if answered => CallState::Done,
336 None if started => {
337 CallState::Running(progress.map(|ToolProgress(line)| line.clone()))
338 }
339 None => CallState::Waiting,
340 };
341 (request.call.id.clone(), state)
342 })
343 .collect()
344 }
345
346 pub(crate) fn outputs(
349 &self,
350 calls: impl IntoIterator<Item = Entity>,
351 ) -> BTreeMap<CallId, String> {
352 let found = calls
353 .into_iter()
354 .filter_map(|call| self.outputs.get(call).ok());
355 let outputs = found.map(|(request, output)| (request.call.id.clone(), output.text()));
356 outputs.filter(|(_, text)| !text.is_empty()).collect()
357 }
358
359 fn name(&self, hold: Entity) -> String {
361 let name = self.names.get(hold).map_or(UNNAMED_HOLD, Name::as_str);
362 name.to_owned()
363 }
364}
365
366pub(crate) struct ShownConversation<'a> {
368 pub(crate) entity: Entity,
369 pub(crate) id: Option<&'a AgentId>,
372 pub(crate) conversation: &'a conversation::Conversation,
373 pub(crate) changed: bool,
375 pub(crate) inbox: Vec<&'a Incoming>,
378 pub(crate) calls: BTreeMap<CallId, CallState>,
380 pub(crate) outputs: BTreeMap<CallId, String>,
382 pub(crate) partial: Option<(Entity, &'a Partial)>,
384 pub(crate) streaming: Vec<ToolCall>,
386 pub(crate) asides: Vec<ShownAside<'a>>,
388 pub(crate) children: ChildCalls,
390}
391
392impl<'a> ShownConversation<'a> {
393 pub(crate) fn value(conversation: &'a conversation::Conversation, entity: Entity) -> Self {
396 Self {
397 entity,
398 id: None,
399 conversation,
400 changed: true,
401 inbox: Vec::new(),
402 calls: BTreeMap::new(),
403 outputs: BTreeMap::new(),
404 partial: None,
405 streaming: Vec::new(),
406 asides: asides(conversation),
407 children: ChildCalls::default(),
408 }
409 }
410}
411
412fn asides(conversation: &conversation::Conversation) -> Vec<ShownAside<'_>> {
414 let asides = conversation.asides();
415 asides.map(|(id, aside)| ShownAside { id, aside }).collect()
416}
417
418type LiveEffect = (
421 Entity,
422 &'static Effect,
423 (
424 Option<&'static ToolRequest>,
425 Option<&'static ModelRequest>,
426 Option<&'static SentTo>,
427 ),
428 (Option<&'static ForAgent>, Option<&'static Outcome>),
429 (Has<Started>, Has<Partial>),
430);
431
432pub(crate) struct Sources<'a> {
434 pub(crate) view: &'a TranscriptView,
436 pub(crate) notices: &'a Notices,
438 pub(crate) renderers: &'a Renderers<'a>,
440 pub(crate) facts: &'a CallFacts,
442 pub(crate) theme: &'a Theme,
444}
445
446#[derive(SystemParam)]
476pub struct TranscriptLayout<'w, 's> {
477 agents: Query<'w, 's, (&'static AgentId, &'static Conversation)>,
478 pub(crate) active: Query<'w, 's, &'static ActiveTurn>,
479 turns: Query<'w, 's, &'static Calls>,
480 partials: Query<'w, 's, &'static Partial, Without<SupersededBy>>,
483 accepted: Query<
484 'w,
485 's,
486 (&'static Effect, &'static Incoming, &'static ForAgent),
487 (With<Started>, Without<Outcome>),
488 >,
489 pub(crate) looks: Looks<'w, 's>,
490 calls: CallStates<'w, 's>,
491 live: Query<'w, 's, LiveEffect, Without<Settled>>,
492 names: Query<'w, 's, &'static Name>,
493 facts: Res<'w, CallFacts>,
494 streaming: Query<'w, 's, &'static StreamingCalls>,
495 notices: Res<'w, Notices>,
496 theme: Res<'w, Theme>,
497}
498
499impl TranscriptLayout<'_, '_> {
500 pub fn lay_out(
506 &self,
507 view: &TranscriptView,
508 agent: Entity,
509 width: u16,
510 ) -> Option<DrawnTranscript> {
511 let shown = self.agent(agent, true)?;
512 let renderers = self.looks.by_kind(None);
513 let sources = Sources {
514 view,
515 notices: self.notices(),
516 renderers: &renderers,
517 facts: &self.facts,
518 theme: &self.theme,
519 };
520 let mut transcript = Transcript::default();
521 let parts = lay_out_transcript(&mut transcript, &sources, Some(&shown), width);
522 Some(transcript.laid_out(Some(agent), &parts, &view.highlights, 0..0))
523 }
524
525 pub(crate) fn agent(&self, entity: Entity, changed: bool) -> Option<ShownConversation<'_>> {
528 let (id, conversation) = self.agents.get(entity).ok()?;
529 let turn = self.active.get(entity).ok();
530 let calls = turn.and_then(|turn| self.turns.get(turn.turn()).ok());
531 let partial = calls.and_then(|calls| {
534 let streams = calls.iter();
535 let mut streams =
536 streams.filter_map(|call| Some((call, self.partials.get(call).ok()?)));
537 streams.next_back()
538 });
539 let streaming = partial.and_then(|(call, _)| self.streaming.get(call).ok());
540 let mut inbox: Vec<(&Effect, &Incoming)> = self
541 .accepted
542 .iter()
543 .filter(|(.., to)| to.0 == entity)
544 .map(|(effect, message, _)| (effect, message))
545 .collect();
546 inbox.sort_by_key(|(effect, _)| effect.id());
547 Some(ShownConversation {
548 entity,
549 id: Some(id),
550 conversation,
551 changed,
552 inbox: inbox.into_iter().map(|(_, message)| message).collect(),
553 calls: self.calls.of(calls.into_iter().flat_map(Calls::iter)),
554 outputs: self.calls.outputs(calls.into_iter().flat_map(Calls::iter)),
555 partial,
556 streaming: streaming
557 .into_iter()
558 .flat_map(StreamingCalls::calls)
559 .collect(),
560 asides: asides(conversation),
561 children: self.facts.children(&self.live_calls(), &id.0),
562 })
563 }
564
565 fn live_calls(&self) -> CallTree {
568 let mut live = CallTree::default();
569 for (entity, effect, requests, (agent, outcome), (started, streams)) in &self.live {
570 let state = match requests.0 {
571 Some(request) => self.calls.of([entity]).remove(&request.call.id),
572 None if outcome.is_some() => Some(CallState::Done),
573 None if streams => Some(CallState::Streaming),
574 None if started => Some(CallState::Running(None)),
575 None => None,
576 };
577 let agent = agent.map(|ForAgent(agent)| *agent);
578 let name = agent.and_then(|agent| self.names.get(agent).ok());
579 let state = state.unwrap_or(CallState::Waiting);
580 let child = live_call(requests, (name, outcome), state);
581 live.add(effect.parent, effect.id(), child, None);
582 let id = agent.and_then(|agent| self.agents.get(agent).ok());
583 if let (Some(request), Some((id, _))) = (requests.0, id) {
584 live.tool_call(effect.id(), &id.0, request.call.id.clone());
585 }
586 }
587 live
588 }
589
590 pub(crate) fn facts(&self) -> &CallFacts {
592 &self.facts
593 }
594
595 pub(crate) fn notices(&self) -> &Notices {
597 &self.notices
598 }
599
600 pub(crate) fn theme(&self) -> &Theme {
602 &self.theme
603 }
604}
605
606pub(crate) fn lay_out_transcript(
613 transcript: &mut Transcript,
614 sources: &Sources<'_>,
615 shown: Option<&ShownConversation<'_>>,
616 width: u16,
617) -> Vec<Part> {
618 let wrap_width = usize::from(width.max(1));
619 let mut parts = Vec::new();
620 let view = sources.view;
621 let Some(shown) = shown else {
622 let (mut extra, mut marks) = (Vec::new(), Marks::new());
623 let notices = sources.notices.numbered();
624 for notice in notices.filter(|(_, notice)| notice.is_for_every_agent()) {
625 notice_lines(notice, &mut extra, &mut marks);
626 }
627 parts.push(Part::rows(&extra, &marks, wrap_width));
628 return parts;
629 };
630 let (conversation, count) = (shown.conversation, shown.conversation.messages().len());
631 let mut after: Vec<Vec<(usize, &ShownNotice)>> = vec![Vec::new(); count + 1];
634 let mut asides_after: Vec<Vec<&ShownAside<'_>>> = vec![Vec::new(); count + 1];
635 let mut look = Look {
636 renderers: sources.renderers,
637 calls: &shown.calls,
638 outputs: &shown.outputs,
639 inside: HashMap::new(),
640 facts: sources.facts,
641 theme: sources.theme,
642 children: &shown.children,
643 marks: &view.marks,
644 };
645 let notices = shown.id.map(|id| (id, sources.notices));
646 let notices = notices
647 .into_iter()
648 .flat_map(|(id, notices)| notices.numbered().map(move |(at, notice)| (id, at, notice)));
649 for (id, at, notice) in notices {
650 let before = match notice.slot(id, conversation) {
651 None => continue,
652 Some(TranscriptSlot::Top) => 0,
653 Some(TranscriptSlot::After(index)) => index + 1,
654 Some(TranscriptSlot::Inside(index, item)) => {
655 let inside = look.inside.entry(index).or_default();
656 inside.push((item, Inside::Notice(at, notice)));
657 continue;
658 }
659 };
660 if let Some(after) = after.get_mut(before) {
661 after.push((at, notice));
662 }
663 }
664 let drawn = shown.asides.iter();
665 for aside in drawn.filter(|shown| sources.renderers.draws(shown.aside)) {
666 let before = match TranscriptSlot::of_aside(aside.aside, conversation) {
667 TranscriptSlot::Top => 0,
668 TranscriptSlot::After(index) => index + 1,
669 TranscriptSlot::Inside(index, item) => {
670 let inside = look.inside.entry(index).or_default();
671 inside.push((item, Inside::Aside(aside)));
672 continue;
673 }
674 };
675 if let Some(at) = asides_after.get_mut(before) {
676 at.push(aside);
677 }
678 }
679 transcript.update(shown.entity, conversation, shown.changed, &look, width);
680 let mut after = after.into_iter().zip(asides_after);
681 for index in 0..=count {
682 let (notices, asides) = after.next().unwrap_or_default();
683 for aside in asides {
684 let mut lines = vec![Line::default()];
685 lines.extend(on_band(
686 aside.lines(sources.renderers, sources.theme),
687 sources.theme.tool,
688 ));
689 let marks = [(aside.item(), 0..lines.len())];
690 parts.push(Part::rows(&lines, &marks, wrap_width));
691 }
692 let (mut extra, mut marks) = (Vec::new(), Marks::new());
693 for notice in notices {
694 notice_lines(notice, &mut extra, &mut marks);
695 }
696 if index == count {
697 if let Some((call, partial)) = shown.partial {
698 let view = |role, text, marks| MessageView {
699 role,
700 message: None,
701 block: 0,
702 text,
703 marks,
704 width: wrap_width,
705 theme: *sources.theme,
706 };
707 let reasoning = view(Role::Reasoning, &partial.reasoning, &[]);
708 extra.extend(reasoning.default_lines());
709 if !partial.text.is_empty() {
710 extra.push(Line::default());
711 let marks = look.marks_in(MarkIn::Streaming(call));
712 extra.extend(view(Role::Assistant, &partial.text, &marks).default_lines());
713 }
714 }
715 for call in &shown.streaming {
716 let mut view = ToolCallView::new(call, None, CallState::Streaming);
717 view.theme = *sources.theme;
718 extra.extend(on_band(sources.renderers.call(&view), sources.theme.tool));
719 }
720 inbox_lines(&shown.inbox, sources.theme, &mut extra);
721 parts.push(Part::rows(&extra, &marks, wrap_width));
722 break;
723 }
724 if !extra.is_empty() {
725 parts.push(Part::rows(&extra, &marks, wrap_width));
726 }
727 parts.push(Part::Message(index));
728 }
729 parts
730}
731
732pub(crate) fn decorate(
735 rows: Vec<Line<'static>>,
736 first: usize,
737 selected: Option<Range<usize>>,
738 theme: &Theme,
739) -> Vec<Line<'static>> {
740 let on = theme.selection();
741 rows.into_iter()
742 .enumerate()
743 .map(|(index, row)| match &selected {
744 Some(rows) if rows.contains(&(first + index)) => {
745 let spans = row.spans.into_iter().map(|span| span.patch_style(on));
746 Line::from(spans.collect::<Vec<_>>()).style(row.style.patch(on))
747 }
748 _ => row,
749 })
750 .collect()
751}
752
753#[derive(Default)]
757pub(crate) struct Lights<'a> {
758 pub(crate) highlights: &'a [Highlight],
759 pub(crate) placed: HashMap<(usize, usize), Vec<Range<usize>>>,
760}
761
762impl Lights<'_> {
763 pub(crate) fn any(&self) -> bool {
765 let lights =
766 |highlight: &Highlight| !highlight.text.is_empty() || !highlight.spans.is_empty();
767 self.highlights.iter().any(lights)
768 }
769}
770
771pub(crate) fn light(
776 rows: &[Line<'static>],
777 continues: &[Option<RowWrap>],
778 first: usize,
779 lights: &Lights<'_>,
780) -> Vec<Line<'static>> {
781 let joints = continues.iter().map(Option::as_ref);
782 let lit_rows = lit(rows.iter().zip(joints), first, lights);
783 rows.iter()
784 .zip(lit_rows)
785 .map(|(line, row)| {
786 if !row.cells.iter().any(|(.., lit)| *lit) {
787 return line.clone();
788 }
789 let mut spans: Vec<Span<'static>> = Vec::new();
790 for (character, style, _) in row.cells {
791 match spans.last_mut() {
792 Some(span) if span.style == style => span.content.to_mut().push(character),
793 _ => spans.push(Span::styled(character.to_string(), style)),
794 }
795 }
796 let mut lit = Line::from(spans).style(line.style);
797 lit.alignment = line.alignment;
798 lit
799 })
800 .collect()
801}
802
803pub(crate) struct LitRow {
807 pub(crate) cells: Vec<(char, Style, bool)>,
808 pub(crate) matches: Vec<usize>,
809}
810
811struct Slot {
815 row: usize,
816 at: Option<usize>,
817 character: char,
818 lit: bool,
819}
820
821pub(crate) fn lit<'a, 'line: 'a>(
827 rows: impl IntoIterator<Item = (&'a Line<'line>, Option<&'a RowWrap>)>,
828 first: usize,
829 lights: &Lights<'_>,
830) -> Vec<LitRow> {
831 let mut lit = Vec::new();
832 let mut line = Vec::new();
834 let mut start = first;
835 for (row, joint) in rows {
836 if joint.is_none() {
837 light_line(&mut lit, &mut line, start, lights);
838 line.clear();
839 start = first + lit.len();
840 }
841 let at = lit.len();
842 let cells: Vec<(char, Style, bool)> = row
843 .spans
844 .iter()
845 .flat_map(|span| span.content.chars().map(move |c| (c, span.style, false)))
846 .collect();
847 let indent = joint.map_or(0, |joint| joint.indent);
848 let gap = joint.map(|joint| joint.gap.chars()).into_iter().flatten();
849 line.extend(gap.map(|character| Slot {
850 row: at,
851 at: None,
852 character,
853 lit: false,
854 }));
855 let drawn = cells.iter().enumerate().skip(indent);
856 line.extend(drawn.map(|(index, (character, ..))| Slot {
857 row: at,
858 at: Some(index),
859 character: *character,
860 lit: false,
861 }));
862 lit.push(LitRow {
863 cells,
864 matches: vec![0; lights.highlights.len()],
865 });
866 }
867 light_line(&mut lit, &mut line, start, lights);
868 lit
869}
870
871fn light_line(rows: &mut [LitRow], line: &mut [Slot], start: usize, lights: &Lights<'_>) {
874 for (index, highlight) in lights.highlights.iter().enumerate() {
875 if !highlight.spans.is_empty() {
876 let spans = lights.placed.get(&(index, start));
877 for bytes in spans.into_iter().flatten() {
878 if let Some(window) = slots_of(line, bytes) {
879 light_match(rows, line, window, (index, highlight.style));
880 }
881 }
882 continue;
883 }
884 let needle: Vec<char> = highlight.text.chars().collect();
885 let same = |a: char, b: char| {
886 if highlight.match_case {
887 a == b
888 } else {
889 a.to_lowercase().eq(b.to_lowercase())
890 }
891 };
892 let mut at = 0;
893 while !needle.is_empty() && at + needle.len() <= line.len() {
894 let window = at..at + needle.len();
895 let text = line.get(window.clone()).unwrap_or_default();
896 let found = text
897 .iter()
898 .zip(&needle)
899 .all(|(slot, n)| same(slot.character, *n));
900 if found && light_match(rows, line, window, (index, highlight.style)) {
901 at += needle.len();
902 } else {
903 at += 1;
904 }
905 }
906 }
907}
908
909fn slots_of(line: &[Slot], bytes: &Range<usize>) -> Option<Range<usize>> {
912 let mut at = 0;
913 let (mut from, mut to) = (None, None);
914 for (index, slot) in line.iter().enumerate() {
915 if at == bytes.start {
916 from = Some(index);
917 }
918 if at == bytes.end {
919 to = Some(index);
920 }
921 at += slot.character.len_utf8();
922 }
923 if at == bytes.end {
924 to = Some(line.len());
925 }
926 Some(from?..to?).filter(|window| !window.is_empty())
927}
928
929fn light_match(
933 rows: &mut [LitRow],
934 line: &mut [Slot],
935 window: Range<usize>,
936 (index, style): (usize, Style),
937) -> bool {
938 let window = line.get_mut(window).unwrap_or_default();
939 if window.iter().any(|slot| slot.lit) {
940 return false;
941 }
942 let start = window.first().map(|slot| slot.row);
943 let count = start.and_then(|row| rows.get_mut(row)?.matches.get_mut(index));
944 if let Some(count) = count {
945 *count += 1;
946 }
947 for slot in window {
948 slot.lit = true;
949 let row = rows.get_mut(slot.row);
950 let cell = slot.at.and_then(|at| row?.cells.get_mut(at));
951 if let Some((_, style_there, lit)) = cell {
952 *style_there = style_there.patch(style);
953 *lit = true;
954 }
955 }
956 true
957}
958
959pub(crate) fn render(
966 (mut tui, mut test): (Option<ResMut<Tui>>, Option<ResMut<TuiTestScreen>>),
967 mut drew: ResMut<TuiFrame>,
968 view: Res<TuiView>,
969 (state, focus, choices): (Res<InputContext>, Res<KeyFocus>, Choices),
970 mut frame_layout: ResMut<FrameLayout>,
971 (panels, carets): (Query<(Entity, &TuiPanel, &PanelCanvas)>, Query<&Caret>),
972 (spawns, ticks): (PanelSpawns, SystemChangeTick),
973 mut showing: Query<(Entity, &mut PickerInput, Option<&PickPreview>)>,
974 mut repaints: MessageReader<RedrawArea>,
975 theme: Res<Theme>,
976) -> Result {
977 if !std::mem::take(&mut frame_layout.due) {
978 return Ok(());
979 }
980 let repaint: Vec<Rect> = repaints.read().map(|repaint| repaint.0).collect();
981 let picker = showing.iter().find(|(_, choosing, _)| !choosing.in_input());
982 let picker = picker.map(|(entity, ..)| entity);
983 let layer = |panel: &TuiPanel| match panel.placement {
986 Placement::Over { .. } => 1,
987 Placement::Within { panel, .. } if Some(panel) == picker => 3,
988 Placement::Within { .. } => 2,
989 _ => 0,
990 };
991 let keyed = state.editor().and_then(|editor| {
992 let found = panels.get(editor).ok();
993 found.map(|(.., canvas)| canvas.area())
994 });
995 let mut panels: Vec<_> = panels.iter().collect();
996 let order = |entity| panel::spawn_order(ticks.this_run(), &spawns, entity);
997 panels.sort_by_key(|(entity, panel, _)| (layer(panel), order(*entity)));
998 let (under, over_picker): (Vec<_>, Vec<_>) = panels
999 .into_iter()
1000 .partition(|(_, panel, _)| layer(panel) < 3);
1001 let edit = showing.iter().find(|(_, choosing, _)| choosing.in_input());
1002 let edit = edit.and_then(|(entity, ..)| choices.shown(entity));
1003 let cursor = carets.iter().find_map(|caret| caret.0);
1004 let cursor = cursor.map(|at| MoveTo(at.x, at.y));
1005 let mut areas = TuiFrame::default();
1006 let draw_frame = |frame: &mut Frame| {
1007 let screen = frame.area();
1008 let mut overlays = Vec::new();
1009 let mut drawn = Vec::new();
1010 let mut copy =
1011 |frame: &mut Frame, (entity, panel, canvas): &(Entity, &TuiPanel, &PanelCanvas)| {
1012 canvas.copy_to(frame.buffer_mut());
1013 drawn.push((*entity, canvas.area()));
1014 if layer(panel) > 0 {
1015 overlays.push(canvas.area());
1016 }
1017 };
1018 for panel in &under {
1019 copy(frame, panel);
1020 }
1021 let body = edit.as_ref().zip(keyed);
1022 let body = body.and_then(|(edit, at)| draw_body(frame, &theme, edit.body, at));
1023 let shown = picker.and_then(|picker| {
1026 let (_, choosing, preview) = showing.get_mut(picker).ok()?;
1027 Some((choices.shown(picker)?, choosing, preview))
1028 });
1029 let mut over = body.into_iter().collect::<Vec<_>>();
1030 match (shown, &view.completion, keyed) {
1031 (Some((shown, mut choosing, preview)), ..) => {
1032 let preview = preview.zip(frame_layout.picker);
1033 let choosing = choosing.bypass_change_detection();
1034 over.push(draw_choice(frame, &theme, choosing, &shown, preview));
1035 for panel in &over_picker {
1036 copy(frame, panel);
1037 }
1038 }
1039 (None, Some(completion), Some(at)) if focus.0.is_none() => {
1040 over.extend(draw_completion(frame, &theme, completion, at));
1041 }
1042 _ => {}
1043 }
1044 overlays.extend(over);
1045 if theme.plain {
1046 strip_colors(frame.buffer_mut());
1047 }
1048 areas = TuiFrame {
1049 screen,
1050 areas: drawn,
1051 overlays,
1052 };
1053 };
1054 let drawn = match (tui.as_deref_mut(), test.as_mut()) {
1055 (Some(tui), _) => {
1056 tui.begin_frame()?;
1057 let drawn = tui.terminal.draw(draw_frame);
1058 let drawn = drawn.map(|completed| cells_in(completed.buffer, &repaint));
1059 let drawn = drawn.and_then(|cells| {
1060 let cells = cells.iter().map(|(x, y, cell)| (*x, *y, cell));
1061 tui.terminal.backend_mut().draw(cells)
1062 });
1063 tui.end_frame(cursor.filter(|_| drawn.is_ok()))?;
1064 drawn
1065 }
1066 (None, Some(test)) => {
1067 test.bypass_change_detection().draw(draw_frame, &repaint);
1069 Ok(())
1070 }
1071 (None, None) => Ok(()),
1072 };
1073 drew.set_if_neq(areas);
1074 drawn?;
1075 Ok(())
1076}
1077
1078fn cells_in(buffer: &Buffer, areas: &[Rect]) -> Vec<(u16, u16, Cell)> {
1080 let areas = areas.iter().map(|area| area.intersection(buffer.area));
1081 let positions = areas.flat_map(|area| area.positions());
1082 positions
1083 .filter_map(|at| Some((at.x, at.y, buffer.cell(at)?.clone())))
1084 .collect()
1085}
1086
1087pub(crate) fn selectable(item: &TranscriptItem) -> bool {
1090 !matches!(
1091 item,
1092 TranscriptItem::Decoration(..) | TranscriptItem::MessageDecoration(..)
1093 )
1094}
1095
1096#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)]
1107pub struct TuiFrame {
1108 pub screen: Rect,
1110 pub areas: Vec<(Entity, Rect)>,
1113 pub overlays: Vec<Rect>,
1117}
1118
1119impl TuiFrame {
1120 pub fn screen_row(&self, drawn: &DrawnTranscript, row: usize) -> Option<u16> {
1123 let on_screen = drawn.on_screen();
1124 if !on_screen.contains(&row) {
1125 return None;
1126 }
1127 let below_top = u16::try_from(row - on_screen.start).ok()?;
1128 Some(drawn.area().y.saturating_add(below_top))
1129 }
1130
1131 pub fn area(&self, entity: Entity) -> Option<Rect> {
1133 let drawn = self.areas.iter().find(|(drawn, _)| *drawn == entity);
1134 drawn.map(|(_, area)| *area)
1135 }
1136
1137 pub fn covered(&self, area: Rect) -> bool {
1139 self.overlays.iter().any(|over| over.intersects(area))
1140 }
1141}
1142
1143fn draw_body(frame: &mut Frame, theme: &Theme, body: &str, input: Rect) -> Option<Rect> {
1146 if body.trim().is_empty() {
1147 return None;
1148 }
1149 let lines = wrap_all(
1150 &plain_lines(body, Style::new()),
1151 usize::from(input.width.saturating_sub(2).max(1)),
1152 );
1153 let rows = u16::try_from(lines.len()).unwrap_or(u16::MAX);
1154 let height = rows.saturating_add(2).min(input.y);
1155 if height < 3 {
1156 return None;
1157 }
1158 let area = Rect::new(input.x, input.y - height, input.width, height);
1159 frame.render_widget(Clear, area);
1160 frame.render_widget(Paragraph::new(lines).block(theme.raised("")), area);
1161 Some(area)
1162}
1163
1164fn draw_completion(
1167 frame: &mut Frame,
1168 theme: &Theme,
1169 completion: &Completion,
1170 input: Rect,
1171) -> Option<Rect> {
1172 let rows = completion.items.len().min(COMPLETION_ROWS);
1173 let height = u16::try_from(rows + 2).unwrap_or(3).min(input.y);
1174 if height < 3 {
1175 return None;
1176 }
1177 let area = Rect::new(input.x, input.y - height, input.width, height);
1178 frame.render_widget(Clear, area);
1179 let marker = completion
1180 .kind
1181 .marker()
1182 .map(String::from)
1183 .unwrap_or_default();
1184 let items: Vec<Line<'static>> = completion
1185 .items
1186 .iter()
1187 .map(|item| {
1188 let mut spans = vec![Span::from(format!("{marker}{}", item.text))];
1189 if !item.detail.is_empty() {
1190 spans.push(Span::from(format!(" {}", item.detail)).dim());
1191 }
1192 Line::from(spans)
1193 })
1194 .collect();
1195 let mut state = ListState::default().with_selected(Some(completion.selected));
1196 frame.render_stateful_widget(
1197 List::new(items)
1198 .block(with_keys(
1199 theme.raised(""),
1200 "Tab or Enter completes · Esc closes",
1201 ))
1202 .highlight_style(theme.selection()),
1203 area,
1204 &mut state,
1205 );
1206 Some(area)
1207}
1208
1209const LEAST_LIST: usize = 3;
1212
1213fn draw_choice(
1219 frame: &mut Frame,
1220 theme: &Theme,
1221 choosing: &mut PickerInput,
1222 shown: &Listing<'_>,
1223 preview: Option<(&PickPreview, Rect)>,
1224) -> Rect {
1225 let area = frame.area();
1226 let boxed = preview.map(|(preview, at)| {
1229 let [list, beside] =
1230 Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(at);
1231 (at, list, preview, beside)
1232 });
1233 let width = boxed.map_or(area.width.saturating_mul(4) / 5, |(_, list, ..)| list.width);
1234 let more = shown.more(choosing);
1235 let body = wrap_all(
1236 &plain_lines(more.unwrap_or(shown.body), Style::new()),
1237 usize::from(width.saturating_sub(2).max(1)),
1238 );
1239 let rows = shown.rows(&choosing.filter);
1240 let wanted = body.len() + usize::from(!body.is_empty()) + 1 + rows.len() + 2;
1243 let height = u16::try_from(wanted)
1244 .unwrap_or(u16::MAX)
1245 .min(area.height.saturating_mul(3) / 4)
1246 .max(5);
1247 let popup = match boxed {
1248 Some((_, list, ..)) => list,
1249 None => area.centered(Constraint::Length(width), Constraint::Length(height)),
1250 };
1251 frame.render_widget(Clear, popup);
1252 if let Some((_, _, PickPreview::Text(text), beside)) = boxed {
1253 let wide = usize::from(beside.width.saturating_sub(2).max(1));
1254 let lines = wrap_all(&plain_lines(text, Style::new()), wide);
1255 frame.render_widget(Clear, beside);
1256 let block = theme.raised("preview");
1257 frame.render_widget(Paragraph::new(lines).block(block), beside);
1258 }
1259 let inner = theme.raised("").inner(popup);
1260 let list_least = rows.len().clamp(1, LEAST_LIST);
1263 let room = usize::from(inner.height).saturating_sub(2 + list_least);
1264 let body_height = body.len().min(room);
1265 choosing.scroll = choosing.scroll.min(body.len() - body_height);
1266 let mut keys = vec![if shown.free_text {
1267 "type to filter or answer"
1268 } else {
1269 "type to filter"
1270 }];
1271 if body_height < body.len() {
1272 keys.push("Shift+↑↓ scrolls");
1273 }
1274 if shown.ticks {
1275 keys.push("Tab ticks");
1276 }
1277 if more.is_some() {
1278 keys.push("← hides more");
1279 } else if shown.items.iter().any(|item| item.more.is_some()) {
1280 keys.push("→ shows more");
1281 }
1282 let keys = format!("{}, Enter picks, Esc closes", keys.join(", "));
1283 frame.render_widget(with_keys(theme.raised(shown.title), &keys), popup);
1284 let [body_area, _, typed, list] = Layout::vertical([
1285 Constraint::Length(u16::try_from(body_height).unwrap_or(u16::MAX)),
1286 Constraint::Length(u16::from(body_height > 0)),
1287 Constraint::Length(1),
1288 Constraint::Min(1),
1289 ])
1290 .areas(inner);
1291 let scroll = u16::try_from(choosing.scroll).unwrap_or(u16::MAX);
1292 frame.render_widget(Paragraph::new(body).scroll((scroll, 0)), body_area);
1293 frame.render_widget(Line::from(format!("› {}▏", choosing.filter)), typed);
1294 let width = usize::from(list.width);
1295 let items: Vec<Line<'static>> = rows
1296 .iter()
1297 .map(|row| match row {
1298 Row::Item(index) => {
1299 let Some(item) = shown.items.get(*index) else {
1300 return Line::default();
1301 };
1302 if !shown.ticks {
1303 return item.line(width);
1304 }
1305 let tick = match (shown.multiple, choosing.ticked.contains(index)) {
1306 (true, true) => "[x] ",
1307 (true, false) => "[ ] ",
1308 (false, true) => "(x) ",
1309 (false, false) => "( ) ",
1310 };
1311 let mut line = item.line(width.saturating_sub(tick.len()));
1312 line.spans.insert(0, Span::from(tick));
1313 line
1314 }
1315 Row::Typed if choosing.filter.trim().is_empty() => {
1316 Line::from("✎ or type an answer").dim()
1317 }
1318 Row::Typed if choosing.ticked.is_empty() => {
1319 Line::from(format!("✎ {}", choosing.filter.trim()))
1320 }
1321 Row::Typed => Line::from(format!("✎ {} (with the ticked)", choosing.filter.trim())),
1322 })
1323 .collect();
1324 let mut state = ListState::default().with_selected(Some(choosing.selected));
1325 frame.render_stateful_widget(
1326 List::new(items).highlight_style(theme.selection()),
1327 list,
1328 &mut state,
1329 );
1330 boxed.map_or(popup, |(whole, ..)| whole)
1331}
1332
1333fn strip_colors(buffer: &mut Buffer) {
1336 for cell in &mut buffer.content {
1337 cell.fg = Color::Reset;
1338 cell.bg = Color::Reset;
1339 cell.underline_color = Color::Reset;
1340 }
1341}
1342
1343fn with_keys<'a>(surface: Block<'a>, keys: &str) -> Block<'a> {
1346 surface
1347 .padding(Padding::horizontal(1))
1348 .title_bottom(Line::from(format!("{keys} ")).dim().right_aligned())
1349}
1350
1351fn inbox_lines(inbox: &[&Incoming], theme: &Theme, lines: &mut Vec<Line<'static>>) {
1354 if inbox.is_empty() {
1355 return;
1356 }
1357 lines.push(Line::default());
1358 for message in inbox {
1359 let when = match message.mode {
1360 DeliveryMode::Steer => "steering",
1361 DeliveryMode::Queue => "after this turn",
1362 DeliveryMode::Inform => "noted",
1363 };
1364 let text = message.text();
1365 let mut rows = text.lines();
1366 let first = Line::from(vec![
1367 Span::from(format!(" ⏵ {when}: ")).dim(),
1368 Span::from(rows.next().unwrap_or_default().to_owned()).dim(),
1369 ]);
1370 let rest = rows.map(|row| Line::from(format!(" {row}")).dim());
1371 lines.extend(on_band(std::iter::once(first).chain(rest), theme.user));
1372 }
1373}
1374
1375#[cfg(test)]
1376mod tests;