pub struct Frame { /* private fields */ }Expand description
A complete declarative terminal viewport.
Each frame owns a fixed cell grid. Wide graphemes reserve continuation cells, so overwriting either half cannot leave a stale terminal cell behind.
Implementations§
Source§impl Frame
impl Frame
Sourcepub fn new(size: Size) -> Self
pub fn new(size: Size) -> Self
Creates a blank frame using the terminal’s default colors.
Examples found in repository?
167 pub fn new(charset: Charset) -> Self {
168 Self {
169 charset,
170 frame: Frame::new(Size::new(0, 0)),
171 title: fmts!(" {} omp v{} ", charset.icon(Icon::Omp), env!("CARGO_PKG_VERSION")),
172 camera: (0.0, 0.0),
173 camera_target: (0.0, 0.0),
174 last_elapsed: 0.0,
175 logo_origin: (0, 0),
176 logo: [[None; LOGO_COLS]; LOGO_ROWS],
177 logo_at: None,
178 backdrop_frame: Frame::new(Size::new(0, 0)),
179 backdrop_at: None,
180 backdrop: Eclipse::default(),
181 surface: Surface::new(),
182 pointer: None,
183 hover: Tween::settled(0.0),
184 }
185 }
186
187 /// Records the pointer (0-based cells) for the hover zone and retargets
188 /// the camera: the pointer's offset from the logo center maps to camera
189 /// lift and a full half-turn of yaw in each direction, matching the
190 /// prototype.
191 pub fn point_at(&mut self, column: u16, row: u16) {
192 self.pointer = Some((column, row));
193 self.logo_at = None;
194 let center_x = f32::from(self.logo_origin.0) + LOGO_COLS as f32 / 2.0;
195 let center_y = f32::from(self.logo_origin.1) + LOGO_ROWS as f32 / 2.0;
196 let horizontal = ((f32::from(column) - center_x) / (LOGO_COLS as f32 / 2.0)).clamp(-1.0, 1.0);
197 let vertical = ((f32::from(row) - center_y) / (LOGO_ROWS as f32 / 2.0)).clamp(-1.0, 1.0);
198 self.camera_target = (-vertical * 0.42, -horizontal * PI);
199 }
200
201 /// Paints the card centered in `viewport` at `elapsed` since boot and
202 /// returns the full-viewport frame (no stable rows, everything damaged).
203 pub fn render(&mut self, viewport: Size, elapsed: Duration) -> &Frame {
204 if self.frame.size() != viewport {
205 self.frame = Frame::new(viewport);
206 self.backdrop_frame = Frame::new(viewport);
207 self.backdrop_at = None;
208 }
209 let clock = elapsed;
210 let elapsed = elapsed.as_secs_f32();
211 // Exponential pointer chase, frame-rate independent (~100ms lag).
212 let delta = (elapsed - self.last_elapsed).max(0.0);
213 self.last_elapsed = elapsed;
214 let response = 1.0 - (-delta * 10.0).exp();
215 self.camera.0 += (self.camera_target.0 - self.camera.0) * response;
216 self.camera.1 += (self.camera_target.1 - self.camera.1) * response;
217 self.draw_backdrop(viewport, clock, elapsed);
218
219 let logo_interval = ambient_interval(clock, LOGO_IDLE_INTERVAL);
220 if self
221 .logo_at
222 .is_none_or(|rendered_at| clock.saturating_sub(rendered_at) >= logo_interval)
223 {
224 self.logo = logo_cells(elapsed, self.camera);
225 self.logo_at = Some(clock);
226 }
227 let cols = if viewport.width >= CARD_COLS && viewport.height >= CARD_ROWS {
228 Some(CARD_COLS)
229 } else if viewport.width >= SMOL_COLS && viewport.height >= CARD_ROWS {
230 Some(SMOL_COLS)
231 } else {
232 None
233 };
234 let Some(cols) = cols else {
235 let left = viewport.width.saturating_sub(LOGO_COLS as u16) / 2;
236 let top = viewport.height.saturating_sub(LOGO_ROWS as u16) / 2;
237 self.logo_origin = (left, top);
238 blit_logo(&mut self.frame, &self.logo, left, top, PLATE);
239 return &self.frame;
240 };
241
242 let left = (viewport.width - cols) / 2;
243 let top = (viewport.height - CARD_ROWS) / 2;
244 let hovered = self.pointer.is_some_and(|(x, y)| {
245 (left..left + cols).contains(&x) && (top..top + CARD_ROWS).contains(&y)
246 });
247 self
248 .hover
249 .retarget(clock, if hovered { 1.0 } else { 0.0 }, HOVER_EASE, Easing::EaseOut);
250 let hover = self.hover.sample(clock).clamp(0.0, 1.0);
251 self.draw_card(cols, left, top, elapsed, hover);
252 &self.frame
253 }More examples
752 pub fn new(ctx: &UiContext) -> Self {
753 let editor = Rc::new(RefCell::new({
754 let mut editor = Editor::new(EditorOptions::default());
755 editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756 editor
757 }));
758 let edit_outcome = Rc::new(RefCell::new(None));
759 let work = Rc::new(RefCell::new(WorkState {
760 working: true,
761 since: Duration::ZERO,
762 fade: Tween::settled(GREEN),
763 }));
764 let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765 let pane = EditorPane::new()
766 .input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767 .status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768 let attachments = pane.attachments();
769 let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770 Self {
771 started_at: Instant::now(),
772 ctx: ctx.clone(),
773 cancel_hint: ctx.charset.icon(Icon::Cancellable),
774 editor_ui,
775 editor,
776 edit_outcome,
777 work,
778 last_working: true,
779 model,
780 attachments,
781 transcript: vec![Entry::Command],
782 drawn_entries: 0,
783 transcript_rows: 0,
784 appended_messages: 0,
785 emitted_shards: 0,
786 last_viewport: Size::new(0, 0),
787 height_floor: 0,
788 frame: Frame::new(Size::new(0, 0)),
789 live_panel: None,
790 live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791 live_label_scratch: StrMut::with_capacity(40),
792 right_inset: 0,
793 switch_requested: false,
794 }
795 }
796
797 /// Routes a key through the editor and reports whether the demo should
798 /// exit. Quit policy lives here, not in the editor: once the editor
799 /// reports a key unused, `esc` first cancels running work and only quits
800 /// at rest; `ctrl-c` always quits.
801 pub fn handle_key(&mut self, key: Key) -> bool {
802 *self.edit_outcome.borrow_mut() = None;
803 let _ = self.editor_ui.handle_key(key);
804 let outcome = self
805 .edit_outcome
806 .borrow_mut()
807 .take()
808 .unwrap_or(EditOutcome::Ignored);
809 match outcome {
810 EditOutcome::Submitted(text) => {
811 let trimmed = text.trim();
812 if trimmed == "/switch" {
813 self.switch_requested = true;
814 return false;
815 }
816 if let Some(path) = trimmed
817 .strip_prefix("/attach")
818 .filter(|rest| rest.is_empty() || rest.starts_with(' '))
819 {
820 let path = path.trim().to_string();
821 if !path.is_empty() {
822 self.attach_image(&path);
823 }
824 return false;
825 }
826 let _ = self.attachments.take();
827 self.refresh_composer();
828 self
829 .transcript
830 .push(Entry::Submitted(Box::new(Submission::new(
831 text,
832 Self::message_width(self.last_viewport.width),
833 &self.ctx,
834 ))));
835 self.set_working(true, self.started_at.elapsed());
836 false
837 },
838 EditOutcome::Changed => {
839 self.reconcile_attachments();
840 false
841 },
842 EditOutcome::Ignored => {
843 if key == Key::Ctrl('c') {
844 return true;
845 }
846 if key != Key::Esc {
847 return false;
848 }
849 if self.work.borrow().working {
850 self.set_working(false, self.started_at.elapsed());
851 return false;
852 }
853 true
854 },
855 }
856 }
857
858 /// Consumes a pending `/switch` request submitted through the composer.
859 pub fn take_switch_request(&mut self) -> bool {
860 std::mem::take(&mut self.switch_requested)
861 }
862
863 /// Routes a document-space mouse report into the editor UI.
864 pub fn handle_mouse(&mut self, report: &MouseReport) {
865 let editor_height = self.editor_ui.height();
866 let editor_y = self.frame.size().height.saturating_sub(editor_height);
867 let editor_bottom = editor_y.saturating_add(editor_height);
868 if report.row < editor_y || report.row >= editor_bottom {
869 return;
870 }
871 let _ = self
872 .editor_ui
873 .handle_mouse(report.col, report.row - editor_y, report.kind);
874 }
875
876 /// Switches the work state and retargets the brand fade. The status bar
877 /// repaints immediately and the fade departs from whatever color is on
878 /// screen, so rapid cancel/resume never snaps.
879 fn set_working(&mut self, working: bool, now: Duration) {
880 {
881 let mut work = self.work.borrow_mut();
882 if work.working == working {
883 return;
884 }
885 work.working = working;
886 work.since = now;
887 let target = if working { GREEN } else { MUTED };
888 work
889 .fade
890 .retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891 }
892 self.editor_ui.invalidate(STATUS_ID);
893 }
894
895 /// Reflects a session model switch in the status bar's model segment.
896 pub fn set_model(&mut self, name: &str) {
897 *self.model.borrow_mut() = Str::from(name);
898 self.editor_ui.invalidate(STATUS_ID);
899 }
900
901 /// Routes sanitized bracketed paste text through the editor. Dropped
902 /// paths to existing image files (quoted, escaped, `file://`, or
903 /// multi-file) and any large paste collapse into composer attachment
904 /// chips instead of raw text.
905 pub fn handle_paste(&mut self, text: &str) {
906 let paths = omp_tui::paste::dropped_paths(text);
907 if !paths.is_empty()
908 && paths.iter().all(|path| {
909 omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910 }) {
911 for path in &paths {
912 self.attach_image(path);
913 }
914 return;
915 }
916 if text.lines().count() > 10 || text.len() > 1000 {
917 self.attach_paste(text);
918 return;
919 }
920 let _ = self.editor_ui.handle_paste(text);
921 }
922
923 /// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924 /// attachment staging, no large-paste collapse — the text stays inline
925 /// and editable.
926 pub fn handle_paste_raw(&mut self, text: &str) {
927 let _ = self.editor_ui.handle_paste_raw(text);
928 }
929
930 /// Stages `path` on the composer and mentions it in the prompt as an
931 /// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932 fn attach_image(&mut self, path: &str) {
933 let attachment = self.attachments.push_image(path);
934 let payload = format!("<ref image={}/>", attachment.marker);
935 self.insert_chip(&attachment, &payload);
936 }
937
938 /// Collapses a large paste into a staged attachment card and an atomic
939 /// composer chip expanding back to the pasted text on submit.
940 fn attach_paste(&mut self, text: &str) {
941 let attachment = self.attachments.push_text(text);
942 self.insert_chip(&attachment, text);
943 }
944
945 /// Inserts one attachment chip as an atomic editor reference.
946 fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947 let chip = chip_label(attachment, self.ctx.charset);
948 {
949 let mut editor = self.editor.borrow_mut();
950 let _ = editor.insert_reference(&chip, payload);
951 let _ = editor.insert_text(" ");
952 }
953 self.refresh_composer();
954 }
955
956 /// Hides staged attachments whose chip the user deleted from the
957 /// composer (and re-shows them after an undo). Presence is derived
958 /// from the buffer's atomic ranges, never from text matching.
959 fn reconcile_attachments(&mut self) {
960 let charset = self.ctx.charset;
961 let changed = {
962 let editor = self.editor.borrow();
963 let text = editor.text();
964 let ranges = editor.atom_ranges();
965 self.attachments.set_visible(|attachment| {
966 let chip = chip_label(attachment, charset);
967 ranges
968 .iter()
969 .any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970 })
971 };
972 if changed {
973 self.refresh_composer();
974 }
975 }
976
977 /// Relayouts the composer after out-of-band state changed its height.
978 fn refresh_composer(&mut self) {
979 let width = self.editor_ui.frame().size().width;
980 if width > 0 {
981 self.editor_ui.resize(width);
982 }
983 }
984
985 /// Reserves `cols` at the right edge for a composited rail, so the
986 /// composer's right-docked chrome stays visible beside it. The next
987 /// render relayouts the editor at the narrowed width.
988 pub const fn set_right_inset(&mut self, cols: u16) {
989 self.right_inset = cols;
990 }
991
992 /// The width the composer may actually occupy at `viewport`.
993 fn composer_width(&self, viewport: Size) -> u16 {
994 viewport.width.saturating_sub(self.right_inset).max(1)
995 }
996
997 /// Updates the retained logical document and reports its repainted rows.
998 pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999 self.render_at(viewport, self.started_at.elapsed())
1000 }
1001
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }68async fn run<'a>(
69 terminal: &'a mut Terminal,
70 renderer: &'a mut Renderer<TtyOut>,
71 charset: Charset,
72) -> io::Result<()> {
73 let started = Instant::now();
74 let mut viewport = terminal.size()?;
75 let mut scroll: u16 = 0;
76 let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
77 loop {
78 tokio::select! {
79 event = terminal.next() => match event? {
80 TerminalEvent::Input(event) => {
81 match event {
82 InputEvent::Key(key) => match key {
83 Key::Char('q') | Key::Esc | Key::Ctrl('c') => return Ok(()),
84 Key::Up | Key::Char('k') => scroll = scroll.saturating_sub(1),
85 Key::Down | Key::Char('j') => scroll = scroll.saturating_add(1),
86 Key::PageUp => scroll = scroll.saturating_sub(viewport.height),
87 Key::PageDown => scroll = scroll.saturating_add(viewport.height),
88 Key::Home => scroll = 0,
89 Key::End => scroll = u16::MAX,
90 _ => {},
91 },
92 InputEvent::Mouse(report) => match report.kind {
93 Mouse::WheelUp => scroll = scroll.saturating_sub(2),
94 Mouse::WheelDown => scroll = scroll.saturating_add(2),
95 _ => {},
96 },
97 InputEvent::Paste(_) | InputEvent::Focus(_) | InputEvent::Response(_) => {},
98 }
99 terminal.sync_renderer(renderer)?;
100 },
101 TerminalEvent::Resize => {
102 if let Some(size) = terminal.take_resize()? {
103 viewport = size;
104 }
105 },
106 TerminalEvent::Debug(_) => {},
107 TerminalEvent::Closed => return Ok(()),
108 },
109 () = tokio::time::sleep(FRAME_INTERVAL) => {},
110 }
111 if viewport.width == 0 || viewport.height == 0 {
112 continue;
113 }
114 let scene = Scene { charset, width: viewport.width, elapsed: started.elapsed() };
115 let document = compose(&scene);
116 scroll = scroll.min(document.size().height.saturating_sub(viewport.height));
117 let mut screen = Frame::new(viewport);
118 screen.fill(Rect::new(0, 0, viewport.width, viewport.height), ink(TEXT));
119 screen.blit(&document, scroll, viewport.height, 0, 0);
120 renderer.preview(&screen, viewport.height, alt_enter.take().as_deref().unwrap_or(""))?;
121 }
122}
123
124/// Per-frame paint inputs shared by every study.
125struct Scene {
126 charset: Charset,
127 width: u16,
128 elapsed: Duration,
129}
130
131impl Scene {
132 const fn spinner(&self) -> &'static str {
133 self.charset.spinner().at(self.elapsed)
134 }
135
136 fn timer(&self) -> Str {
137 let seconds = self.elapsed.as_secs();
138 if seconds < 60 {
139 fmts!("{seconds}s")
140 } else {
141 fmts!("{}m", seconds / 60)
142 }
143 }
144
145 const fn right_edge(&self) -> u16 {
146 self.width.saturating_sub(1)
147 }
148}
149
150/// One study: a header line and a `rows`-tall live mock beneath it.
151struct Study {
152 title: &'static str,
153 note: &'static str,
154 rows: u16,
155 draw: fn(&mut Frame, u16, &Scene),
156}
157
158const STUDIES: [Study; 6] = [
159 Study {
160 title: "pi parity",
161 note: "border carries the band left and the title right; the intent rides its own spinner \
162 row",
163 rows: 4,
164 draw: study_pi_parity,
165 },
166 Study {
167 title: "gap title",
168 note: "the air row earns its keep — session title idles right-aligned in the gap",
169 rows: 4,
170 draw: study_gap_title,
171 },
172 Study {
173 title: "band title",
174 note: "title as the left band's second segment; session facts dock right",
175 rows: 4,
176 draw: study_band_title,
177 },
178 Study {
179 title: "prompt title",
180 note: "split + air untouched; the title rests on the prompt row and yields while typing",
181 rows: 4,
182 draw: study_prompt_title,
183 },
184 Study {
185 title: "crown",
186 note: "title crowns the whole block; narration, gap, and split bands breathe below it",
187 rows: 5,
188 draw: study_crown,
189 },
190 Study {
191 title: "hem",
192 note: "title stitched into the top border, band into the bottom hem",
193 rows: 4,
194 draw: study_hem,
195 },
196];
197
198/// Renders the full document — title block plus every study — at width.
199fn compose(scene: &Scene) -> Frame {
200 let height = STUDIES
201 .iter()
202 .map(|study| study.rows + 3)
203 .fold(3_u16, u16::saturating_add);
204 let mut frame = Frame::new(Size::new(scene.width, height));
205 frame.fill(Rect::new(0, 0, scene.width, height), ink(TEXT));
206
207 let column = frame.put(1, 0, "composer footer studies", ink(TEXT).bold());
208 frame.put(
209 column.saturating_add(2),
210 0,
211 "split + air gap, six session-title placements",
212 ink(MUTED),
213 );
214 frame.put(1, 1, "↑/↓ scroll · PgUp/PgDn page · Home/End jump · q quits", ink(FAINT));
215
216 let mut y = 3_u16;
217 for (index, study) in STUDIES.iter().enumerate() {
218 let number = fmts!("{:>2} ", index + 1);
219 let mut column = frame.put(1, y, &number, ink(GOLD).bold());
220 column = frame.put(column, y, study.title, ink(TEXT).bold());
221 column = frame.put(column, y, " ", ink(FAINT));
222 frame.put(column, y, study.note, ink(MUTED));
223 (study.draw)(&mut frame, y + 1, scene);
224 y = y.saturating_add(study.rows + 3);
225 }
226 frame
227}Sourcepub fn resize_height(&mut self, height: u16, style: Style)
pub fn resize_height(&mut self, height: u16, style: Style)
Changes the document height, preserving retained rows and filling growth with styled blanks.
Examples found in repository?
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }Sourcepub fn set_soft_wrap(&mut self, y: u16)
pub fn set_soft_wrap(&mut self, y: u16)
Flags row y as soft-wrapping onto row y + 1: the pair renders as
one logical line broken mid-word only by the frame width. The
renderer may join the boundary with terminal autowrap so native
selection copies it unbroken, provided the row’s content truly
reaches the final column.
Ignored unless both rows exist. Cleared by Frame::clear,
Frame::resize_height shrinkage, and every rebuild — the flag is
layout metadata, not cell content.
Examples found in repository?
1703fn draw_flowed(frame: &mut Frame, rect: Rect, spans: &[Span<'_>]) -> u16 {
1704 if rect.width == 0 || rect.height == 0 {
1705 return 0;
1706 }
1707 let full_row = rect.x == 0 && rect.width == frame.size().width;
1708 let mut row = 0_u16;
1709 let mut column = 0_u16;
1710 let mut drew_anything = false;
1711
1712 for span in spans {
1713 for grapheme in xutf::graphemes_str(span.text) {
1714 let grapheme_width = visible_width(grapheme);
1715 if grapheme_width == 0 || grapheme_width > rect.width {
1716 continue;
1717 }
1718 if column.saturating_add(grapheme_width) > rect.width {
1719 // Only an exactly-filled row is byte-joinable by autowrap.
1720 if full_row && column == rect.width {
1721 frame.set_soft_wrap(rect.y.saturating_add(row));
1722 }
1723 row += 1;
1724 column = 0;
1725 }
1726 if row >= rect.height {
1727 return rect.height;
1728 }
1729 frame.put(rect.x + column, rect.y + row, grapheme, span.style);
1730 column += grapheme_width;
1731 drew_anything = true;
1732 }
1733 }
1734
1735 if drew_anything { row + 1 } else { 0 }
1736}Sourcepub fn soft_wrap(&self, y: u16) -> bool
pub fn soft_wrap(&self, y: u16) -> bool
Whether row y was flagged as soft-wrapping onto row y + 1.
Sourcepub const fn size(&self) -> Size
pub const fn size(&self) -> Size
Returns the frame dimensions.
Examples found in repository?
864 pub fn handle_mouse(&mut self, report: &MouseReport) {
865 let editor_height = self.editor_ui.height();
866 let editor_y = self.frame.size().height.saturating_sub(editor_height);
867 let editor_bottom = editor_y.saturating_add(editor_height);
868 if report.row < editor_y || report.row >= editor_bottom {
869 return;
870 }
871 let _ = self
872 .editor_ui
873 .handle_mouse(report.col, report.row - editor_y, report.kind);
874 }
875
876 /// Switches the work state and retargets the brand fade. The status bar
877 /// repaints immediately and the fade departs from whatever color is on
878 /// screen, so rapid cancel/resume never snaps.
879 fn set_working(&mut self, working: bool, now: Duration) {
880 {
881 let mut work = self.work.borrow_mut();
882 if work.working == working {
883 return;
884 }
885 work.working = working;
886 work.since = now;
887 let target = if working { GREEN } else { MUTED };
888 work
889 .fade
890 .retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891 }
892 self.editor_ui.invalidate(STATUS_ID);
893 }
894
895 /// Reflects a session model switch in the status bar's model segment.
896 pub fn set_model(&mut self, name: &str) {
897 *self.model.borrow_mut() = Str::from(name);
898 self.editor_ui.invalidate(STATUS_ID);
899 }
900
901 /// Routes sanitized bracketed paste text through the editor. Dropped
902 /// paths to existing image files (quoted, escaped, `file://`, or
903 /// multi-file) and any large paste collapse into composer attachment
904 /// chips instead of raw text.
905 pub fn handle_paste(&mut self, text: &str) {
906 let paths = omp_tui::paste::dropped_paths(text);
907 if !paths.is_empty()
908 && paths.iter().all(|path| {
909 omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910 }) {
911 for path in &paths {
912 self.attach_image(path);
913 }
914 return;
915 }
916 if text.lines().count() > 10 || text.len() > 1000 {
917 self.attach_paste(text);
918 return;
919 }
920 let _ = self.editor_ui.handle_paste(text);
921 }
922
923 /// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924 /// attachment staging, no large-paste collapse — the text stays inline
925 /// and editable.
926 pub fn handle_paste_raw(&mut self, text: &str) {
927 let _ = self.editor_ui.handle_paste_raw(text);
928 }
929
930 /// Stages `path` on the composer and mentions it in the prompt as an
931 /// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932 fn attach_image(&mut self, path: &str) {
933 let attachment = self.attachments.push_image(path);
934 let payload = format!("<ref image={}/>", attachment.marker);
935 self.insert_chip(&attachment, &payload);
936 }
937
938 /// Collapses a large paste into a staged attachment card and an atomic
939 /// composer chip expanding back to the pasted text on submit.
940 fn attach_paste(&mut self, text: &str) {
941 let attachment = self.attachments.push_text(text);
942 self.insert_chip(&attachment, text);
943 }
944
945 /// Inserts one attachment chip as an atomic editor reference.
946 fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947 let chip = chip_label(attachment, self.ctx.charset);
948 {
949 let mut editor = self.editor.borrow_mut();
950 let _ = editor.insert_reference(&chip, payload);
951 let _ = editor.insert_text(" ");
952 }
953 self.refresh_composer();
954 }
955
956 /// Hides staged attachments whose chip the user deleted from the
957 /// composer (and re-shows them after an undo). Presence is derived
958 /// from the buffer's atomic ranges, never from text matching.
959 fn reconcile_attachments(&mut self) {
960 let charset = self.ctx.charset;
961 let changed = {
962 let editor = self.editor.borrow();
963 let text = editor.text();
964 let ranges = editor.atom_ranges();
965 self.attachments.set_visible(|attachment| {
966 let chip = chip_label(attachment, charset);
967 ranges
968 .iter()
969 .any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970 })
971 };
972 if changed {
973 self.refresh_composer();
974 }
975 }
976
977 /// Relayouts the composer after out-of-band state changed its height.
978 fn refresh_composer(&mut self) {
979 let width = self.editor_ui.frame().size().width;
980 if width > 0 {
981 self.editor_ui.resize(width);
982 }
983 }
984
985 /// Reserves `cols` at the right edge for a composited rail, so the
986 /// composer's right-docked chrome stays visible beside it. The next
987 /// render relayouts the editor at the narrowed width.
988 pub const fn set_right_inset(&mut self, cols: u16) {
989 self.right_inset = cols;
990 }
991
992 /// The width the composer may actually occupy at `viewport`.
993 fn composer_width(&self, viewport: Size) -> u16 {
994 viewport.width.saturating_sub(self.right_inset).max(1)
995 }
996
997 /// Updates the retained logical document and reports its repainted rows.
998 pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999 self.render_at(viewport, self.started_at.elapsed())
1000 }
1001
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }
1292
1293 /// Paints the n-th scripted message and returns rows used including
1294 /// the trailing blank. Measurement draws into a scratch frame.
1295 fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296 let margin = u16::from(width >= 50);
1297 let content_width = width.saturating_sub(margin * 2);
1298 if message == 2 {
1299 draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300 return EDIT_BOX_HEIGHT + 1;
1301 }
1302 let bottom = frame.size().height;
1303 let spans = Self::message_spans(message);
1304 // Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305 // wrapped row re-joins byte-for-byte in native selection.
1306 let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307 used.saturating_add(1)
1308 }
1309
1310 fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311 let mut spans = SmallVec::new();
1312 match message {
1313 0 => {
1314 spans.push(Span::new("Transcript rows are ", prose_style()));
1315 spans.push(Span::new("append-only", code_style()));
1316 spans.push(Span::new(
1317 ": every line is painted once, becomes stable, and rides into native scrollback \
1318 with any selection anchored to it.",
1319 prose_style(),
1320 ));
1321 },
1322 1 => {
1323 spans.push(Span::new(
1324 "Only the bottom band repaints in place — the live shard panel, the activity \
1325 shimmer, and the composer. Rows above it are never rewritten.",
1326 prose_style(),
1327 ));
1328 },
1329 _ => {
1330 spans.push(Span::new(
1331 "On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332 the stable transcript through a ",
1333 prose_style(),
1334 ));
1335 spans.push(Span::new("DECSTBM top region", code_style()));
1336 spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337 },
1338 }
1339 spans
1340 }
1341
1342 /// Appends a finished shard's permanent one-line result.
1343 fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344 let margin = u16::from(width >= 50);
1345 let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346 let detail = fmts!(" workspace-{shard:03}.test.ts [100%]");
1347 draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348 Span::new(prefix.as_str(), ink(GREEN)),
1349 Span::new(detail.as_str(), ink(MUTED)),
1350 ]);
1351 }
1352
1353 /// Shimmering activity line above the editor. The spinner and timer
1354 /// live in the status bar's brand segment; this row only narrates.
1355 fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356 if y >= frame.size().height || frame.size().width < 4 {
1357 return;
1358 }
1359 let start = u16::from(frame.size().width >= 50);
1360 let mut column = start;
1361 let length = xutf::graphemes_str(WORKING_MESSAGE)
1362 .count()
1363 .saturating_add(xutf::graphemes_str(hint).count())
1364 .saturating_add(1);
1365 let length = u16::try_from(length).unwrap_or(u16::MAX);
1366 let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367 let right = frame.size().width.saturating_sub(1);
1368 draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369 draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370 draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371 }
1372
1373 /// The session title resting right-aligned in the air row between
1374 /// the working narration and the status band — against the visible
1375 /// right bound, inside any rail reservation — so the gap reads as
1376 /// session identity instead of dead space.
1377 fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378 let width = frame.size().width.saturating_sub(right_inset);
1379 let title_width = visible_width(SESSION_TITLE);
1380 if y >= frame.size().height || width < title_width.saturating_add(2) {
1381 return;
1382 }
1383 let x = width.saturating_sub(title_width.saturating_add(1));
1384 draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385 }
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391 if rect.width < 4 || rect.height < 4 {
1392 return;
1393 }
1394
1395 let content_x = rect.x + 2;
1396 let content_width = rect.width.saturating_sub(4);
1397 let header = [
1398 Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399 Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400 ];
1401 draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402 let command = [
1403 Span::new("$ ", panel_ink(MUTED)),
1404 Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405 Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406 ];
1407 draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632 u16::try_from(
1633 text
1634 .bytes()
1635 .filter(|byte| *byte == b'\n')
1636 .count()
1637 .saturating_add(1),
1638 )
1639 .unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644 if rect.width == 0 || rect.height == 0 {
1645 return;
1646 }
1647 let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648 frame.fill(rect, fill);
1649 let mut glyph = [0_u8; 4];
1650 if rect.width == 1 {
1651 frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652 return;
1653 }
1654
1655 let right = rect.x + rect.width - 1;
1656 let bottom = rect.y + rect.height - 1;
1657 frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658 frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659 for x in rect.x + 1..right {
1660 frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661 }
1662
1663 if rect.height > 1 {
1664 draw_box_bottom(frame, rect, border, charset);
1665 }
1666 for row in rect.y + 1..bottom {
1667 frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668 frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669 }
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673 if rect.width < 2 || rect.height < 2 {
1674 return;
1675 }
1676 let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677 let mut glyph = [0_u8; 4];
1678 let right = rect.x + rect.width - 1;
1679 let bottom = rect.y + rect.height - 1;
1680 frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681 frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682 for x in rect.x + 1..right {
1683 frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684 }
1685}
1686
1687fn draw_line(frame: &mut Frame, x: u16, y: u16, width: u16, spans: &[Span<'_>]) -> u16 {
1688 let right = x.saturating_add(width).min(frame.size().width);
1689 let mut column = x;
1690 for span in spans {
1691 column = frame.put_clipped(column, y, right.saturating_sub(column), span.text, span.style);
1692 if column >= right {
1693 break;
1694 }
1695 }
1696 column
1697}
1698
1699/// Flows `spans` grapheme-exact across the rect like a bare terminal,
1700/// preserving all whitespace and flagging each exactly-filled row boundary
1701/// soft so native selection copies the paragraph as one unbroken line.
1702/// Returns the rows used.
1703fn draw_flowed(frame: &mut Frame, rect: Rect, spans: &[Span<'_>]) -> u16 {
1704 if rect.width == 0 || rect.height == 0 {
1705 return 0;
1706 }
1707 let full_row = rect.x == 0 && rect.width == frame.size().width;
1708 let mut row = 0_u16;
1709 let mut column = 0_u16;
1710 let mut drew_anything = false;
1711
1712 for span in spans {
1713 for grapheme in xutf::graphemes_str(span.text) {
1714 let grapheme_width = visible_width(grapheme);
1715 if grapheme_width == 0 || grapheme_width > rect.width {
1716 continue;
1717 }
1718 if column.saturating_add(grapheme_width) > rect.width {
1719 // Only an exactly-filled row is byte-joinable by autowrap.
1720 if full_row && column == rect.width {
1721 frame.set_soft_wrap(rect.y.saturating_add(row));
1722 }
1723 row += 1;
1724 column = 0;
1725 }
1726 if row >= rect.height {
1727 return rect.height;
1728 }
1729 frame.put(rect.x + column, rect.y + row, grapheme, span.style);
1730 column += grapheme_width;
1731 drew_anything = true;
1732 }
1733 }
1734
1735 if drew_anything { row + 1 } else { 0 }
1736}More examples
307 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308 let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309 let tier = PerfTier::of(viewport.width);
310 if tier != self.tier {
311 self.tier = tier;
312 self.rebuild();
313 }
314 if rows != self.rows {
315 self.rows = rows;
316 // One query row plus the windowed list.
317 self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318 }
319 if self.ui.frame().size().width != viewport.width {
320 self.ui.resize(viewport.width);
321 }
322 Layer { frame: self.ui.frame(), options: &self.options, active: true }
323 }
324
325 /// Applies one surfaced [`UiEvent`] to picker state.
326 fn route(&mut self, event: UiEvent) -> PickerEvent {
327 match event {
328 UiEvent::Cancel => PickerEvent::Close,
329 UiEvent::Changed { value, .. } => value
330 .as_str()
331 .parse()
332 .map_or(PickerEvent::Consumed, PickerEvent::Pick),
333 UiEvent::Highlighted { value, .. } => {
334 self.show_detail(value.as_str().parse().ok());
335 PickerEvent::Consumed
336 },
337 UiEvent::Filtered { query, value, .. } => {
338 let wants_roles = query.starts_with('@');
339 self.query = query;
340 if wants_roles == (self.mode == Mode::Roles) {
341 self.show_detail(value.and_then(|value| value.as_str().parse().ok()));
342 } else {
343 self.mode = if wants_roles {
344 Mode::Roles
345 } else {
346 Mode::Models
347 };
348 self.rebuild();
349 }
350 PickerEvent::Consumed
351 },
352 UiEvent::None | UiEvent::Submit | UiEvent::Pressed(_) => PickerEvent::Consumed,
353 }
354 }
355
356 /// Rebuilds the retained tree for the current mode/tier, reseeding the
357 /// select's query so typing continuity survives the swap.
358 fn rebuild(&mut self) {
359 let width = self.ui.frame().size().width;
360 self.ui = build(self.mode, self.tier, self.current, &self.query, self.rows, width, &self.ctx);
361 let initial = match self.mode {
362 Mode::Models => Some(self.current),
363 // The role list keeps its configured order; detail follows the
364 // first row until the next cursor event.
365 Mode::Roles => ROLES.first().map(|role| role.model),
366 };
367 self.show_detail(initial);
368 }108 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109 let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110 let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111 if rows != self.rows {
112 self.rows = rows;
113 // One query row plus the windowed list.
114 self
115 .ui
116 .set_prop("commands", Prop::H, rows.saturating_add(1));
117 }
118 if self.ui.frame().size().width != width {
119 self.ui = build(&self.query, self.rows, width, &self.ctx);
120 }
121 self.options = self.options.width(Dim::Cells(width));
122 Layer { frame: self.ui.frame(), options: &self.options, active: true }
123 }42async fn main() -> io::Result<()> {
43 let path = std::env::args()
44 .nth(1)
45 .unwrap_or_else(|| "example.tml".into());
46 let source = std::fs::read_to_string(&path)?;
47
48 let mut ctx = None;
49 let mut app = AppOptions::new()
50 .quit([Key::Ctrl('c'), Key::Char('q'), Key::Esc])
51 .start(|env| {
52 let ui = build(&source, env.viewport.width, &env.ctx);
53 ctx = Some(env.ctx);
54 ui
55 })
56 .await?;
57 let ctx = ctx.expect("start ran the builder");
58
59 let handle = app.handle();
60 tokio::spawn(async move {
61 let mut seen = modified(path.as_ref());
62 loop {
63 tokio::time::sleep(Duration::from_millis(150)).await;
64 let stamp = modified(path.as_ref());
65 if stamp == seen {
66 continue;
67 }
68 seen = stamp;
69 let Ok(source) = std::fs::read_to_string(&path) else {
70 continue;
71 };
72 let ctx = ctx.clone();
73 handle.update(move |ui| *ui = build(&source, ui.frame().size().width, &ctx));
74 }
75 });
76
77 while app.next().await?.is_some() {}
78 Ok(())
79}720fn close_overlay(
721 terminal: &mut Terminal,
722 renderer: &mut Renderer<TtyOut>,
723 demo: &mut Demo,
724 sidebar: &mut Sidebar,
725 viewport: Size,
726 elapsed: Duration,
727 overlay_stale: &mut bool,
728 resize: &mut Option<ResizeState>,
729) -> io::Result<()> {
730 *resize = None;
731 let rendered = demo.render(viewport);
732 let layers = rail_layers(sidebar, viewport, elapsed);
733 if *overlay_stale {
734 *overlay_stale = false;
735 let alt_exit = terminal.stage_alt_leave().unwrap_or("");
736 renderer.rebuild(rendered.frame.clone(), viewport.height, rendered.stable_rows, alt_exit)?;
737 if !layers.is_empty() {
738 // The rebuild repainted the raw document; recomposite the rail
739 // without touching the fresh history.
740 renderer.present_overlaid(
741 rendered.frame,
742 &[],
743 viewport.height,
744 rendered.stable_rows,
745 &layers,
746 )?;
747 }
748 } else {
749 terminal.leave_alt()?;
750 renderer.present_overlaid(
751 rendered.frame,
752 &[(0, rendered.frame.size().height)],
753 viewport.height,
754 rendered.stable_rows,
755 &layers,
756 )?;
757 }
758 Ok(())
759}203 pub fn render(&mut self, viewport: Size, elapsed: Duration) -> &Frame {
204 if self.frame.size() != viewport {
205 self.frame = Frame::new(viewport);
206 self.backdrop_frame = Frame::new(viewport);
207 self.backdrop_at = None;
208 }
209 let clock = elapsed;
210 let elapsed = elapsed.as_secs_f32();
211 // Exponential pointer chase, frame-rate independent (~100ms lag).
212 let delta = (elapsed - self.last_elapsed).max(0.0);
213 self.last_elapsed = elapsed;
214 let response = 1.0 - (-delta * 10.0).exp();
215 self.camera.0 += (self.camera_target.0 - self.camera.0) * response;
216 self.camera.1 += (self.camera_target.1 - self.camera.1) * response;
217 self.draw_backdrop(viewport, clock, elapsed);
218
219 let logo_interval = ambient_interval(clock, LOGO_IDLE_INTERVAL);
220 if self
221 .logo_at
222 .is_none_or(|rendered_at| clock.saturating_sub(rendered_at) >= logo_interval)
223 {
224 self.logo = logo_cells(elapsed, self.camera);
225 self.logo_at = Some(clock);
226 }
227 let cols = if viewport.width >= CARD_COLS && viewport.height >= CARD_ROWS {
228 Some(CARD_COLS)
229 } else if viewport.width >= SMOL_COLS && viewport.height >= CARD_ROWS {
230 Some(SMOL_COLS)
231 } else {
232 None
233 };
234 let Some(cols) = cols else {
235 let left = viewport.width.saturating_sub(LOGO_COLS as u16) / 2;
236 let top = viewport.height.saturating_sub(LOGO_ROWS as u16) / 2;
237 self.logo_origin = (left, top);
238 blit_logo(&mut self.frame, &self.logo, left, top, PLATE);
239 return &self.frame;
240 };
241
242 let left = (viewport.width - cols) / 2;
243 let top = (viewport.height - CARD_ROWS) / 2;
244 let hovered = self.pointer.is_some_and(|(x, y)| {
245 (left..left + cols).contains(&x) && (top..top + CARD_ROWS).contains(&y)
246 });
247 self
248 .hover
249 .retarget(clock, if hovered { 1.0 } else { 0.0 }, HOVER_EASE, Easing::EaseOut);
250 let hover = self.hover.sample(clock).clamp(0.0, 1.0);
251 self.draw_card(cols, left, top, elapsed, hover);
252 &self.frame
253 }Sourcepub const fn set_cursor(&mut self, x: u16, y: u16)
pub const fn set_cursor(&mut self, x: u16, y: u16)
Places the terminal’s hardware cursor at a document cell.
The renderer hides the cursor when this cell falls outside the live viewport.
Examples found in repository?
428 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
429 pc.hits
430 .push(Hit { rect, slot: self.slot, tag: HitTag::Press });
431 let editor = self.editor.borrow();
432 let input_x = rect.x.saturating_add(Self::input_offset());
433 let input_width = Self::input_width(rect.width);
434 let input_height = editor.input_height_for(input_width);
435 let theme = Theme::default();
436 let mut in_comment = false;
437 for (offset, row) in editor.view(input_width).iter().enumerate() {
438 let row_y = rect
439 .y
440 .saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
441 if row_y >= pc.clip {
442 break;
443 }
444 if offset == 0 {
445 pc.frame
446 .put(rect.x, row_y, &Self::input_prompt(pc.ctx.charset), ink(FAINT));
447 }
448 let mut spans: SmallVec<Span<'_>, 16> = SmallVec::new();
449 if editor.options().xml {
450 let (runs, next) = highlight_xml(row.text, &theme, in_comment);
451 in_comment = next;
452 push_row_spans(&editor, row.text, &runs, &mut spans);
453 } else {
454 push_row_spans(&editor, row.text, &[], &mut spans);
455 }
456 draw_line(pc.frame, input_x, row_y, input_width, &spans);
457 if let Some(cursor_column) = row.cursor_column {
458 if cursor_column >= visible_width(row.text)
459 && let Some(hint) = editor.inline_hint()
460 {
461 let hint_x = input_x.saturating_add(cursor_column).saturating_add(1);
462 let width = input_width.saturating_sub(cursor_column.saturating_add(1));
463 draw_line(pc.frame, hint_x, row_y, width, &[Span::new(
464 hint.as_str(),
465 ink(MUTED).dim(),
466 )]);
467 }
468 pc.frame.set_cursor(
469 input_x
470 .saturating_add(cursor_column)
471 .min(rect.x.saturating_add(rect.width.saturating_sub(2))),
472 row_y,
473 );
474 }
475 }
476 Self::paint_picker(pc, rect, rect.y.saturating_add(input_height), &editor);
477 }Sourcepub fn fill(&mut self, rect: Rect, style: Style)
pub fn fill(&mut self, rect: Rect, style: Style)
Fills a clipped rectangle with styled blanks.
Examples found in repository?
257 fn draw_backdrop(&mut self, viewport: Size, clock: Duration, elapsed: f32) {
258 let interval = ambient_interval(clock, BACKDROP_IDLE_INTERVAL);
259 if self
260 .backdrop_at
261 .is_none_or(|rendered_at| clock.saturating_sub(rendered_at) >= interval)
262 {
263 let fade = smooth((elapsed / BACKDROP_FADE).clamp(0.0, 1.0));
264 self
265 .backdrop_frame
266 .fill(Rect::new(0, 0, viewport.width, viewport.height), Style::default());
267 let frame = &mut self.backdrop_frame;
268 let mut buffer = [0_u8; 4];
269 let dim = |color: Color| Color::Rgb(0, 0, 0).lerp(color, fade);
270 self.surface.render(
271 &mut self.backdrop,
272 clock,
273 viewport.width,
274 viewport.height,
275 |x, y, glyph, fg, bg| {
276 let style = Style::new().fg(dim(fg));
277 let style = match bg {
278 Some(bg) => style.bg(dim(bg)),
279 None => style,
280 };
281 frame.put(x, y, glyph.encode_utf8(&mut buffer), style);
282 },
283 );
284 self.backdrop_at = Some(clock);
285 }
286 self.frame.clone_from(&self.backdrop_frame);
287 }
288
289 fn draw_card(&mut self, cols: u16, left: u16, top: u16, elapsed: f32, hover: f32) {
290 let full = cols == CARD_COLS;
291 let logo_left = if full {
292 left + 3
293 } else {
294 left + (cols - LOGO_COLS as u16) / 2
295 };
296 self.logo_origin = (logo_left, top + 2);
297 // Pointer-tracking border glow: the brand gradient sampled by angle
298 // around the card center (the disk's own palette), strongest near
299 // the pointer, scaled by the eased hover amount.
300 let pointer = self.pointer;
301 let center =
302 (f32::from(left) + f32::from(cols) / 2.0, f32::from(top) + f32::from(CARD_ROWS) / 2.0);
303 let edge_at = move |x: u16, y: u16| -> Style {
304 let Some((px, py)) = pointer.filter(|_| hover > 0.02) else {
305 return on_card(CARD_BORDER);
306 };
307 let dx = (f32::from(x) - f32::from(px)) * 0.5;
308 let dy = f32::from(y) - f32::from(py);
309 let glow = hover * (-(dx * dx + dy * dy) / 34.0).exp();
310 if glow < 0.02 {
311 return on_card(CARD_BORDER);
312 }
313 let angle = (f32::from(y) - center.1).atan2((f32::from(x) - center.0) * 0.5);
314 let brand = vec3_color(gradient(angle - elapsed * 0.5));
315 on_card(CARD_BORDER.lerp(brand, glow))
316 };
317 let frame = &mut self.frame;
318 frame.fill(Rect::new(left, top, cols, CARD_ROWS), on_card(TEXT));
319
320 let right = left + cols - 1;
321 let bottom = top + CARD_ROWS - 1;
322 let divider = bottom - 2;
323 let (tl, tr, bl, br, horizontal, vertical) = self.charset.border(Border::Round);
324 let grid = self.charset.grid();
325 let mut glyph = [0_u8; 4];
326 frame.put(left, top, tl.encode_utf8(&mut glyph), edge_at(left, top));
327 frame.put(right, top, tr.encode_utf8(&mut glyph), edge_at(right, top));
328 frame.put(left, divider, grid.middle.0.encode_utf8(&mut glyph), edge_at(left, divider));
329 frame.put(right, divider, grid.middle.2.encode_utf8(&mut glyph), edge_at(right, divider));
330 frame.put(left, bottom, bl.encode_utf8(&mut glyph), edge_at(left, bottom));
331 frame.put(right, bottom, br.encode_utf8(&mut glyph), edge_at(right, bottom));
332 for x in left + 1..right {
333 frame.put(x, top, horizontal.encode_utf8(&mut glyph), edge_at(x, top));
334 frame.put(x, divider, horizontal.encode_utf8(&mut glyph), edge_at(x, divider));
335 frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), edge_at(x, bottom));
336 }
337 for y in top + 1..bottom {
338 if y != divider {
339 frame.put(left, y, vertical.encode_utf8(&mut glyph), edge_at(left, y));
340 frame.put(right, y, vertical.encode_utf8(&mut glyph), edge_at(right, y));
341 }
342 }
343
344 frame.put(left + 2, top, self.title.as_str(), on_card(TEXT_STRONG));
345 if full {
346 frame.put(left + 34, top, " SESSION ORBIT ", on_card(FAINT));
347 draw_dust(frame, left, top, elapsed);
348 draw_sessions(frame, left, top, self.charset);
349 draw_beam(frame, left, top, elapsed);
350 }
351
352 blit_logo(frame, &self.logo, logo_left, top + 2, CARD_BG);
353
354 let footer = divider + 1;
355 frame.fill(Rect::new(left + 1, footer, cols - 2, 1), on_footer(TEXT));
356 if full {
357 frame.put(left + 3, divider, " SHORTCUTS ", on_card(FAINT));
358 let dot = fmts!(" {} ", self.charset.icon(Icon::Enabled));
359 let x = frame.put(left + cols - 21, top, &dot, on_card(GREEN));
360 frame.put(x, top, "rust-analyzer ", on_card(MUTED));
361 draw_full_hints(frame, left, footer);
362 } else {
363 draw_smol_hints(frame, left, cols, footer);
364 }
365 }
366}
367
368impl Default for Welcome {
369 fn default() -> Self {
370 Self::new(Charset::NerdFont)
371 }
372}
373
374fn draw_dust(frame: &mut Frame, left: u16, top: u16, elapsed: f32) {
375 for &(x, y, offset) in &DUST {
376 let pulse = 0.5 + 0.5 * (elapsed * 1.4 + offset).sin();
377 let color = FAINT.lerp(CYAN, pulse * 0.28);
378 frame.put(left + x, top + y, "·", on_card(color));
379 }
380 frame.put(left + 1, top + 7, HORIZON, on_card(FAINT.lerp(INDIGO, 0.16)));
381 frame.put(left + 14, top + 1, "+Z", on_card(FAINT));
382}
383
384fn draw_beam(frame: &mut Frame, left: u16, top: u16, elapsed: f32) {
385 let phase = (elapsed * 9.0) as usize % BEAM.len();
386 for (index, &(x, y, glyph)) in BEAM.iter().enumerate() {
387 let direct = index.abs_diff(phase);
388 let distance = direct.min(BEAM.len() - direct);
389 let color = match distance {
390 0 => TEXT_STRONG,
391 1 => CYAN,
392 _ => FAINT.lerp(INDIGO, 0.34),
393 };
394 frame.put(left + x, top + y, glyph, on_card(color));
395 }
396}
397
398fn draw_sessions(frame: &mut Frame, left: u16, top: u16, charset: Charset) {
399 let (_, _, _, _, _, vertical) = charset.border(Border::Round);
400 let mut glyph = [0_u8; 4];
401 let panel_x = left + 36;
402 frame.put(panel_x, top + 2, "RECENT SESSIONS", on_card(MUTED));
403 frame.put(left + CARD_COLS - 14, top + 2, "4 / LOCAL", on_card(FAINT));
404 for y in top + 4..=top + 10 {
405 frame.put(panel_x, y, vertical.encode_utf8(&mut glyph), on_card(FAINT.lerp(INDIGO, 0.18)));
406 }
407 for (index, (label, age)) in SESSIONS.iter().enumerate() {
408 let y = top + 4 + index as u16 * 2;
409 if index == 0 {
410 frame.fill(Rect::new(panel_x - 2, y, CARD_COLS - 35, 1), on_selected(TEXT));
411 frame.put(panel_x - 2, y, charset.rail(), on_selected(GREEN));
412 frame.put(panel_x, y, charset.radio(true), on_selected(GREEN));
413 frame.put(panel_x + 2, y, age, on_selected(GREEN));
414 frame.put(panel_x + 7, y, label, on_selected(TEXT_STRONG));
415 } else {
416 frame.put(panel_x, y, charset.radio(false), on_card(FAINT));
417 frame.put(panel_x + 2, y, age, on_card(FAINT));
418 frame.put(panel_x + 7, y, label, on_card(MUTED));
419 }
420 }
421}More examples
68async fn run<'a>(
69 terminal: &'a mut Terminal,
70 renderer: &'a mut Renderer<TtyOut>,
71 charset: Charset,
72) -> io::Result<()> {
73 let started = Instant::now();
74 let mut viewport = terminal.size()?;
75 let mut scroll: u16 = 0;
76 let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
77 loop {
78 tokio::select! {
79 event = terminal.next() => match event? {
80 TerminalEvent::Input(event) => {
81 match event {
82 InputEvent::Key(key) => match key {
83 Key::Char('q') | Key::Esc | Key::Ctrl('c') => return Ok(()),
84 Key::Up | Key::Char('k') => scroll = scroll.saturating_sub(1),
85 Key::Down | Key::Char('j') => scroll = scroll.saturating_add(1),
86 Key::PageUp => scroll = scroll.saturating_sub(viewport.height),
87 Key::PageDown => scroll = scroll.saturating_add(viewport.height),
88 Key::Home => scroll = 0,
89 Key::End => scroll = u16::MAX,
90 _ => {},
91 },
92 InputEvent::Mouse(report) => match report.kind {
93 Mouse::WheelUp => scroll = scroll.saturating_sub(2),
94 Mouse::WheelDown => scroll = scroll.saturating_add(2),
95 _ => {},
96 },
97 InputEvent::Paste(_) | InputEvent::Focus(_) | InputEvent::Response(_) => {},
98 }
99 terminal.sync_renderer(renderer)?;
100 },
101 TerminalEvent::Resize => {
102 if let Some(size) = terminal.take_resize()? {
103 viewport = size;
104 }
105 },
106 TerminalEvent::Debug(_) => {},
107 TerminalEvent::Closed => return Ok(()),
108 },
109 () = tokio::time::sleep(FRAME_INTERVAL) => {},
110 }
111 if viewport.width == 0 || viewport.height == 0 {
112 continue;
113 }
114 let scene = Scene { charset, width: viewport.width, elapsed: started.elapsed() };
115 let document = compose(&scene);
116 scroll = scroll.min(document.size().height.saturating_sub(viewport.height));
117 let mut screen = Frame::new(viewport);
118 screen.fill(Rect::new(0, 0, viewport.width, viewport.height), ink(TEXT));
119 screen.blit(&document, scroll, viewport.height, 0, 0);
120 renderer.preview(&screen, viewport.height, alt_enter.take().as_deref().unwrap_or(""))?;
121 }
122}
123
124/// Per-frame paint inputs shared by every study.
125struct Scene {
126 charset: Charset,
127 width: u16,
128 elapsed: Duration,
129}
130
131impl Scene {
132 const fn spinner(&self) -> &'static str {
133 self.charset.spinner().at(self.elapsed)
134 }
135
136 fn timer(&self) -> Str {
137 let seconds = self.elapsed.as_secs();
138 if seconds < 60 {
139 fmts!("{seconds}s")
140 } else {
141 fmts!("{}m", seconds / 60)
142 }
143 }
144
145 const fn right_edge(&self) -> u16 {
146 self.width.saturating_sub(1)
147 }
148}
149
150/// One study: a header line and a `rows`-tall live mock beneath it.
151struct Study {
152 title: &'static str,
153 note: &'static str,
154 rows: u16,
155 draw: fn(&mut Frame, u16, &Scene),
156}
157
158const STUDIES: [Study; 6] = [
159 Study {
160 title: "pi parity",
161 note: "border carries the band left and the title right; the intent rides its own spinner \
162 row",
163 rows: 4,
164 draw: study_pi_parity,
165 },
166 Study {
167 title: "gap title",
168 note: "the air row earns its keep — session title idles right-aligned in the gap",
169 rows: 4,
170 draw: study_gap_title,
171 },
172 Study {
173 title: "band title",
174 note: "title as the left band's second segment; session facts dock right",
175 rows: 4,
176 draw: study_band_title,
177 },
178 Study {
179 title: "prompt title",
180 note: "split + air untouched; the title rests on the prompt row and yields while typing",
181 rows: 4,
182 draw: study_prompt_title,
183 },
184 Study {
185 title: "crown",
186 note: "title crowns the whole block; narration, gap, and split bands breathe below it",
187 rows: 5,
188 draw: study_crown,
189 },
190 Study {
191 title: "hem",
192 note: "title stitched into the top border, band into the bottom hem",
193 rows: 4,
194 draw: study_hem,
195 },
196];
197
198/// Renders the full document — title block plus every study — at width.
199fn compose(scene: &Scene) -> Frame {
200 let height = STUDIES
201 .iter()
202 .map(|study| study.rows + 3)
203 .fold(3_u16, u16::saturating_add);
204 let mut frame = Frame::new(Size::new(scene.width, height));
205 frame.fill(Rect::new(0, 0, scene.width, height), ink(TEXT));
206
207 let column = frame.put(1, 0, "composer footer studies", ink(TEXT).bold());
208 frame.put(
209 column.saturating_add(2),
210 0,
211 "split + air gap, six session-title placements",
212 ink(MUTED),
213 );
214 frame.put(1, 1, "↑/↓ scroll · PgUp/PgDn page · Home/End jump · q quits", ink(FAINT));
215
216 let mut y = 3_u16;
217 for (index, study) in STUDIES.iter().enumerate() {
218 let number = fmts!("{:>2} ", index + 1);
219 let mut column = frame.put(1, y, &number, ink(GOLD).bold());
220 column = frame.put(column, y, study.title, ink(TEXT).bold());
221 column = frame.put(column, y, " ", ink(FAINT));
222 frame.put(column, y, study.note, ink(MUTED));
223 (study.draw)(&mut frame, y + 1, scene);
224 y = y.saturating_add(study.rows + 3);
225 }
226 frame
227}1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }
1292
1293 /// Paints the n-th scripted message and returns rows used including
1294 /// the trailing blank. Measurement draws into a scratch frame.
1295 fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296 let margin = u16::from(width >= 50);
1297 let content_width = width.saturating_sub(margin * 2);
1298 if message == 2 {
1299 draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300 return EDIT_BOX_HEIGHT + 1;
1301 }
1302 let bottom = frame.size().height;
1303 let spans = Self::message_spans(message);
1304 // Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305 // wrapped row re-joins byte-for-byte in native selection.
1306 let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307 used.saturating_add(1)
1308 }
1309
1310 fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311 let mut spans = SmallVec::new();
1312 match message {
1313 0 => {
1314 spans.push(Span::new("Transcript rows are ", prose_style()));
1315 spans.push(Span::new("append-only", code_style()));
1316 spans.push(Span::new(
1317 ": every line is painted once, becomes stable, and rides into native scrollback \
1318 with any selection anchored to it.",
1319 prose_style(),
1320 ));
1321 },
1322 1 => {
1323 spans.push(Span::new(
1324 "Only the bottom band repaints in place — the live shard panel, the activity \
1325 shimmer, and the composer. Rows above it are never rewritten.",
1326 prose_style(),
1327 ));
1328 },
1329 _ => {
1330 spans.push(Span::new(
1331 "On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332 the stable transcript through a ",
1333 prose_style(),
1334 ));
1335 spans.push(Span::new("DECSTBM top region", code_style()));
1336 spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337 },
1338 }
1339 spans
1340 }
1341
1342 /// Appends a finished shard's permanent one-line result.
1343 fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344 let margin = u16::from(width >= 50);
1345 let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346 let detail = fmts!(" workspace-{shard:03}.test.ts [100%]");
1347 draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348 Span::new(prefix.as_str(), ink(GREEN)),
1349 Span::new(detail.as_str(), ink(MUTED)),
1350 ]);
1351 }
1352
1353 /// Shimmering activity line above the editor. The spinner and timer
1354 /// live in the status bar's brand segment; this row only narrates.
1355 fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356 if y >= frame.size().height || frame.size().width < 4 {
1357 return;
1358 }
1359 let start = u16::from(frame.size().width >= 50);
1360 let mut column = start;
1361 let length = xutf::graphemes_str(WORKING_MESSAGE)
1362 .count()
1363 .saturating_add(xutf::graphemes_str(hint).count())
1364 .saturating_add(1);
1365 let length = u16::try_from(length).unwrap_or(u16::MAX);
1366 let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367 let right = frame.size().width.saturating_sub(1);
1368 draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369 draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370 draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371 }
1372
1373 /// The session title resting right-aligned in the air row between
1374 /// the working narration and the status band — against the visible
1375 /// right bound, inside any rail reservation — so the gap reads as
1376 /// session identity instead of dead space.
1377 fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378 let width = frame.size().width.saturating_sub(right_inset);
1379 let title_width = visible_width(SESSION_TITLE);
1380 if y >= frame.size().height || width < title_width.saturating_add(2) {
1381 return;
1382 }
1383 let x = width.saturating_sub(title_width.saturating_add(1));
1384 draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385 }
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391 if rect.width < 4 || rect.height < 4 {
1392 return;
1393 }
1394
1395 let content_x = rect.x + 2;
1396 let content_width = rect.width.saturating_sub(4);
1397 let header = [
1398 Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399 Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400 ];
1401 draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402 let command = [
1403 Span::new("$ ", panel_ink(MUTED)),
1404 Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405 Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406 ];
1407 draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632 u16::try_from(
1633 text
1634 .bytes()
1635 .filter(|byte| *byte == b'\n')
1636 .count()
1637 .saturating_add(1),
1638 )
1639 .unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644 if rect.width == 0 || rect.height == 0 {
1645 return;
1646 }
1647 let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648 frame.fill(rect, fill);
1649 let mut glyph = [0_u8; 4];
1650 if rect.width == 1 {
1651 frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652 return;
1653 }
1654
1655 let right = rect.x + rect.width - 1;
1656 let bottom = rect.y + rect.height - 1;
1657 frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658 frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659 for x in rect.x + 1..right {
1660 frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661 }
1662
1663 if rect.height > 1 {
1664 draw_box_bottom(frame, rect, border, charset);
1665 }
1666 for row in rect.y + 1..bottom {
1667 frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668 frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669 }
1670}Sourcepub fn underlay(&mut self, rect: Rect, color: Color)
pub fn underlay(&mut self, rect: Rect, color: Color)
Paints color behind a clipped rectangle: cells still on the
terminal’s default background adopt it, cells that named their own
keep it. Runs after a subtree paints, so glyph styles — which
replace the whole cell — never punch holes in a container’s fill.
Sourcepub fn put_image_cell(
&mut self,
x: u16,
y: u16,
id: u32,
row: u16,
col: u16,
rows: u16,
cols: u16,
)
pub fn put_image_cell( &mut self, x: u16, y: u16, id: u32, row: u16, col: u16, rows: u16, cols: u16, )
Places one typed Kitty image cell.
Sourcepub fn put(&mut self, x: u16, y: u16, text: &str, style: Style) -> u16
pub fn put(&mut self, x: u16, y: u16, text: &str, style: Style) -> u16
Draws printable graphemes until a newline or the right frame edge.
Control characters are ignored. A wide grapheme that would be clipped is omitted rather than leaving a half-cell artifact.
Examples found in repository?
97fn draw_shimmer(
98 frame: &mut Frame,
99 column: &mut u16,
100 start: u16,
101 y: u16,
102 right: u16,
103 text: &str,
104 shimmer: Shimmer,
105 high: Style,
106) {
107 for grapheme in xutf::graphemes_str(text) {
108 if *column >= right {
109 return;
110 }
111 let style = shimmer.pick(*column - start, ink(FAINT), ink(MUTED), high);
112 let next = frame.put(*column, y, grapheme, style);
113 if next == *column {
114 return;
115 }
116 *column = next;
117 }
118}
119
120fn elapsed_label(elapsed: Duration) -> Str {
121 let seconds = elapsed.as_secs();
122 if seconds < 60 {
123 fmts!("{seconds}s")
124 } else if seconds < 3_600 {
125 fmts!("{}m", seconds / 60)
126 } else {
127 fmts!("{}h", (seconds / 3_600).min(99))
128 }
129}
130
131/// The chat demo's slash-command palette. Editor completion is generic;
132/// this list is the demo application's own — the `Ctrl+K` command palette
133/// surfaces the same entries.
134pub fn demo_commands() -> Vec<Command> {
135 vec![
136 Command::new(
137 "security",
138 "Plan, run, inspect, import, and compare OMP-native security scans",
139 &[],
140 )
141 .with_args(&[
142 ("plan", "Draft a scan plan for this workspace", "[focus]"),
143 ("run", "Execute the current scan plan", ""),
144 ("inspect", "Browse findings from the last scan", "[finding-id]"),
145 ("import", "Import an external scan report", "<path>"),
146 ("compare", "Diff two scan runs", "<run-a> <run-b>"),
147 ])
148 .with_hint("plan|run|inspect|import|compare"),
149 Command::new("attach", "Stage an image attachment on the composer", &[]).with_hint("<path>"),
150 Command::new("settings", "Open settings menu", &[]),
151 Command::new("setup", "Open provider setup", &["providers"]).with_hint("[provider]"),
152 Command::new("plan", "Toggle plan mode (agent plans before executing)", &[]),
153 Command::new("plan-review", "Re-open the plan review for the latest plan", &[]),
154 Command::new("vibe", "Toggle persistent fast worker sessions", &[]),
155 Command::new("goal", "Toggle an autonomous objective for this session", &[])
156 .with_hint("<objective>"),
157 Command::new("guided-goal", "Interview you in chat, then set up goal mode", &[]),
158 Command::new("queue", "Queue a message for after the agent yields", &[]),
159 Command::new("switch", "Switch model for this session (same as alt+p)", &[]),
160 Command::new("fast", "Toggle priority service tier", &[]),
161 Command::new("computer", "Toggle the native computer-use tool", &[]),
162 Command::new("vision", "Control inspect_image vision delegation", &[]),
163 Command::new("prewalk", "Switch to a fast model at the next action", &[]),
164 Command::new("advisor", "Toggle the second-model advisor", &[]),
165 Command::new("export", "Export session to an HTML file", &[]),
166 Command::new("dump", "Copy the session transcript to clipboard", &[]),
167 Command::new("share", "Share session via an encrypted link", &[]),
168 Command::new("collab", "Share this session live via a relay", &[]),
169 Command::new("join", "Join a shared collab session", &[]),
170 Command::new("leave", "Leave the collab session", &[]),
171 Command::new("browser", "Toggle browser headless vs visible mode", &[]),
172 Command::new("copy", "Pick conversation text or code to copy", &[]),
173 Command::new("todo", "View or modify the agent's todo list", &[]),
174 Command::new("session", "Session management commands", &[]),
175 Command::new("jobs", "Show async background jobs status", &[]),
176 Command::new("usage", "Show provider usage and limits", &[]),
177 Command::new("stats", "Launch the local stats dashboard", &[]),
178 Command::new("changelog", "Show changelog entries", &[]),
179 Command::new("hotkeys", "Show all keyboard shortcuts", &[]),
180 Command::new("tools", "Show tools currently visible to the agent", &[]),
181 Command::new("context", "Show estimated context usage breakdown", &[]),
182 Command::new("agents", "Open Agent Control Center dashboard", &[]),
183 Command::new("branch", "Create a new branch from a previous message", &[]),
184 Command::new("fork", "Create a new fork from a previous message", &[]),
185 Command::new("tree", "Navigate the session tree", &[]),
186 Command::new("login", "Login with an OAuth provider", &[]),
187 Command::new("logout", "Logout from an OAuth provider", &[]),
188 Command::new("mcp", "Manage MCP servers", &[]),
189 Command::new("ssh", "Manage SSH hosts", &[]),
190 Command::new("new", "Start a new session", &[]),
191 Command::new("fresh", "Reset provider state without changing the transcript", &[]),
192 Command::new("clear", "Clear conversation context, keeping the session", &[]),
193 Command::new("drop", "Delete the current session and start a new one", &[]),
194 Command::new("compact", "Manually compact the session context", &[]),
195 Command::new("shake", "Drop heavy content from context", &[]),
196 Command::new("handoff", "Hand off context to a new session", &[]),
197 Command::new("resume", "Resume a different session", &[]),
198 Command::new("btw", "Ask an ephemeral side question", &[]),
199 Command::new("tan", "Run a background agent on tangential work", &[]),
200 Command::new("omfg", "Forge a rule from a recurring complaint", &[]),
201 Command::new("retry", "Retry the last failed agent turn", &[]),
202 Command::new("debug", "Open the debug tools selector", &[]),
203 Command::new("memory", "Inspect and operate memory maintenance", &[]),
204 Command::new("rename", "Rename the current session", &[]),
205 Command::new("move", "Move the session to a different directory", &[]),
206 Command::new("add-dir", "Add a workspace directory", &[]),
207 Command::new("remove-dir", "Remove a workspace directory", &[]),
208 Command::new("dirs", "List this session's workspace directories", &[]),
209 Command::new("marketplace", "Manage marketplace plugins", &[]),
210 Command::new("plugins", "View and manage installed plugins", &[]),
211 Command::new("reload-plugins", "Reload skills, commands, hooks, tools, and agents", &[]),
212 Command::new("force", "Force the next turn to use a specific tool", &["force:"]),
213 Command::new("live", "Start Codex-backed realtime voice mode", &[]),
214 Command::new("pause", "Freeze all agents until resumed", &[]),
215 Command::new("quit", "Quit the application", &["q"]),
216 ]
217}
218
219/// A submitted message rendered as Markdown (with embedded markup), cached
220/// until the text or the content width changes.
221struct Submission {
222 text: String,
223 width: u16,
224 /// `None` when the text can't be a markdown document (a literal
225 /// `</md>`, or embedded interactive markup) — painted verbatim instead.
226 view: Option<Ui>,
227}
228
229impl Submission {
230 fn new(text: String, width: u16, ctx: &UiContext) -> Self {
231 let view = Self::view(&text, width, ctx);
232 Self { text, width, view }
233 }
234
235 fn view(text: &str, width: u16, ctx: &UiContext) -> Option<Ui> {
236 (!text.contains("</md>") && next_ref_tag(text).is_none())
237 .then(|| Ui::from_markup(format!("<md>{text}</md>"), width, ctx.clone()).ok())
238 .flatten()
239 }
240
241 fn resize(&mut self, width: u16, ctx: &UiContext) {
242 if self.width != width {
243 self.width = width;
244 self.view = Self::view(&self.text, width, ctx);
245 }
246 }
247
248 /// Rendered row count, including the fallback's own line count.
249 fn height(&self) -> u16 {
250 self
251 .view
252 .as_ref()
253 .map_or_else(|| explicit_line_count(&self.text), Ui::height)
254 }
255}
256
257/// One append-only transcript entry. The log is retained so a geometry
258/// rebuild can replay every entry at the new width; between rebuilds each
259/// entry is measured and painted exactly once, then never touched again.
260enum Entry {
261 /// The closed command box that opens the session.
262 Command,
263 /// The n-th scripted narration message.
264 Message(usize),
265 /// A finished shard's permanent result line.
266 ShardDone(u16),
267 /// A message submitted through the composer.
268 Submitted(Box<Submission>),
269}
270
271/// Demo-specific focused editor leaf with completion and syntax rendering.
272struct DemoInput {
273 props: Props,
274 slot: Slot,
275 editor: Rc<RefCell<Editor>>,
276 outcome: Rc<RefCell<Option<EditOutcome>>>,
277}
278
279impl DemoInput {
280 fn new(editor: Rc<RefCell<Editor>>, outcome: Rc<RefCell<Option<EditOutcome>>>) -> Self {
281 Self { props: Props::new(), slot: next_slot(), editor, outcome }
282 }
283
284 /// Cells before the editor text: the two-cell prompt plus a gap —
285 /// identical on every tier.
286 const fn input_offset() -> u16 {
287 3
288 }
289
290 /// The `╰─` composer prompt composed from the tier's round border.
291 fn input_prompt(charset: Charset) -> Str {
292 let (_, _, bl, _, horizontal, _) = charset.border(Border::Round);
293 fmts!("{bl}{horizontal}")
294 }
295
296 const fn input_width(width: u16) -> u16 {
297 width.saturating_sub(Self::input_offset()).saturating_sub(1)
298 }
299
300 fn paint_picker(pc: &mut PaintCtx<'_>, rect: Rect, y: u16, editor: &Editor) {
301 let Some(picker) = editor.picker() else {
302 return;
303 };
304 let (start, suggestions) = picker.visible_suggestions();
305 let overflow = picker.len() > suggestions.len();
306 let row_right = rect
307 .x
308 .saturating_add(rect.width.saturating_sub(u16::from(overflow)));
309 let primary_width = suggestions
310 .iter()
311 .filter_map(|suggestion| match suggestion.display() {
312 SuggestionDisplay::Text(name) => Some(visible_width(name).saturating_add(2)),
313 SuggestionDisplay::Emoji { .. } => None,
314 })
315 .max()
316 .unwrap_or(12)
317 .clamp(12, 32);
318
319 for (offset, suggestion) in suggestions.iter().enumerate() {
320 let Ok(offset) = u16::try_from(offset) else {
321 break;
322 };
323 let row = y.saturating_add(offset);
324 if row >= pc.clip {
325 break;
326 }
327 let selected = start + usize::from(offset) == picker.selected();
328 let label = if selected { ink(GREEN) } else { ink(TEXT) };
329 let description = if selected { ink(GREEN) } else { ink(MUTED) };
330 pc.frame.put(
331 rect.x,
332 row,
333 if selected {
334 pc.ctx.charset.cursor()
335 } else {
336 " "
337 },
338 label,
339 );
340 match suggestion.display() {
341 SuggestionDisplay::Text(name) => {
342 draw_line(
343 pc.frame,
344 rect.x.saturating_add(2),
345 row,
346 row_right.saturating_sub(rect.x.saturating_add(2)),
347 &[Span::new(name, label)],
348 );
349 if let Some(text) = suggestion.description()
350 && rect.width > 40
351 {
352 let description_x = rect
353 .x
354 .saturating_add(2)
355 .saturating_add(primary_width)
356 .min(row_right);
357 draw_line(
358 pc.frame,
359 description_x,
360 row,
361 row_right.saturating_sub(description_x),
362 &[Span::new(text, description)],
363 );
364 }
365 },
366 SuggestionDisplay::Emoji { emoji, shortcode } => {
367 let mut column = pc.frame.put(rect.x.saturating_add(2), row, emoji, label);
368 column = pc.frame.put(column, row, " ", label);
369 if shortcode.starts_with(':') {
370 pc.frame.put(column, row, shortcode, label);
371 } else {
372 column = pc.frame.put(column, row, ":", label);
373 column = pc.frame.put(column, row, shortcode, label);
374 pc.frame.put(column, row, ":", label);
375 }
376 },
377 }
378 }
379
380 if overflow && !suggestions.is_empty() {
381 let (track, thumb_glyph) = pc.ctx.charset.scrollbar();
382 let track_x = rect.x.saturating_add(rect.width.saturating_sub(1));
383 for offset in 0..suggestions.len() {
384 let Ok(offset) = u16::try_from(offset) else {
385 break;
386 };
387 pc.frame
388 .put(track_x, y.saturating_add(offset), track, ink(FAINT));
389 }
390 let thumb = picker
391 .selected()
392 .saturating_mul(suggestions.len().saturating_sub(1))
393 / picker.len().saturating_sub(1);
394 pc.frame.put(
395 track_x,
396 y.saturating_add(u16::try_from(thumb).unwrap_or(u16::MAX)),
397 thumb_glyph,
398 ink(GREEN),
399 );
400 }
401 }
402}
403
404impl Component for DemoInput {
405 fn props(&self) -> &Props {
406 &self.props
407 }
408
409 fn props_mut(&mut self) -> &mut Props {
410 &mut self.props
411 }
412
413 fn slot(&self) -> Slot {
414 self.slot
415 }
416
417 fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
418 (6, 40)
419 }
420
421 fn height(&mut self, _ctx: &UiContext, width: u16) -> u16 {
422 let editor = self.editor.borrow();
423 editor
424 .input_height_for(Self::input_width(width))
425 .saturating_add(editor.picker_height())
426 }
427
428 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
429 pc.hits
430 .push(Hit { rect, slot: self.slot, tag: HitTag::Press });
431 let editor = self.editor.borrow();
432 let input_x = rect.x.saturating_add(Self::input_offset());
433 let input_width = Self::input_width(rect.width);
434 let input_height = editor.input_height_for(input_width);
435 let theme = Theme::default();
436 let mut in_comment = false;
437 for (offset, row) in editor.view(input_width).iter().enumerate() {
438 let row_y = rect
439 .y
440 .saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
441 if row_y >= pc.clip {
442 break;
443 }
444 if offset == 0 {
445 pc.frame
446 .put(rect.x, row_y, &Self::input_prompt(pc.ctx.charset), ink(FAINT));
447 }
448 let mut spans: SmallVec<Span<'_>, 16> = SmallVec::new();
449 if editor.options().xml {
450 let (runs, next) = highlight_xml(row.text, &theme, in_comment);
451 in_comment = next;
452 push_row_spans(&editor, row.text, &runs, &mut spans);
453 } else {
454 push_row_spans(&editor, row.text, &[], &mut spans);
455 }
456 draw_line(pc.frame, input_x, row_y, input_width, &spans);
457 if let Some(cursor_column) = row.cursor_column {
458 if cursor_column >= visible_width(row.text)
459 && let Some(hint) = editor.inline_hint()
460 {
461 let hint_x = input_x.saturating_add(cursor_column).saturating_add(1);
462 let width = input_width.saturating_sub(cursor_column.saturating_add(1));
463 draw_line(pc.frame, hint_x, row_y, width, &[Span::new(
464 hint.as_str(),
465 ink(MUTED).dim(),
466 )]);
467 }
468 pc.frame.set_cursor(
469 input_x
470 .saturating_add(cursor_column)
471 .min(rect.x.saturating_add(rect.width.saturating_sub(2))),
472 row_y,
473 );
474 }
475 }
476 Self::paint_picker(pc, rect, rect.y.saturating_add(input_height), &editor);
477 }
478
479 fn focusable(&self) -> bool {
480 true
481 }
482
483 fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
484 let outcome = self.editor.borrow_mut().handle(key);
485 *self.outcome.borrow_mut() = Some(outcome);
486 // The editor owns every key while focused. In particular, an ignored
487 // picker key must not escape into `Ui`'s focus-ring navigation; the
488 // demo applies its quit policy from the recorded `EditOutcome`.
489 Flow::Consumed
490 }
491
492 fn mouse(
493 &mut self,
494 _ec: &mut EventCtx<'_>,
495 _tag: HitTag,
496 at: (u16, u16),
497 rect: Rect,
498 mouse: Mouse,
499 ) -> Flow {
500 let width = Self::input_width(rect.width);
501 match mouse {
502 Mouse::Click => {
503 self.editor.borrow_mut().set_cursor_visual_row(
504 usize::from(at.1.saturating_sub(rect.y)),
505 at.0
506 .saturating_sub(rect.x.saturating_add(Self::input_offset())),
507 width,
508 );
509 Flow::Consumed
510 },
511 Mouse::WheelUp | Mouse::WheelDown => {
512 let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
513 if self
514 .editor
515 .borrow()
516 .scroll_rows(delta, width, usize::from(rect.height))
517 {
518 Flow::Consumed
519 } else {
520 Flow::Skip
521 }
522 },
523 _ => Flow::Skip,
524 }
525 }
526
527 fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
528 if matches!(self.editor.borrow_mut().insert_text(text), EditOutcome::Changed) {
529 Flow::Consumed
530 } else {
531 Flow::Skip
532 }
533 }
534}
535/// Whether the demo is working, and how the status bar's brand segment
536/// blends between its two states.
537struct WorkState {
538 working: bool,
539 /// When the current mode began; the working timer counts from here.
540 since: Duration,
541 /// Brand foreground: [`GREEN`] while working, [`MUTED`] at rest.
542 fade: Tween<Color>,
543}
544
545/// Powerline status split into a left brand group — spinner and session
546/// timer while working, the omp brand at rest, the foreground tweening
547/// between the two so neither swap ever snaps — and a right-docked
548/// session group (branch, context, cost). Panes too narrow for both
549/// groups fall back to one left-anchored band that sheds from the tail.
550struct DemoStatus {
551 props: Props,
552 slot: Slot,
553 work: Rc<RefCell<WorkState>>,
554 model: Rc<RefCell<Str>>,
555 charset: Charset,
556 right: Status,
557}
558
559impl DemoStatus {
560 fn new(work: Rc<RefCell<WorkState>>, model: Rc<RefCell<Str>>, charset: Charset) -> Self {
561 let mut props = Props::new();
562 props.set(Prop::Id, STATUS_ID);
563 let right = Self::right_group(charset);
564 Self { props, slot: next_slot(), work, model, charset, right }
565 }
566
567 /// One styled band-group shell on the shared dark backdrop.
568 fn group() -> Status {
569 Status::new()
570 .with(Prop::Bg, Color::Rgb(18, 18, 18))
571 .with(Prop::Fg, TEXT)
572 }
573
574 /// The brand segment at `now`: spinner plus session timer while
575 /// working, the omp badge at rest, foreground riding the work fade.
576 fn brand_segment(&self, now: Duration) -> Segment {
577 let work = self.work.borrow();
578 let brand = if work.working {
579 fmts!(
580 "{} {}",
581 self.charset.spinner().at(now),
582 elapsed_label(now.saturating_sub(work.since))
583 )
584 } else {
585 fmts!("{} omp", self.charset.icon(Icon::Omp))
586 };
587 Segment::new()
588 .label(brand)
589 .with(Prop::Fg, work.fade.sample(now))
590 }
591
592 fn model_segment(&self) -> Segment {
593 Segment::new()
594 .label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595 .with(Prop::Fg, GREEN)
596 }
597
598 fn git_segment(charset: Charset) -> Segment {
599 Segment::new()
600 .label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601 .with(Prop::Fg, CYAN)
602 }
603
604 fn context_segment(charset: Charset) -> Segment {
605 Segment::new()
606 .label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607 .with(Prop::Fg, GOLD)
608 }
609
610 fn cost_segment() -> Segment {
611 Segment::new()
612 .label("$60.07 (sub) + $8.65 (adv)")
613 .with(Prop::Fg, PURPLE)
614 }
615
616 /// The left band group: brand and model.
617 fn left_group(&self, now: Duration) -> Status {
618 Self::group()
619 .segment(self.brand_segment(now))
620 .segment(self.model_segment())
621 }
622
623 /// The right band group: branch, context, and cost.
624 fn right_group(charset: Charset) -> Status {
625 Self::group()
626 .with_str(Prop::Align, "right")
627 .segment(Self::git_segment(charset))
628 .segment(Self::context_segment(charset))
629 .segment(Self::cost_segment())
630 }
631
632 /// Every segment in one band, for panes too narrow to split.
633 fn combined(&self, now: Duration) -> Status {
634 Self::group()
635 .segment(self.brand_segment(now))
636 .segment(self.model_segment())
637 .segment(Self::git_segment(self.charset))
638 .segment(Self::context_segment(self.charset))
639 .segment(Self::cost_segment())
640 }
641}
642
643impl Component for DemoStatus {
644 fn props(&self) -> &Props {
645 &self.props
646 }
647
648 fn props_mut(&mut self) -> &mut Props {
649 &mut self.props
650 }
651
652 fn slot(&self) -> Slot {
653 self.slot
654 }
655
656 fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657 self.combined(Duration::ZERO).measure(ctx)
658 }
659
660 fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661 1
662 }
663
664 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665 let mut left = self.left_group(pc.now);
666 let (_, left_width) = left.measure(pc.ctx);
667 let (_, right_width) = self.right.measure(pc.ctx);
668 if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669 left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670 let dock = rect
671 .x
672 .saturating_add(rect.width)
673 .saturating_sub(right_width);
674 self
675 .right
676 .paint(pc, Rect::new(dock, rect.y, right_width, 1));
677 } else {
678 let mut combined = self.combined(pc.now);
679 combined.paint(pc, rect);
680 }
681 let work = self.work.borrow();
682 let fade_frame = work
683 .fade
684 .settles_at()
685 .min(pc.now.saturating_add(FADE_FRAME));
686 let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687 (true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688 (true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689 (false, false) => Some(fade_frame),
690 (false, true) => None,
691 };
692 if let Some(at) = deadline {
693 pc.wake(self.slot, at);
694 }
695 }
696
697 fn paints_background(&self) -> bool {
698 false
699 }
700}
701
702/// One retained chat document update and its exact repainted row ranges.
703pub struct RenderedFrame<'a> {
704 pub(crate) frame: &'a Frame,
705 pub(crate) stable_rows: u16,
706 pub(crate) damage: SmallVec<(u16, u16), 4>,
707}
708
709/// Produces the animated transcript, work indicator, editor, and status
710/// line demo.
711pub struct Demo {
712 started_at: Instant,
713 /// Detected presentation context shared by every retained subtree.
714 ctx: UiContext,
715 /// Cancel hint resolved once through the context's charset.
716 cancel_hint: &'static str,
717 editor_ui: Ui,
718 editor: Rc<RefCell<Editor>>,
719 edit_outcome: Rc<RefCell<Option<EditOutcome>>>,
720 work: Rc<RefCell<WorkState>>,
721 last_working: bool,
722 model: Rc<RefCell<Str>>,
723 /// Images staged on the composer, previewed above the status line.
724 attachments: Attachments,
725 /// Append-only transcript log, replayed in full on geometry rebuilds.
726 transcript: Vec<Entry>,
727 /// Entries already painted into the retained frame.
728 drawn_entries: usize,
729 /// Rows covered by the drawn entries; doubles as `stable_rows`.
730 transcript_rows: u16,
731 appended_messages: usize,
732 emitted_shards: u16,
733 last_viewport: Size,
734 height_floor: u16,
735 frame: Frame,
736 /// Geometry of the retained live panel chrome.
737 live_panel: Option<Rect>,
738 /// Reusable text and placement state for the animated shard rows.
739 live_rows: [LiveRowCache; LIVE_SHARD_ROWS as usize],
740 /// One build buffer rotated through the row caches without reallocating.
741 live_label_scratch: StrMut,
742 /// Columns reserved at the right edge for a composited rail; the
743 /// editor and title dock against the remaining visible width.
744 right_inset: u16,
745 /// The composer submitted `/switch`; the host opens the model picker.
746 switch_requested: bool,
747}
748
749impl Demo {
750 /// Starts the demo's animation clock, presenting through the host's
751 /// detected context.
752 pub fn new(ctx: &UiContext) -> Self {
753 let editor = Rc::new(RefCell::new({
754 let mut editor = Editor::new(EditorOptions::default());
755 editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756 editor
757 }));
758 let edit_outcome = Rc::new(RefCell::new(None));
759 let work = Rc::new(RefCell::new(WorkState {
760 working: true,
761 since: Duration::ZERO,
762 fade: Tween::settled(GREEN),
763 }));
764 let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765 let pane = EditorPane::new()
766 .input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767 .status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768 let attachments = pane.attachments();
769 let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770 Self {
771 started_at: Instant::now(),
772 ctx: ctx.clone(),
773 cancel_hint: ctx.charset.icon(Icon::Cancellable),
774 editor_ui,
775 editor,
776 edit_outcome,
777 work,
778 last_working: true,
779 model,
780 attachments,
781 transcript: vec![Entry::Command],
782 drawn_entries: 0,
783 transcript_rows: 0,
784 appended_messages: 0,
785 emitted_shards: 0,
786 last_viewport: Size::new(0, 0),
787 height_floor: 0,
788 frame: Frame::new(Size::new(0, 0)),
789 live_panel: None,
790 live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791 live_label_scratch: StrMut::with_capacity(40),
792 right_inset: 0,
793 switch_requested: false,
794 }
795 }
796
797 /// Routes a key through the editor and reports whether the demo should
798 /// exit. Quit policy lives here, not in the editor: once the editor
799 /// reports a key unused, `esc` first cancels running work and only quits
800 /// at rest; `ctrl-c` always quits.
801 pub fn handle_key(&mut self, key: Key) -> bool {
802 *self.edit_outcome.borrow_mut() = None;
803 let _ = self.editor_ui.handle_key(key);
804 let outcome = self
805 .edit_outcome
806 .borrow_mut()
807 .take()
808 .unwrap_or(EditOutcome::Ignored);
809 match outcome {
810 EditOutcome::Submitted(text) => {
811 let trimmed = text.trim();
812 if trimmed == "/switch" {
813 self.switch_requested = true;
814 return false;
815 }
816 if let Some(path) = trimmed
817 .strip_prefix("/attach")
818 .filter(|rest| rest.is_empty() || rest.starts_with(' '))
819 {
820 let path = path.trim().to_string();
821 if !path.is_empty() {
822 self.attach_image(&path);
823 }
824 return false;
825 }
826 let _ = self.attachments.take();
827 self.refresh_composer();
828 self
829 .transcript
830 .push(Entry::Submitted(Box::new(Submission::new(
831 text,
832 Self::message_width(self.last_viewport.width),
833 &self.ctx,
834 ))));
835 self.set_working(true, self.started_at.elapsed());
836 false
837 },
838 EditOutcome::Changed => {
839 self.reconcile_attachments();
840 false
841 },
842 EditOutcome::Ignored => {
843 if key == Key::Ctrl('c') {
844 return true;
845 }
846 if key != Key::Esc {
847 return false;
848 }
849 if self.work.borrow().working {
850 self.set_working(false, self.started_at.elapsed());
851 return false;
852 }
853 true
854 },
855 }
856 }
857
858 /// Consumes a pending `/switch` request submitted through the composer.
859 pub fn take_switch_request(&mut self) -> bool {
860 std::mem::take(&mut self.switch_requested)
861 }
862
863 /// Routes a document-space mouse report into the editor UI.
864 pub fn handle_mouse(&mut self, report: &MouseReport) {
865 let editor_height = self.editor_ui.height();
866 let editor_y = self.frame.size().height.saturating_sub(editor_height);
867 let editor_bottom = editor_y.saturating_add(editor_height);
868 if report.row < editor_y || report.row >= editor_bottom {
869 return;
870 }
871 let _ = self
872 .editor_ui
873 .handle_mouse(report.col, report.row - editor_y, report.kind);
874 }
875
876 /// Switches the work state and retargets the brand fade. The status bar
877 /// repaints immediately and the fade departs from whatever color is on
878 /// screen, so rapid cancel/resume never snaps.
879 fn set_working(&mut self, working: bool, now: Duration) {
880 {
881 let mut work = self.work.borrow_mut();
882 if work.working == working {
883 return;
884 }
885 work.working = working;
886 work.since = now;
887 let target = if working { GREEN } else { MUTED };
888 work
889 .fade
890 .retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891 }
892 self.editor_ui.invalidate(STATUS_ID);
893 }
894
895 /// Reflects a session model switch in the status bar's model segment.
896 pub fn set_model(&mut self, name: &str) {
897 *self.model.borrow_mut() = Str::from(name);
898 self.editor_ui.invalidate(STATUS_ID);
899 }
900
901 /// Routes sanitized bracketed paste text through the editor. Dropped
902 /// paths to existing image files (quoted, escaped, `file://`, or
903 /// multi-file) and any large paste collapse into composer attachment
904 /// chips instead of raw text.
905 pub fn handle_paste(&mut self, text: &str) {
906 let paths = omp_tui::paste::dropped_paths(text);
907 if !paths.is_empty()
908 && paths.iter().all(|path| {
909 omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910 }) {
911 for path in &paths {
912 self.attach_image(path);
913 }
914 return;
915 }
916 if text.lines().count() > 10 || text.len() > 1000 {
917 self.attach_paste(text);
918 return;
919 }
920 let _ = self.editor_ui.handle_paste(text);
921 }
922
923 /// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924 /// attachment staging, no large-paste collapse — the text stays inline
925 /// and editable.
926 pub fn handle_paste_raw(&mut self, text: &str) {
927 let _ = self.editor_ui.handle_paste_raw(text);
928 }
929
930 /// Stages `path` on the composer and mentions it in the prompt as an
931 /// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932 fn attach_image(&mut self, path: &str) {
933 let attachment = self.attachments.push_image(path);
934 let payload = format!("<ref image={}/>", attachment.marker);
935 self.insert_chip(&attachment, &payload);
936 }
937
938 /// Collapses a large paste into a staged attachment card and an atomic
939 /// composer chip expanding back to the pasted text on submit.
940 fn attach_paste(&mut self, text: &str) {
941 let attachment = self.attachments.push_text(text);
942 self.insert_chip(&attachment, text);
943 }
944
945 /// Inserts one attachment chip as an atomic editor reference.
946 fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947 let chip = chip_label(attachment, self.ctx.charset);
948 {
949 let mut editor = self.editor.borrow_mut();
950 let _ = editor.insert_reference(&chip, payload);
951 let _ = editor.insert_text(" ");
952 }
953 self.refresh_composer();
954 }
955
956 /// Hides staged attachments whose chip the user deleted from the
957 /// composer (and re-shows them after an undo). Presence is derived
958 /// from the buffer's atomic ranges, never from text matching.
959 fn reconcile_attachments(&mut self) {
960 let charset = self.ctx.charset;
961 let changed = {
962 let editor = self.editor.borrow();
963 let text = editor.text();
964 let ranges = editor.atom_ranges();
965 self.attachments.set_visible(|attachment| {
966 let chip = chip_label(attachment, charset);
967 ranges
968 .iter()
969 .any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970 })
971 };
972 if changed {
973 self.refresh_composer();
974 }
975 }
976
977 /// Relayouts the composer after out-of-band state changed its height.
978 fn refresh_composer(&mut self) {
979 let width = self.editor_ui.frame().size().width;
980 if width > 0 {
981 self.editor_ui.resize(width);
982 }
983 }
984
985 /// Reserves `cols` at the right edge for a composited rail, so the
986 /// composer's right-docked chrome stays visible beside it. The next
987 /// render relayouts the editor at the narrowed width.
988 pub const fn set_right_inset(&mut self, cols: u16) {
989 self.right_inset = cols;
990 }
991
992 /// The width the composer may actually occupy at `viewport`.
993 fn composer_width(&self, viewport: Size) -> u16 {
994 viewport.width.saturating_sub(self.right_inset).max(1)
995 }
996
997 /// Updates the retained logical document and reports its repainted rows.
998 pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999 self.render_at(viewport, self.started_at.elapsed())
1000 }
1001
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }
1292
1293 /// Paints the n-th scripted message and returns rows used including
1294 /// the trailing blank. Measurement draws into a scratch frame.
1295 fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296 let margin = u16::from(width >= 50);
1297 let content_width = width.saturating_sub(margin * 2);
1298 if message == 2 {
1299 draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300 return EDIT_BOX_HEIGHT + 1;
1301 }
1302 let bottom = frame.size().height;
1303 let spans = Self::message_spans(message);
1304 // Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305 // wrapped row re-joins byte-for-byte in native selection.
1306 let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307 used.saturating_add(1)
1308 }
1309
1310 fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311 let mut spans = SmallVec::new();
1312 match message {
1313 0 => {
1314 spans.push(Span::new("Transcript rows are ", prose_style()));
1315 spans.push(Span::new("append-only", code_style()));
1316 spans.push(Span::new(
1317 ": every line is painted once, becomes stable, and rides into native scrollback \
1318 with any selection anchored to it.",
1319 prose_style(),
1320 ));
1321 },
1322 1 => {
1323 spans.push(Span::new(
1324 "Only the bottom band repaints in place — the live shard panel, the activity \
1325 shimmer, and the composer. Rows above it are never rewritten.",
1326 prose_style(),
1327 ));
1328 },
1329 _ => {
1330 spans.push(Span::new(
1331 "On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332 the stable transcript through a ",
1333 prose_style(),
1334 ));
1335 spans.push(Span::new("DECSTBM top region", code_style()));
1336 spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337 },
1338 }
1339 spans
1340 }
1341
1342 /// Appends a finished shard's permanent one-line result.
1343 fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344 let margin = u16::from(width >= 50);
1345 let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346 let detail = fmts!(" workspace-{shard:03}.test.ts [100%]");
1347 draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348 Span::new(prefix.as_str(), ink(GREEN)),
1349 Span::new(detail.as_str(), ink(MUTED)),
1350 ]);
1351 }
1352
1353 /// Shimmering activity line above the editor. The spinner and timer
1354 /// live in the status bar's brand segment; this row only narrates.
1355 fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356 if y >= frame.size().height || frame.size().width < 4 {
1357 return;
1358 }
1359 let start = u16::from(frame.size().width >= 50);
1360 let mut column = start;
1361 let length = xutf::graphemes_str(WORKING_MESSAGE)
1362 .count()
1363 .saturating_add(xutf::graphemes_str(hint).count())
1364 .saturating_add(1);
1365 let length = u16::try_from(length).unwrap_or(u16::MAX);
1366 let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367 let right = frame.size().width.saturating_sub(1);
1368 draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369 draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370 draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371 }
1372
1373 /// The session title resting right-aligned in the air row between
1374 /// the working narration and the status band — against the visible
1375 /// right bound, inside any rail reservation — so the gap reads as
1376 /// session identity instead of dead space.
1377 fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378 let width = frame.size().width.saturating_sub(right_inset);
1379 let title_width = visible_width(SESSION_TITLE);
1380 if y >= frame.size().height || width < title_width.saturating_add(2) {
1381 return;
1382 }
1383 let x = width.saturating_sub(title_width.saturating_add(1));
1384 draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385 }
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391 if rect.width < 4 || rect.height < 4 {
1392 return;
1393 }
1394
1395 let content_x = rect.x + 2;
1396 let content_width = rect.width.saturating_sub(4);
1397 let header = [
1398 Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399 Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400 ];
1401 draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402 let command = [
1403 Span::new("$ ", panel_ink(MUTED)),
1404 Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405 Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406 ];
1407 draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632 u16::try_from(
1633 text
1634 .bytes()
1635 .filter(|byte| *byte == b'\n')
1636 .count()
1637 .saturating_add(1),
1638 )
1639 .unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644 if rect.width == 0 || rect.height == 0 {
1645 return;
1646 }
1647 let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648 frame.fill(rect, fill);
1649 let mut glyph = [0_u8; 4];
1650 if rect.width == 1 {
1651 frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652 return;
1653 }
1654
1655 let right = rect.x + rect.width - 1;
1656 let bottom = rect.y + rect.height - 1;
1657 frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658 frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659 for x in rect.x + 1..right {
1660 frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661 }
1662
1663 if rect.height > 1 {
1664 draw_box_bottom(frame, rect, border, charset);
1665 }
1666 for row in rect.y + 1..bottom {
1667 frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668 frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669 }
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673 if rect.width < 2 || rect.height < 2 {
1674 return;
1675 }
1676 let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677 let mut glyph = [0_u8; 4];
1678 let right = rect.x + rect.width - 1;
1679 let bottom = rect.y + rect.height - 1;
1680 frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681 frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682 for x in rect.x + 1..right {
1683 frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684 }
1685}
1686
1687fn draw_line(frame: &mut Frame, x: u16, y: u16, width: u16, spans: &[Span<'_>]) -> u16 {
1688 let right = x.saturating_add(width).min(frame.size().width);
1689 let mut column = x;
1690 for span in spans {
1691 column = frame.put_clipped(column, y, right.saturating_sub(column), span.text, span.style);
1692 if column >= right {
1693 break;
1694 }
1695 }
1696 column
1697}
1698
1699/// Flows `spans` grapheme-exact across the rect like a bare terminal,
1700/// preserving all whitespace and flagging each exactly-filled row boundary
1701/// soft so native selection copies the paragraph as one unbroken line.
1702/// Returns the rows used.
1703fn draw_flowed(frame: &mut Frame, rect: Rect, spans: &[Span<'_>]) -> u16 {
1704 if rect.width == 0 || rect.height == 0 {
1705 return 0;
1706 }
1707 let full_row = rect.x == 0 && rect.width == frame.size().width;
1708 let mut row = 0_u16;
1709 let mut column = 0_u16;
1710 let mut drew_anything = false;
1711
1712 for span in spans {
1713 for grapheme in xutf::graphemes_str(span.text) {
1714 let grapheme_width = visible_width(grapheme);
1715 if grapheme_width == 0 || grapheme_width > rect.width {
1716 continue;
1717 }
1718 if column.saturating_add(grapheme_width) > rect.width {
1719 // Only an exactly-filled row is byte-joinable by autowrap.
1720 if full_row && column == rect.width {
1721 frame.set_soft_wrap(rect.y.saturating_add(row));
1722 }
1723 row += 1;
1724 column = 0;
1725 }
1726 if row >= rect.height {
1727 return rect.height;
1728 }
1729 frame.put(rect.x + column, rect.y + row, grapheme, span.style);
1730 column += grapheme_width;
1731 drew_anything = true;
1732 }
1733 }
1734
1735 if drew_anything { row + 1 } else { 0 }
1736}More examples
199fn compose(scene: &Scene) -> Frame {
200 let height = STUDIES
201 .iter()
202 .map(|study| study.rows + 3)
203 .fold(3_u16, u16::saturating_add);
204 let mut frame = Frame::new(Size::new(scene.width, height));
205 frame.fill(Rect::new(0, 0, scene.width, height), ink(TEXT));
206
207 let column = frame.put(1, 0, "composer footer studies", ink(TEXT).bold());
208 frame.put(
209 column.saturating_add(2),
210 0,
211 "split + air gap, six session-title placements",
212 ink(MUTED),
213 );
214 frame.put(1, 1, "↑/↓ scroll · PgUp/PgDn page · Home/End jump · q quits", ink(FAINT));
215
216 let mut y = 3_u16;
217 for (index, study) in STUDIES.iter().enumerate() {
218 let number = fmts!("{:>2} ", index + 1);
219 let mut column = frame.put(1, y, &number, ink(GOLD).bold());
220 column = frame.put(column, y, study.title, ink(TEXT).bold());
221 column = frame.put(column, y, " ", ink(FAINT));
222 frame.put(column, y, study.note, ink(MUTED));
223 (study.draw)(&mut frame, y + 1, scene);
224 y = y.saturating_add(study.rows + 3);
225 }
226 frame
227}
228
229// ── studies ─────────────────────────────────────────────────────────────────
230
231/// 1: the pi layout verbatim — a bordered composer whose top border
232/// carries the session band on the left and the title on the right, with
233/// the spinner narrating intent on its own row above the box.
234fn study_pi_parity(frame: &mut Frame, y: u16, scene: &Scene) {
235 draw_working_spin(frame, 1, y, scene);
236 let (tl, tr, bl, br, horizontal, vertical) = border_glyphs(scene.charset);
237 let right = scene.right_edge();
238 draw_border_row(frame, y + 1, scene, tl, tr, horizontal);
239 let segments = [model(scene), omp_brand(scene), git(scene), context(scene), cost()];
240 draw_band(frame, 2, y + 1, scene, &segments);
241 let band_end = 2_u16.saturating_add(band_width(scene, &segments));
242 draw_border_title(frame, y + 1, scene, band_end.saturating_add(2));
243 frame.put(0, y + 2, vertical, ink(FAINT));
244 frame.put(2, y + 2, beam(scene.charset), ink(TEXT));
245 frame.put(right, y + 2, vertical, ink(FAINT));
246 draw_border_row(frame, y + 3, scene, bl, br, horizontal);
247}
248
249/// 2: split + air gap as picked, with the title moving into the air row
250/// so the breathing space doubles as identity.
251fn study_gap_title(frame: &mut Frame, y: u16, scene: &Scene) {
252 draw_working(frame, 1, y, scene);
253 let title = fit_title(scene, scene.width.saturating_sub(2));
254 let x = scene
255 .width
256 .saturating_sub(width_of(&title).saturating_add(1));
257 frame.put(x, y + 1, &title, ink(FAINT).italic());
258 draw_split_bands(frame, y + 2, scene);
259 draw_input(frame, 0, y + 3, scene);
260}
261
262/// 3: the title joins the left band beside the brand segment, so the
263/// band row itself answers "what session is this"; session facts keep
264/// the right cap.
265fn study_band_title(frame: &mut Frame, y: u16, scene: &Scene) {
266 draw_working(frame, 1, y, scene);
267 let right = [model(scene), git(scene), context(scene), Seg::new(COST_SHORT, PURPLE)];
268 let right_width = band_width(scene, &right);
269 let brand_seg = brand(scene);
270 let (_, separator, _) = band_chrome(scene.charset);
271 let fixed = band_width(scene, std::slice::from_ref(&brand_seg))
272 .saturating_add(width_of(separator).saturating_add(2));
273 let budget = scene
274 .width
275 .saturating_sub(right_width.saturating_add(2))
276 .saturating_sub(fixed);
277 let left = [brand_seg, Seg::new(fit_title(scene, budget), TEXT)];
278 draw_band(frame, 0, y + 2, scene, &left);
279 draw_band(frame, scene.width.saturating_sub(right_width), y + 2, scene, &right);
280 draw_input(frame, 0, y + 3, scene);
281}
282
283/// 4: split + air untouched; the title borrows the prompt row's right
284/// edge and would yield to long input lines.
285fn study_prompt_title(frame: &mut Frame, y: u16, scene: &Scene) {
286 draw_working(frame, 1, y, scene);
287 draw_split_bands(frame, y + 2, scene);
288 draw_input(frame, 0, y + 3, scene);
289 let title = fit_title(scene, scene.width.saturating_sub(8));
290 let x = scene
291 .width
292 .saturating_sub(width_of(&title).saturating_add(1));
293 frame.put(x, y + 3, &title, ink(FAINT).italic());
294}
295
296/// 5: the title gets its own dim row above the narration, heading the
297/// whole live block like a section title.
298fn study_crown(frame: &mut Frame, y: u16, scene: &Scene) {
299 let title = fit_title(scene, scene.width.saturating_sub(2));
300 frame.put(1, y, &title, ink(MUTED).bold());
301 draw_working(frame, 1, y + 1, scene);
302 draw_split_bands(frame, y + 3, scene);
303 draw_input(frame, 0, y + 4, scene);
304}
305
306/// 6: a bordered composer again, but the title takes the top border and
307/// the band moves into the bottom hem, so chrome frames the input from
308/// both sides.
309fn study_hem(frame: &mut Frame, y: u16, scene: &Scene) {
310 draw_working(frame, 1, y, scene);
311 let (tl, tr, bl, br, horizontal, vertical) = border_glyphs(scene.charset);
312 let right = scene.right_edge();
313 draw_border_row(frame, y + 1, scene, tl, tr, horizontal);
314 draw_border_title(frame, y + 1, scene, 4);
315 frame.put(0, y + 2, vertical, ink(FAINT));
316 frame.put(2, y + 2, beam(scene.charset), ink(TEXT));
317 frame.put(right, y + 2, vertical, ink(FAINT));
318 draw_border_row(frame, y + 3, scene, bl, br, horizontal);
319 draw_band(frame, 2, y + 3, scene, &full_band(scene));
320}
321
322// ── shared chrome ───────────────────────────────────────────────────────────
323
324/// One status item: a label painted in its identity color.
325struct Seg {
326 label: Str,
327 color: Color,
328}
329
330impl Seg {
331 fn new(label: impl Into<Str>, color: Color) -> Self {
332 Self { label: label.into(), color }
333 }
334}
335
336fn brand(scene: &Scene) -> Seg {
337 Seg::new(fmts!("{} {}", scene.spinner(), scene.timer()), GREEN)
338}
339
340fn omp_brand(scene: &Scene) -> Seg {
341 Seg::new(fmts!("{} omp", scene.charset.icon(Icon::Omp)), MUTED)
342}
343
344fn model(scene: &Scene) -> Seg {
345 Seg::new(fmts!("{} {MODEL}", scene.charset.icon(Icon::Model)), GREEN)
346}
347
348fn git(scene: &Scene) -> Seg {
349 Seg::new(fmts!("{} {GIT}", scene.charset.icon(Icon::Branch)), CYAN)
350}
351
352fn context(scene: &Scene) -> Seg {
353 Seg::new(fmts!("{} {CONTEXT}", scene.charset.icon(Icon::Context)), GOLD)
354}
355
356fn cost() -> Seg {
357 Seg::new(Str::new_static(COST), PURPLE)
358}
359
360fn full_band(scene: &Scene) -> [Seg; 5] {
361 [brand(scene), model(scene), git(scene), context(scene), cost()]
362}
363
364/// The picked split arrangement: brand caps left, session caps right.
365fn draw_split_bands(frame: &mut Frame, y: u16, scene: &Scene) {
366 let left = [brand(scene), model(scene)];
367 let right = [git(scene), context(scene), cost()];
368 draw_band(frame, 0, y, scene, &left);
369 let x = scene.width.saturating_sub(band_width(scene, &right));
370 draw_band(frame, x, y, scene, &right);
371}
372
373/// Status-band chrome per tier, mirroring the `<status>` component.
374const fn band_chrome(charset: Charset) -> (&'static str, &'static str, &'static str) {
375 match charset {
376 Charset::Ascii => ("", ">", ">"),
377 Charset::Unicode => ("", "›", "›"),
378 Charset::NerdFont => ("\u{e0b6}", "\u{e0b1}", "\u{e0b0}"),
379 }
380}
381
382const fn border_glyphs(
383 charset: Charset,
384) -> (&'static str, &'static str, &'static str, &'static str, &'static str, &'static str) {
385 match charset {
386 Charset::Ascii => ("+", "+", "+", "+", "-", "|"),
387 _ => ("╭", "╮", "╰", "╯", "─", "│"),
388 }
389}
390
391const fn beam(charset: Charset) -> &'static str {
392 match charset {
393 Charset::Ascii => "_",
394 _ => "▏",
395 }
396}
397
398const fn ink(color: Color) -> Style {
399 Style::new().fg(color)
400}
401
402fn width_of(text: &str) -> u16 {
403 u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
404}
405
406/// [`TITLE`] truncated to at most `max` cells, ellipsized when it cannot
407/// fit whole.
408fn fit_title(scene: &Scene, max: u16) -> Str {
409 if width_of(TITLE) <= max {
410 return Str::new_static(TITLE);
411 }
412 let ellipsis = match scene.charset {
413 Charset::Ascii => "...",
414 _ => "…",
415 };
416 let budget = max.saturating_sub(width_of(ellipsis));
417 let mut used = 0_u16;
418 let mut end = 0_usize;
419 for grapheme in xutf::graphemes_str(TITLE) {
420 let cells = width_of(grapheme);
421 if used.saturating_add(cells) > budget {
422 break;
423 }
424 used = used.saturating_add(cells);
425 end += grapheme.len();
426 }
427 if end == 0 {
428 return Str::default();
429 }
430 fmts!("{}{ellipsis}", TITLE[..end].trim_end())
431}
432
433/// Total cells a powerline band with `segments` occupies, mirroring the
434/// `<status>` component's measurement.
435fn band_width(scene: &Scene, segments: &[Seg]) -> u16 {
436 let (left_cap, separator, right_cap) = band_chrome(scene.charset);
437 let text = segments
438 .iter()
439 .map(|segment| width_of(&segment.label))
440 .fold(0_u16, u16::saturating_add);
441 let separators = u16::try_from(segments.len().saturating_sub(1))
442 .unwrap_or(u16::MAX)
443 .saturating_mul(width_of(separator).saturating_add(2));
444 text
445 .saturating_add(separators)
446 .saturating_add(width_of(left_cap))
447 .saturating_add(2)
448 .saturating_add(width_of(right_cap))
449}
450
451/// Paints a powerline band at `x`: cap, padded segments, cap.
452fn draw_band(frame: &mut Frame, x: u16, y: u16, scene: &Scene, segments: &[Seg]) {
453 let (left_cap, separator, right_cap) = band_chrome(scene.charset);
454 let base = Style::new().fg(TEXT).bg(BAND_BG);
455 let edge = ink(BAND_BG);
456 let mut column = frame.put(x, y, left_cap, edge);
457 column = frame.put(column, y, " ", base);
458 for (index, segment) in segments.iter().enumerate() {
459 if index > 0 {
460 column = frame.put(column, y, " ", base.dim());
461 column = frame.put(column, y, separator, base.dim());
462 column = frame.put(column, y, " ", base.dim());
463 }
464 column = frame.put(column, y, &segment.label, base.fg(segment.color));
465 }
466 column = frame.put(column, y, " ", base);
467 frame.put(column, y, right_cap, edge);
468}
469
470/// A full-width horizontal border row: corner, rule fill, corner.
471fn draw_border_row(
472 frame: &mut Frame,
473 y: u16,
474 scene: &Scene,
475 left: &str,
476 right: &str,
477 horizontal: &str,
478) {
479 let edge = scene.right_edge();
480 let mut column = frame.put(0, y, left, ink(FAINT));
481 while column < edge {
482 column = frame.put(column, y, horizontal, ink(FAINT));
483 }
484 frame.put(edge, y, right, ink(FAINT));
485}
486
487/// Right-aligns ` TITLE ` into an already-painted border row, keeping two
488/// rule cells before the corner and truncating against `min_x`.
489fn draw_border_title(frame: &mut Frame, y: u16, scene: &Scene, min_x: u16) {
490 let slot_end = scene.right_edge().saturating_sub(2);
491 let title = fit_title(scene, slot_end.saturating_sub(min_x).saturating_sub(2));
492 if title.is_empty() {
493 return;
494 }
495 let x = slot_end.saturating_sub(width_of(&title).saturating_add(2));
496 let column = frame.put(x, y, " ", ink(FAINT));
497 let column = frame.put(column, y, &title, ink(TEXT));
498 frame.put(column, y, " ", ink(FAINT));
499}
500
501/// The shimmering working line: cancel hint, then the narration, riding
502/// one crest sweep exactly like the chat demo's activity row.
503fn draw_working(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
504 let hint = scene.charset.icon(Icon::Cancellable);
505 let length = width_of(hint)
506 .saturating_add(1)
507 .saturating_add(width_of(WORKING));
508 let shimmer = Shimmer::new(scene.elapsed, SHIMMER_PERIOD, length);
509 let mut column = x;
510 draw_shimmer(frame, &mut column, x, y, scene.right_edge(), hint, shimmer, ink(CYAN));
511 draw_shimmer(frame, &mut column, x, y, scene.right_edge(), " ", shimmer, ink(GREEN));
512 draw_shimmer(frame, &mut column, x, y, scene.right_edge(), WORKING, shimmer, ink(GREEN));
513}
514
515/// The pi flavor of the working line: the spinner and timer lead, then
516/// the shimmering narration — the band below carries no brand segment.
517fn draw_working_spin(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
518 let mut column = frame.put(x, y, scene.spinner(), ink(GREEN));
519 column = frame.put(column, y, " ", ink(GREEN));
520 column = frame.put(column, y, &scene.timer(), ink(MUTED));
521 column = frame.put(column, y, " ", ink(MUTED));
522 let shimmer = Shimmer::new(scene.elapsed, SHIMMER_PERIOD, width_of(WORKING));
523 let start = column;
524 draw_shimmer(frame, &mut column, start, y, scene.right_edge(), WORKING, shimmer, ink(GREEN));
525}
526
527/// Paints `text` under the crest, advancing `column`; `start` anchors
528/// cell zero so every segment rides one sweep.
529#[allow(clippy::too_many_arguments, reason = "immediate-mode painter threading frame state")]
530fn draw_shimmer(
531 frame: &mut Frame,
532 column: &mut u16,
533 start: u16,
534 y: u16,
535 right: u16,
536 text: &str,
537 shimmer: Shimmer,
538 high: Style,
539) {
540 for grapheme in xutf::graphemes_str(text) {
541 if *column >= right {
542 return;
543 }
544 let style = shimmer.pick(*column - start, ink(FAINT), ink(MUTED), high);
545 let next = frame.put(*column, y, grapheme, style);
546 if next == *column {
547 return;
548 }
549 *column = next;
550 }
551}
552
553/// The composer prompt row: corner, then the idle cursor beam.
554fn draw_input(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
555 let prompt = match scene.charset {
556 Charset::Ascii => "+-",
557 _ => "╰─",
558 };
559 let column = frame.put(x, y, prompt, ink(FAINT));
560 frame.put(column.saturating_add(1), y, beam(scene.charset), ink(TEXT));
561}257 fn draw_backdrop(&mut self, viewport: Size, clock: Duration, elapsed: f32) {
258 let interval = ambient_interval(clock, BACKDROP_IDLE_INTERVAL);
259 if self
260 .backdrop_at
261 .is_none_or(|rendered_at| clock.saturating_sub(rendered_at) >= interval)
262 {
263 let fade = smooth((elapsed / BACKDROP_FADE).clamp(0.0, 1.0));
264 self
265 .backdrop_frame
266 .fill(Rect::new(0, 0, viewport.width, viewport.height), Style::default());
267 let frame = &mut self.backdrop_frame;
268 let mut buffer = [0_u8; 4];
269 let dim = |color: Color| Color::Rgb(0, 0, 0).lerp(color, fade);
270 self.surface.render(
271 &mut self.backdrop,
272 clock,
273 viewport.width,
274 viewport.height,
275 |x, y, glyph, fg, bg| {
276 let style = Style::new().fg(dim(fg));
277 let style = match bg {
278 Some(bg) => style.bg(dim(bg)),
279 None => style,
280 };
281 frame.put(x, y, glyph.encode_utf8(&mut buffer), style);
282 },
283 );
284 self.backdrop_at = Some(clock);
285 }
286 self.frame.clone_from(&self.backdrop_frame);
287 }
288
289 fn draw_card(&mut self, cols: u16, left: u16, top: u16, elapsed: f32, hover: f32) {
290 let full = cols == CARD_COLS;
291 let logo_left = if full {
292 left + 3
293 } else {
294 left + (cols - LOGO_COLS as u16) / 2
295 };
296 self.logo_origin = (logo_left, top + 2);
297 // Pointer-tracking border glow: the brand gradient sampled by angle
298 // around the card center (the disk's own palette), strongest near
299 // the pointer, scaled by the eased hover amount.
300 let pointer = self.pointer;
301 let center =
302 (f32::from(left) + f32::from(cols) / 2.0, f32::from(top) + f32::from(CARD_ROWS) / 2.0);
303 let edge_at = move |x: u16, y: u16| -> Style {
304 let Some((px, py)) = pointer.filter(|_| hover > 0.02) else {
305 return on_card(CARD_BORDER);
306 };
307 let dx = (f32::from(x) - f32::from(px)) * 0.5;
308 let dy = f32::from(y) - f32::from(py);
309 let glow = hover * (-(dx * dx + dy * dy) / 34.0).exp();
310 if glow < 0.02 {
311 return on_card(CARD_BORDER);
312 }
313 let angle = (f32::from(y) - center.1).atan2((f32::from(x) - center.0) * 0.5);
314 let brand = vec3_color(gradient(angle - elapsed * 0.5));
315 on_card(CARD_BORDER.lerp(brand, glow))
316 };
317 let frame = &mut self.frame;
318 frame.fill(Rect::new(left, top, cols, CARD_ROWS), on_card(TEXT));
319
320 let right = left + cols - 1;
321 let bottom = top + CARD_ROWS - 1;
322 let divider = bottom - 2;
323 let (tl, tr, bl, br, horizontal, vertical) = self.charset.border(Border::Round);
324 let grid = self.charset.grid();
325 let mut glyph = [0_u8; 4];
326 frame.put(left, top, tl.encode_utf8(&mut glyph), edge_at(left, top));
327 frame.put(right, top, tr.encode_utf8(&mut glyph), edge_at(right, top));
328 frame.put(left, divider, grid.middle.0.encode_utf8(&mut glyph), edge_at(left, divider));
329 frame.put(right, divider, grid.middle.2.encode_utf8(&mut glyph), edge_at(right, divider));
330 frame.put(left, bottom, bl.encode_utf8(&mut glyph), edge_at(left, bottom));
331 frame.put(right, bottom, br.encode_utf8(&mut glyph), edge_at(right, bottom));
332 for x in left + 1..right {
333 frame.put(x, top, horizontal.encode_utf8(&mut glyph), edge_at(x, top));
334 frame.put(x, divider, horizontal.encode_utf8(&mut glyph), edge_at(x, divider));
335 frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), edge_at(x, bottom));
336 }
337 for y in top + 1..bottom {
338 if y != divider {
339 frame.put(left, y, vertical.encode_utf8(&mut glyph), edge_at(left, y));
340 frame.put(right, y, vertical.encode_utf8(&mut glyph), edge_at(right, y));
341 }
342 }
343
344 frame.put(left + 2, top, self.title.as_str(), on_card(TEXT_STRONG));
345 if full {
346 frame.put(left + 34, top, " SESSION ORBIT ", on_card(FAINT));
347 draw_dust(frame, left, top, elapsed);
348 draw_sessions(frame, left, top, self.charset);
349 draw_beam(frame, left, top, elapsed);
350 }
351
352 blit_logo(frame, &self.logo, logo_left, top + 2, CARD_BG);
353
354 let footer = divider + 1;
355 frame.fill(Rect::new(left + 1, footer, cols - 2, 1), on_footer(TEXT));
356 if full {
357 frame.put(left + 3, divider, " SHORTCUTS ", on_card(FAINT));
358 let dot = fmts!(" {} ", self.charset.icon(Icon::Enabled));
359 let x = frame.put(left + cols - 21, top, &dot, on_card(GREEN));
360 frame.put(x, top, "rust-analyzer ", on_card(MUTED));
361 draw_full_hints(frame, left, footer);
362 } else {
363 draw_smol_hints(frame, left, cols, footer);
364 }
365 }
366}
367
368impl Default for Welcome {
369 fn default() -> Self {
370 Self::new(Charset::NerdFont)
371 }
372}
373
374fn draw_dust(frame: &mut Frame, left: u16, top: u16, elapsed: f32) {
375 for &(x, y, offset) in &DUST {
376 let pulse = 0.5 + 0.5 * (elapsed * 1.4 + offset).sin();
377 let color = FAINT.lerp(CYAN, pulse * 0.28);
378 frame.put(left + x, top + y, "·", on_card(color));
379 }
380 frame.put(left + 1, top + 7, HORIZON, on_card(FAINT.lerp(INDIGO, 0.16)));
381 frame.put(left + 14, top + 1, "+Z", on_card(FAINT));
382}
383
384fn draw_beam(frame: &mut Frame, left: u16, top: u16, elapsed: f32) {
385 let phase = (elapsed * 9.0) as usize % BEAM.len();
386 for (index, &(x, y, glyph)) in BEAM.iter().enumerate() {
387 let direct = index.abs_diff(phase);
388 let distance = direct.min(BEAM.len() - direct);
389 let color = match distance {
390 0 => TEXT_STRONG,
391 1 => CYAN,
392 _ => FAINT.lerp(INDIGO, 0.34),
393 };
394 frame.put(left + x, top + y, glyph, on_card(color));
395 }
396}
397
398fn draw_sessions(frame: &mut Frame, left: u16, top: u16, charset: Charset) {
399 let (_, _, _, _, _, vertical) = charset.border(Border::Round);
400 let mut glyph = [0_u8; 4];
401 let panel_x = left + 36;
402 frame.put(panel_x, top + 2, "RECENT SESSIONS", on_card(MUTED));
403 frame.put(left + CARD_COLS - 14, top + 2, "4 / LOCAL", on_card(FAINT));
404 for y in top + 4..=top + 10 {
405 frame.put(panel_x, y, vertical.encode_utf8(&mut glyph), on_card(FAINT.lerp(INDIGO, 0.18)));
406 }
407 for (index, (label, age)) in SESSIONS.iter().enumerate() {
408 let y = top + 4 + index as u16 * 2;
409 if index == 0 {
410 frame.fill(Rect::new(panel_x - 2, y, CARD_COLS - 35, 1), on_selected(TEXT));
411 frame.put(panel_x - 2, y, charset.rail(), on_selected(GREEN));
412 frame.put(panel_x, y, charset.radio(true), on_selected(GREEN));
413 frame.put(panel_x + 2, y, age, on_selected(GREEN));
414 frame.put(panel_x + 7, y, label, on_selected(TEXT_STRONG));
415 } else {
416 frame.put(panel_x, y, charset.radio(false), on_card(FAINT));
417 frame.put(panel_x + 2, y, age, on_card(FAINT));
418 frame.put(panel_x + 7, y, label, on_card(MUTED));
419 }
420 }
421}
422
423fn draw_full_hints(frame: &mut Frame, left: u16, y: u16) {
424 frame.put(left + 3, y, "#", on_footer(CYAN));
425 frame.put(left + 5, y, "actions", on_footer(MUTED));
426 frame.put(left + 14, y, "/", on_footer(GREEN));
427 frame.put(left + 16, y, "commands", on_footer(MUTED));
428 frame.put(left + 27, y, "!", on_footer(AMBER));
429 frame.put(left + 29, y, "shell", on_footer(MUTED));
430 frame.put(left + 37, y, "$", on_footer(VIOLET));
431 frame.put(left + 39, y, "python", on_footer(MUTED));
432 frame.put(left + CARD_COLS - 23, y, "↑↓ move", on_footer(FAINT));
433 frame.put(left + CARD_COLS - 13, y, "↵ resume", on_footer(TEXT_STRONG));
434}
435
436fn draw_smol_hints(frame: &mut Frame, left: u16, cols: u16, y: u16) {
437 frame.put(left + 3, y, "#", on_footer(CYAN).bold());
438 frame.put(left + 5, y, "/", on_footer(CYAN).bold());
439 frame.put(left + 7, y, "!", on_footer(AMBER).bold());
440 frame.put(left + 9, y, "$", on_footer(GREEN).bold());
441 frame.put(left + cols - 14, y, "enter", on_footer(FAINT));
442 frame.put(left + cols - 8, y, "resume", on_footer(TEXT_STRONG).bold());
443}
444
445fn blit_logo(frame: &mut Frame, logo: &LogoGrid, left: u16, top: u16, background: Color) {
446 let mut buffer = [0_u8; 4];
447 for (row, cells) in logo.iter().enumerate() {
448 for (column, cell) in cells.iter().enumerate() {
449 let Some((glyph, color)) = cell else { continue };
450 let style = Style::new().fg(*color).bg(background);
451 frame.put(left + column as u16, top + row as u16, glyph.encode_utf8(&mut buffer), style);
452 }
453 }
454}Sourcepub fn put_clipped(
&mut self,
x: u16,
y: u16,
width: u16,
text: &str,
style: Style,
) -> u16
pub fn put_clipped( &mut self, x: u16, y: u16, width: u16, text: &str, style: Style, ) -> u16
Draws printable graphemes within width cells.
The cell bound is also clipped to the frame edge. A wide grapheme that crosses either bound is omitted rather than leaving a half-cell artifact.
Examples found in repository?
1412fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632 u16::try_from(
1633 text
1634 .bytes()
1635 .filter(|byte| *byte == b'\n')
1636 .count()
1637 .saturating_add(1),
1638 )
1639 .unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644 if rect.width == 0 || rect.height == 0 {
1645 return;
1646 }
1647 let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648 frame.fill(rect, fill);
1649 let mut glyph = [0_u8; 4];
1650 if rect.width == 1 {
1651 frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652 return;
1653 }
1654
1655 let right = rect.x + rect.width - 1;
1656 let bottom = rect.y + rect.height - 1;
1657 frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658 frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659 for x in rect.x + 1..right {
1660 frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661 }
1662
1663 if rect.height > 1 {
1664 draw_box_bottom(frame, rect, border, charset);
1665 }
1666 for row in rect.y + 1..bottom {
1667 frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668 frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669 }
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673 if rect.width < 2 || rect.height < 2 {
1674 return;
1675 }
1676 let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677 let mut glyph = [0_u8; 4];
1678 let right = rect.x + rect.width - 1;
1679 let bottom = rect.y + rect.height - 1;
1680 frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681 frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682 for x in rect.x + 1..right {
1683 frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684 }
1685}
1686
1687fn draw_line(frame: &mut Frame, x: u16, y: u16, width: u16, spans: &[Span<'_>]) -> u16 {
1688 let right = x.saturating_add(width).min(frame.size().width);
1689 let mut column = x;
1690 for span in spans {
1691 column = frame.put_clipped(column, y, right.saturating_sub(column), span.text, span.style);
1692 if column >= right {
1693 break;
1694 }
1695 }
1696 column
1697}Sourcepub fn blit(
&mut self,
src: &Self,
src_top: u16,
rows: u16,
dst_x: u16,
dst_y: u16,
)
pub fn blit( &mut self, src: &Self, src_top: u16, rows: u16, dst_x: u16, dst_y: u16, )
Copies a cell region from src into this frame — the scroll
viewport blit, and the way an embedder composites a sub-document
(e.g. a crate::Ui-rendered message) into a hand-painted frame.
src rows [src_top, src_top + rows) land at (dst_x, dst_y),
clipped to both frames. Wide glyphs whose lead cell falls outside
the copied span degrade to blanks rather than leaving orphan
continuations.
A cursor set on src inside the copied region is translated into
this frame’s coordinates; outside it, this frame’s cursor is kept.
Examples found in repository?
68async fn run<'a>(
69 terminal: &'a mut Terminal,
70 renderer: &'a mut Renderer<TtyOut>,
71 charset: Charset,
72) -> io::Result<()> {
73 let started = Instant::now();
74 let mut viewport = terminal.size()?;
75 let mut scroll: u16 = 0;
76 let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
77 loop {
78 tokio::select! {
79 event = terminal.next() => match event? {
80 TerminalEvent::Input(event) => {
81 match event {
82 InputEvent::Key(key) => match key {
83 Key::Char('q') | Key::Esc | Key::Ctrl('c') => return Ok(()),
84 Key::Up | Key::Char('k') => scroll = scroll.saturating_sub(1),
85 Key::Down | Key::Char('j') => scroll = scroll.saturating_add(1),
86 Key::PageUp => scroll = scroll.saturating_sub(viewport.height),
87 Key::PageDown => scroll = scroll.saturating_add(viewport.height),
88 Key::Home => scroll = 0,
89 Key::End => scroll = u16::MAX,
90 _ => {},
91 },
92 InputEvent::Mouse(report) => match report.kind {
93 Mouse::WheelUp => scroll = scroll.saturating_sub(2),
94 Mouse::WheelDown => scroll = scroll.saturating_add(2),
95 _ => {},
96 },
97 InputEvent::Paste(_) | InputEvent::Focus(_) | InputEvent::Response(_) => {},
98 }
99 terminal.sync_renderer(renderer)?;
100 },
101 TerminalEvent::Resize => {
102 if let Some(size) = terminal.take_resize()? {
103 viewport = size;
104 }
105 },
106 TerminalEvent::Debug(_) => {},
107 TerminalEvent::Closed => return Ok(()),
108 },
109 () = tokio::time::sleep(FRAME_INTERVAL) => {},
110 }
111 if viewport.width == 0 || viewport.height == 0 {
112 continue;
113 }
114 let scene = Scene { charset, width: viewport.width, elapsed: started.elapsed() };
115 let document = compose(&scene);
116 scroll = scroll.min(document.size().height.saturating_sub(viewport.height));
117 let mut screen = Frame::new(viewport);
118 screen.fill(Rect::new(0, 0, viewport.width, viewport.height), ink(TEXT));
119 screen.blit(&document, scroll, viewport.height, 0, 0);
120 renderer.preview(&screen, viewport.height, alt_enter.take().as_deref().unwrap_or(""))?;
121 }
122}More examples
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }
1292
1293 /// Paints the n-th scripted message and returns rows used including
1294 /// the trailing blank. Measurement draws into a scratch frame.
1295 fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296 let margin = u16::from(width >= 50);
1297 let content_width = width.saturating_sub(margin * 2);
1298 if message == 2 {
1299 draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300 return EDIT_BOX_HEIGHT + 1;
1301 }
1302 let bottom = frame.size().height;
1303 let spans = Self::message_spans(message);
1304 // Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305 // wrapped row re-joins byte-for-byte in native selection.
1306 let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307 used.saturating_add(1)
1308 }
1309
1310 fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311 let mut spans = SmallVec::new();
1312 match message {
1313 0 => {
1314 spans.push(Span::new("Transcript rows are ", prose_style()));
1315 spans.push(Span::new("append-only", code_style()));
1316 spans.push(Span::new(
1317 ": every line is painted once, becomes stable, and rides into native scrollback \
1318 with any selection anchored to it.",
1319 prose_style(),
1320 ));
1321 },
1322 1 => {
1323 spans.push(Span::new(
1324 "Only the bottom band repaints in place — the live shard panel, the activity \
1325 shimmer, and the composer. Rows above it are never rewritten.",
1326 prose_style(),
1327 ));
1328 },
1329 _ => {
1330 spans.push(Span::new(
1331 "On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332 the stable transcript through a ",
1333 prose_style(),
1334 ));
1335 spans.push(Span::new("DECSTBM top region", code_style()));
1336 spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337 },
1338 }
1339 spans
1340 }
1341
1342 /// Appends a finished shard's permanent one-line result.
1343 fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344 let margin = u16::from(width >= 50);
1345 let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346 let detail = fmts!(" workspace-{shard:03}.test.ts [100%]");
1347 draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348 Span::new(prefix.as_str(), ink(GREEN)),
1349 Span::new(detail.as_str(), ink(MUTED)),
1350 ]);
1351 }
1352
1353 /// Shimmering activity line above the editor. The spinner and timer
1354 /// live in the status bar's brand segment; this row only narrates.
1355 fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356 if y >= frame.size().height || frame.size().width < 4 {
1357 return;
1358 }
1359 let start = u16::from(frame.size().width >= 50);
1360 let mut column = start;
1361 let length = xutf::graphemes_str(WORKING_MESSAGE)
1362 .count()
1363 .saturating_add(xutf::graphemes_str(hint).count())
1364 .saturating_add(1);
1365 let length = u16::try_from(length).unwrap_or(u16::MAX);
1366 let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367 let right = frame.size().width.saturating_sub(1);
1368 draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369 draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370 draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371 }
1372
1373 /// The session title resting right-aligned in the air row between
1374 /// the working narration and the status band — against the visible
1375 /// right bound, inside any rail reservation — so the gap reads as
1376 /// session identity instead of dead space.
1377 fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378 let width = frame.size().width.saturating_sub(right_inset);
1379 let title_width = visible_width(SESSION_TITLE);
1380 if y >= frame.size().height || width < title_width.saturating_add(2) {
1381 return;
1382 }
1383 let x = width.saturating_sub(title_width.saturating_add(1));
1384 draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385 }
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391 if rect.width < 4 || rect.height < 4 {
1392 return;
1393 }
1394
1395 let content_x = rect.x + 2;
1396 let content_width = rect.width.saturating_sub(4);
1397 let header = [
1398 Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399 Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400 ];
1401 draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402 let command = [
1403 Span::new("$ ", panel_ink(MUTED)),
1404 Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405 Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406 ];
1407 draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}Trait Implementations§
Auto Trait Implementations§
impl Freeze for Frame
impl RefUnwindSafe for Frame
impl Send for Frame
impl Sync for Frame
impl Unpin for Frame
impl UnsafeUnpin for Frame
impl UnwindSafe for Frame
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can
then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.Source§fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be
further downcast into Rc<ConcreteType> where ConcreteType implements Trait.Source§fn as_any(&self) -> &(dyn Any + 'static)
fn as_any(&self) -> &(dyn Any + 'static)
&Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &Any’s vtable from &Trait’s.Source§fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.Source§impl<T> DowncastSync for T
impl<T> DowncastSync for T
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more