Skip to main content

Editor

Struct Editor 

Source
pub struct Editor { /* private fields */ }
Expand description

Editable multiline input with Pi-compatible completion and editing.

Wraps an EditBuffer with a pluggable EditorCompletion dropdown, inline ghost hints, built-in emoji expansion, and prompt history — each governed by EditorOptions.

Implementations§

Source§

impl Editor

Source

pub fn new(options: EditorOptions) -> Self

Creates an empty editor with the given feature switches.

Examples found in repository?
examples/chat/demo.rs (line 754)
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	}
Source

pub fn set_completion(&mut self, completion: Box<dyn EditorCompletion>)

Registers the completion engine driving the dropdown, ghost text, and Tab behavior; replaces any previous one.

Examples found in repository?
examples/chat/demo.rs (line 755)
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	}
Source

pub const fn options(&self) -> EditorOptions

Returns the feature switches the editor was built with, so renderers can honor them (e.g. XML highlighting).

Examples found in repository?
examples/chat/demo.rs (line 449)
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 text(&self) -> &str

Returns the visible text, with paste markers unexpanded.

Examples found in repository?
examples/chat/demo.rs (line 963)
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}
1737
1738fn visible_width(text: &str) -> u16 {
1739	u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
1740}
1741
1742/// Finds the first `<ref image=N/>` tag in submitted text: its byte range
1743/// plus the marker number `N`.
1744fn next_ref_tag(text: &str) -> Option<(usize, usize, usize)> {
1745	const HEAD: &str = "<ref image=";
1746	let mut from = 0;
1747	while let Some(at) = text[from..].find(HEAD) {
1748		let start = from + at;
1749		let body = &text[start + HEAD.len()..];
1750		let digits = body.bytes().take_while(u8::is_ascii_digit).count();
1751		if digits > 0 && body[digits..].starts_with("/>") {
1752			let marker = body[..digits].parse().unwrap_or(usize::MAX);
1753			return Some((start, start + HEAD.len() + digits + 2, marker));
1754		}
1755		from = start + HEAD.len();
1756	}
1757	None
1758}
1759
1760/// Splits one composer input row into base-styled text and attachment
1761/// chips, chip styling winning over any overlapping XML run.
1762///
1763/// Chips are located through the buffer's atomic ranges — like the XML
1764/// pass, styling happens at paint time, and typed lookalike text is never
1765/// recolored.
1766fn push_row_spans<'a>(
1767	editor: &Editor,
1768	row: &'a str,
1769	runs: &[SyntaxRun],
1770	spans: &mut SmallVec<Span<'a>, 16>,
1771) {
1772	let text = editor.text();
1773	let buffer_start = text.as_ptr() as usize;
1774	let row_start = (row.as_ptr() as usize).saturating_sub(buffer_start);
1775	let row_end = row_start + row.len();
1776	// Chip segments clipped to this row; style derives from the FULL atom
1777	// text, so a chip wrapped across rows keeps its color on every row.
1778	let mut chips: SmallVec<(usize, usize, Style), 4> = SmallVec::new();
1779	for (start, end) in editor.atom_ranges() {
1780		let from = start.max(row_start);
1781		let to = end.min(row_end);
1782		if from < to {
1783			chips.push((from - row_start, to - row_start, chip_style(&text[start..end])));
1784		}
1785	}
1786	// The style and extent of the base segment covering `at`: its XML run,
1787	// or plain text up to the next run.
1788	let base = |at: usize| {
1789		runs
1790			.iter()
1791			.find(|run| run.start <= at && at < run.end)
1792			.map_or_else(
1793				|| {
1794					let next = runs
1795						.iter()
1796						.map(|run| run.start)
1797						.filter(|start| *start > at)
1798						.min()
1799						.unwrap_or(row.len());
1800					(next, base_style())
1801				},
1802				|run| (run.end, run.style),
1803			)
1804	};
1805	fn emit<'a>(
1806		row: &'a str,
1807		base: &impl Fn(usize) -> (usize, Style),
1808		from: usize,
1809		to: usize,
1810		spans: &mut SmallVec<Span<'a>, 16>,
1811	) {
1812		let mut at = from;
1813		while at < to {
1814			let (run_end, style) = base(at);
1815			let end = run_end.min(to);
1816			spans.push(Span::new(&row[at..end], style));
1817			at = end;
1818		}
1819	}
1820	let mut at = 0;
1821	for (start, end, style) in chips {
1822		emit(row, &base, at, start, spans);
1823		spans.push(Span::new(&row[start..end], style));
1824		at = end;
1825	}
1826	emit(row, &base, at, row.len(), spans);
1827}
Source

pub const fn picker(&self) -> Option<&Picker>

Returns the open completion dropdown, if any.

Examples found in repository?
examples/chat/demo.rs (line 301)
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	}
Source

pub fn picker_height(&self) -> u16

Returns the rows the open completion dropdown occupies (0 when closed).

Examples found in repository?
examples/chat/demo.rs (line 425)
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	}
Source

pub fn input_height_for(&self, width: u16) -> u16

Returns the clipped input row count at width, remembering the width for subsequent key handling.

Examples found in repository?
examples/chat/demo.rs (line 424)
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	}
Source

pub fn view(&self, width: u16) -> SmallVec<VisualRow<'_>, 8>

Returns the cursor-centered visible input rows at width.

Examples found in repository?
examples/chat/demo.rs (line 437)
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 set_cursor_visual_row(&mut self, row: usize, column: u16, width: u16)

Places the cursor on a visual input row and refreshes derived editor state.

