Skip to main content

Ui

Struct Ui 

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

A parsed, laid-out, retained component tree painting into a Frame.

Implementations§

Source§

impl Ui

Source

pub fn from_markup( source: impl Into<Str>, width: u16, ctx: UiContext, ) -> Result<Self, ParseError>

Parses runtime markup and produces the first fully painted frame.

Prefer Ui::from_root with crate::dom! when the structure is known at compile time; this path is for markup that only exists at runtime (configuration, generated text, editable source).

§Errors

Returns ParseError for malformed markup.

Examples found in repository?
examples/chat/demo.rs (line 237)
235	fn view(text: &str, width: u16, ctx: &UiContext) -> Option<Ui> {
236		(!text.contains("</md>") && next_ref_tag(text).is_none())
237			.then(|| Ui::from_markup(format!("<md>{text}</md>"), width, ctx.clone()).ok())
238			.flatten()
239	}
More examples
Hide additional examples
examples/tml.rs (line 18)
17fn build(source: &str, width: u16, ctx: &UiContext) -> Ui {
18	match Ui::from_markup(source, width, ctx.clone()) {
19		Ok(ui) => ui,
20		Err(error) => {
21			let message = error.to_string();
22			Ui::from_root(
23				dom! {
24					<box border=round bc=err title="parse error" pad="0 1">
25						<text fg=err>{message}</text>
26					</box>
27				},
28				width,
29				ctx.clone(),
30			)
31		},
32	}
33}
Source

pub fn from_root(root: impl IntoComponent, width: u16, ctx: UiContext) -> Self

Builds a retained UI directly from a component tree.

Examples found in repository?
examples/chat/picker.rs (line 538)
529fn build(
530	mode: Mode,
531	tier: PerfTier,
532	current: usize,
533	query: &str,
534	rows: u16,
535	width: u16,
536	ctx: &UiContext,
537) -> Ui {
538	Ui::from_root(tree(mode, tier, current, query, rows, ctx.charset), width, ctx.clone())
539}
More examples
Hide additional examples
examples/tml.rs (lines 22-30)
17fn build(source: &str, width: u16, ctx: &UiContext) -> Ui {
18	match Ui::from_markup(source, width, ctx.clone()) {
19		Ok(ui) => ui,
20		Err(error) => {
21			let message = error.to_string();
22			Ui::from_root(
23				dom! {
24					<box border=round bc=err title="parse error" pad="0 1">
25						<text fg=err>{message}</text>
26					</box>
27				},
28				width,
29				ctx.clone(),
30			)
31		},
32	}
33}
examples/chat/commands.rs (lines 212-233)
208fn build(query: &str, rows: u16, width: u16, ctx: &UiContext) -> Ui {
209	let list = entries();
210	let seed = Str::from(query);
211	let height = rows.saturating_add(1);
212	Ui::from_root(
213		dom! {
214			<box border=round title="Commands" pad-x=1>
215				<col>
216					<select id="commands" filter={seed} h={height}>
217						for entry in list {
218							<option value={entry.value} label={entry.label}>
219								<td><pre fg={entry.name_fg}>{entry.name}</pre></td>
220								<td truncate grow><pre fg={DIM}>{entry.detail}</pre></td>
221								if !entry.key.is_empty() {
222									<td align=end><pre fg={DIM}>{entry.key}</pre></td>
223								}
224							</option>
225						}
226					</select>
227					<text dim truncate>{HINT}</text>
228				</col>
229			</box>
230		},
231		width,
232		ctx.clone(),
233	)
234}
examples/chat/sidebar.rs (lines 172-209)
170fn build(model: &str, ctx: &UiContext) -> Ui {
171	let files = FILES;
172	Ui::from_root(
173		dom! {
174			<row id="rail" h=24>
175				<hr/>
176				<col id="body" h=24 grow pad="0 1" gap=1>
177					<text bold fg={CYAN}>{"session"}</text>
178					<col>
179						<row gap=1>
180							<text fg={DIM} w=8>{"model"}</text>
181							<text id="model" truncate>{model}</text>
182						</row>
183						<row gap=1>
184							<text fg={DIM} w=8>{"elapsed"}</text>
185							<text id="elapsed">{"0:00"}</text>
186						</row>
187						<row gap=1>
188							<text fg={DIM} w=8>{"branch"}</text>
189							<text truncate>{"tui/seam-commits"}</text>
190						</row>
191					</col>
192					<hr/>
193					<text bold fg={CYAN}>{"files"}</text>
194					<select id="files" h={files.len() as u16}>
195						for (name, delta) in files {
196							<option value={name} label={name}>
197								<td grow truncate><pre>{name}</pre></td>
198								<td align=end><pre fg={GREEN}>{delta}</pre></td>
199							</option>
200						}
201					</select>
202					<spacer grow/>
203					<text dim truncate>{"ctrl+b rail · esc back"}</text>
204				</col>
205			</row>
206		},
207		WIDTH,
208		ctx.clone(),
209	)
210}
examples/chat/demo.rs (line 769)
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	}
examples/companies.rs (line 153)
107fn build_ui(viewport: Size, context: UiContext) -> Ui {
108	let ids = PROVIDERS
109		.iter()
110		.enumerate()
111		.map(|(index, provider)| {
112			(format!("{ASSET_DIR}/{}.png", provider.id), u32::try_from(index + 1).unwrap())
113		})
114		.collect::<HashMap<_, _>>();
115	let elements = Elements::builder()
116		.with("logo", move |_: &str, props: Props, _: Vec<Cached>| {
117			let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
118			let id = ids.get(source).copied().unwrap_or(1);
119			Box::new(
120				Img::new()
121					.with_str(Prop::Src, source)
122					.with(Prop::W, 4_u16)
123					.kitty(id, 2, 4),
124			) as Box<dyn omp_tui::Component>
125		})
126		.build();
127	let root = dom! {
128		<col gap=1>
129			<row gap=1>
130				<i:log-in/>
131				<text bold fg="accent..info">{"Choose a provider"}</text>
132				<text dim>{format!("{} providers", PROVIDERS.len())}</text>
133			</row>
134			<scroll id={SCROLL_ID} h={scroll_height(viewport)}>
135				<row wrap gap=1 justify=center>
136					for provider in PROVIDERS.iter() {
137						<box focus id={provider.id} w={CARD_W} border=round bc="muted..muted"
138							hover="#38bdf8..#c084fc" lift=1 anim=220 ease=in-out
139							align=center pad-x=1>
140							<logo src={format!("{ASSET_DIR}/{}.png", provider.id)}/>
141							<text bold truncate align=center>{provider.name}</text>
142						</box>
143					}
144				</row>
145			</scroll>
146			<row gap=2>
147				<text dim>{"↹/←→/↑↓ pick · ↵ login · wheel scroll · Ctrl-C quit"}</text>
148				<text id={HUD_ID} dim>{"repaint: 0 cells"}</text>
149			</row>
150		</col>
151	};
152	let context = UiContext { elements, ..context };
153	Ui::from_root(root, viewport.width, context)
154}
Source

pub const fn frame(&self) -> &Frame

The retained document frame.

Examples found in repository?
examples/chat/demo.rs (line 979)
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}
More examples
Hide additional examples
examples/chat/picker.rs (line 319)
307	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308		let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309		let tier = PerfTier::of(viewport.width);
310		if tier != self.tier {
311			self.tier = tier;
312			self.rebuild();
313		}
314		if rows != self.rows {
315			self.rows = rows;
316			// One query row plus the windowed list.
317			self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318		}
319		if self.ui.frame().size().width != viewport.width {
320			self.ui.resize(viewport.width);
321		}
322		Layer { frame: self.ui.frame(), options: &self.options, active: true }
323	}
324
325	/// Applies one surfaced [`UiEvent`] to picker state.
326	fn route(&mut self, event: UiEvent) -> PickerEvent {
327		match event {
328			UiEvent::Cancel => PickerEvent::Close,
329			UiEvent::Changed { value, .. } => value
330				.as_str()
331				.parse()
332				.map_or(PickerEvent::Consumed, PickerEvent::Pick),
333			UiEvent::Highlighted { value, .. } => {
334				self.show_detail(value.as_str().parse().ok());
335				PickerEvent::Consumed
336			},
337			UiEvent::Filtered { query, value, .. } => {
338				let wants_roles = query.starts_with('@');
339				self.query = query;
340				if wants_roles == (self.mode == Mode::Roles) {
341					self.show_detail(value.and_then(|value| value.as_str().parse().ok()));
342				} else {
343					self.mode = if wants_roles {
344						Mode::Roles
345					} else {
346						Mode::Models
347					};
348					self.rebuild();
349				}
350				PickerEvent::Consumed
351			},
352			UiEvent::None | UiEvent::Submit | UiEvent::Pressed(_) => PickerEvent::Consumed,
353		}
354	}
355
356	/// Rebuilds the retained tree for the current mode/tier, reseeding the
357	/// select's query so typing continuity survives the swap.
358	fn rebuild(&mut self) {
359		let width = self.ui.frame().size().width;
360		self.ui = build(self.mode, self.tier, self.current, &self.query, self.rows, width, &self.ctx);
361		let initial = match self.mode {
362			Mode::Models => Some(self.current),
363			// The role list keeps its configured order; detail follows the
364			// first row until the next cursor event.
365			Mode::Roles => ROLES.first().map(|role| role.model),
366		};
367		self.show_detail(initial);
368	}
examples/chat/commands.rs (line 118)
108	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109		let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110		let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111		if rows != self.rows {
112			self.rows = rows;
113			// One query row plus the windowed list.
114			self
115				.ui
116				.set_prop("commands", Prop::H, rows.saturating_add(1));
117		}
118		if self.ui.frame().size().width != width {
119			self.ui = build(&self.query, self.rows, width, &self.ctx);
120		}
121		self.options = self.options.width(Dim::Cells(width));
122		Layer { frame: self.ui.frame(), options: &self.options, active: true }
123	}
examples/chat/sidebar.rs (line 155)
138	pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139		if !self.visible(viewport) {
140			if self.focused {
141				self.blur();
142			}
143			return None;
144		}
145		if self.height != viewport.height {
146			self.height = viewport.height;
147			self.ui.set_prop("rail", Prop::H, viewport.height);
148			self.ui.set_prop("body", Prop::H, viewport.height);
149		}
150		let seconds = elapsed.as_secs();
151		if seconds != self.elapsed_seconds {
152			self.elapsed_seconds = seconds;
153			self.ui.set_text("elapsed", elapsed_label(seconds));
154		}
155		Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156	}
examples/tml.rs (line 73)
42async fn main() -> io::Result<()> {
43	let path = std::env::args()
44		.nth(1)
45		.unwrap_or_else(|| "example.tml".into());
46	let source = std::fs::read_to_string(&path)?;
47
48	let mut ctx = None;
49	let mut app = AppOptions::new()
50		.quit([Key::Ctrl('c'), Key::Char('q'), Key::Esc])
51		.start(|env| {
52			let ui = build(&source, env.viewport.width, &env.ctx);
53			ctx = Some(env.ctx);
54			ui
55		})
56		.await?;
57	let ctx = ctx.expect("start ran the builder");
58
59	let handle = app.handle();
60	tokio::spawn(async move {
61		let mut seen = modified(path.as_ref());
62		loop {
63			tokio::time::sleep(Duration::from_millis(150)).await;
64			let stamp = modified(path.as_ref());
65			if stamp == seen {
66				continue;
67			}
68			seen = stamp;
69			let Ok(source) = std::fs::read_to_string(&path) else {
70				continue;
71			};
72			let ctx = ctx.clone();
73			handle.update(move |ui| *ui = build(&source, ui.frame().size().width, &ctx));
74		}
75	});
76
77	while app.next().await?.is_some() {}
78	Ok(())
79}
Source

