Skip to main content

Frame

Struct Frame 

Source
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

Source

pub fn new(size: Size) -> Self

Creates a blank frame using the terminal’s default colors.

Examples found in repository?
examples/chat/welcome.rs (line 170)
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
Hide additional examples
examples/chat/demo.rs (line 788)
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	}
examples/footers.rs (line 117)
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}
Source

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?
examples/chat/demo.rs (line 1077)
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	}
Source

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?
examples/chat/demo.rs (line 1721)
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}
Source

pub fn soft_wrap(&self, y: u16) -> bool

Whether row y was flagged as soft-wrapping onto row y + 1.

Source

pub const fn size(&self) -> Size

Returns the frame dimensions.

Examples found in repository?
examples/chat/demo.rs (line 866)
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
Hide additional examples
examples/chat/picker.rs (line 319)
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	}
examples/chat/commands.rs (line 118)
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	}
examples/tml.rs (line 73)
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}
examples/chat/main.rs (line 752)
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}
examples/chat/welcome.rs (line 204)
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	}
Source

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?
examples/chat/demo.rs (lines 468-473)
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	}
Source

pub fn clear(&mut self, style: Style)

Replaces every cell with a styled blank.

Source

pub fn fill(&mut self, rect: Rect, style: Style)

Fills a clipped rectangle with styled blanks.

Examples found in repository?
examples/chat/welcome.rs (line 266)
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
Hide additional examples
examples/footers.rs (line 118)
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}
examples/chat/demo.rs (lines 1080-1088)
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}
Source

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.

Source

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.

Source

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?
examples/chat/demo.rs (line 112)
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
Hide additional examples
examples/footers.rs (line 207)
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}
examples/chat/welcome.rs (line 281)
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}
Source

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?
examples/chat/demo.rs (line 1505)
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}
Source

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?
examples/footers.rs (line 119)
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
Hide additional examples
examples/chat/demo.rs (line 1127)
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§

Source§

impl Clone for Frame

Source§

fn clone(&self) -> Frame

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Frame

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

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> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> Downcast for T
where T: Any,

Source§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert 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>

Convert 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)

Convert &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)

Convert &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
where T: Any + Send + Sync,

Source§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync>

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts 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 more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts 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
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.