Examples found in repository?
examples/chat/demo.rs (lines 503-508)
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	}
Source

pub fn scroll_rows(&self, delta: i32, width: u16, max_rows: usize) -> bool

Scrolls the input viewport by delta visual rows.

Returns whether the clamped viewport offset changed.

Examples found in repository?
examples/chat/demo.rs (line 516)
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	}
Source

pub fn handle_key(&mut self, key: Key) -> EditOutcome

Applies one decoded terminal key.

Source

pub fn handle(&mut self, key: Key) -> EditOutcome

Applies one decoded editor key.

While the dropdown is open, navigation and acceptance keys drive it and Esc closes it; every other key edits the buffer as usual.

Examples found in repository?
examples/chat/demo.rs (line 484)
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	}
Source

pub fn insert_text(&mut self, text: &str) -> EditOutcome

Inserts sanitized text at the cursor (pastes, programmatic prefill).

Examples found in repository?
examples/chat/demo.rs (line 528)
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	}
Source

pub fn insert_reference(&mut self, marker: &str, payload: &str) -> EditOutcome

Inserts an atomic reference at the cursor; see EditBuffer::insert_reference.

Examples found in repository?
examples/chat/demo.rs (line 950)
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	}
Source

pub fn atom_ranges(&self) -> SmallVec<(usize, usize), 4>

Byte ranges of atomic markers in the visible text; see EditBuffer::atom_ranges.

Examples found in repository?
examples/chat/demo.rs (line 964)
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}
1737
1738fn visible_width(text: &str) -> u16 {
1739	u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
1740}
1741
1742/// Finds the first `<ref image=N/>` tag in submitted text: its byte range
1743/// plus the marker number `N`.
1744fn next_ref_tag(text: &str) -> Option<(usize, usize, usize)> {
1745	const HEAD: &str = "<ref image=";
1746	let mut from = 0;
1747	while let Some(at) = text[from..].find(HEAD) {
1748		let start = from + at;
1749		let body = &text[start + HEAD.len()..];
1750		let digits = body.bytes().take_while(u8::is_ascii_digit).count();
1751		if digits > 0 && body[digits..].starts_with("/>") {
1752			let marker = body[..digits].parse().unwrap_or(usize::MAX);
1753			return Some((start, start + HEAD.len() + digits + 2, marker));
1754		}
1755		from = start + HEAD.len();
1756	}
1757	None
1758}
1759
1760/// Splits one composer input row into base-styled text and attachment
1761/// chips, chip styling winning over any overlapping XML run.
1762///
1763/// Chips are located through the buffer's atomic ranges — like the XML
1764/// pass, styling happens at paint time, and typed lookalike text is never
1765/// recolored.
1766fn push_row_spans<'a>(
1767	editor: &Editor,
1768	row: &'a str,
1769	runs: &[SyntaxRun],
1770	spans: &mut SmallVec<Span<'a>, 16>,
1771) {
1772	let text = editor.text();
1773	let buffer_start = text.as_ptr() as usize;
1774	let row_start = (row.as_ptr() as usize).saturating_sub(buffer_start);
1775	let row_end = row_start + row.len();
1776	// Chip segments clipped to this row; style derives from the FULL atom
1777	// text, so a chip wrapped across rows keeps its color on every row.
1778	let mut chips: SmallVec<(usize, usize, Style), 4> = SmallVec::new();
1779	for (start, end) in editor.atom_ranges() {
1780		let from = start.max(row_start);
1781		let to = end.min(row_end);
1782		if from < to {
1783			chips.push((from - row_start, to - row_start, chip_style(&text[start..end])));
1784		}
1785	}
1786	// The style and extent of the base segment covering `at`: its XML run,
1787	// or plain text up to the next run.
1788	let base = |at: usize| {
1789		runs
1790			.iter()
1791			.find(|run| run.start <= at && at < run.end)
1792			.map_or_else(
1793				|| {
1794					let next = runs
1795						.iter()
1796						.map(|run| run.start)
1797						.filter(|start| *start > at)
1798						.min()
1799						.unwrap_or(row.len());
1800					(next, base_style())
1801				},
1802				|run| (run.end, run.style),
1803			)
1804	};
1805	fn emit<'a>(
1806		row: &'a str,
1807		base: &impl Fn(usize) -> (usize, Style),
1808		from: usize,
1809		to: usize,
1810		spans: &mut SmallVec<Span<'a>, 16>,
1811	) {
1812		let mut at = from;
1813		while at < to {
1814			let (run_end, style) = base(at);
1815			let end = run_end.min(to);
1816			spans.push(Span::new(&row[at..end], style));
1817			at = end;
1818		}
1819	}
1820	let mut at = 0;
1821	for (start, end, style) in chips {
1822		emit(row, &base, at, start, spans);
1823		spans.push(Span::new(&row[start..end], style));
1824		at = end;
1825	}
1826	emit(row, &base, at, row.len(), spans);
1827}
Source

pub fn inline_hint(&self) -> Option<Str>

Dim ghost text rendered after the cursor: the selected suggestion’s hint while the dropdown is open, otherwise the completion engine’s latest EditorCompletion::hint.

Examples found in repository?
examples/chat/demo.rs (line 459)
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	}

Auto Trait Implementations§

§

impl !Freeze for Editor

§

impl !RefUnwindSafe for Editor

§

impl !Send for Editor

§

impl !Sync for Editor

§

impl !UnwindSafe for Editor

§

impl Unpin for Editor

§

impl UnsafeUnpin for Editor

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> 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> 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, 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.