pub const fn context(&self) -> &UiContext

The presentation context this tree renders with.

Source

pub fn set_context(&mut self, ctx: UiContext) -> bool

Swaps the presentation context and refreshes the whole document.

Applies ctx to this tree and every stacked overlay, advances the cache revision so geometry and render memos discard context-derived output, then relays out and repaints. A context that compares equal (appearance, charset, graphics, Jamo policy, theme, elements) is a no-op returning false. The presentation clock is retained, and a context without an image loader keeps the installed one. Structure parsed from markup is retained: swapping elements affects future parses only.

Source

pub const fn height(&self) -> u16

Document height in rows after the last layout.

Examples found in repository?
examples/chat/demo.rs (line 865)
864	pub fn handle_mouse(&mut self, report: &MouseReport) {
865		let editor_height = self.editor_ui.height();
866		let editor_y = self.frame.size().height.saturating_sub(editor_height);
867		let editor_bottom = editor_y.saturating_add(editor_height);
868		if report.row < editor_y || report.row >= editor_bottom {
869			return;
870		}
871		let _ = self
872			.editor_ui
873			.handle_mouse(report.col, report.row - editor_y, report.kind);
874	}
875
876	/// Switches the work state and retargets the brand fade. The status bar
877	/// repaints immediately and the fade departs from whatever color is on
878	/// screen, so rapid cancel/resume never snaps.
879	fn set_working(&mut self, working: bool, now: Duration) {
880		{
881			let mut work = self.work.borrow_mut();
882			if work.working == working {
883				return;
884			}
885			work.working = working;
886			work.since = now;
887			let target = if working { GREEN } else { MUTED };
888			work
889				.fade
890				.retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891		}
892		self.editor_ui.invalidate(STATUS_ID);
893	}
894
895	/// Reflects a session model switch in the status bar's model segment.
896	pub fn set_model(&mut self, name: &str) {
897		*self.model.borrow_mut() = Str::from(name);
898		self.editor_ui.invalidate(STATUS_ID);
899	}
900
901	/// Routes sanitized bracketed paste text through the editor. Dropped
902	/// paths to existing image files (quoted, escaped, `file://`, or
903	/// multi-file) and any large paste collapse into composer attachment
904	/// chips instead of raw text.
905	pub fn handle_paste(&mut self, text: &str) {
906		let paths = omp_tui::paste::dropped_paths(text);
907		if !paths.is_empty()
908			&& paths.iter().all(|path| {
909				omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910			}) {
911			for path in &paths {
912				self.attach_image(path);
913			}
914			return;
915		}
916		if text.lines().count() > 10 || text.len() > 1000 {
917			self.attach_paste(text);
918			return;
919		}
920		let _ = self.editor_ui.handle_paste(text);
921	}
922
923	/// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924	/// attachment staging, no large-paste collapse — the text stays inline
925	/// and editable.
926	pub fn handle_paste_raw(&mut self, text: &str) {
927		let _ = self.editor_ui.handle_paste_raw(text);
928	}
929
930	/// Stages `path` on the composer and mentions it in the prompt as an
931	/// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932	fn attach_image(&mut self, path: &str) {
933		let attachment = self.attachments.push_image(path);
934		let payload = format!("<ref image={}/>", attachment.marker);
935		self.insert_chip(&attachment, &payload);
936	}
937
938	/// Collapses a large paste into a staged attachment card and an atomic
939	/// composer chip expanding back to the pasted text on submit.
940	fn attach_paste(&mut self, text: &str) {
941		let attachment = self.attachments.push_text(text);
942		self.insert_chip(&attachment, text);
943	}
944
945	/// Inserts one attachment chip as an atomic editor reference.
946	fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947		let chip = chip_label(attachment, self.ctx.charset);
948		{
949			let mut editor = self.editor.borrow_mut();
950			let _ = editor.insert_reference(&chip, payload);
951			let _ = editor.insert_text(" ");
952		}
953		self.refresh_composer();
954	}
955
956	/// Hides staged attachments whose chip the user deleted from the
957	/// composer (and re-shows them after an undo). Presence is derived
958	/// from the buffer's atomic ranges, never from text matching.
959	fn reconcile_attachments(&mut self) {
960		let charset = self.ctx.charset;
961		let changed = {
962			let editor = self.editor.borrow();
963			let text = editor.text();
964			let ranges = editor.atom_ranges();
965			self.attachments.set_visible(|attachment| {
966				let chip = chip_label(attachment, charset);
967				ranges
968					.iter()
969					.any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970			})
971		};
972		if changed {
973			self.refresh_composer();
974		}
975	}
976
977	/// Relayouts the composer after out-of-band state changed its height.
978	fn refresh_composer(&mut self) {
979		let width = self.editor_ui.frame().size().width;
980		if width > 0 {
981			self.editor_ui.resize(width);
982		}
983	}
984
985	/// Reserves `cols` at the right edge for a composited rail, so the
986	/// composer's right-docked chrome stays visible beside it. The next
987	/// render relayouts the editor at the narrowed width.
988	pub const fn set_right_inset(&mut self, cols: u16) {
989		self.right_inset = cols;
990	}
991
992	/// The width the composer may actually occupy at `viewport`.
993	fn composer_width(&self, viewport: Size) -> u16 {
994		viewport.width.saturating_sub(self.right_inset).max(1)
995	}
996
997	/// Updates the retained logical document and reports its repainted rows.
998	pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999		self.render_at(viewport, self.started_at.elapsed())
1000	}
1001
1002	fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003		if viewport.width == 0 || viewport.height == 0 {
1004			self.last_viewport = viewport;
1005			self.height_floor = 0;
1006			self.drawn_entries = 0;
1007			self.transcript_rows = 0;
1008			self.live_panel = None;
1009			self.frame = Frame::new(viewport);
1010			return RenderedFrame {
1011				frame:       &self.frame,
1012				stable_rows: 0,
1013				damage:      SmallVec::new(),
1014			};
1015		}
1016		let composer_width = self.composer_width(viewport);
1017		if self.editor_ui.frame().size().width != composer_width {
1018			self.editor_ui.resize(composer_width);
1019		}
1020		// Fires due animation wakes (the status bar's spinner and brand
1021		// fade) so the blit below picks up fresh retained pixels.
1022		self.editor_ui.tick(elapsed);
1023		let editor_changed = self.editor_ui.take_frame_damage();
1024
1025		// A viewport change starts a fresh renderer session: replay the
1026		// whole transcript log at the new width. Between rebuilds the log
1027		// is append-only and every drawn row is final, so selections over
1028		// transcript text stay anchored to it in every terminal.
1029		let rebuild = self.last_viewport != viewport;
1030		if rebuild {
1031			self.last_viewport = viewport;
1032			self.height_floor = 0;
1033			self.drawn_entries = 0;
1034			self.transcript_rows = 0;
1035			let message_width = Self::message_width(viewport.width);
1036			for entry in &mut self.transcript {
1037				if let Entry::Submitted(submission) = entry {
1038					submission.resize(message_width, &self.ctx);
1039				}
1040			}
1041		}
1042		while self.appended_messages < Self::visible_messages(elapsed) {
1043			self.transcript.push(Entry::Message(self.appended_messages));
1044			self.appended_messages += 1;
1045		}
1046		while self.emitted_shards < Self::finished_shards(elapsed) {
1047			self.emitted_shards += 1;
1048			self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049		}
1050
1051		let mut new_rows = 0_u16;
1052		for entry in &self.transcript[self.drawn_entries..] {
1053			new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054		}
1055		let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056		let editor_height = self.editor_ui.height();
1057		// Native scrollback is append-only, so the logical document may
1058		// never shrink while the seam is live: band rows that close again
1059		// (extra input lines) become blank padding that heals as the
1060		// transcript grows.
1061		let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062		self.height_floor = self.height_floor.max(natural_height);
1063		let document_height = self.height_floor.max(viewport.height);
1064		let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065		let margin = u16::from(viewport.width >= 50);
1066		let content_width = viewport.width.saturating_sub(margin * 2);
1067		let editor_y = document_height.saturating_sub(editor_height);
1068		let title_y = editor_y.saturating_sub(1);
1069		let working_y = title_y.saturating_sub(1);
1070		let panel_height = LIVE_SHARD_ROWS + 2;
1071		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072		let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073		let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074		if rebuild {
1075			self.frame = Frame::new(Size::new(viewport.width, document_height));
1076		} else {
1077			self.frame.resize_height(document_height, base_style());
1078		}
1079		if repaint_suffix {
1080			self.frame.fill(
1081				Rect::new(
1082					0,
1083					transcript_damage_start,
1084					viewport.width,
1085					document_height.saturating_sub(transcript_damage_start),
1086				),
1087				base_style(),
1088			);
1089		}
1090
1091		// Paint the new transcript entries; rows above `transcript_rows`
1092		// are final and never repainted.
1093		let mut y = self.transcript_rows;
1094		for index in self.drawn_entries..self.transcript.len() {
1095			let used = self.draw_entry_at(index, y, viewport.width);
1096			y = y.saturating_add(used);
1097		}
1098		self.drawn_entries = self.transcript.len();
1099		self.transcript_rows = y;
1100
1101		// The live band repaints in place at the bottom of the document.
1102		let animation_frame = Self::animation_frame(elapsed);
1103		let panel_changed = draw_live_panel(
1104			&mut self.frame,
1105			&mut self.live_rows,
1106			&mut self.live_label_scratch,
1107			panel,
1108			repaint_suffix,
1109			self.emitted_shards,
1110			animation_frame,
1111			self.ctx.charset,
1112		);
1113		let working = self.work.borrow().working;
1114		let working_changed = self.last_working != working;
1115		if !repaint_suffix && self.last_working && !working {
1116			self
1117				.frame
1118				.fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119		}
1120		if working {
1121			Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122		}
1123		Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124		if repaint_suffix || editor_changed {
1125			self
1126				.frame
1127				.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128		}
1129		let mut damage = SmallVec::new();
1130		if repaint_suffix {
1131			damage.push((transcript_damage_start, document_height));
1132		} else {
1133			if panel_changed {
1134				damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135			}
1136			if working || working_changed {
1137				damage.push((working_y, working_y.saturating_add(1)));
1138			}
1139			if editor_changed {
1140				damage.push((editor_y, document_height));
1141			}
1142		}
1143		self.last_working = working;
1144		self.live_panel = Some(panel);
1145
1146		RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147	}
1148
1149	fn generation(elapsed: Duration) -> u64 {
1150		u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151	}
1152
1153	fn animation_frame(elapsed: Duration) -> u64 {
1154		u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155	}
1156
1157	fn visible_messages(elapsed: Duration) -> usize {
1158		let interval = MESSAGE_INTERVAL.as_millis();
1159		usize::try_from(elapsed.as_millis() / interval + 1)
1160			.unwrap_or(usize::MAX)
1161			.min(4)
1162	}
1163
1164	/// Shards whose permanent result line has been appended by `elapsed`:
1165	/// two per emit tick, capped well inside `u16` document heights.
1166	fn finished_shards(elapsed: Duration) -> u16 {
1167		u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168			.expect("finished shard count is clamped")
1169	}
1170
1171	/// Rows the bottom live band occupies: the shard panel, a blank
1172	/// separator, the activity row, the title air row, and the editor
1173	/// block.
1174	const fn band_height(editor_height: u16) -> u16 {
1175		LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176	}
1177
1178	/// Rows `entry` will occupy at `width`, including its trailing blank.
1179	fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180		match entry {
1181			Entry::Command => 5,
1182			Entry::Message(message) => {
1183				let mut scratch = Frame::new(Size::new(width, 48));
1184				Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185			},
1186			Entry::ShardDone(_) => 1,
1187			Entry::Submitted(submission) => submission.height().saturating_add(1),
1188		}
1189	}
1190
1191	const fn message_width(width: u16) -> u16 {
1192		let narrowed = width.saturating_sub(3);
1193		if narrowed == 0 { 1 } else { narrowed }
1194	}
1195
1196	/// Paints one transcript entry at `y` and returns the rows it used.
1197	fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198		Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199	}
1200
1201	/// Paints `entry` into any frame at `y` and returns the rows it used,
1202	/// including the trailing blank.
1203	fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204		let margin = u16::from(width >= 50);
1205		let content_width = width.saturating_sub(margin * 2);
1206		match entry {
1207			Entry::Command => {
1208				draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209				5
1210			},
1211			Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212			Entry::ShardDone(shard) => {
1213				Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214				1
1215			},
1216			Entry::Submitted(submission) => {
1217				draw_submission(frame, y, submission, ctx.charset);
1218				submission.height().saturating_add(1)
1219			},
1220		}
1221	}
1222
1223	/// Composes exactly one viewport of throwaway resize-drag content at the
1224	/// new geometry: the live band anchors to the bottom, then transcript
1225	/// entries are walked backward and rewrapped at `viewport.width` until
1226	/// the screen is full — O(viewport) work per drag frame, with the
1227	/// topmost entry sliced when it only partially fits. Retained transcript
1228	/// state is untouched, so the settle rebuild replays full history
1229	/// exactly once.
1230	pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231		let elapsed = self.started_at.elapsed();
1232		let mut frame = Frame::new(viewport);
1233		if viewport.width == 0 || viewport.height == 0 {
1234			return frame;
1235		}
1236		frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237		let composer_width = self.composer_width(viewport);
1238		if self.editor_ui.frame().size().width != composer_width {
1239			self.editor_ui.resize(composer_width);
1240		}
1241		self.editor_ui.tick(elapsed);
1242
1243		// The live band, laid out exactly like the retained document's.
1244		let margin = u16::from(viewport.width >= 50);
1245		let content_width = viewport.width.saturating_sub(margin * 2);
1246		let editor_height = self.editor_ui.height();
1247		let editor_y = viewport.height.saturating_sub(editor_height);
1248		let title_y = editor_y.saturating_sub(1);
1249		let working_y = title_y.saturating_sub(1);
1250		let panel_height = LIVE_SHARD_ROWS + 2;
1251		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252		draw_live_panel(
1253			&mut frame,
1254			&mut self.live_rows,
1255			&mut self.live_label_scratch,
1256			Rect::new(margin, panel_y, content_width, panel_height),
1257			true,
1258			self.emitted_shards,
1259			Self::animation_frame(elapsed),
1260			self.ctx.charset,
1261		);
1262		if self.work.borrow().working {
1263			Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264		}
1265		Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266		frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268		// Transcript tail, bottom-up above the band.
1269		let mut remaining = panel_y;
1270		for entry in self.transcript.iter().rev() {
1271			if remaining == 0 {
1272				break;
1273			}
1274			let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275			if height == 0 {
1276				continue;
1277			}
1278			if height <= remaining {
1279				remaining -= height;
1280				Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281			} else {
1282				// Slice the bottom rows of the partially visible entry.
1283				let mut scratch = Frame::new(Size::new(viewport.width, height));
1284				scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285				Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286				frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287				remaining = 0;
1288			}
1289		}
1290		frame
1291	}
1292
1293	/// Paints the n-th scripted message and returns rows used including
1294	/// the trailing blank. Measurement draws into a scratch frame.
1295	fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296		let margin = u16::from(width >= 50);
1297		let content_width = width.saturating_sub(margin * 2);
1298		if message == 2 {
1299			draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300			return EDIT_BOX_HEIGHT + 1;
1301		}
1302		let bottom = frame.size().height;
1303		let spans = Self::message_spans(message);
1304		// Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305		// wrapped row re-joins byte-for-byte in native selection.
1306		let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307		used.saturating_add(1)
1308	}
1309
1310	fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311		let mut spans = SmallVec::new();
1312		match message {
1313			0 => {
1314				spans.push(Span::new("Transcript rows are ", prose_style()));
1315				spans.push(Span::new("append-only", code_style()));
1316				spans.push(Span::new(
1317					": every line is painted once, becomes stable, and rides into native scrollback \
1318					 with any selection anchored to it.",
1319					prose_style(),
1320				));
1321			},
1322			1 => {
1323				spans.push(Span::new(
1324					"Only the bottom band repaints in place — the live shard panel, the activity \
1325					 shimmer, and the composer. Rows above it are never rewritten.",
1326					prose_style(),
1327				));
1328			},
1329			_ => {
1330				spans.push(Span::new(
1331					"On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332					 the stable transcript through a ",
1333					prose_style(),
1334				));
1335				spans.push(Span::new("DECSTBM top region", code_style()));
1336				spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337			},
1338		}
1339		spans
1340	}
1341
1342	/// Appends a finished shard's permanent one-line result.
1343	fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344		let margin = u16::from(width >= 50);
1345		let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346		let detail = fmts!("  workspace-{shard:03}.test.ts  [100%]");
1347		draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348			Span::new(prefix.as_str(), ink(GREEN)),
1349			Span::new(detail.as_str(), ink(MUTED)),
1350		]);
1351	}
1352
1353	/// Shimmering activity line above the editor. The spinner and timer
1354	/// live in the status bar's brand segment; this row only narrates.
1355	fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356		if y >= frame.size().height || frame.size().width < 4 {
1357			return;
1358		}
1359		let start = u16::from(frame.size().width >= 50);
1360		let mut column = start;
1361		let length = xutf::graphemes_str(WORKING_MESSAGE)
1362			.count()
1363			.saturating_add(xutf::graphemes_str(hint).count())
1364			.saturating_add(1);
1365		let length = u16::try_from(length).unwrap_or(u16::MAX);
1366		let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367		let right = frame.size().width.saturating_sub(1);
1368		draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369		draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370		draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371	}
1372
1373	/// The session title resting right-aligned in the air row between
1374	/// the working narration and the status band — against the visible
1375	/// right bound, inside any rail reservation — so the gap reads as
1376	/// session identity instead of dead space.
1377	fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378		let width = frame.size().width.saturating_sub(right_inset);
1379		let title_width = visible_width(SESSION_TITLE);
1380		if y >= frame.size().height || width < title_width.saturating_add(2) {
1381			return;
1382		}
1383		let x = width.saturating_sub(title_width.saturating_add(1));
1384		draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385	}
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391	if rect.width < 4 || rect.height < 4 {
1392		return;
1393	}
1394
1395	let content_x = rect.x + 2;
1396	let content_width = rect.width.saturating_sub(4);
1397	let header = [
1398		Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399		Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400	];
1401	draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402	let command = [
1403		Span::new("$ ", panel_ink(MUTED)),
1404		Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405		Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406	];
1407	draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413	frame: &mut Frame,
1414	rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415	label_scratch: &mut StrMut,
1416	rect: Rect,
1417	repaint_chrome: bool,
1418	emitted_shards: u16,
1419	animation_frame: u64,
1420	charset: Charset,
1421) -> bool {
1422	let mut changed = repaint_chrome;
1423	if repaint_chrome {
1424		draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425	}
1426	if rect.width < 4 || rect.height < 3 {
1427		return changed;
1428	}
1429
1430	if repaint_chrome {
1431		let title = [
1432			Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433			Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434		];
1435		draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436	}
1437	let content_x = rect.x + 2;
1438	let content_width = rect.width.saturating_sub(4);
1439	for row in 0..rect.height.saturating_sub(2) {
1440		let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441		let phase = (u64::from(row) + animation_frame) % 11;
1442		let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443			0 => (
1444				0,
1445				"⠼",
1446				"running",
1447				panel_ink(GREEN).bold(),
1448				(u64::from(row) * 17 + animation_frame * 7) % 100,
1449			),
1450			1..=7 => {
1451				(1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452			},
1453			_ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454		};
1455		let row_y = rect.y + 1 + row;
1456		let right = content_x
1457			.saturating_add(content_width)
1458			.min(frame.size().width);
1459		let cache = &mut rows[usize::from(row)];
1460		let prefix_changed = repaint_chrome
1461			|| !cache.prefix_valid
1462			|| cache.prefix_shard != shard
1463			|| cache.prefix_phase != prefix_phase;
1464		changed |= prefix_changed;
1465		let label_x = if prefix_changed {
1466			let prefix = fmts!(" {symbol} shard {shard:03} ");
1467			let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468			let next_x = content_x
1469				.saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470				.saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471				.saturating_add(2)
1472				.min(right);
1473			if !repaint_chrome && cache.label_x != next_x {
1474				clear_cached_label(frame, cache, row_y, right);
1475			}
1476			let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477				Span::new(prefix.as_str(), state_style),
1478				Span::new(state, state_style),
1479				Span::new("  ", panel_ink(FAINT)),
1480			]);
1481			cache.prefix_shard = shard;
1482			cache.prefix_phase = prefix_phase;
1483			cache.prefix_valid = true;
1484			next_x
1485		} else {
1486			cache.label_x
1487		};
1488		let moved = cache.label_x != label_x;
1489		let label_changed = repaint_chrome
1490			|| moved
1491			|| !cache.label_valid
1492			|| cache.label_shard != shard
1493			|| cache.label_progress != progress;
1494		changed |= label_changed;
1495		if label_changed {
1496			label_scratch.truncate(0);
1497			write!(label_scratch, "workspace-{shard:03}.test.ts  [{progress:>3}%]")
1498				.expect("shard label formatting is infallible");
1499			let resized = cache.label.len() != label_scratch.len();
1500			if !repaint_chrome && resized && !moved {
1501				clear_cached_label(frame, cache, row_y, right);
1502			}
1503			let width = right.saturating_sub(label_x);
1504			if repaint_chrome || moved || resized {
1505				frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506			} else {
1507				draw_ascii_changes(
1508					frame,
1509					label_x,
1510					row_y,
1511					width,
1512					cache.label.as_str(),
1513					label_scratch.as_str(),
1514					panel_ink(MUTED),
1515				);
1516			}
1517			std::mem::swap(&mut cache.label, label_scratch);
1518			cache.label_shard = shard;
1519			cache.label_progress = progress;
1520			cache.label_valid = true;
1521		}
1522		cache.label_x = label_x;
1523	}
1524	changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528	if cache.label.is_empty() {
1529		return;
1530	}
1531	let width = u16::try_from(cache.label.len())
1532		.unwrap_or(u16::MAX)
1533		.min(right.saturating_sub(cache.label_x));
1534	frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539	frame: &mut Frame,
1540	x: u16,
1541	y: u16,
1542	width: u16,
1543	previous: &str,
1544	next: &str,
1545	style: Style,
1546) {
1547	if width == 0 || previous == next {
1548		return;
1549	}
1550	if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551		frame.put_clipped(x, y, width, next, style);
1552		return;
1553	}
1554	let previous = previous.as_bytes();
1555	let next_bytes = next.as_bytes();
1556	let limit = previous.len().min(usize::from(width));
1557	let mut index = 0;
1558	while index < limit {
1559		while index < limit && previous[index] == next_bytes[index] {
1560			index += 1;
1561		}
1562		let start = index;
1563		while index < limit && previous[index] != next_bytes[index] {
1564			index += 1;
1565		}
1566		if start < index {
1567			let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568			frame.put_clipped(
1569				x.saturating_add(offset),
1570				y,
1571				u16::try_from(index - start).unwrap_or(u16::MAX),
1572				&next[start..index],
1573				style,
1574			);
1575		}
1576	}
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581	if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582		return;
1583	}
1584
1585	let title = [
1586		Span::new(" Live ", panel_ink(GREEN).bold()),
1587		Span::new("band · selection semantics ", panel_ink(CYAN)),
1588	];
1589	draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590	draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591		Span::new(charset.check(), panel_ink(GREEN).bold()),
1592		Span::new(" ", panel_ink(GREEN)),
1593		Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594	]);
1595	draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596		"  margin commits pin the band on kitty-class terminals",
1597		panel_ink(MUTED),
1598	)]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605	if frame.size().width < 4 {
1606		return;
1607	}
1608	let Some(view) = &submission.view else {
1609		for (offset, line) in submission.text.split('\n').enumerate() {
1610			let Ok(offset) = u16::try_from(offset) else {
1611				break;
1612			};
1613			let row = y.saturating_add(offset);
1614			if row >= frame.size().height {
1615				break;
1616			}
1617			let prompt = if offset == 0 { charset.cursor() } else { "  " };
1618			let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619			let width = frame
1620				.size()
1621				.width
1622				.saturating_sub(1)
1623				.saturating_sub(text_x.saturating_sub(1));
1624			draw_submission_text(frame, text_x, row, width, line, charset);
1625		}
1626		return;
1627	};
1628	frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629	frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
Source

pub fn has_damage(&self) -> bool

Whether a present is needed after the most recent mutation.

Source

pub fn take_frame_damage(&mut self) -> bool

Consumes raw-frame damage after an embedder copies Ui::frame.

Examples found in repository?
examples/chat/demo.rs (line 1023)
1002	fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003		if viewport.width == 0 || viewport.height == 0 {
1004			self.last_viewport = viewport;
1005			self.height_floor = 0;
1006			self.drawn_entries = 0;
1007			self.transcript_rows = 0;
1008			self.live_panel = None;
1009			self.frame = Frame::new(viewport);
1010			return RenderedFrame {
1011				frame:       &self.frame,
1012				stable_rows: 0,
1013				damage:      SmallVec::new(),
1014			};
1015		}
1016		let composer_width = self.composer_width(viewport);
1017		if self.editor_ui.frame().size().width != composer_width {
1018			self.editor_ui.resize(composer_width);
1019		}
1020		// Fires due animation wakes (the status bar's spinner and brand
1021		// fade) so the blit below picks up fresh retained pixels.
1022		self.editor_ui.tick(elapsed);
1023		let editor_changed = self.editor_ui.take_frame_damage();
1024
1025		// A viewport change starts a fresh renderer session: replay the
1026		// whole transcript log at the new width. Between rebuilds the log
1027		// is append-only and every drawn row is final, so selections over
1028		// transcript text stay anchored to it in every terminal.
1029		let rebuild = self.last_viewport != viewport;
1030		if rebuild {
1031			self.last_viewport = viewport;
1032			self.height_floor = 0;
1033			self.drawn_entries = 0;
1034			self.transcript_rows = 0;
1035			let message_width = Self::message_width(viewport.width);
1036			for entry in &mut self.transcript {
1037				if let Entry::Submitted(submission) = entry {
1038					submission.resize(message_width, &self.ctx);
1039				}
1040			}
1041		}
1042		while self.appended_messages < Self::visible_messages(elapsed) {
1043			self.transcript.push(Entry::Message(self.appended_messages));
1044			self.appended_messages += 1;
1045		}
1046		while self.emitted_shards < Self::finished_shards(elapsed) {
1047			self.emitted_shards += 1;
1048			self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049		}
1050
1051		let mut new_rows = 0_u16;
1052		for entry in &self.transcript[self.drawn_entries..] {
1053			new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054		}
1055		let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056		let editor_height = self.editor_ui.height();
1057		// Native scrollback is append-only, so the logical document may
1058		// never shrink while the seam is live: band rows that close again
1059		// (extra input lines) become blank padding that heals as the
1060		// transcript grows.
1061		let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062		self.height_floor = self.height_floor.max(natural_height);
1063		let document_height = self.height_floor.max(viewport.height);
1064		let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065		let margin = u16::from(viewport.width >= 50);
1066		let content_width = viewport.width.saturating_sub(margin * 2);
1067		let editor_y = document_height.saturating_sub(editor_height);
1068		let title_y = editor_y.saturating_sub(1);
1069		let working_y = title_y.saturating_sub(1);
1070		let panel_height = LIVE_SHARD_ROWS + 2;
1071		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072		let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073		let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074		if rebuild {
1075			self.frame = Frame::new(Size::new(viewport.width, document_height));
1076		} else {
1077			self.frame.resize_height(document_height, base_style());
1078		}
1079		if repaint_suffix {
1080			self.frame.fill(
1081				Rect::new(
1082					0,
1083					transcript_damage_start,
1084					viewport.width,
1085					document_height.saturating_sub(transcript_damage_start),
1086				),
1087				base_style(),
1088			);
1089		}
1090
1091		// Paint the new transcript entries; rows above `transcript_rows`
1092		// are final and never repainted.
1093		let mut y = self.transcript_rows;
1094		for index in self.drawn_entries..self.transcript.len() {
1095			let used = self.draw_entry_at(index, y, viewport.width);
1096			y = y.saturating_add(used);
1097		}
1098		self.drawn_entries = self.transcript.len();
1099		self.transcript_rows = y;
1100
1101		// The live band repaints in place at the bottom of the document.
1102		let animation_frame = Self::animation_frame(elapsed);
1103		let panel_changed = draw_live_panel(
1104			&mut self.frame,
1105			&mut self.live_rows,
1106			&mut self.live_label_scratch,
1107			panel,
1108			repaint_suffix,
1109			self.emitted_shards,
1110			animation_frame,
1111			self.ctx.charset,
1112		);
1113		let working = self.work.borrow().working;
1114		let working_changed = self.last_working != working;
1115		if !repaint_suffix && self.last_working && !working {
1116			self
1117				.frame
1118				.fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119		}
1120		if working {
1121			Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122		}
1123		Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124		if repaint_suffix || editor_changed {
1125			self
1126				.frame
1127				.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128		}
1129		let mut damage = SmallVec::new();
1130		if repaint_suffix {
1131			damage.push((transcript_damage_start, document_height));
1132		} else {
1133			if panel_changed {
1134				damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135			}
1136			if working || working_changed {
1137				damage.push((working_y, working_y.saturating_add(1)));
1138			}
1139			if editor_changed {
1140				damage.push((editor_y, document_height));
1141			}
1142		}
1143		self.last_working = working;
1144		self.live_panel = Some(panel);
1145
1146		RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147	}
Source

pub fn set_text(&mut self, id: &str, text: impl Into<Str>) -> bool

Replaces a named component’s text and refreshes the smallest safe region.

Examples found in repository?
examples/chat/sidebar.rs (line 131)
130	pub fn set_model(&mut self, name: &str) {
131		self.ui.set_text("model", name);
132	}
133
134	/// The composited rail for this frame, laid out to the full viewport
135	/// height; `None` when toggled off or gated out by a small viewport.
136	/// Shrinking below the minimum blurs the rail so keys never route into
137	/// an invisible layer.
138	pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139		if !self.visible(viewport) {
140			if self.focused {
141				self.blur();
142			}
143			return None;
144		}
145		if self.height != viewport.height {
146			self.height = viewport.height;
147			self.ui.set_prop("rail", Prop::H, viewport.height);
148			self.ui.set_prop("body", Prop::H, viewport.height);
149		}
150		let seconds = elapsed.as_secs();
151		if seconds != self.elapsed_seconds {
152			self.elapsed_seconds = seconds;
153			self.ui.set_text("elapsed", elapsed_label(seconds));
154		}
155		Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156	}
More examples
Hide additional examples
examples/gallery/render.rs (line 129)
126pub(crate) fn sync_preview(ui: &mut Ui, synced: &mut String) {
127	let text = ui.values()["src"].as_str().unwrap_or_default().to_owned();
128	if text != *synced {
129		ui.set_text("preview", text.clone());
130		*synced = text;
131	}
132}
examples/gallery/anim.rs (line 115)
109	fn mood(&mut self, ui: &mut Ui) {
110		self.mood = (self.mood + 1) % MOODS.len();
111		let (token, bg, text) = MOODS[self.mood];
112		ui.set_prop("mood", Prop::Bc, token);
113		ui.set_prop("mood", Prop::Bg, bg);
114		ui.set_prop("mood-text", Prop::Fg, token);
115		ui.set_text("mood-text", text);
116	}
examples/companies.rs (lines 164-173)
156fn show_stats(app: &mut App, chosen: Option<&str>) {
157	let caps = app.caps();
158	let stats = app.last_stats();
159	let pixels = caps.cell_px.map_or_else(
160		|| "cell-px ?".to_owned(),
161		|(width, height)| format!("cell-px {width}×{height}"),
162	);
163	let login = chosen.map_or(String::new(), |id| format!("login: {id} · "));
164	app.ui_mut().set_text(
165		HUD_ID,
166		format!(
167			"{login}{} · {} · {} · repaint: {} cells",
168			graphics_label(caps.graphics),
169			caps.id,
170			pixels,
171			stats.changed_cells,
172		),
173	);
174}
examples/chat/picker.rs (line 381)
379pub fn show_detail_on(ui: &mut Ui, model: Option<usize>) {
380	let facts = model.map_or_else(|| Str::new_static(" "), |index| facts(&MODELS[index]));
381	ui.set_text("facts", facts);
382	// Hide before show: the document must never transiently exceed its
383	// steady height — a raw-frame layer's retained frame keeps the
384	// high-water mark, which would leave a stale extra row.
385	for index in (0..MODELS.len()).filter(|&index| model != Some(index)) {
386		ui.set_visible(&fmts!("chips-{index}"), false);
387	}
388	if let Some(index) = model {
389		ui.set_visible(&fmts!("chips-{index}"), true);
390	}
391}
examples/gallery/main.rs (line 170)
112async fn main() -> io::Result<()> {
113	let mut app = AppOptions::new()
114		.mouse()
115		.quit([Key::Ctrl('c'), Key::Ctrl('q')])
116		.start(|env| build_ui(env.viewport, env.ctx))
117		.await?;
118	// The picker tab opens with the first model's details, like the chat
119	// overlay does.
120	picker::show_detail_on(app.ui_mut(), Some(0));
121
122	let mut synced = String::new();
123	let mut lab = anim::Lab::new();
124	let mut layers = Layers::default();
125	let mut next_step = tokio::time::Instant::now() + anim::AUTOPLAY_STEP;
126
127	loop {
128		let event = tokio::select! {
129			event = app.next() => match event? {
130				Some(event) => event,
131				None => break,
132			},
133			() = tokio::time::sleep_until(next_step) => {
134				if lab.autoplay && active_tab(app.ui()) == "Anim" {
135					lab.advance(app.ui_mut());
136				}
137				next_step += anim::AUTOPLAY_STEP;
138				continue;
139			},
140		};
141		match event {
142			AppEvent::Resized(viewport) => {
143				for pane in render::PANE_IDS {
144					app.ui_mut().set_height(pane, render::pane_height(viewport));
145				}
146			},
147			AppEvent::Key(key) => match active_tab(app.ui()).as_str() {
148				"Anim" => lab.handle_key(key, app.ui_mut()),
149				"Overlay" => match key {
150					Key::Ctrl('k') if layers.picker.is_none() => {
151						layers.picker = Some(overlay::show_picker(app.ui_mut()));
152					},
153					Key::Ctrl('g') => match layers.help.take() {
154						Some(id) => {
155							app.ui_mut().close_overlay(id);
156						},
157						None => layers.help = Some(overlay::show_help(app.ui_mut())),
158					},
159					_ => {},
160				},
161				_ => {},
162			},
163			// The Overlay tab's modal select committed a model.
164			AppEvent::Changed { id, value } if id == "model" => {
165				if let Some(overlay) = layers.picker.take() {
166					let label = overlay::MODELS
167						.iter()
168						.find(|(short, ..)| *short == value)
169						.map_or(value.as_str(), |(_, label, _)| label);
170					app.ui_mut().set_text("status", format!("model: {label}"));
171					app.ui_mut().close_overlay(overlay);
172				}
173			},
174			// The Picker tab's select moved: mirror the chat picker's
175			// facts-and-chips detail line.
176			AppEvent::Highlighted { id, value } if id == "models" => {
177				picker::show_detail_on(app.ui_mut(), value.as_str().parse().ok());
178			},
179			AppEvent::Filtered { id, value, .. } if id == "models" => {
180				let model = value.and_then(|value| value.as_str().parse().ok());
181				picker::show_detail_on(app.ui_mut(), model);
182			},
183			AppEvent::OverlayClosed(id) => {
184				if layers.picker == Some(id) {
185					layers.picker = None;
186				}
187				if layers.help == Some(id) {
188					layers.help = None;
189				}
190			},
191			_ => {},
192		}
193		render::sync_preview(app.ui_mut(), &mut synced);
194		// Reserve the Overlay tab's chords only while it is showing, so the
195		// focused composer can't spend Ctrl+K on kill-line — and the Live
196		// tab's editor keeps it.
197		let chords: &[Key] = if active_tab(app.ui()) == "Overlay" {
198			&[Key::Ctrl('k'), Key::Ctrl('g')]
199		} else {
200			&[]
201		};
202		app.set_hotkeys(chords.iter().copied());
203	}
204	Ok(())
205}
Source

pub fn set_prop( &mut self, id: &str, prop: Prop, value: impl Into<PropValue>, ) -> bool

Sets a named component’s property and refreshes the smallest safe region. Size properties relayout the document; components with an anim property tween toward the new value from whatever is on screen. A matching value is a no-op. Returns false for an unknown id.

§Panics

Panics when a textual value is invalid for prop, matching [Props::set].

Examples found in repository?
examples/gallery/anim.rs (line 105)
101	fn race(&mut self, ui: &mut Ui) {
102		self.race_wide = !self.race_wide;
103		let target = if self.race_wide { "88%" } else { "12%" };
104		for (id, _) in BARS {
105			ui.set_prop(id, Prop::W, target);
106		}
107	}
108
109	fn mood(&mut self, ui: &mut Ui) {
110		self.mood = (self.mood + 1) % MOODS.len();
111		let (token, bg, text) = MOODS[self.mood];
112		ui.set_prop("mood", Prop::Bc, token);
113		ui.set_prop("mood", Prop::Bg, bg);
114		ui.set_prop("mood-text", Prop::Fg, token);
115		ui.set_text("mood-text", text);
116	}
117
118	fn palette(&mut self, ui: &mut Ui) {
119		self.palette = (self.palette + 1) % PALETTES.len();
120		ui.set_prop("hero", Prop::Bg, PALETTES[self.palette]);
121	}
122
123	fn sidebar(&mut self, ui: &mut Ui) {
124		self.sidebar_wide = !self.sidebar_wide;
125		ui.set_prop("sidebar", Prop::W, if self.sidebar_wide { 30_u16 } else { 14 });
126	}
More examples
Hide additional examples
examples/chat/picker.rs (line 317)
307	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308		let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309		let tier = PerfTier::of(viewport.width);
310		if tier != self.tier {
311			self.tier = tier;
312			self.rebuild();
313		}
314		if rows != self.rows {
315			self.rows = rows;
316			// One query row plus the windowed list.
317			self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318		}
319		if self.ui.frame().size().width != viewport.width {
320			self.ui.resize(viewport.width);
321		}
322		Layer { frame: self.ui.frame(), options: &self.options, active: true }
323	}
examples/chat/commands.rs (line 116)
108	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109		let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110		let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111		if rows != self.rows {
112			self.rows = rows;
113			// One query row plus the windowed list.
114			self
115				.ui
116				.set_prop("commands", Prop::H, rows.saturating_add(1));
117		}
118		if self.ui.frame().size().width != width {
119			self.ui = build(&self.query, self.rows, width, &self.ctx);
120		}
121		self.options = self.options.width(Dim::Cells(width));
122		Layer { frame: self.ui.frame(), options: &self.options, active: true }
123	}
examples/chat/sidebar.rs (line 147)
138	pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139		if !self.visible(viewport) {
140			if self.focused {
141				self.blur();
142			}
143			return None;
144		}
145		if self.height != viewport.height {
146			self.height = viewport.height;
147			self.ui.set_prop("rail", Prop::H, viewport.height);
148			self.ui.set_prop("body", Prop::H, viewport.height);
149		}
150		let seconds = elapsed.as_secs();
151		if seconds != self.elapsed_seconds {
152			self.elapsed_seconds = seconds;
153			self.ui.set_text("elapsed", elapsed_label(seconds));
154		}
155		Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156	}
Source

pub fn set_visible(&mut self, id: &str, visible: bool) -> bool

Shows or hides a named component and relayouts the document; a hidden component skips layout, paint, focus, and hit-testing. Prefer when= conditions for value-driven visibility — this is the imperative counterpart for hosts driving visibility from app state (a detail pane following a list cursor). Returns false for an unknown id.

Examples found in repository?
examples/chat/picker.rs (line 386)
379pub fn show_detail_on(ui: &mut Ui, model: Option<usize>) {
380	let facts = model.map_or_else(|| Str::new_static(" "), |index| facts(&MODELS[index]));
381	ui.set_text("facts", facts);
382	// Hide before show: the document must never transiently exceed its
383	// steady height — a raw-frame layer's retained frame keeps the
384	// high-water mark, which would leave a stale extra row.
385	for index in (0..MODELS.len()).filter(|&index| model != Some(index)) {
386		ui.set_visible(&fmts!("chips-{index}"), false);
387	}
388	if let Some(index) = model {
389		ui.set_visible(&fmts!("chips-{index}"), true);
390	}
391}
Source

pub fn tick(&mut self, now: Duration) -> bool

Advances the deterministic presentation clock and repaints every component whose wake deadline has passed.

crate::App drives this clock in production; tests and custom hosts can supply their own monotonic Duration. Returns whether anything repainted.

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

pub fn next_wake(&self) -> Option<Duration>

Earliest pending animation deadline, if any component is animating. crate::App schedules it; custom hosts may do the same.

Source

pub fn invalidate(&mut self, id: &str) -> bool

Refreshes a named component whose externally shared state changed.

The out-of-band companion to event routing: components that read application state through interior mutability cannot be reached by a key or mouse path, so the owner mutates the state and invalidates the component by id. Returns false for an unknown id.

Examples found in repository?
examples/chat/demo.rs (line 892)
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	}
More examples
Hide additional examples
examples/companies.rs (line 207)
192async fn main() -> io::Result<()> {
193	let mut app = AppOptions::new()
194		.mouse()
195		.probe(Duration::from_millis(150))
196		.graphics_with(forced_from_args)
197		.start(|env| build_ui(env.viewport, env.ctx))
198		.await?;
199	if app.caps().graphics != Graphics::Cells {
200		for (index, provider) in PROVIDERS.iter().enumerate() {
201			let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202			app.renderer_mut().register_image(
203				u32::try_from(index + 1).expect("provider count fits image IDs"),
204				png,
205			)?;
206		}
207		app.ui_mut().invalidate(SCROLL_ID);
208	}
209	show_stats(&mut app, None);
210	let mut chosen: Option<String> = None;
211	while let Some(event) = app.next().await? {
212		match event {
213			AppEvent::Resized(viewport) => {
214				app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215			},
216			AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217			_ => {},
218		}
219		show_stats(&mut app, chosen.as_deref());
220	}
221	Ok(())
222}
Source

pub fn resize(&mut self, width: u16)

Relayouts and repaints everything at a new width.

Examples found in repository?
examples/chat/demo.rs (line 981)
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	}
More examples
Hide additional examples
examples/chat/picker.rs (line 320)
307	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308		let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309		let tier = PerfTier::of(viewport.width);
310		if tier != self.tier {
311			self.tier = tier;
312			self.rebuild();
313		}
314		if rows != self.rows {
315			self.rows = rows;
316			// One query row plus the windowed list.
317			self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318		}
319		if self.ui.frame().size().width != viewport.width {
320			self.ui.resize(viewport.width);
321		}
322		Layer { frame: self.ui.frame(), options: &self.options, active: true }
323	}
Source

pub fn set_height(&mut self, id: &str, height: u16) -> bool

Sets a named component’s fixed height.

Examples found in repository?
examples/gallery/anim.rs (line 130)
128	fn drawer(&mut self, ui: &mut Ui) {
129		self.drawer_open = !self.drawer_open;
130		ui.set_height("drawer", if self.drawer_open { 10 } else { 4 });
131	}
More examples
Hide additional examples
examples/companies.rs (line 214)
192async fn main() -> io::Result<()> {
193	let mut app = AppOptions::new()
194		.mouse()
195		.probe(Duration::from_millis(150))
196		.graphics_with(forced_from_args)
197		.start(|env| build_ui(env.viewport, env.ctx))
198		.await?;
199	if app.caps().graphics != Graphics::Cells {
200		for (index, provider) in PROVIDERS.iter().enumerate() {
201			let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202			app.renderer_mut().register_image(
203				u32::try_from(index + 1).expect("provider count fits image IDs"),
204				png,
205			)?;
206		}
207		app.ui_mut().invalidate(SCROLL_ID);
208	}
209	show_stats(&mut app, None);
210	let mut chosen: Option<String> = None;
211	while let Some(event) = app.next().await? {
212		match event {
213			AppEvent::Resized(viewport) => {
214				app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215			},
216			AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217			_ => {},
218		}
219		show_stats(&mut app, chosen.as_deref());
220	}
221	Ok(())
222}
examples/gallery/main.rs (line 144)
112async fn main() -> io::Result<()> {
113	let mut app = AppOptions::new()
114		.mouse()
115		.quit([Key::Ctrl('c'), Key::Ctrl('q')])
116		.start(|env| build_ui(env.viewport, env.ctx))
117		.await?;
118	// The picker tab opens with the first model's details, like the chat
119	// overlay does.
120	picker::show_detail_on(app.ui_mut(), Some(0));
121
122	let mut synced = String::new();
123	let mut lab = anim::Lab::new();
124	let mut layers = Layers::default();
125	let mut next_step = tokio::time::Instant::now() + anim::AUTOPLAY_STEP;
126
127	loop {
128		let event = tokio::select! {
129			event = app.next() => match event? {
130				Some(event) => event,
131				None => break,
132			},
133			() = tokio::time::sleep_until(next_step) => {
134				if lab.autoplay && active_tab(app.ui()) == "Anim" {
135					lab.advance(app.ui_mut());
136				}
137				next_step += anim::AUTOPLAY_STEP;
138				continue;
139			},
140		};
141		match event {
142			AppEvent::Resized(viewport) => {
143				for pane in render::PANE_IDS {
144					app.ui_mut().set_height(pane, render::pane_height(viewport));
145				}
146			},
147			AppEvent::Key(key) => match active_tab(app.ui()).as_str() {
148				"Anim" => lab.handle_key(key, app.ui_mut()),
149				"Overlay" => match key {
150					Key::Ctrl('k') if layers.picker.is_none() => {
151						layers.picker = Some(overlay::show_picker(app.ui_mut()));
152					},
153					Key::Ctrl('g') => match layers.help.take() {
154						Some(id) => {
155							app.ui_mut().close_overlay(id);
156						},
157						None => layers.help = Some(overlay::show_help(app.ui_mut())),
158					},
159					_ => {},
160				},
161				_ => {},
162			},
163			// The Overlay tab's modal select committed a model.
164			AppEvent::Changed { id, value } if id == "model" => {
165				if let Some(overlay) = layers.picker.take() {
166					let label = overlay::MODELS
167						.iter()
168						.find(|(short, ..)| *short == value)
169						.map_or(value.as_str(), |(_, label, _)| label);
170					app.ui_mut().set_text("status", format!("model: {label}"));
171					app.ui_mut().close_overlay(overlay);
172				}
173			},
174			// The Picker tab's select moved: mirror the chat picker's
175			// facts-and-chips detail line.
176			AppEvent::Highlighted { id, value } if id == "models" => {
177				picker::show_detail_on(app.ui_mut(), value.as_str().parse().ok());
178			},
179			AppEvent::Filtered { id, value, .. } if id == "models" => {
180				let model = value.and_then(|value| value.as_str().parse().ok());
181				picker::show_detail_on(app.ui_mut(), model);
182			},
183			AppEvent::OverlayClosed(id) => {
184				if layers.picker == Some(id) {
185					layers.picker = None;
186				}
187				if layers.help == Some(id) {
188					layers.help = None;
189				}
190			},
191			_ => {},
192		}
193		render::sync_preview(app.ui_mut(), &mut synced);
194		// Reserve the Overlay tab's chords only while it is showing, so the
195		// focused composer can't spend Ctrl+K on kill-line — and the Live
196		// tab's editor keeps it.
197		let chords: &[Key] = if active_tab(app.ui()) == "Overlay" {
198			&[Key::Ctrl('k'), Key::Ctrl('g')]
199		} else {
200			&[]
201		};
202		app.set_hotkeys(chords.iter().copied());
203	}
204	Ok(())
205}
Source

pub fn present<W: Write>( &mut self, renderer: &mut Renderer<W>, viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>

Presents the retained frame without copying it, compositing every visible overlay above the document for this viewport.

§Errors

Propagates the renderer’s contract and writer errors.

Source

pub fn preview<W: Write>( &mut self, renderer: &mut Renderer<W>, viewport_height: u16, leading_sequence: &str, ) -> Result<PaintStats>

Paints the composited viewport as a throwaway frame, leaving the renderer’s committed history untouched.

Alternate-screen presentation: hosts holding the alternate screen — for a modal overlay or a fullscreen scene — repaint with this on every damage or geometry change, and leading_sequence lets the buffer switch ride the same synchronized update (see Terminal::stage_alt_enter). Damage is consumed exactly like Ui::present.

§Errors

Propagates the renderer’s contract and writer errors.

Source

pub fn compose_resize_tail(&mut self, viewport: Size) -> Option<Frame>

Composes one screen of throwaway drag content at viewport without relayouting the retained tree.

The root’s tail children (crate::Component::resize_tail) are composed bottom-up at the new width until the viewport is full — O(viewport) work per drag frame. Nested vertical stacks (including the implicit markup root) are walked recursively without ever computing their full height, and a leaf taller than the space left is sliced to its bottom rows, so work stays bounded by the screen rather than document history. None means the root has no tail fast path; callers fall back to a full Ui::resize. Child placement is transient: the deferred Ui::resize at settle re-places everything.

Source

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

Routes a key to the layer holding the keyboard — the topmost visible modal overlay, else the non-modal layer focused through Ui::focus_overlay or a click — falling back to the base tree’s focused component with focus-ring fallback.

Examples found in repository?
examples/chat/sidebar.rs (line 99)
98	pub fn handle_key(&mut self, key: Key) {
99		if self.ui.handle_key(key) == UiEvent::Cancel {
100			self.blur();
101		}
102	}
More examples
Hide additional examples
examples/chat/picker.rs (line 281)
280	pub fn handle_key(&mut self, key: Key) -> PickerEvent {
281		let event = self.ui.handle_key(key);
282		self.route(event)
283	}
examples/chat/commands.rs (line 83)
82	pub fn handle_key(&mut self, key: Key) -> PaletteEvent {
83		let event = self.ui.handle_key(key);
84		self.route(event)
85	}
examples/chat/demo.rs (line 803)
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	}
Source

pub fn handle_paste(&mut self, text: &str) -> UiEvent

Routes sanitized paste text to the focused component; the returned event mirrors Ui::handle_key.

Examples found in repository?
examples/chat/picker.rs (line 287)
286	pub fn handle_paste(&mut self, text: &str) -> PickerEvent {
287		let event = self.ui.handle_paste(text);
288		self.route(event)
289	}
More examples
Hide additional examples
examples/chat/commands.rs (line 89)
88	pub fn handle_paste(&mut self, text: &str) -> PaletteEvent {
89		let event = self.ui.handle_paste(text);
90		self.route(event)
91	}
examples/chat/demo.rs (line 920)
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	}
Source

pub fn handle_paste_raw(&mut self, text: &str) -> UiEvent

Routes paste text for verbatim insertion ([Component::paste_raw]): no drop classification, no large-paste collapse. Backs the Ctrl+Shift+V clipboard fallback.

Examples found in repository?
examples/chat/demo.rs (line 927)
926	pub fn handle_paste_raw(&mut self, text: &str) {
927		let _ = self.editor_ui.handle_paste_raw(text);
928	}
Source

pub fn focus_first(&mut self)

Moves focus to the first focusable component when nothing is focused yet, activating keyboard chrome.

The entry half of a raw-frame layer host’s keyboard hand-off; Ui::blur is the exit half. Retained stacks get both through Ui::focus_overlay and Ui::blur_overlay.

Examples found in repository?
examples/chat/sidebar.rs (line 91)
87	pub fn toggle(&mut self) {
88		self.open = !self.open;
89		if self.open {
90			self.focused = true;
91			self.ui.focus_first();
92		} else {
93			self.blur();
94		}
95	}
96
97	/// Routes a key while the rail holds the keyboard; `Esc` hands it back.
98	pub fn handle_key(&mut self, key: Key) {
99		if self.ui.handle_key(key) == UiEvent::Cancel {
100			self.blur();
101		}
102	}
103
104	/// Routes a mouse report through the rail's band. A click inside takes
105	/// the keyboard, a click outside returns it; `false` means the gesture
106	/// was not consumed and belongs to the transcript.
107	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108		if !self.open {
109			return false;
110		}
111		if self
112			.ui
113			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114			.is_some()
115		{
116			if kind == Mouse::Click && !self.focused {
117				self.focused = true;
118				self.ui.focus_first();
119			}
120			true
121		} else {
122			if kind == Mouse::Click {
123				self.blur();
124			}
125			false
126		}
127	}
Source

pub fn blur(&mut self)

Clears this tree’s focus, removing focus chrome and the caret.

Raw-frame layer hosts call this when the keyboard returns to the document, so no stale chrome suggests typing still lands here.

Examples found in repository?
examples/chat/sidebar.rs (line 65)
56	pub fn new(model: &str, ctx: &UiContext) -> Self {
57		let options = OverlayOptions::default()
58			.anchor(OverlayAnchor::Right)
59			.width(Dim::Cells(WIDTH))
60			.non_modal()
61			.min_viewport(MIN_VIEWPORT);
62		let mut ui = build(model, ctx);
63		// The rail starts without the keyboard: no focus chrome or frame
64		// cursor until `toggle` or a click hands it over.
65		ui.blur();
66		Self { ui, options, open: true, focused: false, elapsed_seconds: 0, height: 0 }
67	}
68
69	/// Whether the rail composites for `viewport`.
70	const fn visible(&self, viewport: Size) -> bool {
71		self.open && viewport.width >= MIN_VIEWPORT.width && viewport.height >= MIN_VIEWPORT.height
72	}
73
74	/// Columns the rail reserves at `viewport`: its full width while
75	/// composited, zero when toggled off or gated out. The composer docks
76	/// its right-aligned chrome against the remaining width.
77	pub const fn reserved(&self, viewport: Size) -> u16 {
78		if self.visible(viewport) { WIDTH } else { 0 }
79	}
80
81	/// Whether the rail currently holds the keyboard.
82	pub const fn focused(&self) -> bool {
83		self.focused
84	}
85
86	/// `Ctrl+B`: opening hands the rail the keyboard, closing returns it.
87	pub fn toggle(&mut self) {
88		self.open = !self.open;
89		if self.open {
90			self.focused = true;
91			self.ui.focus_first();
92		} else {
93			self.blur();
94		}
95	}
96
97	/// Routes a key while the rail holds the keyboard; `Esc` hands it back.
98	pub fn handle_key(&mut self, key: Key) {
99		if self.ui.handle_key(key) == UiEvent::Cancel {
100			self.blur();
101		}
102	}
103
104	/// Routes a mouse report through the rail's band. A click inside takes
105	/// the keyboard, a click outside returns it; `false` means the gesture
106	/// was not consumed and belongs to the transcript.
107	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108		if !self.open {
109			return false;
110		}
111		if self
112			.ui
113			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114			.is_some()
115		{
116			if kind == Mouse::Click && !self.focused {
117				self.focused = true;
118				self.ui.focus_first();
119			}
120			true
121		} else {
122			if kind == Mouse::Click {
123				self.blur();
124			}
125			false
126		}
127	}
128
129	/// Reflects a session model switch in the rail's model row.
130	pub fn set_model(&mut self, name: &str) {
131		self.ui.set_text("model", name);
132	}
133
134	/// The composited rail for this frame, laid out to the full viewport
135	/// height; `None` when toggled off or gated out by a small viewport.
136	/// Shrinking below the minimum blurs the rail so keys never route into
137	/// an invisible layer.
138	pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139		if !self.visible(viewport) {
140			if self.focused {
141				self.blur();
142			}
143			return None;
144		}
145		if self.height != viewport.height {
146			self.height = viewport.height;
147			self.ui.set_prop("rail", Prop::H, viewport.height);
148			self.ui.set_prop("body", Prop::H, viewport.height);
149		}
150		let seconds = elapsed.as_secs();
151		if seconds != self.elapsed_seconds {
152			self.elapsed_seconds = seconds;
153			self.ui.set_text("elapsed", elapsed_label(seconds));
154		}
155		Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156	}
157
158	fn blur(&mut self) {
159		self.focused = false;
160		self.ui.blur();
161	}
Source

pub fn handle_mouse(&mut self, x: u16, y: u16, mouse: Mouse) -> UiEvent

Routes a mouse gesture in document cell coordinates; visible overlays occlude the document within their bounds.

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

pub fn handle_mouse_as_layer( &mut self, options: &OverlayOptions, viewport: Size, x: u16, y: u16, mouse: Mouse, ) -> Option<UiEvent>

Routes a viewport-coordinate mouse gesture into this tree when it is composited as a raw crate::Layer under options — the raw-frame host counterpart of the overlay stack’s own routing (crate::Renderer::present_overlaid instead of Ui::show_overlay). The band is resolved exactly as the compositor resolves it, coordinates are translated into this tree’s local cells, and a drag that started inside stays captured. None means the gesture fell outside the layer (a Move outside also clears hover chrome).

Examples found in repository?
examples/chat/picker.rs (line 297)
294	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> PickerEvent {
295		match self
296			.ui
297			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
298		{
299			Some(event) => self.route(event),
300			None if kind == Mouse::Click => PickerEvent::Close,
301			None => PickerEvent::Consumed,
302		}
303	}
More examples
Hide additional examples
examples/chat/commands.rs (line 98)
95	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> PaletteEvent {
96		match self
97			.ui
98			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
99		{
100			Some(event) => self.route(event),
101			None if kind == Mouse::Click => PaletteEvent::Close,
102			None => PaletteEvent::Consumed,
103		}
104	}
examples/chat/sidebar.rs (line 113)
107	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108		if !self.open {
109			return false;
110		}
111		if self
112			.ui
113			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114			.is_some()
115		{
116			if kind == Mouse::Click && !self.focused {
117				self.focused = true;
118				self.ui.focus_first();
119			}
120			true
121		} else {
122			if kind == Mouse::Click {
123				self.blur();
124			}
125			false
126		}
127	}
Source

pub fn values(&self) -> Value

Collects values from every visible component of the base tree; overlay trees report through Ui::overlay.

Examples found in repository?
examples/gallery/main.rs (line 101)
100fn active_tab(ui: &Ui) -> String {
101	ui.values()["view"].as_str().unwrap_or_default().to_owned()
102}
More examples
Hide additional examples
examples/gallery/render.rs (line 127)
126pub(crate) fn sync_preview(ui: &mut Ui, synced: &mut String) {
127	let text = ui.values()["src"].as_str().unwrap_or_default().to_owned();
128	if text != *synced {
129		ui.set_text("preview", text.clone());
130		*synced = text;
131	}
132}
Source

pub fn show_overlay( &mut self, root: impl IntoComponent, options: OverlayOptions, ) -> OverlayId

Stacks an overlay tree above the document.

The overlay is its own retained Ui: address it through Ui::overlay / Ui::overlay_mut for set_text, values, and friends. Placement follows options against the viewport of each Ui::present; the layer composites above the document and never enters native terminal scrollback. Explicit z orders layers regardless of creation order; later overlays stack on top among equal z. The topmost visible modal overlay receives every key and paste until closed or hidden; a non-modal layer (OverlayOptions::non_modal) leaves the keyboard with the base tree until focused through Ui::focus_overlay or a click inside its band.

§Panics

Panics when called on an overlay’s own tree: overlays stack on the presenting Ui.

Examples found in repository?
examples/gallery/overlay.rs (lines 36-55)
35pub(crate) fn show_picker(ui: &mut Ui) -> OverlayId {
36	ui.show_overlay(
37		dom! {
38			<box border=round title="Switch Model">
39				<col gap=1>
40					<text dim>{"Session-only switch — role models stay unchanged"}</text>
41					<select id=model>
42						for (value, label, stats) in MODELS {
43							<option value={value} desc={stats}>{label}</option>
44						}
45					</select>
46				</col>
47			</box>
48		},
49		OverlayOptions::default()
50			.anchor(OverlayAnchor::Center)
51			.width(Dim::Pct(70))
52			.min_width(48)
53			.max_height(Dim::Pct(60))
54			.min_viewport(Size::new(40, 8)),
55	)
56}
57
58/// Opens the keybinding help layer.
59pub(crate) fn show_help(ui: &mut Ui) -> OverlayId {
60	ui.show_overlay(
61		dom! {
62			<box border=round title="Help">
63				<col>
64					<text>{"Ctrl+K  switch model"}</text>
65					<text>{"Ctrl+G  toggle this help"}</text>
66					<text>{"Esc     close top layer"}</text>
67					<text>{"Ctrl+C  quit"}</text>
68				</col>
69			</box>
70		},
71		OverlayOptions::default()
72			.anchor(OverlayAnchor::BottomRight)
73			.width(Dim::Cells(30))
74			.margin(OverlayMargin::uniform(1)),
75	)
76}
Source

pub fn close_overlay(&mut self, id: OverlayId) -> bool

Removes an overlay; the next present repaints the document beneath it.

Returns false for an unknown id.

Examples found in repository?
examples/gallery/main.rs (line 155)
112async fn main() -> io::Result<()> {
113	let mut app = AppOptions::new()
114		.mouse()
115		.quit([Key::Ctrl('c'), Key::Ctrl('q')])
116		.start(|env| build_ui(env.viewport, env.ctx))
117		.await?;
118	// The picker tab opens with the first model's details, like the chat
119	// overlay does.
120	picker::show_detail_on(app.ui_mut(), Some(0));
121
122	let mut synced = String::new();
123	let mut lab = anim::Lab::new();
124	let mut layers = Layers::default();
125	let mut next_step = tokio::time::Instant::now() + anim::AUTOPLAY_STEP;
126
127	loop {
128		let event = tokio::select! {
129			event = app.next() => match event? {
130				Some(event) => event,
131				None => break,
132			},
133			() = tokio::time::sleep_until(next_step) => {
134				if lab.autoplay && active_tab(app.ui()) == "Anim" {
135					lab.advance(app.ui_mut());
136				}
137				next_step += anim::AUTOPLAY_STEP;
138				continue;
139			},
140		};
141		match event {
142			AppEvent::Resized(viewport) => {
143				for pane in render::PANE_IDS {
144					app.ui_mut().set_height(pane, render::pane_height(viewport));
145				}
146			},
147			AppEvent::Key(key) => match active_tab(app.ui()).as_str() {
148				"Anim" => lab.handle_key(key, app.ui_mut()),
149				"Overlay" => match key {
150					Key::Ctrl('k') if layers.picker.is_none() => {
151						layers.picker = Some(overlay::show_picker(app.ui_mut()));
152					},
153					Key::Ctrl('g') => match layers.help.take() {
154						Some(id) => {
155							app.ui_mut().close_overlay(id);
156						},
157						None => layers.help = Some(overlay::show_help(app.ui_mut())),
158					},
159					_ => {},
160				},
161				_ => {},
162			},
163			// The Overlay tab's modal select committed a model.
164			AppEvent::Changed { id, value } if id == "model" => {
165				if let Some(overlay) = layers.picker.take() {
166					let label = overlay::MODELS
167						.iter()
168						.find(|(short, ..)| *short == value)
169						.map_or(value.as_str(), |(_, label, _)| label);
170					app.ui_mut().set_text("status", format!("model: {label}"));
171					app.ui_mut().close_overlay(overlay);
172				}
173			},
174			// The Picker tab's select moved: mirror the chat picker's
175			// facts-and-chips detail line.
176			AppEvent::Highlighted { id, value } if id == "models" => {
177				picker::show_detail_on(app.ui_mut(), value.as_str().parse().ok());
178			},
179			AppEvent::Filtered { id, value, .. } if id == "models" => {
180				let model = value.and_then(|value| value.as_str().parse().ok());
181				picker::show_detail_on(app.ui_mut(), model);
182			},
183			AppEvent::OverlayClosed(id) => {
184				if layers.picker == Some(id) {
185					layers.picker = None;
186				}
187				if layers.help == Some(id) {
188					layers.help = None;
189				}
190			},
191			_ => {},
192		}
193		render::sync_preview(app.ui_mut(), &mut synced);
194		// Reserve the Overlay tab's chords only while it is showing, so the
195		// focused composer can't spend Ctrl+K on kill-line — and the Live
196		// tab's editor keeps it.
197		let chords: &[Key] = if active_tab(app.ui()) == "Overlay" {
198			&[Key::Ctrl('k'), Key::Ctrl('g')]
199		} else {
200			&[]
201		};
202		app.set_hotkeys(chords.iter().copied());
203	}
204	Ok(())
205}
Source

pub fn close_top_overlay(&mut self) -> Option<OverlayId>

Removes the topmost layer (highest z, most recent among ties), if any.

This pops the stack regardless of modality; for dismissing the layer that emitted a UiEvent::Cancel, use Ui::close_active_overlay — the stack top may be a non-modal pane sitting above the modal that routed the key.

Source

pub fn close_active_overlay(&mut self) -> Option<OverlayId>

Closes the layer currently receiving keys — the topmost visible modal overlay, else the focused non-modal pane — returning its id.

The manual-host counterpart of the crate::App cancel policy: after a UiEvent::Cancel surfaces from the overlay stack, this dismisses the layer that emitted it, even when a higher-z non-modal pane stacks above it.

Source

pub fn set_overlay_hidden(&mut self, id: OverlayId, hidden: bool) -> bool

Temporarily hides or reshows an overlay without discarding its state.

Hiding the layer holding the keyboard returns keys to the base tree. Returns false for an unknown id.

Source

pub fn overlay_hidden(&self, id: OverlayId) -> Option<bool>

Whether an overlay is temporarily hidden; None for an unknown id.

Source

pub fn overlay(&self, id: OverlayId) -> Option<&Self>

Borrows an overlay’s retained tree.

Source

pub fn overlay_mut(&mut self, id: OverlayId) -> Option<&mut Self>

Mutably borrows an overlay’s retained tree for set_text and friends.

Source

pub fn has_overlay(&self) -> bool

Whether any modal overlay is currently visible (not hidden or gated).

While one is, crate::App holds the terminal’s alternate screen (vim/less idiom): the whole composited viewport paints there with mouse tracking active, and the untouched main screen restores when the last visible modal overlay closes. Non-modal layers never hold: they composite into the live inline viewport while the document keeps committing to native scrollback beneath them.

Source

pub fn top_overlay(&self) -> Option<OverlayId>

Identity of the layer receiving keys — the topmost visible modal overlay, else the focused non-modal layer.

Source

pub fn focus_overlay(&mut self, id: OverlayId) -> bool

Directs keys and paste to a layer until it is blurred, closed, or hidden, or a modal overlay opens above it.

The layer’s focus ring activates so its chrome shows where typing lands. Intended for non-modal layers — a modal overlay already captures the keyboard while topmost. Returns false for an unknown id.

Source

pub fn blur_overlay(&mut self) -> Option<OverlayId>

Returns the keyboard to the base tree, clearing the previously focused layer’s own focus so no stale chrome (or hardware caret) suggests typing still lands there. Returns the layer that had key focus.

Source

pub const fn focused_overlay(&self) -> Option<OverlayId>

The non-modal layer holding the keyboard through Ui::focus_overlay or a click, if any.

Auto Trait Implementations§

§

impl !Freeze for Ui

§

impl !RefUnwindSafe for Ui

§

impl !Send for Ui

§

impl !Sync for Ui

§

impl !UnwindSafe for Ui

§

impl Unpin for Ui

§

impl UnsafeUnpin for Ui

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.