Skip to main content

Charset

Enum Charset 

Source
pub enum Charset {
    Unicode,
    NerdFont,
    Ascii,
}
Expand description

Glyph capability tier, mirroring the unicode | nerd | ascii symbol presets in the coding agent.

Variants§

§

Unicode

Full Unicode box drawing, geometric shapes, half blocks.

§

NerdFont

Unicode plus Nerd Font private-use glyphs where they read better.

§

Ascii

Pure 7-bit ASCII: every terminal, every font, every era.

Implementations§

Source§

impl Charset

Source

pub const fn icon(self, icon: Icon) -> &'static str

Resolves a semantic icon through this terminal’s capability tier.

Examples found in repository?
examples/footers.rs (line 341)
340fn omp_brand(scene: &Scene) -> Seg {
341	Seg::new(fmts!("{} omp", scene.charset.icon(Icon::Omp)), MUTED)
342}
343
344fn model(scene: &Scene) -> Seg {
345	Seg::new(fmts!("{} {MODEL}", scene.charset.icon(Icon::Model)), GREEN)
346}
347
348fn git(scene: &Scene) -> Seg {
349	Seg::new(fmts!("{} {GIT}", scene.charset.icon(Icon::Branch)), CYAN)
350}
351
352fn context(scene: &Scene) -> Seg {
353	Seg::new(fmts!("{} {CONTEXT}", scene.charset.icon(Icon::Context)), GOLD)
354}
355
356fn cost() -> Seg {
357	Seg::new(Str::new_static(COST), PURPLE)
358}
359
360fn full_band(scene: &Scene) -> [Seg; 5] {
361	[brand(scene), model(scene), git(scene), context(scene), cost()]
362}
363
364/// The picked split arrangement: brand caps left, session caps right.
365fn draw_split_bands(frame: &mut Frame, y: u16, scene: &Scene) {
366	let left = [brand(scene), model(scene)];
367	let right = [git(scene), context(scene), cost()];
368	draw_band(frame, 0, y, scene, &left);
369	let x = scene.width.saturating_sub(band_width(scene, &right));
370	draw_band(frame, x, y, scene, &right);
371}
372
373/// Status-band chrome per tier, mirroring the `<status>` component.
374const fn band_chrome(charset: Charset) -> (&'static str, &'static str, &'static str) {
375	match charset {
376		Charset::Ascii => ("", ">", ">"),
377		Charset::Unicode => ("", "›", "›"),
378		Charset::NerdFont => ("\u{e0b6}", "\u{e0b1}", "\u{e0b0}"),
379	}
380}
381
382const fn border_glyphs(
383	charset: Charset,
384) -> (&'static str, &'static str, &'static str, &'static str, &'static str, &'static str) {
385	match charset {
386		Charset::Ascii => ("+", "+", "+", "+", "-", "|"),
387		_ => ("╭", "╮", "╰", "╯", "─", "│"),
388	}
389}
390
391const fn beam(charset: Charset) -> &'static str {
392	match charset {
393		Charset::Ascii => "_",
394		_ => "▏",
395	}
396}
397
398const fn ink(color: Color) -> Style {
399	Style::new().fg(color)
400}
401
402fn width_of(text: &str) -> u16 {
403	u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
404}
405
406/// [`TITLE`] truncated to at most `max` cells, ellipsized when it cannot
407/// fit whole.
408fn fit_title(scene: &Scene, max: u16) -> Str {
409	if width_of(TITLE) <= max {
410		return Str::new_static(TITLE);
411	}
412	let ellipsis = match scene.charset {
413		Charset::Ascii => "...",
414		_ => "…",
415	};
416	let budget = max.saturating_sub(width_of(ellipsis));
417	let mut used = 0_u16;
418	let mut end = 0_usize;
419	for grapheme in xutf::graphemes_str(TITLE) {
420		let cells = width_of(grapheme);
421		if used.saturating_add(cells) > budget {
422			break;
423		}
424		used = used.saturating_add(cells);
425		end += grapheme.len();
426	}
427	if end == 0 {
428		return Str::default();
429	}
430	fmts!("{}{ellipsis}", TITLE[..end].trim_end())
431}
432
433/// Total cells a powerline band with `segments` occupies, mirroring the
434/// `<status>` component's measurement.
435fn band_width(scene: &Scene, segments: &[Seg]) -> u16 {
436	let (left_cap, separator, right_cap) = band_chrome(scene.charset);
437	let text = segments
438		.iter()
439		.map(|segment| width_of(&segment.label))
440		.fold(0_u16, u16::saturating_add);
441	let separators = u16::try_from(segments.len().saturating_sub(1))
442		.unwrap_or(u16::MAX)
443		.saturating_mul(width_of(separator).saturating_add(2));
444	text
445		.saturating_add(separators)
446		.saturating_add(width_of(left_cap))
447		.saturating_add(2)
448		.saturating_add(width_of(right_cap))
449}
450
451/// Paints a powerline band at `x`: cap, padded segments, cap.
452fn draw_band(frame: &mut Frame, x: u16, y: u16, scene: &Scene, segments: &[Seg]) {
453	let (left_cap, separator, right_cap) = band_chrome(scene.charset);
454	let base = Style::new().fg(TEXT).bg(BAND_BG);
455	let edge = ink(BAND_BG);
456	let mut column = frame.put(x, y, left_cap, edge);
457	column = frame.put(column, y, " ", base);
458	for (index, segment) in segments.iter().enumerate() {
459		if index > 0 {
460			column = frame.put(column, y, " ", base.dim());
461			column = frame.put(column, y, separator, base.dim());
462			column = frame.put(column, y, " ", base.dim());
463		}
464		column = frame.put(column, y, &segment.label, base.fg(segment.color));
465	}
466	column = frame.put(column, y, " ", base);
467	frame.put(column, y, right_cap, edge);
468}
469
470/// A full-width horizontal border row: corner, rule fill, corner.
471fn draw_border_row(
472	frame: &mut Frame,
473	y: u16,
474	scene: &Scene,
475	left: &str,
476	right: &str,
477	horizontal: &str,
478) {
479	let edge = scene.right_edge();
480	let mut column = frame.put(0, y, left, ink(FAINT));
481	while column < edge {
482		column = frame.put(column, y, horizontal, ink(FAINT));
483	}
484	frame.put(edge, y, right, ink(FAINT));
485}
486
487/// Right-aligns ` TITLE ` into an already-painted border row, keeping two
488/// rule cells before the corner and truncating against `min_x`.
489fn draw_border_title(frame: &mut Frame, y: u16, scene: &Scene, min_x: u16) {
490	let slot_end = scene.right_edge().saturating_sub(2);
491	let title = fit_title(scene, slot_end.saturating_sub(min_x).saturating_sub(2));
492	if title.is_empty() {
493		return;
494	}
495	let x = slot_end.saturating_sub(width_of(&title).saturating_add(2));
496	let column = frame.put(x, y, " ", ink(FAINT));
497	let column = frame.put(column, y, &title, ink(TEXT));
498	frame.put(column, y, " ", ink(FAINT));
499}
500
501/// The shimmering working line: cancel hint, then the narration, riding
502/// one crest sweep exactly like the chat demo's activity row.
503fn draw_working(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
504	let hint = scene.charset.icon(Icon::Cancellable);
505	let length = width_of(hint)
506		.saturating_add(1)
507		.saturating_add(width_of(WORKING));
508	let shimmer = Shimmer::new(scene.elapsed, SHIMMER_PERIOD, length);
509	let mut column = x;
510	draw_shimmer(frame, &mut column, x, y, scene.right_edge(), hint, shimmer, ink(CYAN));
511	draw_shimmer(frame, &mut column, x, y, scene.right_edge(), " ", shimmer, ink(GREEN));
512	draw_shimmer(frame, &mut column, x, y, scene.right_edge(), WORKING, shimmer, ink(GREEN));
513}
More examples
Hide additional examples
examples/chat/demo.rs (line 585)
576	fn brand_segment(&self, now: Duration) -> Segment {
577		let work = self.work.borrow();
578		let brand = if work.working {
579			fmts!(
580				"{} {}",
581				self.charset.spinner().at(now),
582				elapsed_label(now.saturating_sub(work.since))
583			)
584		} else {
585			fmts!("{} omp", self.charset.icon(Icon::Omp))
586		};
587		Segment::new()
588			.label(brand)
589			.with(Prop::Fg, work.fade.sample(now))
590	}
591
592	fn model_segment(&self) -> Segment {
593		Segment::new()
594			.label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595			.with(Prop::Fg, GREEN)
596	}
597
598	fn git_segment(charset: Charset) -> Segment {
599		Segment::new()
600			.label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601			.with(Prop::Fg, CYAN)
602	}
603
604	fn context_segment(charset: Charset) -> Segment {
605		Segment::new()
606			.label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607			.with(Prop::Fg, GOLD)
608	}
609
610	fn cost_segment() -> Segment {
611		Segment::new()
612			.label("$60.07 (sub) + $8.65 (adv)")
613			.with(Prop::Fg, PURPLE)
614	}
615
616	/// The left band group: brand and model.
617	fn left_group(&self, now: Duration) -> Status {
618		Self::group()
619			.segment(self.brand_segment(now))
620			.segment(self.model_segment())
621	}
622
623	/// The right band group: branch, context, and cost.
624	fn right_group(charset: Charset) -> Status {
625		Self::group()
626			.with_str(Prop::Align, "right")
627			.segment(Self::git_segment(charset))
628			.segment(Self::context_segment(charset))
629			.segment(Self::cost_segment())
630	}
631
632	/// Every segment in one band, for panes too narrow to split.
633	fn combined(&self, now: Duration) -> Status {
634		Self::group()
635			.segment(self.brand_segment(now))
636			.segment(self.model_segment())
637			.segment(Self::git_segment(self.charset))
638			.segment(Self::context_segment(self.charset))
639			.segment(Self::cost_segment())
640	}
641}
642
643impl Component for DemoStatus {
644	fn props(&self) -> &Props {
645		&self.props
646	}
647
648	fn props_mut(&mut self) -> &mut Props {
649		&mut self.props
650	}
651
652	fn slot(&self) -> Slot {
653		self.slot
654	}
655
656	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657		self.combined(Duration::ZERO).measure(ctx)
658	}
659
660	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661		1
662	}
663
664	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665		let mut left = self.left_group(pc.now);
666		let (_, left_width) = left.measure(pc.ctx);
667		let (_, right_width) = self.right.measure(pc.ctx);
668		if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669			left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670			let dock = rect
671				.x
672				.saturating_add(rect.width)
673				.saturating_sub(right_width);
674			self
675				.right
676				.paint(pc, Rect::new(dock, rect.y, right_width, 1));
677		} else {
678			let mut combined = self.combined(pc.now);
679			combined.paint(pc, rect);
680		}
681		let work = self.work.borrow();
682		let fade_frame = work
683			.fade
684			.settles_at()
685			.min(pc.now.saturating_add(FADE_FRAME));
686		let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687			(true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688			(true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689			(false, false) => Some(fade_frame),
690			(false, true) => None,
691		};
692		if let Some(at) = deadline {
693			pc.wake(self.slot, at);
694		}
695	}
696
697	fn paints_background(&self) -> bool {
698		false
699	}
700}
701
702/// One retained chat document update and its exact repainted row ranges.
703pub struct RenderedFrame<'a> {
704	pub(crate) frame:       &'a Frame,
705	pub(crate) stable_rows: u16,
706	pub(crate) damage:      SmallVec<(u16, u16), 4>,
707}
708
709/// Produces the animated transcript, work indicator, editor, and status
710/// line demo.
711pub struct Demo {
712	started_at:         Instant,
713	/// Detected presentation context shared by every retained subtree.
714	ctx:                UiContext,
715	/// Cancel hint resolved once through the context's charset.
716	cancel_hint:        &'static str,
717	editor_ui:          Ui,
718	editor:             Rc<RefCell<Editor>>,
719	edit_outcome:       Rc<RefCell<Option<EditOutcome>>>,
720	work:               Rc<RefCell<WorkState>>,
721	last_working:       bool,
722	model:              Rc<RefCell<Str>>,
723	/// Images staged on the composer, previewed above the status line.
724	attachments:        Attachments,
725	/// Append-only transcript log, replayed in full on geometry rebuilds.
726	transcript:         Vec<Entry>,
727	/// Entries already painted into the retained frame.
728	drawn_entries:      usize,
729	/// Rows covered by the drawn entries; doubles as `stable_rows`.
730	transcript_rows:    u16,
731	appended_messages:  usize,
732	emitted_shards:     u16,
733	last_viewport:      Size,
734	height_floor:       u16,
735	frame:              Frame,
736	/// Geometry of the retained live panel chrome.
737	live_panel:         Option<Rect>,
738	/// Reusable text and placement state for the animated shard rows.
739	live_rows:          [LiveRowCache; LIVE_SHARD_ROWS as usize],
740	/// One build buffer rotated through the row caches without reallocating.
741	live_label_scratch: StrMut,
742	/// Columns reserved at the right edge for a composited rail; the
743	/// editor and title dock against the remaining visible width.
744	right_inset:        u16,
745	/// The composer submitted `/switch`; the host opens the model picker.
746	switch_requested:   bool,
747}
748
749impl Demo {
750	/// Starts the demo's animation clock, presenting through the host's
751	/// detected context.
752	pub fn new(ctx: &UiContext) -> Self {
753		let editor = Rc::new(RefCell::new({
754			let mut editor = Editor::new(EditorOptions::default());
755			editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756			editor
757		}));
758		let edit_outcome = Rc::new(RefCell::new(None));
759		let work = Rc::new(RefCell::new(WorkState {
760			working: true,
761			since:   Duration::ZERO,
762			fade:    Tween::settled(GREEN),
763		}));
764		let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765		let pane = EditorPane::new()
766			.input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767			.status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768		let attachments = pane.attachments();
769		let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770		Self {
771			started_at: Instant::now(),
772			ctx: ctx.clone(),
773			cancel_hint: ctx.charset.icon(Icon::Cancellable),
774			editor_ui,
775			editor,
776			edit_outcome,
777			work,
778			last_working: true,
779			model,
780			attachments,
781			transcript: vec![Entry::Command],
782			drawn_entries: 0,
783			transcript_rows: 0,
784			appended_messages: 0,
785			emitted_shards: 0,
786			last_viewport: Size::new(0, 0),
787			height_floor: 0,
788			frame: Frame::new(Size::new(0, 0)),
789			live_panel: None,
790			live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791			live_label_scratch: StrMut::with_capacity(40),
792			right_inset: 0,
793			switch_requested: false,
794		}
795	}
796
797	/// Routes a key through the editor and reports whether the demo should
798	/// exit. Quit policy lives here, not in the editor: once the editor
799	/// reports a key unused, `esc` first cancels running work and only quits
800	/// at rest; `ctrl-c` always quits.
801	pub fn handle_key(&mut self, key: Key) -> bool {
802		*self.edit_outcome.borrow_mut() = None;
803		let _ = self.editor_ui.handle_key(key);
804		let outcome = self
805			.edit_outcome
806			.borrow_mut()
807			.take()
808			.unwrap_or(EditOutcome::Ignored);
809		match outcome {
810			EditOutcome::Submitted(text) => {
811				let trimmed = text.trim();
812				if trimmed == "/switch" {
813					self.switch_requested = true;
814					return false;
815				}
816				if let Some(path) = trimmed
817					.strip_prefix("/attach")
818					.filter(|rest| rest.is_empty() || rest.starts_with(' '))
819				{
820					let path = path.trim().to_string();
821					if !path.is_empty() {
822						self.attach_image(&path);
823					}
824					return false;
825				}
826				let _ = self.attachments.take();
827				self.refresh_composer();
828				self
829					.transcript
830					.push(Entry::Submitted(Box::new(Submission::new(
831						text,
832						Self::message_width(self.last_viewport.width),
833						&self.ctx,
834					))));
835				self.set_working(true, self.started_at.elapsed());
836				false
837			},
838			EditOutcome::Changed => {
839				self.reconcile_attachments();
840				false
841			},
842			EditOutcome::Ignored => {
843				if key == Key::Ctrl('c') {
844					return true;
845				}
846				if key != Key::Esc {
847					return false;
848				}
849				if self.work.borrow().working {
850					self.set_working(false, self.started_at.elapsed());
851					return false;
852				}
853				true
854			},
855		}
856	}
857
858	/// Consumes a pending `/switch` request submitted through the composer.
859	pub fn take_switch_request(&mut self) -> bool {
860		std::mem::take(&mut self.switch_requested)
861	}
862
863	/// Routes a document-space mouse report into the editor UI.
864	pub fn handle_mouse(&mut self, report: &MouseReport) {
865		let editor_height = self.editor_ui.height();
866		let editor_y = self.frame.size().height.saturating_sub(editor_height);
867		let editor_bottom = editor_y.saturating_add(editor_height);
868		if report.row < editor_y || report.row >= editor_bottom {
869			return;
870		}
871		let _ = self
872			.editor_ui
873			.handle_mouse(report.col, report.row - editor_y, report.kind);
874	}
875
876	/// Switches the work state and retargets the brand fade. The status bar
877	/// repaints immediately and the fade departs from whatever color is on
878	/// screen, so rapid cancel/resume never snaps.
879	fn set_working(&mut self, working: bool, now: Duration) {
880		{
881			let mut work = self.work.borrow_mut();
882			if work.working == working {
883				return;
884			}
885			work.working = working;
886			work.since = now;
887			let target = if working { GREEN } else { MUTED };
888			work
889				.fade
890				.retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891		}
892		self.editor_ui.invalidate(STATUS_ID);
893	}
894
895	/// Reflects a session model switch in the status bar's model segment.
896	pub fn set_model(&mut self, name: &str) {
897		*self.model.borrow_mut() = Str::from(name);
898		self.editor_ui.invalidate(STATUS_ID);
899	}
900
901	/// Routes sanitized bracketed paste text through the editor. Dropped
902	/// paths to existing image files (quoted, escaped, `file://`, or
903	/// multi-file) and any large paste collapse into composer attachment
904	/// chips instead of raw text.
905	pub fn handle_paste(&mut self, text: &str) {
906		let paths = omp_tui::paste::dropped_paths(text);
907		if !paths.is_empty()
908			&& paths.iter().all(|path| {
909				omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910			}) {
911			for path in &paths {
912				self.attach_image(path);
913			}
914			return;
915		}
916		if text.lines().count() > 10 || text.len() > 1000 {
917			self.attach_paste(text);
918			return;
919		}
920		let _ = self.editor_ui.handle_paste(text);
921	}
922
923	/// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924	/// attachment staging, no large-paste collapse — the text stays inline
925	/// and editable.
926	pub fn handle_paste_raw(&mut self, text: &str) {
927		let _ = self.editor_ui.handle_paste_raw(text);
928	}
929
930	/// Stages `path` on the composer and mentions it in the prompt as an
931	/// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932	fn attach_image(&mut self, path: &str) {
933		let attachment = self.attachments.push_image(path);
934		let payload = format!("<ref image={}/>", attachment.marker);
935		self.insert_chip(&attachment, &payload);
936	}
937
938	/// Collapses a large paste into a staged attachment card and an atomic
939	/// composer chip expanding back to the pasted text on submit.
940	fn attach_paste(&mut self, text: &str) {
941		let attachment = self.attachments.push_text(text);
942		self.insert_chip(&attachment, text);
943	}
944
945	/// Inserts one attachment chip as an atomic editor reference.
946	fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947		let chip = chip_label(attachment, self.ctx.charset);
948		{
949			let mut editor = self.editor.borrow_mut();
950			let _ = editor.insert_reference(&chip, payload);
951			let _ = editor.insert_text(" ");
952		}
953		self.refresh_composer();
954	}
955
956	/// Hides staged attachments whose chip the user deleted from the
957	/// composer (and re-shows them after an undo). Presence is derived
958	/// from the buffer's atomic ranges, never from text matching.
959	fn reconcile_attachments(&mut self) {
960		let charset = self.ctx.charset;
961		let changed = {
962			let editor = self.editor.borrow();
963			let text = editor.text();
964			let ranges = editor.atom_ranges();
965			self.attachments.set_visible(|attachment| {
966				let chip = chip_label(attachment, charset);
967				ranges
968					.iter()
969					.any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970			})
971		};
972		if changed {
973			self.refresh_composer();
974		}
975	}
976
977	/// Relayouts the composer after out-of-band state changed its height.
978	fn refresh_composer(&mut self) {
979		let width = self.editor_ui.frame().size().width;
980		if width > 0 {
981			self.editor_ui.resize(width);
982		}
983	}
984
985	/// Reserves `cols` at the right edge for a composited rail, so the
986	/// composer's right-docked chrome stays visible beside it. The next
987	/// render relayouts the editor at the narrowed width.
988	pub const fn set_right_inset(&mut self, cols: u16) {
989		self.right_inset = cols;
990	}
991
992	/// The width the composer may actually occupy at `viewport`.
993	fn composer_width(&self, viewport: Size) -> u16 {
994		viewport.width.saturating_sub(self.right_inset).max(1)
995	}
996
997	/// Updates the retained logical document and reports its repainted rows.
998	pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999		self.render_at(viewport, self.started_at.elapsed())
1000	}
1001
1002	fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003		if viewport.width == 0 || viewport.height == 0 {
1004			self.last_viewport = viewport;
1005			self.height_floor = 0;
1006			self.drawn_entries = 0;
1007			self.transcript_rows = 0;
1008			self.live_panel = None;
1009			self.frame = Frame::new(viewport);
1010			return RenderedFrame {
1011				frame:       &self.frame,
1012				stable_rows: 0,
1013				damage:      SmallVec::new(),
1014			};
1015		}
1016		let composer_width = self.composer_width(viewport);
1017		if self.editor_ui.frame().size().width != composer_width {
1018			self.editor_ui.resize(composer_width);
1019		}
1020		// Fires due animation wakes (the status bar's spinner and brand
1021		// fade) so the blit below picks up fresh retained pixels.
1022		self.editor_ui.tick(elapsed);
1023		let editor_changed = self.editor_ui.take_frame_damage();
1024
1025		// A viewport change starts a fresh renderer session: replay the
1026		// whole transcript log at the new width. Between rebuilds the log
1027		// is append-only and every drawn row is final, so selections over
1028		// transcript text stay anchored to it in every terminal.
1029		let rebuild = self.last_viewport != viewport;
1030		if rebuild {
1031			self.last_viewport = viewport;
1032			self.height_floor = 0;
1033			self.drawn_entries = 0;
1034			self.transcript_rows = 0;
1035			let message_width = Self::message_width(viewport.width);
1036			for entry in &mut self.transcript {
1037				if let Entry::Submitted(submission) = entry {
1038					submission.resize(message_width, &self.ctx);
1039				}
1040			}
1041		}
1042		while self.appended_messages < Self::visible_messages(elapsed) {
1043			self.transcript.push(Entry::Message(self.appended_messages));
1044			self.appended_messages += 1;
1045		}
1046		while self.emitted_shards < Self::finished_shards(elapsed) {
1047			self.emitted_shards += 1;
1048			self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049		}
1050
1051		let mut new_rows = 0_u16;
1052		for entry in &self.transcript[self.drawn_entries..] {
1053			new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054		}
1055		let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056		let editor_height = self.editor_ui.height();
1057		// Native scrollback is append-only, so the logical document may
1058		// never shrink while the seam is live: band rows that close again
1059		// (extra input lines) become blank padding that heals as the
1060		// transcript grows.
1061		let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062		self.height_floor = self.height_floor.max(natural_height);
1063		let document_height = self.height_floor.max(viewport.height);
1064		let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065		let margin = u16::from(viewport.width >= 50);
1066		let content_width = viewport.width.saturating_sub(margin * 2);
1067		let editor_y = document_height.saturating_sub(editor_height);
1068		let title_y = editor_y.saturating_sub(1);
1069		let working_y = title_y.saturating_sub(1);
1070		let panel_height = LIVE_SHARD_ROWS + 2;
1071		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072		let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073		let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074		if rebuild {
1075			self.frame = Frame::new(Size::new(viewport.width, document_height));
1076		} else {
1077			self.frame.resize_height(document_height, base_style());
1078		}
1079		if repaint_suffix {
1080			self.frame.fill(
1081				Rect::new(
1082					0,
1083					transcript_damage_start,
1084					viewport.width,
1085					document_height.saturating_sub(transcript_damage_start),
1086				),
1087				base_style(),
1088			);
1089		}
1090
1091		// Paint the new transcript entries; rows above `transcript_rows`
1092		// are final and never repainted.
1093		let mut y = self.transcript_rows;
1094		for index in self.drawn_entries..self.transcript.len() {
1095			let used = self.draw_entry_at(index, y, viewport.width);
1096			y = y.saturating_add(used);
1097		}
1098		self.drawn_entries = self.transcript.len();
1099		self.transcript_rows = y;
1100
1101		// The live band repaints in place at the bottom of the document.
1102		let animation_frame = Self::animation_frame(elapsed);
1103		let panel_changed = draw_live_panel(
1104			&mut self.frame,
1105			&mut self.live_rows,
1106			&mut self.live_label_scratch,
1107			panel,
1108			repaint_suffix,
1109			self.emitted_shards,
1110			animation_frame,
1111			self.ctx.charset,
1112		);
1113		let working = self.work.borrow().working;
1114		let working_changed = self.last_working != working;
1115		if !repaint_suffix && self.last_working && !working {
1116			self
1117				.frame
1118				.fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119		}
1120		if working {
1121			Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122		}
1123		Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124		if repaint_suffix || editor_changed {
1125			self
1126				.frame
1127				.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128		}
1129		let mut damage = SmallVec::new();
1130		if repaint_suffix {
1131			damage.push((transcript_damage_start, document_height));
1132		} else {
1133			if panel_changed {
1134				damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135			}
1136			if working || working_changed {
1137				damage.push((working_y, working_y.saturating_add(1)));
1138			}
1139			if editor_changed {
1140				damage.push((editor_y, document_height));
1141			}
1142		}
1143		self.last_working = working;
1144		self.live_panel = Some(panel);
1145
1146		RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147	}
1148
1149	fn generation(elapsed: Duration) -> u64 {
1150		u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151	}
1152
1153	fn animation_frame(elapsed: Duration) -> u64 {
1154		u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155	}
1156
1157	fn visible_messages(elapsed: Duration) -> usize {
1158		let interval = MESSAGE_INTERVAL.as_millis();
1159		usize::try_from(elapsed.as_millis() / interval + 1)
1160			.unwrap_or(usize::MAX)
1161			.min(4)
1162	}
1163
1164	/// Shards whose permanent result line has been appended by `elapsed`:
1165	/// two per emit tick, capped well inside `u16` document heights.
1166	fn finished_shards(elapsed: Duration) -> u16 {
1167		u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168			.expect("finished shard count is clamped")
1169	}
1170
1171	/// Rows the bottom live band occupies: the shard panel, a blank
1172	/// separator, the activity row, the title air row, and the editor
1173	/// block.
1174	const fn band_height(editor_height: u16) -> u16 {
1175		LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176	}
1177
1178	/// Rows `entry` will occupy at `width`, including its trailing blank.
1179	fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180		match entry {
1181			Entry::Command => 5,
1182			Entry::Message(message) => {
1183				let mut scratch = Frame::new(Size::new(width, 48));
1184				Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185			},
1186			Entry::ShardDone(_) => 1,
1187			Entry::Submitted(submission) => submission.height().saturating_add(1),
1188		}
1189	}
1190
1191	const fn message_width(width: u16) -> u16 {
1192		let narrowed = width.saturating_sub(3);
1193		if narrowed == 0 { 1 } else { narrowed }
1194	}
1195
1196	/// Paints one transcript entry at `y` and returns the rows it used.
1197	fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198		Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199	}
1200
1201	/// Paints `entry` into any frame at `y` and returns the rows it used,
1202	/// including the trailing blank.
1203	fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204		let margin = u16::from(width >= 50);
1205		let content_width = width.saturating_sub(margin * 2);
1206		match entry {
1207			Entry::Command => {
1208				draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209				5
1210			},
1211			Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212			Entry::ShardDone(shard) => {
1213				Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214				1
1215			},
1216			Entry::Submitted(submission) => {
1217				draw_submission(frame, y, submission, ctx.charset);
1218				submission.height().saturating_add(1)
1219			},
1220		}
1221	}
1222
1223	/// Composes exactly one viewport of throwaway resize-drag content at the
1224	/// new geometry: the live band anchors to the bottom, then transcript
1225	/// entries are walked backward and rewrapped at `viewport.width` until
1226	/// the screen is full — O(viewport) work per drag frame, with the
1227	/// topmost entry sliced when it only partially fits. Retained transcript
1228	/// state is untouched, so the settle rebuild replays full history
1229	/// exactly once.
1230	pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231		let elapsed = self.started_at.elapsed();
1232		let mut frame = Frame::new(viewport);
1233		if viewport.width == 0 || viewport.height == 0 {
1234			return frame;
1235		}
1236		frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237		let composer_width = self.composer_width(viewport);
1238		if self.editor_ui.frame().size().width != composer_width {
1239			self.editor_ui.resize(composer_width);
1240		}
1241		self.editor_ui.tick(elapsed);
1242
1243		// The live band, laid out exactly like the retained document's.
1244		let margin = u16::from(viewport.width >= 50);
1245		let content_width = viewport.width.saturating_sub(margin * 2);
1246		let editor_height = self.editor_ui.height();
1247		let editor_y = viewport.height.saturating_sub(editor_height);
1248		let title_y = editor_y.saturating_sub(1);
1249		let working_y = title_y.saturating_sub(1);
1250		let panel_height = LIVE_SHARD_ROWS + 2;
1251		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252		draw_live_panel(
1253			&mut frame,
1254			&mut self.live_rows,
1255			&mut self.live_label_scratch,
1256			Rect::new(margin, panel_y, content_width, panel_height),
1257			true,
1258			self.emitted_shards,
1259			Self::animation_frame(elapsed),
1260			self.ctx.charset,
1261		);
1262		if self.work.borrow().working {
1263			Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264		}
1265		Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266		frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268		// Transcript tail, bottom-up above the band.
1269		let mut remaining = panel_y;
1270		for entry in self.transcript.iter().rev() {
1271			if remaining == 0 {
1272				break;
1273			}
1274			let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275			if height == 0 {
1276				continue;
1277			}
1278			if height <= remaining {
1279				remaining -= height;
1280				Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281			} else {
1282				// Slice the bottom rows of the partially visible entry.
1283				let mut scratch = Frame::new(Size::new(viewport.width, height));
1284				scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285				Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286				frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287				remaining = 0;
1288			}
1289		}
1290		frame
1291	}
1292
1293	/// Paints the n-th scripted message and returns rows used including
1294	/// the trailing blank. Measurement draws into a scratch frame.
1295	fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296		let margin = u16::from(width >= 50);
1297		let content_width = width.saturating_sub(margin * 2);
1298		if message == 2 {
1299			draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300			return EDIT_BOX_HEIGHT + 1;
1301		}
1302		let bottom = frame.size().height;
1303		let spans = Self::message_spans(message);
1304		// Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305		// wrapped row re-joins byte-for-byte in native selection.
1306		let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307		used.saturating_add(1)
1308	}
1309
1310	fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311		let mut spans = SmallVec::new();
1312		match message {
1313			0 => {
1314				spans.push(Span::new("Transcript rows are ", prose_style()));
1315				spans.push(Span::new("append-only", code_style()));
1316				spans.push(Span::new(
1317					": every line is painted once, becomes stable, and rides into native scrollback \
1318					 with any selection anchored to it.",
1319					prose_style(),
1320				));
1321			},
1322			1 => {
1323				spans.push(Span::new(
1324					"Only the bottom band repaints in place — the live shard panel, the activity \
1325					 shimmer, and the composer. Rows above it are never rewritten.",
1326					prose_style(),
1327				));
1328			},
1329			_ => {
1330				spans.push(Span::new(
1331					"On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332					 the stable transcript through a ",
1333					prose_style(),
1334				));
1335				spans.push(Span::new("DECSTBM top region", code_style()));
1336				spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337			},
1338		}
1339		spans
1340	}
1341
1342	/// Appends a finished shard's permanent one-line result.
1343	fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344		let margin = u16::from(width >= 50);
1345		let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346		let detail = fmts!("  workspace-{shard:03}.test.ts  [100%]");
1347		draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348			Span::new(prefix.as_str(), ink(GREEN)),
1349			Span::new(detail.as_str(), ink(MUTED)),
1350		]);
1351	}
1352
1353	/// Shimmering activity line above the editor. The spinner and timer
1354	/// live in the status bar's brand segment; this row only narrates.
1355	fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356		if y >= frame.size().height || frame.size().width < 4 {
1357			return;
1358		}
1359		let start = u16::from(frame.size().width >= 50);
1360		let mut column = start;
1361		let length = xutf::graphemes_str(WORKING_MESSAGE)
1362			.count()
1363			.saturating_add(xutf::graphemes_str(hint).count())
1364			.saturating_add(1);
1365		let length = u16::try_from(length).unwrap_or(u16::MAX);
1366		let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367		let right = frame.size().width.saturating_sub(1);
1368		draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369		draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370		draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371	}
1372
1373	/// The session title resting right-aligned in the air row between
1374	/// the working narration and the status band — against the visible
1375	/// right bound, inside any rail reservation — so the gap reads as
1376	/// session identity instead of dead space.
1377	fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378		let width = frame.size().width.saturating_sub(right_inset);
1379		let title_width = visible_width(SESSION_TITLE);
1380		if y >= frame.size().height || width < title_width.saturating_add(2) {
1381			return;
1382		}
1383		let x = width.saturating_sub(title_width.saturating_add(1));
1384		draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385	}
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391	if rect.width < 4 || rect.height < 4 {
1392		return;
1393	}
1394
1395	let content_x = rect.x + 2;
1396	let content_width = rect.width.saturating_sub(4);
1397	let header = [
1398		Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399		Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400	];
1401	draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402	let command = [
1403		Span::new("$ ", panel_ink(MUTED)),
1404		Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405		Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406	];
1407	draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413	frame: &mut Frame,
1414	rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415	label_scratch: &mut StrMut,
1416	rect: Rect,
1417	repaint_chrome: bool,
1418	emitted_shards: u16,
1419	animation_frame: u64,
1420	charset: Charset,
1421) -> bool {
1422	let mut changed = repaint_chrome;
1423	if repaint_chrome {
1424		draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425	}
1426	if rect.width < 4 || rect.height < 3 {
1427		return changed;
1428	}
1429
1430	if repaint_chrome {
1431		let title = [
1432			Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433			Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434		];
1435		draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436	}
1437	let content_x = rect.x + 2;
1438	let content_width = rect.width.saturating_sub(4);
1439	for row in 0..rect.height.saturating_sub(2) {
1440		let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441		let phase = (u64::from(row) + animation_frame) % 11;
1442		let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443			0 => (
1444				0,
1445				"⠼",
1446				"running",
1447				panel_ink(GREEN).bold(),
1448				(u64::from(row) * 17 + animation_frame * 7) % 100,
1449			),
1450			1..=7 => {
1451				(1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452			},
1453			_ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454		};
1455		let row_y = rect.y + 1 + row;
1456		let right = content_x
1457			.saturating_add(content_width)
1458			.min(frame.size().width);
1459		let cache = &mut rows[usize::from(row)];
1460		let prefix_changed = repaint_chrome
1461			|| !cache.prefix_valid
1462			|| cache.prefix_shard != shard
1463			|| cache.prefix_phase != prefix_phase;
1464		changed |= prefix_changed;
1465		let label_x = if prefix_changed {
1466			let prefix = fmts!(" {symbol} shard {shard:03} ");
1467			let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468			let next_x = content_x
1469				.saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470				.saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471				.saturating_add(2)
1472				.min(right);
1473			if !repaint_chrome && cache.label_x != next_x {
1474				clear_cached_label(frame, cache, row_y, right);
1475			}
1476			let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477				Span::new(prefix.as_str(), state_style),
1478				Span::new(state, state_style),
1479				Span::new("  ", panel_ink(FAINT)),
1480			]);
1481			cache.prefix_shard = shard;
1482			cache.prefix_phase = prefix_phase;
1483			cache.prefix_valid = true;
1484			next_x
1485		} else {
1486			cache.label_x
1487		};
1488		let moved = cache.label_x != label_x;
1489		let label_changed = repaint_chrome
1490			|| moved
1491			|| !cache.label_valid
1492			|| cache.label_shard != shard
1493			|| cache.label_progress != progress;
1494		changed |= label_changed;
1495		if label_changed {
1496			label_scratch.truncate(0);
1497			write!(label_scratch, "workspace-{shard:03}.test.ts  [{progress:>3}%]")
1498				.expect("shard label formatting is infallible");
1499			let resized = cache.label.len() != label_scratch.len();
1500			if !repaint_chrome && resized && !moved {
1501				clear_cached_label(frame, cache, row_y, right);
1502			}
1503			let width = right.saturating_sub(label_x);
1504			if repaint_chrome || moved || resized {
1505				frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506			} else {
1507				draw_ascii_changes(
1508					frame,
1509					label_x,
1510					row_y,
1511					width,
1512					cache.label.as_str(),
1513					label_scratch.as_str(),
1514					panel_ink(MUTED),
1515				);
1516			}
1517			std::mem::swap(&mut cache.label, label_scratch);
1518			cache.label_shard = shard;
1519			cache.label_progress = progress;
1520			cache.label_valid = true;
1521		}
1522		cache.label_x = label_x;
1523	}
1524	changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528	if cache.label.is_empty() {
1529		return;
1530	}
1531	let width = u16::try_from(cache.label.len())
1532		.unwrap_or(u16::MAX)
1533		.min(right.saturating_sub(cache.label_x));
1534	frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539	frame: &mut Frame,
1540	x: u16,
1541	y: u16,
1542	width: u16,
1543	previous: &str,
1544	next: &str,
1545	style: Style,
1546) {
1547	if width == 0 || previous == next {
1548		return;
1549	}
1550	if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551		frame.put_clipped(x, y, width, next, style);
1552		return;
1553	}
1554	let previous = previous.as_bytes();
1555	let next_bytes = next.as_bytes();
1556	let limit = previous.len().min(usize::from(width));
1557	let mut index = 0;
1558	while index < limit {
1559		while index < limit && previous[index] == next_bytes[index] {
1560			index += 1;
1561		}
1562		let start = index;
1563		while index < limit && previous[index] != next_bytes[index] {
1564			index += 1;
1565		}
1566		if start < index {
1567			let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568			frame.put_clipped(
1569				x.saturating_add(offset),
1570				y,
1571				u16::try_from(index - start).unwrap_or(u16::MAX),
1572				&next[start..index],
1573				style,
1574			);
1575		}
1576	}
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581	if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582		return;
1583	}
1584
1585	let title = [
1586		Span::new(" Live ", panel_ink(GREEN).bold()),
1587		Span::new("band · selection semantics ", panel_ink(CYAN)),
1588	];
1589	draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590	draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591		Span::new(charset.check(), panel_ink(GREEN).bold()),
1592		Span::new(" ", panel_ink(GREEN)),
1593		Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594	]);
1595	draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596		"  margin commits pin the band on kitty-class terminals",
1597		panel_ink(MUTED),
1598	)]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605	if frame.size().width < 4 {
1606		return;
1607	}
1608	let Some(view) = &submission.view else {
1609		for (offset, line) in submission.text.split('\n').enumerate() {
1610			let Ok(offset) = u16::try_from(offset) else {
1611				break;
1612			};
1613			let row = y.saturating_add(offset);
1614			if row >= frame.size().height {
1615				break;
1616			}
1617			let prompt = if offset == 0 { charset.cursor() } else { "  " };
1618			let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619			let width = frame
1620				.size()
1621				.width
1622				.saturating_sub(1)
1623				.saturating_sub(text_x.saturating_sub(1));
1624			draw_submission_text(frame, text_x, row, width, line, charset);
1625		}
1626		return;
1627	};
1628	frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629	frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632	u16::try_from(
1633		text
1634			.bytes()
1635			.filter(|byte| *byte == b'\n')
1636			.count()
1637			.saturating_add(1),
1638	)
1639	.unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644	if rect.width == 0 || rect.height == 0 {
1645		return;
1646	}
1647	let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648	frame.fill(rect, fill);
1649	let mut glyph = [0_u8; 4];
1650	if rect.width == 1 {
1651		frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652		return;
1653	}
1654
1655	let right = rect.x + rect.width - 1;
1656	let bottom = rect.y + rect.height - 1;
1657	frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658	frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659	for x in rect.x + 1..right {
1660		frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661	}
1662
1663	if rect.height > 1 {
1664		draw_box_bottom(frame, rect, border, charset);
1665	}
1666	for row in rect.y + 1..bottom {
1667		frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668		frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669	}
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673	if rect.width < 2 || rect.height < 2 {
1674		return;
1675	}
1676	let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677	let mut glyph = [0_u8; 4];
1678	let right = rect.x + rect.width - 1;
1679	let bottom = rect.y + rect.height - 1;
1680	frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681	frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682	for x in rect.x + 1..right {
1683		frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684	}
1685}
1686
1687fn draw_line(frame: &mut Frame, x: u16, y: u16, width: u16, spans: &[Span<'_>]) -> u16 {
1688	let right = x.saturating_add(width).min(frame.size().width);
1689	let mut column = x;
1690	for span in spans {
1691		column = frame.put_clipped(column, y, right.saturating_sub(column), span.text, span.style);
1692		if column >= right {
1693			break;
1694		}
1695	}
1696	column
1697}
1698
1699/// Flows `spans` grapheme-exact across the rect like a bare terminal,
1700/// preserving all whitespace and flagging each exactly-filled row boundary
1701/// soft so native selection copies the paragraph as one unbroken line.
1702/// Returns the rows used.
1703fn draw_flowed(frame: &mut Frame, rect: Rect, spans: &[Span<'_>]) -> u16 {
1704	if rect.width == 0 || rect.height == 0 {
1705		return 0;
1706	}
1707	let full_row = rect.x == 0 && rect.width == frame.size().width;
1708	let mut row = 0_u16;
1709	let mut column = 0_u16;
1710	let mut drew_anything = false;
1711
1712	for span in spans {
1713		for grapheme in xutf::graphemes_str(span.text) {
1714			let grapheme_width = visible_width(grapheme);
1715			if grapheme_width == 0 || grapheme_width > rect.width {
1716				continue;
1717			}
1718			if column.saturating_add(grapheme_width) > rect.width {
1719				// Only an exactly-filled row is byte-joinable by autowrap.
1720				if full_row && column == rect.width {
1721					frame.set_soft_wrap(rect.y.saturating_add(row));
1722				}
1723				row += 1;
1724				column = 0;
1725			}
1726			if row >= rect.height {
1727				return rect.height;
1728			}
1729			frame.put(rect.x + column, rect.y + row, grapheme, span.style);
1730			column += grapheme_width;
1731			drew_anything = true;
1732		}
1733	}
1734
1735	if drew_anything { row + 1 } else { 0 }
1736}
1737
1738fn visible_width(text: &str) -> u16 {
1739	u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
1740}
1741
1742/// Finds the first `<ref image=N/>` tag in submitted text: its byte range
1743/// plus the marker number `N`.
1744fn next_ref_tag(text: &str) -> Option<(usize, usize, usize)> {
1745	const HEAD: &str = "<ref image=";
1746	let mut from = 0;
1747	while let Some(at) = text[from..].find(HEAD) {
1748		let start = from + at;
1749		let body = &text[start + HEAD.len()..];
1750		let digits = body.bytes().take_while(u8::is_ascii_digit).count();
1751		if digits > 0 && body[digits..].starts_with("/>") {
1752			let marker = body[..digits].parse().unwrap_or(usize::MAX);
1753			return Some((start, start + HEAD.len() + digits + 2, marker));
1754		}
1755		from = start + HEAD.len();
1756	}
1757	None
1758}
1759
1760/// Splits one composer input row into base-styled text and attachment
1761/// chips, chip styling winning over any overlapping XML run.
1762///
1763/// Chips are located through the buffer's atomic ranges — like the XML
1764/// pass, styling happens at paint time, and typed lookalike text is never
1765/// recolored.
1766fn push_row_spans<'a>(
1767	editor: &Editor,
1768	row: &'a str,
1769	runs: &[SyntaxRun],
1770	spans: &mut SmallVec<Span<'a>, 16>,
1771) {
1772	let text = editor.text();
1773	let buffer_start = text.as_ptr() as usize;
1774	let row_start = (row.as_ptr() as usize).saturating_sub(buffer_start);
1775	let row_end = row_start + row.len();
1776	// Chip segments clipped to this row; style derives from the FULL atom
1777	// text, so a chip wrapped across rows keeps its color on every row.
1778	let mut chips: SmallVec<(usize, usize, Style), 4> = SmallVec::new();
1779	for (start, end) in editor.atom_ranges() {
1780		let from = start.max(row_start);
1781		let to = end.min(row_end);
1782		if from < to {
1783			chips.push((from - row_start, to - row_start, chip_style(&text[start..end])));
1784		}
1785	}
1786	// The style and extent of the base segment covering `at`: its XML run,
1787	// or plain text up to the next run.
1788	let base = |at: usize| {
1789		runs
1790			.iter()
1791			.find(|run| run.start <= at && at < run.end)
1792			.map_or_else(
1793				|| {
1794					let next = runs
1795						.iter()
1796						.map(|run| run.start)
1797						.filter(|start| *start > at)
1798						.min()
1799						.unwrap_or(row.len());
1800					(next, base_style())
1801				},
1802				|run| (run.end, run.style),
1803			)
1804	};
1805	fn emit<'a>(
1806		row: &'a str,
1807		base: &impl Fn(usize) -> (usize, Style),
1808		from: usize,
1809		to: usize,
1810		spans: &mut SmallVec<Span<'a>, 16>,
1811	) {
1812		let mut at = from;
1813		while at < to {
1814			let (run_end, style) = base(at);
1815			let end = run_end.min(to);
1816			spans.push(Span::new(&row[at..end], style));
1817			at = end;
1818		}
1819	}
1820	let mut at = 0;
1821	for (start, end, style) in chips {
1822		emit(row, &base, at, start, spans);
1823		spans.push(Span::new(&row[start..end], style));
1824		at = end;
1825	}
1826	emit(row, &base, at, row.len(), spans);
1827}
1828
1829/// Style for one atomic chip: a trailing `#N` selects the marker's
1830/// identity color; other atoms stay plain.
1831fn chip_style(chip: &str) -> Style {
1832	let Some(hash) = chip.rfind('#') else {
1833		return base_style();
1834	};
1835	let digits = &chip[hash + 1..];
1836	if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
1837		return base_style();
1838	}
1839	match digits.parse::<usize>() {
1840		Ok(marker) if marker > 0 => ink(attachment_color(marker)).bold(),
1841		_ => base_style(),
1842	}
1843}
1844
1845/// Paints one transcript line, rendering each `<ref image=N/>` tag as a
1846/// compact `<icon> #N` pill filled with the attachment's identity color.
1847fn draw_submission_text(
1848	frame: &mut Frame,
1849	x: u16,
1850	y: u16,
1851	width: u16,
1852	line: &str,
1853	charset: Charset,
1854) {
1855	let icon = charset.icon(Icon::Image);
1856	let mut chips: SmallVec<(usize, usize, String, usize), 4> = SmallVec::new();
1857	let mut base = 0;
1858	while let Some((start, end, marker)) = next_ref_tag(&line[base..]) {
1859		chips.push((base + start, base + end, format!("{icon} #{marker}"), marker));
1860		base += end;
1861	}
1862	let mut spans: SmallVec<Span<'_>, 8> = SmallVec::new();
1863	let mut at = 0;
1864	for (start, end, label, marker) in &chips {
1865		if *start > at {
1866			spans.push(Span::new(&line[at..*start], ink(TEXT)));
1867		}
1868		spans.push(Span::new(label, Style::new().fg(PANEL).bg(attachment_color(*marker)).bold()));
1869		at = *end;
1870	}
1871	if at < line.len() {
1872		spans.push(Span::new(&line[at..], ink(TEXT)));
1873	}
1874	draw_line(frame, x, y, width, &spans);
1875}
examples/chat/welcome.rs (line 171)
167	pub fn new(charset: Charset) -> Self {
168		Self {
169			charset,
170			frame: Frame::new(Size::new(0, 0)),
171			title: fmts!(" {} omp v{} ", charset.icon(Icon::Omp), env!("CARGO_PKG_VERSION")),
172			camera: (0.0, 0.0),
173			camera_target: (0.0, 0.0),
174			last_elapsed: 0.0,
175			logo_origin: (0, 0),
176			logo: [[None; LOGO_COLS]; LOGO_ROWS],
177			logo_at: None,
178			backdrop_frame: Frame::new(Size::new(0, 0)),
179			backdrop_at: None,
180			backdrop: Eclipse::default(),
181			surface: Surface::new(),
182			pointer: None,
183			hover: Tween::settled(0.0),
184		}
185	}
186
187	/// Records the pointer (0-based cells) for the hover zone and retargets
188	/// the camera: the pointer's offset from the logo center maps to camera
189	/// lift and a full half-turn of yaw in each direction, matching the
190	/// prototype.
191	pub fn point_at(&mut self, column: u16, row: u16) {
192		self.pointer = Some((column, row));
193		self.logo_at = None;
194		let center_x = f32::from(self.logo_origin.0) + LOGO_COLS as f32 / 2.0;
195		let center_y = f32::from(self.logo_origin.1) + LOGO_ROWS as f32 / 2.0;
196		let horizontal = ((f32::from(column) - center_x) / (LOGO_COLS as f32 / 2.0)).clamp(-1.0, 1.0);
197		let vertical = ((f32::from(row) - center_y) / (LOGO_ROWS as f32 / 2.0)).clamp(-1.0, 1.0);
198		self.camera_target = (-vertical * 0.42, -horizontal * PI);
199	}
200
201	/// Paints the card centered in `viewport` at `elapsed` since boot and
202	/// returns the full-viewport frame (no stable rows, everything damaged).
203	pub fn render(&mut self, viewport: Size, elapsed: Duration) -> &Frame {
204		if self.frame.size() != viewport {
205			self.frame = Frame::new(viewport);
206			self.backdrop_frame = Frame::new(viewport);
207			self.backdrop_at = None;
208		}
209		let clock = elapsed;
210		let elapsed = elapsed.as_secs_f32();
211		// Exponential pointer chase, frame-rate independent (~100ms lag).
212		let delta = (elapsed - self.last_elapsed).max(0.0);
213		self.last_elapsed = elapsed;
214		let response = 1.0 - (-delta * 10.0).exp();
215		self.camera.0 += (self.camera_target.0 - self.camera.0) * response;
216		self.camera.1 += (self.camera_target.1 - self.camera.1) * response;
217		self.draw_backdrop(viewport, clock, elapsed);
218
219		let logo_interval = ambient_interval(clock, LOGO_IDLE_INTERVAL);
220		if self
221			.logo_at
222			.is_none_or(|rendered_at| clock.saturating_sub(rendered_at) >= logo_interval)
223		{
224			self.logo = logo_cells(elapsed, self.camera);
225			self.logo_at = Some(clock);
226		}
227		let cols = if viewport.width >= CARD_COLS && viewport.height >= CARD_ROWS {
228			Some(CARD_COLS)
229		} else if viewport.width >= SMOL_COLS && viewport.height >= CARD_ROWS {
230			Some(SMOL_COLS)
231		} else {
232			None
233		};
234		let Some(cols) = cols else {
235			let left = viewport.width.saturating_sub(LOGO_COLS as u16) / 2;
236			let top = viewport.height.saturating_sub(LOGO_ROWS as u16) / 2;
237			self.logo_origin = (left, top);
238			blit_logo(&mut self.frame, &self.logo, left, top, PLATE);
239			return &self.frame;
240		};
241
242		let left = (viewport.width - cols) / 2;
243		let top = (viewport.height - CARD_ROWS) / 2;
244		let hovered = self.pointer.is_some_and(|(x, y)| {
245			(left..left + cols).contains(&x) && (top..top + CARD_ROWS).contains(&y)
246		});
247		self
248			.hover
249			.retarget(clock, if hovered { 1.0 } else { 0.0 }, HOVER_EASE, Easing::EaseOut);
250		let hover = self.hover.sample(clock).clamp(0.0, 1.0);
251		self.draw_card(cols, left, top, elapsed, hover);
252		&self.frame
253	}
254
255	/// Paints the eclipse across the whole viewport, resolving out of
256	/// black over the first [`BACKDROP_FADE`] seconds of boot.
257	fn draw_backdrop(&mut self, viewport: Size, clock: Duration, elapsed: f32) {
258		let interval = ambient_interval(clock, BACKDROP_IDLE_INTERVAL);
259		if self
260			.backdrop_at
261			.is_none_or(|rendered_at| clock.saturating_sub(rendered_at) >= interval)
262		{
263			let fade = smooth((elapsed / BACKDROP_FADE).clamp(0.0, 1.0));
264			self
265				.backdrop_frame
266				.fill(Rect::new(0, 0, viewport.width, viewport.height), Style::default());
267			let frame = &mut self.backdrop_frame;
268			let mut buffer = [0_u8; 4];
269			let dim = |color: Color| Color::Rgb(0, 0, 0).lerp(color, fade);
270			self.surface.render(
271				&mut self.backdrop,
272				clock,
273				viewport.width,
274				viewport.height,
275				|x, y, glyph, fg, bg| {
276					let style = Style::new().fg(dim(fg));
277					let style = match bg {
278						Some(bg) => style.bg(dim(bg)),
279						None => style,
280					};
281					frame.put(x, y, glyph.encode_utf8(&mut buffer), style);
282				},
283			);
284			self.backdrop_at = Some(clock);
285		}
286		self.frame.clone_from(&self.backdrop_frame);
287	}
288
289	fn draw_card(&mut self, cols: u16, left: u16, top: u16, elapsed: f32, hover: f32) {
290		let full = cols == CARD_COLS;
291		let logo_left = if full {
292			left + 3
293		} else {
294			left + (cols - LOGO_COLS as u16) / 2
295		};
296		self.logo_origin = (logo_left, top + 2);
297		// Pointer-tracking border glow: the brand gradient sampled by angle
298		// around the card center (the disk's own palette), strongest near
299		// the pointer, scaled by the eased hover amount.
300		let pointer = self.pointer;
301		let center =
302			(f32::from(left) + f32::from(cols) / 2.0, f32::from(top) + f32::from(CARD_ROWS) / 2.0);
303		let edge_at = move |x: u16, y: u16| -> Style {
304			let Some((px, py)) = pointer.filter(|_| hover > 0.02) else {
305				return on_card(CARD_BORDER);
306			};
307			let dx = (f32::from(x) - f32::from(px)) * 0.5;
308			let dy = f32::from(y) - f32::from(py);
309			let glow = hover * (-(dx * dx + dy * dy) / 34.0).exp();
310			if glow < 0.02 {
311				return on_card(CARD_BORDER);
312			}
313			let angle = (f32::from(y) - center.1).atan2((f32::from(x) - center.0) * 0.5);
314			let brand = vec3_color(gradient(angle - elapsed * 0.5));
315			on_card(CARD_BORDER.lerp(brand, glow))
316		};
317		let frame = &mut self.frame;
318		frame.fill(Rect::new(left, top, cols, CARD_ROWS), on_card(TEXT));
319
320		let right = left + cols - 1;
321		let bottom = top + CARD_ROWS - 1;
322		let divider = bottom - 2;
323		let (tl, tr, bl, br, horizontal, vertical) = self.charset.border(Border::Round);
324		let grid = self.charset.grid();
325		let mut glyph = [0_u8; 4];
326		frame.put(left, top, tl.encode_utf8(&mut glyph), edge_at(left, top));
327		frame.put(right, top, tr.encode_utf8(&mut glyph), edge_at(right, top));
328		frame.put(left, divider, grid.middle.0.encode_utf8(&mut glyph), edge_at(left, divider));
329		frame.put(right, divider, grid.middle.2.encode_utf8(&mut glyph), edge_at(right, divider));
330		frame.put(left, bottom, bl.encode_utf8(&mut glyph), edge_at(left, bottom));
331		frame.put(right, bottom, br.encode_utf8(&mut glyph), edge_at(right, bottom));
332		for x in left + 1..right {
333			frame.put(x, top, horizontal.encode_utf8(&mut glyph), edge_at(x, top));
334			frame.put(x, divider, horizontal.encode_utf8(&mut glyph), edge_at(x, divider));
335			frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), edge_at(x, bottom));
336		}
337		for y in top + 1..bottom {
338			if y != divider {
339				frame.put(left, y, vertical.encode_utf8(&mut glyph), edge_at(left, y));
340				frame.put(right, y, vertical.encode_utf8(&mut glyph), edge_at(right, y));
341			}
342		}
343
344		frame.put(left + 2, top, self.title.as_str(), on_card(TEXT_STRONG));
345		if full {
346			frame.put(left + 34, top, " SESSION ORBIT ", on_card(FAINT));
347			draw_dust(frame, left, top, elapsed);
348			draw_sessions(frame, left, top, self.charset);
349			draw_beam(frame, left, top, elapsed);
350		}
351
352		blit_logo(frame, &self.logo, logo_left, top + 2, CARD_BG);
353
354		let footer = divider + 1;
355		frame.fill(Rect::new(left + 1, footer, cols - 2, 1), on_footer(TEXT));
356		if full {
357			frame.put(left + 3, divider, " SHORTCUTS ", on_card(FAINT));
358			let dot = fmts!(" {} ", self.charset.icon(Icon::Enabled));
359			let x = frame.put(left + cols - 21, top, &dot, on_card(GREEN));
360			frame.put(x, top, "rust-analyzer ", on_card(MUTED));
361			draw_full_hints(frame, left, footer);
362		} else {
363			draw_smol_hints(frame, left, cols, footer);
364		}
365	}
examples/chat/picker.rs (line 448)
433fn model_rows(tier: PerfTier, current: usize, charset: Charset) -> Vec<RowSpec> {
434	MODELS
435		.iter()
436		.enumerate()
437		.map(|(index, model)| RowSpec {
438			value:       fmts!("{index}"),
439			label:       fmts!("{}/{}", model.provider, model.id),
440			logo:        fmts!("{LOGO_DIR}/{}.png", model.provider),
441			prefix:      fmts!("{}/", model.provider),
442			prefix_fg:   DIM,
443			name:        Str::new_static(model.id),
444			name_fg:     TEXT,
445			current:     index == current,
446			recommended: index == current,
447			perf:        tier.cell(model),
448			ctx:         fmts!("{} {}", model.ctx, charset.icon(Icon::Context)),
449			cost:        Str::new_static(model.cost),
450		})
451		.collect()
452}
453
454fn role_rows(tier: PerfTier, current: usize, charset: Charset) -> Vec<RowSpec> {
455	ROLES
456		.iter()
457		.enumerate()
458		.map(|(index, role)| {
459			let model = &MODELS[role.model];
460			let name = match role.thinking {
461				Some(glyph) => fmts!("{} {glyph}", role.name),
462				None => Str::new_static(role.name),
463			};
464			RowSpec {
465				value: fmts!("{}", role.model),
466				// The `@` stays in the haystack so `@arc` matches and the
467				// bare `@` keeps every role visible.
468				label: fmts!("@{}", role.name),
469				logo: fmts!("{LOGO_DIR}/{}.png", model.provider),
470				prefix: Str::default(),
471				prefix_fg: DIM,
472				name,
473				name_fg: role.color,
474				current: role.model == current,
475				recommended: index == 0,
476				perf: tier.cell(model),
477				ctx: fmts!("{} {}", model.ctx, charset.icon(Icon::Context)),
478				cost: Str::new_static(model.cost),
479			}
480		})
481		.collect()
482}
483
484/// One role chip (`● default`, `○ plan ◑`) under the facts line.
485struct Chip {
486	text:  Str,
487	color: Color,
488}
489
490/// The chip row for `model`: its current marker plus every role resolving
491/// to it. Dots resolve through the charset — solid (`enabled`) for
492/// configured roles, hollow (`shadowed`) for auto-selected ones.
493fn chips(model: usize, current: usize, charset: Charset) -> Vec<Chip> {
494	let mut chips = Vec::new();
495	if model == current {
496		chips.push(Chip { text: fmts!("{} current", charset.icon(Icon::Enabled)), color: GREEN });
497	}
498	for role in ROLES.iter().filter(|role| role.model == model) {
499		let dot = if role.configured {
500			charset.icon(Icon::Enabled)
501		} else {
502			charset.icon(Icon::Shadowed)
503		};
504		let color = if role.configured { role.color } else { DIM };
505		let mut text = StrMut::with_capacity(16);
506		text.push_str(dot);
507		text.push(' ');
508		text.push_str(role.name);
509		if let Some(glyph) = role.thinking {
510			text.push(' ');
511			text.push_str(glyph);
512		}
513		chips.push(Chip { text: text.freeze(), color });
514	}
515	if chips.is_empty() {
516		chips.push(Chip { text: Str::new_static(" "), color: DIM });
517	}
518	chips
519}
520
521/// The models-catalog picker pane at full perf tier for `width`: the tree
522/// behind [`ModelPicker`], reusable as inline content by other examples.
523#[allow(dead_code, reason = "consumed by the gallery example's #[path] include of this module")]
524pub fn models_pane(current: usize, rows: u16, width: u16, charset: Charset) -> Box<dyn Component> {
525	tree(Mode::Models, PerfTier::of(width), current, "", rows, charset)
526}
527
528/// Builds the retained overlay tree for one catalog mode.
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}
540
541/// The picker component tree for one catalog mode.
542fn tree(
543	mode: Mode,
544	tier: PerfTier,
545	current: usize,
546	query: &str,
547	rows: u16,
548	charset: Charset,
549) -> Box<dyn Component> {
550	let list = match mode {
551		Mode::Models => model_rows(tier, current, charset),
552		Mode::Roles => role_rows(tier, current, charset),
553	};
554	let status = match mode {
555		Mode::Models => STATUS_MODELS,
556		Mode::Roles => STATUS_ROLES,
557	};
558	let hint = match mode {
559		Mode::Models => HINT_MODELS,
560		Mode::Roles => HINT_ROLES,
561	};
562	let current_dot = fmts!(" {}", charset.icon(Icon::Enabled));
563	let seed = Str::from(query);
564	let height = rows.saturating_add(1);
565	dom! {
566			<box border=round title="Switch Model" pad-x=1>
567				<col>
568					<text fg=muted truncate>{status}</text>
569					<select id="models" filter={seed} h={height}>
570						for row in list {
571							<option value={row.value} label={row.label} recommended={row.recommended}>
572								<td><img src={row.logo} w=2 h=1 trim/></td>
573								<td truncate=start grow>
574									if !row.prefix.is_empty() {
575										<pre fg={row.prefix_fg}>{row.prefix}</pre>
576									}
577									<pre fg={row.name_fg}>{row.name}</pre>
578									if row.current {
579										<pre fg={GREEN}>{current_dot.clone()}</pre>
580									}
581								</td>
582								if tier != PerfTier::None {
583									<td align=end><pre fg={DIM}>{row.perf}</pre></td>
584								}
585								<td align=end><pre fg={DIM}>{row.ctx}</pre></td>
586								<td align=end><pre fg={DIM}>{row.cost}</pre></td>
587							</option>
588						}
589					</select>
590					<spacer h=1/>
591					<text id="facts" fg=muted truncate>{" "}</text>
592					for model in 0..MODELS.len() {
593						<row id={fmts!("chips-{model}")}>
594							for (index, chip) in chips(model, current, charset).into_iter().enumerate() {
595								if index > 0 {
596									<pre fg={DIM}>{" · "}</pre>
597								}
598								<pre fg={chip.color}>{chip.text}</pre>
599							}
600						</row>
601					}
602					<text dim truncate>{hint}</text>
603				</col>
604			</box>
605	}
606	.into_component()
607}
Source

pub fn icon_named(self, name: &str) -> Option<&'static str>

Resolves a short icon name or qualified compatibility alias.

Source

pub const fn border( self, border: Border, ) -> (char, char, char, char, char, char)

Border glyph set for a box: (tl, tr, bl, br, horizontal, vertical). Public so raw-frame hosts painting their own chrome share the widget tier policy instead of hardcoding box drawing.

Examples found in repository?
examples/chat/demo.rs (line 292)
291	fn input_prompt(charset: Charset) -> Str {
292		let (_, _, bl, _, horizontal, _) = charset.border(Border::Round);
293		fmts!("{bl}{horizontal}")
294	}
295
296	const fn input_width(width: u16) -> u16 {
297		width.saturating_sub(Self::input_offset()).saturating_sub(1)
298	}
299
300	fn paint_picker(pc: &mut PaintCtx<'_>, rect: Rect, y: u16, editor: &Editor) {
301		let Some(picker) = editor.picker() else {
302			return;
303		};
304		let (start, suggestions) = picker.visible_suggestions();
305		let overflow = picker.len() > suggestions.len();
306		let row_right = rect
307			.x
308			.saturating_add(rect.width.saturating_sub(u16::from(overflow)));
309		let primary_width = suggestions
310			.iter()
311			.filter_map(|suggestion| match suggestion.display() {
312				SuggestionDisplay::Text(name) => Some(visible_width(name).saturating_add(2)),
313				SuggestionDisplay::Emoji { .. } => None,
314			})
315			.max()
316			.unwrap_or(12)
317			.clamp(12, 32);
318
319		for (offset, suggestion) in suggestions.iter().enumerate() {
320			let Ok(offset) = u16::try_from(offset) else {
321				break;
322			};
323			let row = y.saturating_add(offset);
324			if row >= pc.clip {
325				break;
326			}
327			let selected = start + usize::from(offset) == picker.selected();
328			let label = if selected { ink(GREEN) } else { ink(TEXT) };
329			let description = if selected { ink(GREEN) } else { ink(MUTED) };
330			pc.frame.put(
331				rect.x,
332				row,
333				if selected {
334					pc.ctx.charset.cursor()
335				} else {
336					"  "
337				},
338				label,
339			);
340			match suggestion.display() {
341				SuggestionDisplay::Text(name) => {
342					draw_line(
343						pc.frame,
344						rect.x.saturating_add(2),
345						row,
346						row_right.saturating_sub(rect.x.saturating_add(2)),
347						&[Span::new(name, label)],
348					);
349					if let Some(text) = suggestion.description()
350						&& rect.width > 40
351					{
352						let description_x = rect
353							.x
354							.saturating_add(2)
355							.saturating_add(primary_width)
356							.min(row_right);
357						draw_line(
358							pc.frame,
359							description_x,
360							row,
361							row_right.saturating_sub(description_x),
362							&[Span::new(text, description)],
363						);
364					}
365				},
366				SuggestionDisplay::Emoji { emoji, shortcode } => {
367					let mut column = pc.frame.put(rect.x.saturating_add(2), row, emoji, label);
368					column = pc.frame.put(column, row, "  ", label);
369					if shortcode.starts_with(':') {
370						pc.frame.put(column, row, shortcode, label);
371					} else {
372						column = pc.frame.put(column, row, ":", label);
373						column = pc.frame.put(column, row, shortcode, label);
374						pc.frame.put(column, row, ":", label);
375					}
376				},
377			}
378		}
379
380		if overflow && !suggestions.is_empty() {
381			let (track, thumb_glyph) = pc.ctx.charset.scrollbar();
382			let track_x = rect.x.saturating_add(rect.width.saturating_sub(1));
383			for offset in 0..suggestions.len() {
384				let Ok(offset) = u16::try_from(offset) else {
385					break;
386				};
387				pc.frame
388					.put(track_x, y.saturating_add(offset), track, ink(FAINT));
389			}
390			let thumb = picker
391				.selected()
392				.saturating_mul(suggestions.len().saturating_sub(1))
393				/ picker.len().saturating_sub(1);
394			pc.frame.put(
395				track_x,
396				y.saturating_add(u16::try_from(thumb).unwrap_or(u16::MAX)),
397				thumb_glyph,
398				ink(GREEN),
399			);
400		}
401	}
402}
403
404impl Component for DemoInput {
405	fn props(&self) -> &Props {
406		&self.props
407	}
408
409	fn props_mut(&mut self) -> &mut Props {
410		&mut self.props
411	}
412
413	fn slot(&self) -> Slot {
414		self.slot
415	}
416
417	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
418		(6, 40)
419	}
420
421	fn height(&mut self, _ctx: &UiContext, width: u16) -> u16 {
422		let editor = self.editor.borrow();
423		editor
424			.input_height_for(Self::input_width(width))
425			.saturating_add(editor.picker_height())
426	}
427
428	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
429		pc.hits
430			.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
431		let editor = self.editor.borrow();
432		let input_x = rect.x.saturating_add(Self::input_offset());
433		let input_width = Self::input_width(rect.width);
434		let input_height = editor.input_height_for(input_width);
435		let theme = Theme::default();
436		let mut in_comment = false;
437		for (offset, row) in editor.view(input_width).iter().enumerate() {
438			let row_y = rect
439				.y
440				.saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
441			if row_y >= pc.clip {
442				break;
443			}
444			if offset == 0 {
445				pc.frame
446					.put(rect.x, row_y, &Self::input_prompt(pc.ctx.charset), ink(FAINT));
447			}
448			let mut spans: SmallVec<Span<'_>, 16> = SmallVec::new();
449			if editor.options().xml {
450				let (runs, next) = highlight_xml(row.text, &theme, in_comment);
451				in_comment = next;
452				push_row_spans(&editor, row.text, &runs, &mut spans);
453			} else {
454				push_row_spans(&editor, row.text, &[], &mut spans);
455			}
456			draw_line(pc.frame, input_x, row_y, input_width, &spans);
457			if let Some(cursor_column) = row.cursor_column {
458				if cursor_column >= visible_width(row.text)
459					&& let Some(hint) = editor.inline_hint()
460				{
461					let hint_x = input_x.saturating_add(cursor_column).saturating_add(1);
462					let width = input_width.saturating_sub(cursor_column.saturating_add(1));
463					draw_line(pc.frame, hint_x, row_y, width, &[Span::new(
464						hint.as_str(),
465						ink(MUTED).dim(),
466					)]);
467				}
468				pc.frame.set_cursor(
469					input_x
470						.saturating_add(cursor_column)
471						.min(rect.x.saturating_add(rect.width.saturating_sub(2))),
472					row_y,
473				);
474			}
475		}
476		Self::paint_picker(pc, rect, rect.y.saturating_add(input_height), &editor);
477	}
478
479	fn focusable(&self) -> bool {
480		true
481	}
482
483	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
484		let outcome = self.editor.borrow_mut().handle(key);
485		*self.outcome.borrow_mut() = Some(outcome);
486		// The editor owns every key while focused. In particular, an ignored
487		// picker key must not escape into `Ui`'s focus-ring navigation; the
488		// demo applies its quit policy from the recorded `EditOutcome`.
489		Flow::Consumed
490	}
491
492	fn mouse(
493		&mut self,
494		_ec: &mut EventCtx<'_>,
495		_tag: HitTag,
496		at: (u16, u16),
497		rect: Rect,
498		mouse: Mouse,
499	) -> Flow {
500		let width = Self::input_width(rect.width);
501		match mouse {
502			Mouse::Click => {
503				self.editor.borrow_mut().set_cursor_visual_row(
504					usize::from(at.1.saturating_sub(rect.y)),
505					at.0
506						.saturating_sub(rect.x.saturating_add(Self::input_offset())),
507					width,
508				);
509				Flow::Consumed
510			},
511			Mouse::WheelUp | Mouse::WheelDown => {
512				let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
513				if self
514					.editor
515					.borrow()
516					.scroll_rows(delta, width, usize::from(rect.height))
517				{
518					Flow::Consumed
519				} else {
520					Flow::Skip
521				}
522			},
523			_ => Flow::Skip,
524		}
525	}
526
527	fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
528		if matches!(self.editor.borrow_mut().insert_text(text), EditOutcome::Changed) {
529			Flow::Consumed
530		} else {
531			Flow::Skip
532		}
533	}
534}
535/// Whether the demo is working, and how the status bar's brand segment
536/// blends between its two states.
537struct WorkState {
538	working: bool,
539	/// When the current mode began; the working timer counts from here.
540	since:   Duration,
541	/// Brand foreground: [`GREEN`] while working, [`MUTED`] at rest.
542	fade:    Tween<Color>,
543}
544
545/// Powerline status split into a left brand group — spinner and session
546/// timer while working, the omp brand at rest, the foreground tweening
547/// between the two so neither swap ever snaps — and a right-docked
548/// session group (branch, context, cost). Panes too narrow for both
549/// groups fall back to one left-anchored band that sheds from the tail.
550struct DemoStatus {
551	props:   Props,
552	slot:    Slot,
553	work:    Rc<RefCell<WorkState>>,
554	model:   Rc<RefCell<Str>>,
555	charset: Charset,
556	right:   Status,
557}
558
559impl DemoStatus {
560	fn new(work: Rc<RefCell<WorkState>>, model: Rc<RefCell<Str>>, charset: Charset) -> Self {
561		let mut props = Props::new();
562		props.set(Prop::Id, STATUS_ID);
563		let right = Self::right_group(charset);
564		Self { props, slot: next_slot(), work, model, charset, right }
565	}
566
567	/// One styled band-group shell on the shared dark backdrop.
568	fn group() -> Status {
569		Status::new()
570			.with(Prop::Bg, Color::Rgb(18, 18, 18))
571			.with(Prop::Fg, TEXT)
572	}
573
574	/// The brand segment at `now`: spinner plus session timer while
575	/// working, the omp badge at rest, foreground riding the work fade.
576	fn brand_segment(&self, now: Duration) -> Segment {
577		let work = self.work.borrow();
578		let brand = if work.working {
579			fmts!(
580				"{} {}",
581				self.charset.spinner().at(now),
582				elapsed_label(now.saturating_sub(work.since))
583			)
584		} else {
585			fmts!("{} omp", self.charset.icon(Icon::Omp))
586		};
587		Segment::new()
588			.label(brand)
589			.with(Prop::Fg, work.fade.sample(now))
590	}
591
592	fn model_segment(&self) -> Segment {
593		Segment::new()
594			.label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595			.with(Prop::Fg, GREEN)
596	}
597
598	fn git_segment(charset: Charset) -> Segment {
599		Segment::new()
600			.label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601			.with(Prop::Fg, CYAN)
602	}
603
604	fn context_segment(charset: Charset) -> Segment {
605		Segment::new()
606			.label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607			.with(Prop::Fg, GOLD)
608	}
609
610	fn cost_segment() -> Segment {
611		Segment::new()
612			.label("$60.07 (sub) + $8.65 (adv)")
613			.with(Prop::Fg, PURPLE)
614	}
615
616	/// The left band group: brand and model.
617	fn left_group(&self, now: Duration) -> Status {
618		Self::group()
619			.segment(self.brand_segment(now))
620			.segment(self.model_segment())
621	}
622
623	/// The right band group: branch, context, and cost.
624	fn right_group(charset: Charset) -> Status {
625		Self::group()
626			.with_str(Prop::Align, "right")
627			.segment(Self::git_segment(charset))
628			.segment(Self::context_segment(charset))
629			.segment(Self::cost_segment())
630	}
631
632	/// Every segment in one band, for panes too narrow to split.
633	fn combined(&self, now: Duration) -> Status {
634		Self::group()
635			.segment(self.brand_segment(now))
636			.segment(self.model_segment())
637			.segment(Self::git_segment(self.charset))
638			.segment(Self::context_segment(self.charset))
639			.segment(Self::cost_segment())
640	}
641}
642
643impl Component for DemoStatus {
644	fn props(&self) -> &Props {
645		&self.props
646	}
647
648	fn props_mut(&mut self) -> &mut Props {
649		&mut self.props
650	}
651
652	fn slot(&self) -> Slot {
653		self.slot
654	}
655
656	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657		self.combined(Duration::ZERO).measure(ctx)
658	}
659
660	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661		1
662	}
663
664	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665		let mut left = self.left_group(pc.now);
666		let (_, left_width) = left.measure(pc.ctx);
667		let (_, right_width) = self.right.measure(pc.ctx);
668		if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669			left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670			let dock = rect
671				.x
672				.saturating_add(rect.width)
673				.saturating_sub(right_width);
674			self
675				.right
676				.paint(pc, Rect::new(dock, rect.y, right_width, 1));
677		} else {
678			let mut combined = self.combined(pc.now);
679			combined.paint(pc, rect);
680		}
681		let work = self.work.borrow();
682		let fade_frame = work
683			.fade
684			.settles_at()
685			.min(pc.now.saturating_add(FADE_FRAME));
686		let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687			(true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688			(true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689			(false, false) => Some(fade_frame),
690			(false, true) => None,
691		};
692		if let Some(at) = deadline {
693			pc.wake(self.slot, at);
694		}
695	}
696
697	fn paints_background(&self) -> bool {
698		false
699	}
700}
701
702/// One retained chat document update and its exact repainted row ranges.
703pub struct RenderedFrame<'a> {
704	pub(crate) frame:       &'a Frame,
705	pub(crate) stable_rows: u16,
706	pub(crate) damage:      SmallVec<(u16, u16), 4>,
707}
708
709/// Produces the animated transcript, work indicator, editor, and status
710/// line demo.
711pub struct Demo {
712	started_at:         Instant,
713	/// Detected presentation context shared by every retained subtree.
714	ctx:                UiContext,
715	/// Cancel hint resolved once through the context's charset.
716	cancel_hint:        &'static str,
717	editor_ui:          Ui,
718	editor:             Rc<RefCell<Editor>>,
719	edit_outcome:       Rc<RefCell<Option<EditOutcome>>>,
720	work:               Rc<RefCell<WorkState>>,
721	last_working:       bool,
722	model:              Rc<RefCell<Str>>,
723	/// Images staged on the composer, previewed above the status line.
724	attachments:        Attachments,
725	/// Append-only transcript log, replayed in full on geometry rebuilds.
726	transcript:         Vec<Entry>,
727	/// Entries already painted into the retained frame.
728	drawn_entries:      usize,
729	/// Rows covered by the drawn entries; doubles as `stable_rows`.
730	transcript_rows:    u16,
731	appended_messages:  usize,
732	emitted_shards:     u16,
733	last_viewport:      Size,
734	height_floor:       u16,
735	frame:              Frame,
736	/// Geometry of the retained live panel chrome.
737	live_panel:         Option<Rect>,
738	/// Reusable text and placement state for the animated shard rows.
739	live_rows:          [LiveRowCache; LIVE_SHARD_ROWS as usize],
740	/// One build buffer rotated through the row caches without reallocating.
741	live_label_scratch: StrMut,
742	/// Columns reserved at the right edge for a composited rail; the
743	/// editor and title dock against the remaining visible width.
744	right_inset:        u16,
745	/// The composer submitted `/switch`; the host opens the model picker.
746	switch_requested:   bool,
747}
748
749impl Demo {
750	/// Starts the demo's animation clock, presenting through the host's
751	/// detected context.
752	pub fn new(ctx: &UiContext) -> Self {
753		let editor = Rc::new(RefCell::new({
754			let mut editor = Editor::new(EditorOptions::default());
755			editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756			editor
757		}));
758		let edit_outcome = Rc::new(RefCell::new(None));
759		let work = Rc::new(RefCell::new(WorkState {
760			working: true,
761			since:   Duration::ZERO,
762			fade:    Tween::settled(GREEN),
763		}));
764		let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765		let pane = EditorPane::new()
766			.input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767			.status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768		let attachments = pane.attachments();
769		let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770		Self {
771			started_at: Instant::now(),
772			ctx: ctx.clone(),
773			cancel_hint: ctx.charset.icon(Icon::Cancellable),
774			editor_ui,
775			editor,
776			edit_outcome,
777			work,
778			last_working: true,
779			model,
780			attachments,
781			transcript: vec![Entry::Command],
782			drawn_entries: 0,
783			transcript_rows: 0,
784			appended_messages: 0,
785			emitted_shards: 0,
786			last_viewport: Size::new(0, 0),
787			height_floor: 0,
788			frame: Frame::new(Size::new(0, 0)),
789			live_panel: None,
790			live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791			live_label_scratch: StrMut::with_capacity(40),
792			right_inset: 0,
793			switch_requested: false,
794		}
795	}
796
797	/// Routes a key through the editor and reports whether the demo should
798	/// exit. Quit policy lives here, not in the editor: once the editor
799	/// reports a key unused, `esc` first cancels running work and only quits
800	/// at rest; `ctrl-c` always quits.
801	pub fn handle_key(&mut self, key: Key) -> bool {
802		*self.edit_outcome.borrow_mut() = None;
803		let _ = self.editor_ui.handle_key(key);
804		let outcome = self
805			.edit_outcome
806			.borrow_mut()
807			.take()
808			.unwrap_or(EditOutcome::Ignored);
809		match outcome {
810			EditOutcome::Submitted(text) => {
811				let trimmed = text.trim();
812				if trimmed == "/switch" {
813					self.switch_requested = true;
814					return false;
815				}
816				if let Some(path) = trimmed
817					.strip_prefix("/attach")
818					.filter(|rest| rest.is_empty() || rest.starts_with(' '))
819				{
820					let path = path.trim().to_string();
821					if !path.is_empty() {
822						self.attach_image(&path);
823					}
824					return false;
825				}
826				let _ = self.attachments.take();
827				self.refresh_composer();
828				self
829					.transcript
830					.push(Entry::Submitted(Box::new(Submission::new(
831						text,
832						Self::message_width(self.last_viewport.width),
833						&self.ctx,
834					))));
835				self.set_working(true, self.started_at.elapsed());
836				false
837			},
838			EditOutcome::Changed => {
839				self.reconcile_attachments();
840				false
841			},
842			EditOutcome::Ignored => {
843				if key == Key::Ctrl('c') {
844					return true;
845				}
846				if key != Key::Esc {
847					return false;
848				}
849				if self.work.borrow().working {
850					self.set_working(false, self.started_at.elapsed());
851					return false;
852				}
853				true
854			},
855		}
856	}
857
858	/// Consumes a pending `/switch` request submitted through the composer.
859	pub fn take_switch_request(&mut self) -> bool {
860		std::mem::take(&mut self.switch_requested)
861	}
862
863	/// Routes a document-space mouse report into the editor UI.
864	pub fn handle_mouse(&mut self, report: &MouseReport) {
865		let editor_height = self.editor_ui.height();
866		let editor_y = self.frame.size().height.saturating_sub(editor_height);
867		let editor_bottom = editor_y.saturating_add(editor_height);
868		if report.row < editor_y || report.row >= editor_bottom {
869			return;
870		}
871		let _ = self
872			.editor_ui
873			.handle_mouse(report.col, report.row - editor_y, report.kind);
874	}
875
876	/// Switches the work state and retargets the brand fade. The status bar
877	/// repaints immediately and the fade departs from whatever color is on
878	/// screen, so rapid cancel/resume never snaps.
879	fn set_working(&mut self, working: bool, now: Duration) {
880		{
881			let mut work = self.work.borrow_mut();
882			if work.working == working {
883				return;
884			}
885			work.working = working;
886			work.since = now;
887			let target = if working { GREEN } else { MUTED };
888			work
889				.fade
890				.retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891		}
892		self.editor_ui.invalidate(STATUS_ID);
893	}
894
895	/// Reflects a session model switch in the status bar's model segment.
896	pub fn set_model(&mut self, name: &str) {
897		*self.model.borrow_mut() = Str::from(name);
898		self.editor_ui.invalidate(STATUS_ID);
899	}
900
901	/// Routes sanitized bracketed paste text through the editor. Dropped
902	/// paths to existing image files (quoted, escaped, `file://`, or
903	/// multi-file) and any large paste collapse into composer attachment
904	/// chips instead of raw text.
905	pub fn handle_paste(&mut self, text: &str) {
906		let paths = omp_tui::paste::dropped_paths(text);
907		if !paths.is_empty()
908			&& paths.iter().all(|path| {
909				omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910			}) {
911			for path in &paths {
912				self.attach_image(path);
913			}
914			return;
915		}
916		if text.lines().count() > 10 || text.len() > 1000 {
917			self.attach_paste(text);
918			return;
919		}
920		let _ = self.editor_ui.handle_paste(text);
921	}
922
923	/// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924	/// attachment staging, no large-paste collapse — the text stays inline
925	/// and editable.
926	pub fn handle_paste_raw(&mut self, text: &str) {
927		let _ = self.editor_ui.handle_paste_raw(text);
928	}
929
930	/// Stages `path` on the composer and mentions it in the prompt as an
931	/// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932	fn attach_image(&mut self, path: &str) {
933		let attachment = self.attachments.push_image(path);
934		let payload = format!("<ref image={}/>", attachment.marker);
935		self.insert_chip(&attachment, &payload);
936	}
937
938	/// Collapses a large paste into a staged attachment card and an atomic
939	/// composer chip expanding back to the pasted text on submit.
940	fn attach_paste(&mut self, text: &str) {
941		let attachment = self.attachments.push_text(text);
942		self.insert_chip(&attachment, text);
943	}
944
945	/// Inserts one attachment chip as an atomic editor reference.
946	fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947		let chip = chip_label(attachment, self.ctx.charset);
948		{
949			let mut editor = self.editor.borrow_mut();
950			let _ = editor.insert_reference(&chip, payload);
951			let _ = editor.insert_text(" ");
952		}
953		self.refresh_composer();
954	}
955
956	/// Hides staged attachments whose chip the user deleted from the
957	/// composer (and re-shows them after an undo). Presence is derived
958	/// from the buffer's atomic ranges, never from text matching.
959	fn reconcile_attachments(&mut self) {
960		let charset = self.ctx.charset;
961		let changed = {
962			let editor = self.editor.borrow();
963			let text = editor.text();
964			let ranges = editor.atom_ranges();
965			self.attachments.set_visible(|attachment| {
966				let chip = chip_label(attachment, charset);
967				ranges
968					.iter()
969					.any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970			})
971		};
972		if changed {
973			self.refresh_composer();
974		}
975	}
976
977	/// Relayouts the composer after out-of-band state changed its height.
978	fn refresh_composer(&mut self) {
979		let width = self.editor_ui.frame().size().width;
980		if width > 0 {
981			self.editor_ui.resize(width);
982		}
983	}
984
985	/// Reserves `cols` at the right edge for a composited rail, so the
986	/// composer's right-docked chrome stays visible beside it. The next
987	/// render relayouts the editor at the narrowed width.
988	pub const fn set_right_inset(&mut self, cols: u16) {
989		self.right_inset = cols;
990	}
991
992	/// The width the composer may actually occupy at `viewport`.
993	fn composer_width(&self, viewport: Size) -> u16 {
994		viewport.width.saturating_sub(self.right_inset).max(1)
995	}
996
997	/// Updates the retained logical document and reports its repainted rows.
998	pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999		self.render_at(viewport, self.started_at.elapsed())
1000	}
1001
1002	fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003		if viewport.width == 0 || viewport.height == 0 {
1004			self.last_viewport = viewport;
1005			self.height_floor = 0;
1006			self.drawn_entries = 0;
1007			self.transcript_rows = 0;
1008			self.live_panel = None;
1009			self.frame = Frame::new(viewport);
1010			return RenderedFrame {
1011				frame:       &self.frame,
1012				stable_rows: 0,
1013				damage:      SmallVec::new(),
1014			};
1015		}
1016		let composer_width = self.composer_width(viewport);
1017		if self.editor_ui.frame().size().width != composer_width {
1018			self.editor_ui.resize(composer_width);
1019		}
1020		// Fires due animation wakes (the status bar's spinner and brand
1021		// fade) so the blit below picks up fresh retained pixels.
1022		self.editor_ui.tick(elapsed);
1023		let editor_changed = self.editor_ui.take_frame_damage();
1024
1025		// A viewport change starts a fresh renderer session: replay the
1026		// whole transcript log at the new width. Between rebuilds the log
1027		// is append-only and every drawn row is final, so selections over
1028		// transcript text stay anchored to it in every terminal.
1029		let rebuild = self.last_viewport != viewport;
1030		if rebuild {
1031			self.last_viewport = viewport;
1032			self.height_floor = 0;
1033			self.drawn_entries = 0;
1034			self.transcript_rows = 0;
1035			let message_width = Self::message_width(viewport.width);
1036			for entry in &mut self.transcript {
1037				if let Entry::Submitted(submission) = entry {
1038					submission.resize(message_width, &self.ctx);
1039				}
1040			}
1041		}
1042		while self.appended_messages < Self::visible_messages(elapsed) {
1043			self.transcript.push(Entry::Message(self.appended_messages));
1044			self.appended_messages += 1;
1045		}
1046		while self.emitted_shards < Self::finished_shards(elapsed) {
1047			self.emitted_shards += 1;
1048			self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049		}
1050
1051		let mut new_rows = 0_u16;
1052		for entry in &self.transcript[self.drawn_entries..] {
1053			new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054		}
1055		let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056		let editor_height = self.editor_ui.height();
1057		// Native scrollback is append-only, so the logical document may
1058		// never shrink while the seam is live: band rows that close again
1059		// (extra input lines) become blank padding that heals as the
1060		// transcript grows.
1061		let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062		self.height_floor = self.height_floor.max(natural_height);
1063		let document_height = self.height_floor.max(viewport.height);
1064		let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065		let margin = u16::from(viewport.width >= 50);
1066		let content_width = viewport.width.saturating_sub(margin * 2);
1067		let editor_y = document_height.saturating_sub(editor_height);
1068		let title_y = editor_y.saturating_sub(1);
1069		let working_y = title_y.saturating_sub(1);
1070		let panel_height = LIVE_SHARD_ROWS + 2;
1071		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072		let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073		let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074		if rebuild {
1075			self.frame = Frame::new(Size::new(viewport.width, document_height));
1076		} else {
1077			self.frame.resize_height(document_height, base_style());
1078		}
1079		if repaint_suffix {
1080			self.frame.fill(
1081				Rect::new(
1082					0,
1083					transcript_damage_start,
1084					viewport.width,
1085					document_height.saturating_sub(transcript_damage_start),
1086				),
1087				base_style(),
1088			);
1089		}
1090
1091		// Paint the new transcript entries; rows above `transcript_rows`
1092		// are final and never repainted.
1093		let mut y = self.transcript_rows;
1094		for index in self.drawn_entries..self.transcript.len() {
1095			let used = self.draw_entry_at(index, y, viewport.width);
1096			y = y.saturating_add(used);
1097		}
1098		self.drawn_entries = self.transcript.len();
1099		self.transcript_rows = y;
1100
1101		// The live band repaints in place at the bottom of the document.
1102		let animation_frame = Self::animation_frame(elapsed);
1103		let panel_changed = draw_live_panel(
1104			&mut self.frame,
1105			&mut self.live_rows,
1106			&mut self.live_label_scratch,
1107			panel,
1108			repaint_suffix,
1109			self.emitted_shards,
1110			animation_frame,
1111			self.ctx.charset,
1112		);
1113		let working = self.work.borrow().working;
1114		let working_changed = self.last_working != working;
1115		if !repaint_suffix && self.last_working && !working {
1116			self
1117				.frame
1118				.fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119		}
1120		if working {
1121			Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122		}
1123		Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124		if repaint_suffix || editor_changed {
1125			self
1126				.frame
1127				.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128		}
1129		let mut damage = SmallVec::new();
1130		if repaint_suffix {
1131			damage.push((transcript_damage_start, document_height));
1132		} else {
1133			if panel_changed {
1134				damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135			}
1136			if working || working_changed {
1137				damage.push((working_y, working_y.saturating_add(1)));
1138			}
1139			if editor_changed {
1140				damage.push((editor_y, document_height));
1141			}
1142		}
1143		self.last_working = working;
1144		self.live_panel = Some(panel);
1145
1146		RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147	}
1148
1149	fn generation(elapsed: Duration) -> u64 {
1150		u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151	}
1152
1153	fn animation_frame(elapsed: Duration) -> u64 {
1154		u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155	}
1156
1157	fn visible_messages(elapsed: Duration) -> usize {
1158		let interval = MESSAGE_INTERVAL.as_millis();
1159		usize::try_from(elapsed.as_millis() / interval + 1)
1160			.unwrap_or(usize::MAX)
1161			.min(4)
1162	}
1163
1164	/// Shards whose permanent result line has been appended by `elapsed`:
1165	/// two per emit tick, capped well inside `u16` document heights.
1166	fn finished_shards(elapsed: Duration) -> u16 {
1167		u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168			.expect("finished shard count is clamped")
1169	}
1170
1171	/// Rows the bottom live band occupies: the shard panel, a blank
1172	/// separator, the activity row, the title air row, and the editor
1173	/// block.
1174	const fn band_height(editor_height: u16) -> u16 {
1175		LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176	}
1177
1178	/// Rows `entry` will occupy at `width`, including its trailing blank.
1179	fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180		match entry {
1181			Entry::Command => 5,
1182			Entry::Message(message) => {
1183				let mut scratch = Frame::new(Size::new(width, 48));
1184				Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185			},
1186			Entry::ShardDone(_) => 1,
1187			Entry::Submitted(submission) => submission.height().saturating_add(1),
1188		}
1189	}
1190
1191	const fn message_width(width: u16) -> u16 {
1192		let narrowed = width.saturating_sub(3);
1193		if narrowed == 0 { 1 } else { narrowed }
1194	}
1195
1196	/// Paints one transcript entry at `y` and returns the rows it used.
1197	fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198		Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199	}
1200
1201	/// Paints `entry` into any frame at `y` and returns the rows it used,
1202	/// including the trailing blank.
1203	fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204		let margin = u16::from(width >= 50);
1205		let content_width = width.saturating_sub(margin * 2);
1206		match entry {
1207			Entry::Command => {
1208				draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209				5
1210			},
1211			Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212			Entry::ShardDone(shard) => {
1213				Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214				1
1215			},
1216			Entry::Submitted(submission) => {
1217				draw_submission(frame, y, submission, ctx.charset);
1218				submission.height().saturating_add(1)
1219			},
1220		}
1221	}
1222
1223	/// Composes exactly one viewport of throwaway resize-drag content at the
1224	/// new geometry: the live band anchors to the bottom, then transcript
1225	/// entries are walked backward and rewrapped at `viewport.width` until
1226	/// the screen is full — O(viewport) work per drag frame, with the
1227	/// topmost entry sliced when it only partially fits. Retained transcript
1228	/// state is untouched, so the settle rebuild replays full history
1229	/// exactly once.
1230	pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231		let elapsed = self.started_at.elapsed();
1232		let mut frame = Frame::new(viewport);
1233		if viewport.width == 0 || viewport.height == 0 {
1234			return frame;
1235		}
1236		frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237		let composer_width = self.composer_width(viewport);
1238		if self.editor_ui.frame().size().width != composer_width {
1239			self.editor_ui.resize(composer_width);
1240		}
1241		self.editor_ui.tick(elapsed);
1242
1243		// The live band, laid out exactly like the retained document's.
1244		let margin = u16::from(viewport.width >= 50);
1245		let content_width = viewport.width.saturating_sub(margin * 2);
1246		let editor_height = self.editor_ui.height();
1247		let editor_y = viewport.height.saturating_sub(editor_height);
1248		let title_y = editor_y.saturating_sub(1);
1249		let working_y = title_y.saturating_sub(1);
1250		let panel_height = LIVE_SHARD_ROWS + 2;
1251		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252		draw_live_panel(
1253			&mut frame,
1254			&mut self.live_rows,
1255			&mut self.live_label_scratch,
1256			Rect::new(margin, panel_y, content_width, panel_height),
1257			true,
1258			self.emitted_shards,
1259			Self::animation_frame(elapsed),
1260			self.ctx.charset,
1261		);
1262		if self.work.borrow().working {
1263			Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264		}
1265		Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266		frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268		// Transcript tail, bottom-up above the band.
1269		let mut remaining = panel_y;
1270		for entry in self.transcript.iter().rev() {
1271			if remaining == 0 {
1272				break;
1273			}
1274			let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275			if height == 0 {
1276				continue;
1277			}
1278			if height <= remaining {
1279				remaining -= height;
1280				Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281			} else {
1282				// Slice the bottom rows of the partially visible entry.
1283				let mut scratch = Frame::new(Size::new(viewport.width, height));
1284				scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285				Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286				frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287				remaining = 0;
1288			}
1289		}
1290		frame
1291	}
1292
1293	/// Paints the n-th scripted message and returns rows used including
1294	/// the trailing blank. Measurement draws into a scratch frame.
1295	fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296		let margin = u16::from(width >= 50);
1297		let content_width = width.saturating_sub(margin * 2);
1298		if message == 2 {
1299			draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300			return EDIT_BOX_HEIGHT + 1;
1301		}
1302		let bottom = frame.size().height;
1303		let spans = Self::message_spans(message);
1304		// Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305		// wrapped row re-joins byte-for-byte in native selection.
1306		let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307		used.saturating_add(1)
1308	}
1309
1310	fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311		let mut spans = SmallVec::new();
1312		match message {
1313			0 => {
1314				spans.push(Span::new("Transcript rows are ", prose_style()));
1315				spans.push(Span::new("append-only", code_style()));
1316				spans.push(Span::new(
1317					": every line is painted once, becomes stable, and rides into native scrollback \
1318					 with any selection anchored to it.",
1319					prose_style(),
1320				));
1321			},
1322			1 => {
1323				spans.push(Span::new(
1324					"Only the bottom band repaints in place — the live shard panel, the activity \
1325					 shimmer, and the composer. Rows above it are never rewritten.",
1326					prose_style(),
1327				));
1328			},
1329			_ => {
1330				spans.push(Span::new(
1331					"On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332					 the stable transcript through a ",
1333					prose_style(),
1334				));
1335				spans.push(Span::new("DECSTBM top region", code_style()));
1336				spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337			},
1338		}
1339		spans
1340	}
1341
1342	/// Appends a finished shard's permanent one-line result.
1343	fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344		let margin = u16::from(width >= 50);
1345		let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346		let detail = fmts!("  workspace-{shard:03}.test.ts  [100%]");
1347		draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348			Span::new(prefix.as_str(), ink(GREEN)),
1349			Span::new(detail.as_str(), ink(MUTED)),
1350		]);
1351	}
1352
1353	/// Shimmering activity line above the editor. The spinner and timer
1354	/// live in the status bar's brand segment; this row only narrates.
1355	fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356		if y >= frame.size().height || frame.size().width < 4 {
1357			return;
1358		}
1359		let start = u16::from(frame.size().width >= 50);
1360		let mut column = start;
1361		let length = xutf::graphemes_str(WORKING_MESSAGE)
1362			.count()
1363			.saturating_add(xutf::graphemes_str(hint).count())
1364			.saturating_add(1);
1365		let length = u16::try_from(length).unwrap_or(u16::MAX);
1366		let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367		let right = frame.size().width.saturating_sub(1);
1368		draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369		draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370		draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371	}
1372
1373	/// The session title resting right-aligned in the air row between
1374	/// the working narration and the status band — against the visible
1375	/// right bound, inside any rail reservation — so the gap reads as
1376	/// session identity instead of dead space.
1377	fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378		let width = frame.size().width.saturating_sub(right_inset);
1379		let title_width = visible_width(SESSION_TITLE);
1380		if y >= frame.size().height || width < title_width.saturating_add(2) {
1381			return;
1382		}
1383		let x = width.saturating_sub(title_width.saturating_add(1));
1384		draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385	}
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391	if rect.width < 4 || rect.height < 4 {
1392		return;
1393	}
1394
1395	let content_x = rect.x + 2;
1396	let content_width = rect.width.saturating_sub(4);
1397	let header = [
1398		Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399		Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400	];
1401	draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402	let command = [
1403		Span::new("$ ", panel_ink(MUTED)),
1404		Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405		Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406	];
1407	draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413	frame: &mut Frame,
1414	rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415	label_scratch: &mut StrMut,
1416	rect: Rect,
1417	repaint_chrome: bool,
1418	emitted_shards: u16,
1419	animation_frame: u64,
1420	charset: Charset,
1421) -> bool {
1422	let mut changed = repaint_chrome;
1423	if repaint_chrome {
1424		draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425	}
1426	if rect.width < 4 || rect.height < 3 {
1427		return changed;
1428	}
1429
1430	if repaint_chrome {
1431		let title = [
1432			Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433			Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434		];
1435		draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436	}
1437	let content_x = rect.x + 2;
1438	let content_width = rect.width.saturating_sub(4);
1439	for row in 0..rect.height.saturating_sub(2) {
1440		let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441		let phase = (u64::from(row) + animation_frame) % 11;
1442		let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443			0 => (
1444				0,
1445				"⠼",
1446				"running",
1447				panel_ink(GREEN).bold(),
1448				(u64::from(row) * 17 + animation_frame * 7) % 100,
1449			),
1450			1..=7 => {
1451				(1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452			},
1453			_ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454		};
1455		let row_y = rect.y + 1 + row;
1456		let right = content_x
1457			.saturating_add(content_width)
1458			.min(frame.size().width);
1459		let cache = &mut rows[usize::from(row)];
1460		let prefix_changed = repaint_chrome
1461			|| !cache.prefix_valid
1462			|| cache.prefix_shard != shard
1463			|| cache.prefix_phase != prefix_phase;
1464		changed |= prefix_changed;
1465		let label_x = if prefix_changed {
1466			let prefix = fmts!(" {symbol} shard {shard:03} ");
1467			let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468			let next_x = content_x
1469				.saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470				.saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471				.saturating_add(2)
1472				.min(right);
1473			if !repaint_chrome && cache.label_x != next_x {
1474				clear_cached_label(frame, cache, row_y, right);
1475			}
1476			let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477				Span::new(prefix.as_str(), state_style),
1478				Span::new(state, state_style),
1479				Span::new("  ", panel_ink(FAINT)),
1480			]);
1481			cache.prefix_shard = shard;
1482			cache.prefix_phase = prefix_phase;
1483			cache.prefix_valid = true;
1484			next_x
1485		} else {
1486			cache.label_x
1487		};
1488		let moved = cache.label_x != label_x;
1489		let label_changed = repaint_chrome
1490			|| moved
1491			|| !cache.label_valid
1492			|| cache.label_shard != shard
1493			|| cache.label_progress != progress;
1494		changed |= label_changed;
1495		if label_changed {
1496			label_scratch.truncate(0);
1497			write!(label_scratch, "workspace-{shard:03}.test.ts  [{progress:>3}%]")
1498				.expect("shard label formatting is infallible");
1499			let resized = cache.label.len() != label_scratch.len();
1500			if !repaint_chrome && resized && !moved {
1501				clear_cached_label(frame, cache, row_y, right);
1502			}
1503			let width = right.saturating_sub(label_x);
1504			if repaint_chrome || moved || resized {
1505				frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506			} else {
1507				draw_ascii_changes(
1508					frame,
1509					label_x,
1510					row_y,
1511					width,
1512					cache.label.as_str(),
1513					label_scratch.as_str(),
1514					panel_ink(MUTED),
1515				);
1516			}
1517			std::mem::swap(&mut cache.label, label_scratch);
1518			cache.label_shard = shard;
1519			cache.label_progress = progress;
1520			cache.label_valid = true;
1521		}
1522		cache.label_x = label_x;
1523	}
1524	changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528	if cache.label.is_empty() {
1529		return;
1530	}
1531	let width = u16::try_from(cache.label.len())
1532		.unwrap_or(u16::MAX)
1533		.min(right.saturating_sub(cache.label_x));
1534	frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539	frame: &mut Frame,
1540	x: u16,
1541	y: u16,
1542	width: u16,
1543	previous: &str,
1544	next: &str,
1545	style: Style,
1546) {
1547	if width == 0 || previous == next {
1548		return;
1549	}
1550	if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551		frame.put_clipped(x, y, width, next, style);
1552		return;
1553	}
1554	let previous = previous.as_bytes();
1555	let next_bytes = next.as_bytes();
1556	let limit = previous.len().min(usize::from(width));
1557	let mut index = 0;
1558	while index < limit {
1559		while index < limit && previous[index] == next_bytes[index] {
1560			index += 1;
1561		}
1562		let start = index;
1563		while index < limit && previous[index] != next_bytes[index] {
1564			index += 1;
1565		}
1566		if start < index {
1567			let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568			frame.put_clipped(
1569				x.saturating_add(offset),
1570				y,
1571				u16::try_from(index - start).unwrap_or(u16::MAX),
1572				&next[start..index],
1573				style,
1574			);
1575		}
1576	}
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581	if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582		return;
1583	}
1584
1585	let title = [
1586		Span::new(" Live ", panel_ink(GREEN).bold()),
1587		Span::new("band · selection semantics ", panel_ink(CYAN)),
1588	];
1589	draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590	draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591		Span::new(charset.check(), panel_ink(GREEN).bold()),
1592		Span::new(" ", panel_ink(GREEN)),
1593		Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594	]);
1595	draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596		"  margin commits pin the band on kitty-class terminals",
1597		panel_ink(MUTED),
1598	)]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605	if frame.size().width < 4 {
1606		return;
1607	}
1608	let Some(view) = &submission.view else {
1609		for (offset, line) in submission.text.split('\n').enumerate() {
1610			let Ok(offset) = u16::try_from(offset) else {
1611				break;
1612			};
1613			let row = y.saturating_add(offset);
1614			if row >= frame.size().height {
1615				break;
1616			}
1617			let prompt = if offset == 0 { charset.cursor() } else { "  " };
1618			let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619			let width = frame
1620				.size()
1621				.width
1622				.saturating_sub(1)
1623				.saturating_sub(text_x.saturating_sub(1));
1624			draw_submission_text(frame, text_x, row, width, line, charset);
1625		}
1626		return;
1627	};
1628	frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629	frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632	u16::try_from(
1633		text
1634			.bytes()
1635			.filter(|byte| *byte == b'\n')
1636			.count()
1637			.saturating_add(1),
1638	)
1639	.unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644	if rect.width == 0 || rect.height == 0 {
1645		return;
1646	}
1647	let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648	frame.fill(rect, fill);
1649	let mut glyph = [0_u8; 4];
1650	if rect.width == 1 {
1651		frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652		return;
1653	}
1654
1655	let right = rect.x + rect.width - 1;
1656	let bottom = rect.y + rect.height - 1;
1657	frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658	frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659	for x in rect.x + 1..right {
1660		frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661	}
1662
1663	if rect.height > 1 {
1664		draw_box_bottom(frame, rect, border, charset);
1665	}
1666	for row in rect.y + 1..bottom {
1667		frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668		frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669	}
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673	if rect.width < 2 || rect.height < 2 {
1674		return;
1675	}
1676	let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677	let mut glyph = [0_u8; 4];
1678	let right = rect.x + rect.width - 1;
1679	let bottom = rect.y + rect.height - 1;
1680	frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681	frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682	for x in rect.x + 1..right {
1683		frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684	}
1685}
More examples
Hide additional examples
examples/chat/welcome.rs (line 323)
289	fn draw_card(&mut self, cols: u16, left: u16, top: u16, elapsed: f32, hover: f32) {
290		let full = cols == CARD_COLS;
291		let logo_left = if full {
292			left + 3
293		} else {
294			left + (cols - LOGO_COLS as u16) / 2
295		};
296		self.logo_origin = (logo_left, top + 2);
297		// Pointer-tracking border glow: the brand gradient sampled by angle
298		// around the card center (the disk's own palette), strongest near
299		// the pointer, scaled by the eased hover amount.
300		let pointer = self.pointer;
301		let center =
302			(f32::from(left) + f32::from(cols) / 2.0, f32::from(top) + f32::from(CARD_ROWS) / 2.0);
303		let edge_at = move |x: u16, y: u16| -> Style {
304			let Some((px, py)) = pointer.filter(|_| hover > 0.02) else {
305				return on_card(CARD_BORDER);
306			};
307			let dx = (f32::from(x) - f32::from(px)) * 0.5;
308			let dy = f32::from(y) - f32::from(py);
309			let glow = hover * (-(dx * dx + dy * dy) / 34.0).exp();
310			if glow < 0.02 {
311				return on_card(CARD_BORDER);
312			}
313			let angle = (f32::from(y) - center.1).atan2((f32::from(x) - center.0) * 0.5);
314			let brand = vec3_color(gradient(angle - elapsed * 0.5));
315			on_card(CARD_BORDER.lerp(brand, glow))
316		};
317		let frame = &mut self.frame;
318		frame.fill(Rect::new(left, top, cols, CARD_ROWS), on_card(TEXT));
319
320		let right = left + cols - 1;
321		let bottom = top + CARD_ROWS - 1;
322		let divider = bottom - 2;
323		let (tl, tr, bl, br, horizontal, vertical) = self.charset.border(Border::Round);
324		let grid = self.charset.grid();
325		let mut glyph = [0_u8; 4];
326		frame.put(left, top, tl.encode_utf8(&mut glyph), edge_at(left, top));
327		frame.put(right, top, tr.encode_utf8(&mut glyph), edge_at(right, top));
328		frame.put(left, divider, grid.middle.0.encode_utf8(&mut glyph), edge_at(left, divider));
329		frame.put(right, divider, grid.middle.2.encode_utf8(&mut glyph), edge_at(right, divider));
330		frame.put(left, bottom, bl.encode_utf8(&mut glyph), edge_at(left, bottom));
331		frame.put(right, bottom, br.encode_utf8(&mut glyph), edge_at(right, bottom));
332		for x in left + 1..right {
333			frame.put(x, top, horizontal.encode_utf8(&mut glyph), edge_at(x, top));
334			frame.put(x, divider, horizontal.encode_utf8(&mut glyph), edge_at(x, divider));
335			frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), edge_at(x, bottom));
336		}
337		for y in top + 1..bottom {
338			if y != divider {
339				frame.put(left, y, vertical.encode_utf8(&mut glyph), edge_at(left, y));
340				frame.put(right, y, vertical.encode_utf8(&mut glyph), edge_at(right, y));
341			}
342		}
343
344		frame.put(left + 2, top, self.title.as_str(), on_card(TEXT_STRONG));
345		if full {
346			frame.put(left + 34, top, " SESSION ORBIT ", on_card(FAINT));
347			draw_dust(frame, left, top, elapsed);
348			draw_sessions(frame, left, top, self.charset);
349			draw_beam(frame, left, top, elapsed);
350		}
351
352		blit_logo(frame, &self.logo, logo_left, top + 2, CARD_BG);
353
354		let footer = divider + 1;
355		frame.fill(Rect::new(left + 1, footer, cols - 2, 1), on_footer(TEXT));
356		if full {
357			frame.put(left + 3, divider, " SHORTCUTS ", on_card(FAINT));
358			let dot = fmts!(" {} ", self.charset.icon(Icon::Enabled));
359			let x = frame.put(left + cols - 21, top, &dot, on_card(GREEN));
360			frame.put(x, top, "rust-analyzer ", on_card(MUTED));
361			draw_full_hints(frame, left, footer);
362		} else {
363			draw_smol_hints(frame, left, cols, footer);
364		}
365	}
366}
367
368impl Default for Welcome {
369	fn default() -> Self {
370		Self::new(Charset::NerdFont)
371	}
372}
373
374fn draw_dust(frame: &mut Frame, left: u16, top: u16, elapsed: f32) {
375	for &(x, y, offset) in &DUST {
376		let pulse = 0.5 + 0.5 * (elapsed * 1.4 + offset).sin();
377		let color = FAINT.lerp(CYAN, pulse * 0.28);
378		frame.put(left + x, top + y, "·", on_card(color));
379	}
380	frame.put(left + 1, top + 7, HORIZON, on_card(FAINT.lerp(INDIGO, 0.16)));
381	frame.put(left + 14, top + 1, "+Z", on_card(FAINT));
382}
383
384fn draw_beam(frame: &mut Frame, left: u16, top: u16, elapsed: f32) {
385	let phase = (elapsed * 9.0) as usize % BEAM.len();
386	for (index, &(x, y, glyph)) in BEAM.iter().enumerate() {
387		let direct = index.abs_diff(phase);
388		let distance = direct.min(BEAM.len() - direct);
389		let color = match distance {
390			0 => TEXT_STRONG,
391			1 => CYAN,
392			_ => FAINT.lerp(INDIGO, 0.34),
393		};
394		frame.put(left + x, top + y, glyph, on_card(color));
395	}
396}
397
398fn draw_sessions(frame: &mut Frame, left: u16, top: u16, charset: Charset) {
399	let (_, _, _, _, _, vertical) = charset.border(Border::Round);
400	let mut glyph = [0_u8; 4];
401	let panel_x = left + 36;
402	frame.put(panel_x, top + 2, "RECENT SESSIONS", on_card(MUTED));
403	frame.put(left + CARD_COLS - 14, top + 2, "4 / LOCAL", on_card(FAINT));
404	for y in top + 4..=top + 10 {
405		frame.put(panel_x, y, vertical.encode_utf8(&mut glyph), on_card(FAINT.lerp(INDIGO, 0.18)));
406	}
407	for (index, (label, age)) in SESSIONS.iter().enumerate() {
408		let y = top + 4 + index as u16 * 2;
409		if index == 0 {
410			frame.fill(Rect::new(panel_x - 2, y, CARD_COLS - 35, 1), on_selected(TEXT));
411			frame.put(panel_x - 2, y, charset.rail(), on_selected(GREEN));
412			frame.put(panel_x, y, charset.radio(true), on_selected(GREEN));
413			frame.put(panel_x + 2, y, age, on_selected(GREEN));
414			frame.put(panel_x + 7, y, label, on_selected(TEXT_STRONG));
415		} else {
416			frame.put(panel_x, y, charset.radio(false), on_card(FAINT));
417			frame.put(panel_x + 2, y, age, on_card(FAINT));
418			frame.put(panel_x + 7, y, label, on_card(MUTED));
419		}
420	}
421}
Source

pub const fn cursor(self) -> &'static str

Focus cursor prefix, two cells wide.

Examples found in repository?
examples/chat/demo.rs (line 334)
300	fn paint_picker(pc: &mut PaintCtx<'_>, rect: Rect, y: u16, editor: &Editor) {
301		let Some(picker) = editor.picker() else {
302			return;
303		};
304		let (start, suggestions) = picker.visible_suggestions();
305		let overflow = picker.len() > suggestions.len();
306		let row_right = rect
307			.x
308			.saturating_add(rect.width.saturating_sub(u16::from(overflow)));
309		let primary_width = suggestions
310			.iter()
311			.filter_map(|suggestion| match suggestion.display() {
312				SuggestionDisplay::Text(name) => Some(visible_width(name).saturating_add(2)),
313				SuggestionDisplay::Emoji { .. } => None,
314			})
315			.max()
316			.unwrap_or(12)
317			.clamp(12, 32);
318
319		for (offset, suggestion) in suggestions.iter().enumerate() {
320			let Ok(offset) = u16::try_from(offset) else {
321				break;
322			};
323			let row = y.saturating_add(offset);
324			if row >= pc.clip {
325				break;
326			}
327			let selected = start + usize::from(offset) == picker.selected();
328			let label = if selected { ink(GREEN) } else { ink(TEXT) };
329			let description = if selected { ink(GREEN) } else { ink(MUTED) };
330			pc.frame.put(
331				rect.x,
332				row,
333				if selected {
334					pc.ctx.charset.cursor()
335				} else {
336					"  "
337				},
338				label,
339			);
340			match suggestion.display() {
341				SuggestionDisplay::Text(name) => {
342					draw_line(
343						pc.frame,
344						rect.x.saturating_add(2),
345						row,
346						row_right.saturating_sub(rect.x.saturating_add(2)),
347						&[Span::new(name, label)],
348					);
349					if let Some(text) = suggestion.description()
350						&& rect.width > 40
351					{
352						let description_x = rect
353							.x
354							.saturating_add(2)
355							.saturating_add(primary_width)
356							.min(row_right);
357						draw_line(
358							pc.frame,
359							description_x,
360							row,
361							row_right.saturating_sub(description_x),
362							&[Span::new(text, description)],
363						);
364					}
365				},
366				SuggestionDisplay::Emoji { emoji, shortcode } => {
367					let mut column = pc.frame.put(rect.x.saturating_add(2), row, emoji, label);
368					column = pc.frame.put(column, row, "  ", label);
369					if shortcode.starts_with(':') {
370						pc.frame.put(column, row, shortcode, label);
371					} else {
372						column = pc.frame.put(column, row, ":", label);
373						column = pc.frame.put(column, row, shortcode, label);
374						pc.frame.put(column, row, ":", label);
375					}
376				},
377			}
378		}
379
380		if overflow && !suggestions.is_empty() {
381			let (track, thumb_glyph) = pc.ctx.charset.scrollbar();
382			let track_x = rect.x.saturating_add(rect.width.saturating_sub(1));
383			for offset in 0..suggestions.len() {
384				let Ok(offset) = u16::try_from(offset) else {
385					break;
386				};
387				pc.frame
388					.put(track_x, y.saturating_add(offset), track, ink(FAINT));
389			}
390			let thumb = picker
391				.selected()
392				.saturating_mul(suggestions.len().saturating_sub(1))
393				/ picker.len().saturating_sub(1);
394			pc.frame.put(
395				track_x,
396				y.saturating_add(u16::try_from(thumb).unwrap_or(u16::MAX)),
397				thumb_glyph,
398				ink(GREEN),
399			);
400		}
401	}
402}
403
404impl Component for DemoInput {
405	fn props(&self) -> &Props {
406		&self.props
407	}
408
409	fn props_mut(&mut self) -> &mut Props {
410		&mut self.props
411	}
412
413	fn slot(&self) -> Slot {
414		self.slot
415	}
416
417	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
418		(6, 40)
419	}
420
421	fn height(&mut self, _ctx: &UiContext, width: u16) -> u16 {
422		let editor = self.editor.borrow();
423		editor
424			.input_height_for(Self::input_width(width))
425			.saturating_add(editor.picker_height())
426	}
427
428	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
429		pc.hits
430			.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
431		let editor = self.editor.borrow();
432		let input_x = rect.x.saturating_add(Self::input_offset());
433		let input_width = Self::input_width(rect.width);
434		let input_height = editor.input_height_for(input_width);
435		let theme = Theme::default();
436		let mut in_comment = false;
437		for (offset, row) in editor.view(input_width).iter().enumerate() {
438			let row_y = rect
439				.y
440				.saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
441			if row_y >= pc.clip {
442				break;
443			}
444			if offset == 0 {
445				pc.frame
446					.put(rect.x, row_y, &Self::input_prompt(pc.ctx.charset), ink(FAINT));
447			}
448			let mut spans: SmallVec<Span<'_>, 16> = SmallVec::new();
449			if editor.options().xml {
450				let (runs, next) = highlight_xml(row.text, &theme, in_comment);
451				in_comment = next;
452				push_row_spans(&editor, row.text, &runs, &mut spans);
453			} else {
454				push_row_spans(&editor, row.text, &[], &mut spans);
455			}
456			draw_line(pc.frame, input_x, row_y, input_width, &spans);
457			if let Some(cursor_column) = row.cursor_column {
458				if cursor_column >= visible_width(row.text)
459					&& let Some(hint) = editor.inline_hint()
460				{
461					let hint_x = input_x.saturating_add(cursor_column).saturating_add(1);
462					let width = input_width.saturating_sub(cursor_column.saturating_add(1));
463					draw_line(pc.frame, hint_x, row_y, width, &[Span::new(
464						hint.as_str(),
465						ink(MUTED).dim(),
466					)]);
467				}
468				pc.frame.set_cursor(
469					input_x
470						.saturating_add(cursor_column)
471						.min(rect.x.saturating_add(rect.width.saturating_sub(2))),
472					row_y,
473				);
474			}
475		}
476		Self::paint_picker(pc, rect, rect.y.saturating_add(input_height), &editor);
477	}
478
479	fn focusable(&self) -> bool {
480		true
481	}
482
483	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
484		let outcome = self.editor.borrow_mut().handle(key);
485		*self.outcome.borrow_mut() = Some(outcome);
486		// The editor owns every key while focused. In particular, an ignored
487		// picker key must not escape into `Ui`'s focus-ring navigation; the
488		// demo applies its quit policy from the recorded `EditOutcome`.
489		Flow::Consumed
490	}
491
492	fn mouse(
493		&mut self,
494		_ec: &mut EventCtx<'_>,
495		_tag: HitTag,
496		at: (u16, u16),
497		rect: Rect,
498		mouse: Mouse,
499	) -> Flow {
500		let width = Self::input_width(rect.width);
501		match mouse {
502			Mouse::Click => {
503				self.editor.borrow_mut().set_cursor_visual_row(
504					usize::from(at.1.saturating_sub(rect.y)),
505					at.0
506						.saturating_sub(rect.x.saturating_add(Self::input_offset())),
507					width,
508				);
509				Flow::Consumed
510			},
511			Mouse::WheelUp | Mouse::WheelDown => {
512				let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
513				if self
514					.editor
515					.borrow()
516					.scroll_rows(delta, width, usize::from(rect.height))
517				{
518					Flow::Consumed
519				} else {
520					Flow::Skip
521				}
522			},
523			_ => Flow::Skip,
524		}
525	}
526
527	fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
528		if matches!(self.editor.borrow_mut().insert_text(text), EditOutcome::Changed) {
529			Flow::Consumed
530		} else {
531			Flow::Skip
532		}
533	}
534}
535/// Whether the demo is working, and how the status bar's brand segment
536/// blends between its two states.
537struct WorkState {
538	working: bool,
539	/// When the current mode began; the working timer counts from here.
540	since:   Duration,
541	/// Brand foreground: [`GREEN`] while working, [`MUTED`] at rest.
542	fade:    Tween<Color>,
543}
544
545/// Powerline status split into a left brand group — spinner and session
546/// timer while working, the omp brand at rest, the foreground tweening
547/// between the two so neither swap ever snaps — and a right-docked
548/// session group (branch, context, cost). Panes too narrow for both
549/// groups fall back to one left-anchored band that sheds from the tail.
550struct DemoStatus {
551	props:   Props,
552	slot:    Slot,
553	work:    Rc<RefCell<WorkState>>,
554	model:   Rc<RefCell<Str>>,
555	charset: Charset,
556	right:   Status,
557}
558
559impl DemoStatus {
560	fn new(work: Rc<RefCell<WorkState>>, model: Rc<RefCell<Str>>, charset: Charset) -> Self {
561		let mut props = Props::new();
562		props.set(Prop::Id, STATUS_ID);
563		let right = Self::right_group(charset);
564		Self { props, slot: next_slot(), work, model, charset, right }
565	}
566
567	/// One styled band-group shell on the shared dark backdrop.
568	fn group() -> Status {
569		Status::new()
570			.with(Prop::Bg, Color::Rgb(18, 18, 18))
571			.with(Prop::Fg, TEXT)
572	}
573
574	/// The brand segment at `now`: spinner plus session timer while
575	/// working, the omp badge at rest, foreground riding the work fade.
576	fn brand_segment(&self, now: Duration) -> Segment {
577		let work = self.work.borrow();
578		let brand = if work.working {
579			fmts!(
580				"{} {}",
581				self.charset.spinner().at(now),
582				elapsed_label(now.saturating_sub(work.since))
583			)
584		} else {
585			fmts!("{} omp", self.charset.icon(Icon::Omp))
586		};
587		Segment::new()
588			.label(brand)
589			.with(Prop::Fg, work.fade.sample(now))
590	}
591
592	fn model_segment(&self) -> Segment {
593		Segment::new()
594			.label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595			.with(Prop::Fg, GREEN)
596	}
597
598	fn git_segment(charset: Charset) -> Segment {
599		Segment::new()
600			.label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601			.with(Prop::Fg, CYAN)
602	}
603
604	fn context_segment(charset: Charset) -> Segment {
605		Segment::new()
606			.label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607			.with(Prop::Fg, GOLD)
608	}
609
610	fn cost_segment() -> Segment {
611		Segment::new()
612			.label("$60.07 (sub) + $8.65 (adv)")
613			.with(Prop::Fg, PURPLE)
614	}
615
616	/// The left band group: brand and model.
617	fn left_group(&self, now: Duration) -> Status {
618		Self::group()
619			.segment(self.brand_segment(now))
620			.segment(self.model_segment())
621	}
622
623	/// The right band group: branch, context, and cost.
624	fn right_group(charset: Charset) -> Status {
625		Self::group()
626			.with_str(Prop::Align, "right")
627			.segment(Self::git_segment(charset))
628			.segment(Self::context_segment(charset))
629			.segment(Self::cost_segment())
630	}
631
632	/// Every segment in one band, for panes too narrow to split.
633	fn combined(&self, now: Duration) -> Status {
634		Self::group()
635			.segment(self.brand_segment(now))
636			.segment(self.model_segment())
637			.segment(Self::git_segment(self.charset))
638			.segment(Self::context_segment(self.charset))
639			.segment(Self::cost_segment())
640	}
641}
642
643impl Component for DemoStatus {
644	fn props(&self) -> &Props {
645		&self.props
646	}
647
648	fn props_mut(&mut self) -> &mut Props {
649		&mut self.props
650	}
651
652	fn slot(&self) -> Slot {
653		self.slot
654	}
655
656	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657		self.combined(Duration::ZERO).measure(ctx)
658	}
659
660	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661		1
662	}
663
664	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665		let mut left = self.left_group(pc.now);
666		let (_, left_width) = left.measure(pc.ctx);
667		let (_, right_width) = self.right.measure(pc.ctx);
668		if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669			left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670			let dock = rect
671				.x
672				.saturating_add(rect.width)
673				.saturating_sub(right_width);
674			self
675				.right
676				.paint(pc, Rect::new(dock, rect.y, right_width, 1));
677		} else {
678			let mut combined = self.combined(pc.now);
679			combined.paint(pc, rect);
680		}
681		let work = self.work.borrow();
682		let fade_frame = work
683			.fade
684			.settles_at()
685			.min(pc.now.saturating_add(FADE_FRAME));
686		let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687			(true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688			(true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689			(false, false) => Some(fade_frame),
690			(false, true) => None,
691		};
692		if let Some(at) = deadline {
693			pc.wake(self.slot, at);
694		}
695	}
696
697	fn paints_background(&self) -> bool {
698		false
699	}
700}
701
702/// One retained chat document update and its exact repainted row ranges.
703pub struct RenderedFrame<'a> {
704	pub(crate) frame:       &'a Frame,
705	pub(crate) stable_rows: u16,
706	pub(crate) damage:      SmallVec<(u16, u16), 4>,
707}
708
709/// Produces the animated transcript, work indicator, editor, and status
710/// line demo.
711pub struct Demo {
712	started_at:         Instant,
713	/// Detected presentation context shared by every retained subtree.
714	ctx:                UiContext,
715	/// Cancel hint resolved once through the context's charset.
716	cancel_hint:        &'static str,
717	editor_ui:          Ui,
718	editor:             Rc<RefCell<Editor>>,
719	edit_outcome:       Rc<RefCell<Option<EditOutcome>>>,
720	work:               Rc<RefCell<WorkState>>,
721	last_working:       bool,
722	model:              Rc<RefCell<Str>>,
723	/// Images staged on the composer, previewed above the status line.
724	attachments:        Attachments,
725	/// Append-only transcript log, replayed in full on geometry rebuilds.
726	transcript:         Vec<Entry>,
727	/// Entries already painted into the retained frame.
728	drawn_entries:      usize,
729	/// Rows covered by the drawn entries; doubles as `stable_rows`.
730	transcript_rows:    u16,
731	appended_messages:  usize,
732	emitted_shards:     u16,
733	last_viewport:      Size,
734	height_floor:       u16,
735	frame:              Frame,
736	/// Geometry of the retained live panel chrome.
737	live_panel:         Option<Rect>,
738	/// Reusable text and placement state for the animated shard rows.
739	live_rows:          [LiveRowCache; LIVE_SHARD_ROWS as usize],
740	/// One build buffer rotated through the row caches without reallocating.
741	live_label_scratch: StrMut,
742	/// Columns reserved at the right edge for a composited rail; the
743	/// editor and title dock against the remaining visible width.
744	right_inset:        u16,
745	/// The composer submitted `/switch`; the host opens the model picker.
746	switch_requested:   bool,
747}
748
749impl Demo {
750	/// Starts the demo's animation clock, presenting through the host's
751	/// detected context.
752	pub fn new(ctx: &UiContext) -> Self {
753		let editor = Rc::new(RefCell::new({
754			let mut editor = Editor::new(EditorOptions::default());
755			editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756			editor
757		}));
758		let edit_outcome = Rc::new(RefCell::new(None));
759		let work = Rc::new(RefCell::new(WorkState {
760			working: true,
761			since:   Duration::ZERO,
762			fade:    Tween::settled(GREEN),
763		}));
764		let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765		let pane = EditorPane::new()
766			.input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767			.status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768		let attachments = pane.attachments();
769		let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770		Self {
771			started_at: Instant::now(),
772			ctx: ctx.clone(),
773			cancel_hint: ctx.charset.icon(Icon::Cancellable),
774			editor_ui,
775			editor,
776			edit_outcome,
777			work,
778			last_working: true,
779			model,
780			attachments,
781			transcript: vec![Entry::Command],
782			drawn_entries: 0,
783			transcript_rows: 0,
784			appended_messages: 0,
785			emitted_shards: 0,
786			last_viewport: Size::new(0, 0),
787			height_floor: 0,
788			frame: Frame::new(Size::new(0, 0)),
789			live_panel: None,
790			live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791			live_label_scratch: StrMut::with_capacity(40),
792			right_inset: 0,
793			switch_requested: false,
794		}
795	}
796
797	/// Routes a key through the editor and reports whether the demo should
798	/// exit. Quit policy lives here, not in the editor: once the editor
799	/// reports a key unused, `esc` first cancels running work and only quits
800	/// at rest; `ctrl-c` always quits.
801	pub fn handle_key(&mut self, key: Key) -> bool {
802		*self.edit_outcome.borrow_mut() = None;
803		let _ = self.editor_ui.handle_key(key);
804		let outcome = self
805			.edit_outcome
806			.borrow_mut()
807			.take()
808			.unwrap_or(EditOutcome::Ignored);
809		match outcome {
810			EditOutcome::Submitted(text) => {
811				let trimmed = text.trim();
812				if trimmed == "/switch" {
813					self.switch_requested = true;
814					return false;
815				}
816				if let Some(path) = trimmed
817					.strip_prefix("/attach")
818					.filter(|rest| rest.is_empty() || rest.starts_with(' '))
819				{
820					let path = path.trim().to_string();
821					if !path.is_empty() {
822						self.attach_image(&path);
823					}
824					return false;
825				}
826				let _ = self.attachments.take();
827				self.refresh_composer();
828				self
829					.transcript
830					.push(Entry::Submitted(Box::new(Submission::new(
831						text,
832						Self::message_width(self.last_viewport.width),
833						&self.ctx,
834					))));
835				self.set_working(true, self.started_at.elapsed());
836				false
837			},
838			EditOutcome::Changed => {
839				self.reconcile_attachments();
840				false
841			},
842			EditOutcome::Ignored => {
843				if key == Key::Ctrl('c') {
844					return true;
845				}
846				if key != Key::Esc {
847					return false;
848				}
849				if self.work.borrow().working {
850					self.set_working(false, self.started_at.elapsed());
851					return false;
852				}
853				true
854			},
855		}
856	}
857
858	/// Consumes a pending `/switch` request submitted through the composer.
859	pub fn take_switch_request(&mut self) -> bool {
860		std::mem::take(&mut self.switch_requested)
861	}
862
863	/// Routes a document-space mouse report into the editor UI.
864	pub fn handle_mouse(&mut self, report: &MouseReport) {
865		let editor_height = self.editor_ui.height();
866		let editor_y = self.frame.size().height.saturating_sub(editor_height);
867		let editor_bottom = editor_y.saturating_add(editor_height);
868		if report.row < editor_y || report.row >= editor_bottom {
869			return;
870		}
871		let _ = self
872			.editor_ui
873			.handle_mouse(report.col, report.row - editor_y, report.kind);
874	}
875
876	/// Switches the work state and retargets the brand fade. The status bar
877	/// repaints immediately and the fade departs from whatever color is on
878	/// screen, so rapid cancel/resume never snaps.
879	fn set_working(&mut self, working: bool, now: Duration) {
880		{
881			let mut work = self.work.borrow_mut();
882			if work.working == working {
883				return;
884			}
885			work.working = working;
886			work.since = now;
887			let target = if working { GREEN } else { MUTED };
888			work
889				.fade
890				.retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891		}
892		self.editor_ui.invalidate(STATUS_ID);
893	}
894
895	/// Reflects a session model switch in the status bar's model segment.
896	pub fn set_model(&mut self, name: &str) {
897		*self.model.borrow_mut() = Str::from(name);
898		self.editor_ui.invalidate(STATUS_ID);
899	}
900
901	/// Routes sanitized bracketed paste text through the editor. Dropped
902	/// paths to existing image files (quoted, escaped, `file://`, or
903	/// multi-file) and any large paste collapse into composer attachment
904	/// chips instead of raw text.
905	pub fn handle_paste(&mut self, text: &str) {
906		let paths = omp_tui::paste::dropped_paths(text);
907		if !paths.is_empty()
908			&& paths.iter().all(|path| {
909				omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910			}) {
911			for path in &paths {
912				self.attach_image(path);
913			}
914			return;
915		}
916		if text.lines().count() > 10 || text.len() > 1000 {
917			self.attach_paste(text);
918			return;
919		}
920		let _ = self.editor_ui.handle_paste(text);
921	}
922
923	/// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924	/// attachment staging, no large-paste collapse — the text stays inline
925	/// and editable.
926	pub fn handle_paste_raw(&mut self, text: &str) {
927		let _ = self.editor_ui.handle_paste_raw(text);
928	}
929
930	/// Stages `path` on the composer and mentions it in the prompt as an
931	/// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932	fn attach_image(&mut self, path: &str) {
933		let attachment = self.attachments.push_image(path);
934		let payload = format!("<ref image={}/>", attachment.marker);
935		self.insert_chip(&attachment, &payload);
936	}
937
938	/// Collapses a large paste into a staged attachment card and an atomic
939	/// composer chip expanding back to the pasted text on submit.
940	fn attach_paste(&mut self, text: &str) {
941		let attachment = self.attachments.push_text(text);
942		self.insert_chip(&attachment, text);
943	}
944
945	/// Inserts one attachment chip as an atomic editor reference.
946	fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947		let chip = chip_label(attachment, self.ctx.charset);
948		{
949			let mut editor = self.editor.borrow_mut();
950			let _ = editor.insert_reference(&chip, payload);
951			let _ = editor.insert_text(" ");
952		}
953		self.refresh_composer();
954	}
955
956	/// Hides staged attachments whose chip the user deleted from the
957	/// composer (and re-shows them after an undo). Presence is derived
958	/// from the buffer's atomic ranges, never from text matching.
959	fn reconcile_attachments(&mut self) {
960		let charset = self.ctx.charset;
961		let changed = {
962			let editor = self.editor.borrow();
963			let text = editor.text();
964			let ranges = editor.atom_ranges();
965			self.attachments.set_visible(|attachment| {
966				let chip = chip_label(attachment, charset);
967				ranges
968					.iter()
969					.any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970			})
971		};
972		if changed {
973			self.refresh_composer();
974		}
975	}
976
977	/// Relayouts the composer after out-of-band state changed its height.
978	fn refresh_composer(&mut self) {
979		let width = self.editor_ui.frame().size().width;
980		if width > 0 {
981			self.editor_ui.resize(width);
982		}
983	}
984
985	/// Reserves `cols` at the right edge for a composited rail, so the
986	/// composer's right-docked chrome stays visible beside it. The next
987	/// render relayouts the editor at the narrowed width.
988	pub const fn set_right_inset(&mut self, cols: u16) {
989		self.right_inset = cols;
990	}
991
992	/// The width the composer may actually occupy at `viewport`.
993	fn composer_width(&self, viewport: Size) -> u16 {
994		viewport.width.saturating_sub(self.right_inset).max(1)
995	}
996
997	/// Updates the retained logical document and reports its repainted rows.
998	pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999		self.render_at(viewport, self.started_at.elapsed())
1000	}
1001
1002	fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003		if viewport.width == 0 || viewport.height == 0 {
1004			self.last_viewport = viewport;
1005			self.height_floor = 0;
1006			self.drawn_entries = 0;
1007			self.transcript_rows = 0;
1008			self.live_panel = None;
1009			self.frame = Frame::new(viewport);
1010			return RenderedFrame {
1011				frame:       &self.frame,
1012				stable_rows: 0,
1013				damage:      SmallVec::new(),
1014			};
1015		}
1016		let composer_width = self.composer_width(viewport);
1017		if self.editor_ui.frame().size().width != composer_width {
1018			self.editor_ui.resize(composer_width);
1019		}
1020		// Fires due animation wakes (the status bar's spinner and brand
1021		// fade) so the blit below picks up fresh retained pixels.
1022		self.editor_ui.tick(elapsed);
1023		let editor_changed = self.editor_ui.take_frame_damage();
1024
1025		// A viewport change starts a fresh renderer session: replay the
1026		// whole transcript log at the new width. Between rebuilds the log
1027		// is append-only and every drawn row is final, so selections over
1028		// transcript text stay anchored to it in every terminal.
1029		let rebuild = self.last_viewport != viewport;
1030		if rebuild {
1031			self.last_viewport = viewport;
1032			self.height_floor = 0;
1033			self.drawn_entries = 0;
1034			self.transcript_rows = 0;
1035			let message_width = Self::message_width(viewport.width);
1036			for entry in &mut self.transcript {
1037				if let Entry::Submitted(submission) = entry {
1038					submission.resize(message_width, &self.ctx);
1039				}
1040			}
1041		}
1042		while self.appended_messages < Self::visible_messages(elapsed) {
1043			self.transcript.push(Entry::Message(self.appended_messages));
1044			self.appended_messages += 1;
1045		}
1046		while self.emitted_shards < Self::finished_shards(elapsed) {
1047			self.emitted_shards += 1;
1048			self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049		}
1050
1051		let mut new_rows = 0_u16;
1052		for entry in &self.transcript[self.drawn_entries..] {
1053			new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054		}
1055		let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056		let editor_height = self.editor_ui.height();
1057		// Native scrollback is append-only, so the logical document may
1058		// never shrink while the seam is live: band rows that close again
1059		// (extra input lines) become blank padding that heals as the
1060		// transcript grows.
1061		let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062		self.height_floor = self.height_floor.max(natural_height);
1063		let document_height = self.height_floor.max(viewport.height);
1064		let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065		let margin = u16::from(viewport.width >= 50);
1066		let content_width = viewport.width.saturating_sub(margin * 2);
1067		let editor_y = document_height.saturating_sub(editor_height);
1068		let title_y = editor_y.saturating_sub(1);
1069		let working_y = title_y.saturating_sub(1);
1070		let panel_height = LIVE_SHARD_ROWS + 2;
1071		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072		let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073		let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074		if rebuild {
1075			self.frame = Frame::new(Size::new(viewport.width, document_height));
1076		} else {
1077			self.frame.resize_height(document_height, base_style());
1078		}
1079		if repaint_suffix {
1080			self.frame.fill(
1081				Rect::new(
1082					0,
1083					transcript_damage_start,
1084					viewport.width,
1085					document_height.saturating_sub(transcript_damage_start),
1086				),
1087				base_style(),
1088			);
1089		}
1090
1091		// Paint the new transcript entries; rows above `transcript_rows`
1092		// are final and never repainted.
1093		let mut y = self.transcript_rows;
1094		for index in self.drawn_entries..self.transcript.len() {
1095			let used = self.draw_entry_at(index, y, viewport.width);
1096			y = y.saturating_add(used);
1097		}
1098		self.drawn_entries = self.transcript.len();
1099		self.transcript_rows = y;
1100
1101		// The live band repaints in place at the bottom of the document.
1102		let animation_frame = Self::animation_frame(elapsed);
1103		let panel_changed = draw_live_panel(
1104			&mut self.frame,
1105			&mut self.live_rows,
1106			&mut self.live_label_scratch,
1107			panel,
1108			repaint_suffix,
1109			self.emitted_shards,
1110			animation_frame,
1111			self.ctx.charset,
1112		);
1113		let working = self.work.borrow().working;
1114		let working_changed = self.last_working != working;
1115		if !repaint_suffix && self.last_working && !working {
1116			self
1117				.frame
1118				.fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119		}
1120		if working {
1121			Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122		}
1123		Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124		if repaint_suffix || editor_changed {
1125			self
1126				.frame
1127				.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128		}
1129		let mut damage = SmallVec::new();
1130		if repaint_suffix {
1131			damage.push((transcript_damage_start, document_height));
1132		} else {
1133			if panel_changed {
1134				damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135			}
1136			if working || working_changed {
1137				damage.push((working_y, working_y.saturating_add(1)));
1138			}
1139			if editor_changed {
1140				damage.push((editor_y, document_height));
1141			}
1142		}
1143		self.last_working = working;
1144		self.live_panel = Some(panel);
1145
1146		RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147	}
1148
1149	fn generation(elapsed: Duration) -> u64 {
1150		u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151	}
1152
1153	fn animation_frame(elapsed: Duration) -> u64 {
1154		u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155	}
1156
1157	fn visible_messages(elapsed: Duration) -> usize {
1158		let interval = MESSAGE_INTERVAL.as_millis();
1159		usize::try_from(elapsed.as_millis() / interval + 1)
1160			.unwrap_or(usize::MAX)
1161			.min(4)
1162	}
1163
1164	/// Shards whose permanent result line has been appended by `elapsed`:
1165	/// two per emit tick, capped well inside `u16` document heights.
1166	fn finished_shards(elapsed: Duration) -> u16 {
1167		u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168			.expect("finished shard count is clamped")
1169	}
1170
1171	/// Rows the bottom live band occupies: the shard panel, a blank
1172	/// separator, the activity row, the title air row, and the editor
1173	/// block.
1174	const fn band_height(editor_height: u16) -> u16 {
1175		LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176	}
1177
1178	/// Rows `entry` will occupy at `width`, including its trailing blank.
1179	fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180		match entry {
1181			Entry::Command => 5,
1182			Entry::Message(message) => {
1183				let mut scratch = Frame::new(Size::new(width, 48));
1184				Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185			},
1186			Entry::ShardDone(_) => 1,
1187			Entry::Submitted(submission) => submission.height().saturating_add(1),
1188		}
1189	}
1190
1191	const fn message_width(width: u16) -> u16 {
1192		let narrowed = width.saturating_sub(3);
1193		if narrowed == 0 { 1 } else { narrowed }
1194	}
1195
1196	/// Paints one transcript entry at `y` and returns the rows it used.
1197	fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198		Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199	}
1200
1201	/// Paints `entry` into any frame at `y` and returns the rows it used,
1202	/// including the trailing blank.
1203	fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204		let margin = u16::from(width >= 50);
1205		let content_width = width.saturating_sub(margin * 2);
1206		match entry {
1207			Entry::Command => {
1208				draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209				5
1210			},
1211			Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212			Entry::ShardDone(shard) => {
1213				Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214				1
1215			},
1216			Entry::Submitted(submission) => {
1217				draw_submission(frame, y, submission, ctx.charset);
1218				submission.height().saturating_add(1)
1219			},
1220		}
1221	}
1222
1223	/// Composes exactly one viewport of throwaway resize-drag content at the
1224	/// new geometry: the live band anchors to the bottom, then transcript
1225	/// entries are walked backward and rewrapped at `viewport.width` until
1226	/// the screen is full — O(viewport) work per drag frame, with the
1227	/// topmost entry sliced when it only partially fits. Retained transcript
1228	/// state is untouched, so the settle rebuild replays full history
1229	/// exactly once.
1230	pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231		let elapsed = self.started_at.elapsed();
1232		let mut frame = Frame::new(viewport);
1233		if viewport.width == 0 || viewport.height == 0 {
1234			return frame;
1235		}
1236		frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237		let composer_width = self.composer_width(viewport);
1238		if self.editor_ui.frame().size().width != composer_width {
1239			self.editor_ui.resize(composer_width);
1240		}
1241		self.editor_ui.tick(elapsed);
1242
1243		// The live band, laid out exactly like the retained document's.
1244		let margin = u16::from(viewport.width >= 50);
1245		let content_width = viewport.width.saturating_sub(margin * 2);
1246		let editor_height = self.editor_ui.height();
1247		let editor_y = viewport.height.saturating_sub(editor_height);
1248		let title_y = editor_y.saturating_sub(1);
1249		let working_y = title_y.saturating_sub(1);
1250		let panel_height = LIVE_SHARD_ROWS + 2;
1251		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252		draw_live_panel(
1253			&mut frame,
1254			&mut self.live_rows,
1255			&mut self.live_label_scratch,
1256			Rect::new(margin, panel_y, content_width, panel_height),
1257			true,
1258			self.emitted_shards,
1259			Self::animation_frame(elapsed),
1260			self.ctx.charset,
1261		);
1262		if self.work.borrow().working {
1263			Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264		}
1265		Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266		frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268		// Transcript tail, bottom-up above the band.
1269		let mut remaining = panel_y;
1270		for entry in self.transcript.iter().rev() {
1271			if remaining == 0 {
1272				break;
1273			}
1274			let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275			if height == 0 {
1276				continue;
1277			}
1278			if height <= remaining {
1279				remaining -= height;
1280				Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281			} else {
1282				// Slice the bottom rows of the partially visible entry.
1283				let mut scratch = Frame::new(Size::new(viewport.width, height));
1284				scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285				Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286				frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287				remaining = 0;
1288			}
1289		}
1290		frame
1291	}
1292
1293	/// Paints the n-th scripted message and returns rows used including
1294	/// the trailing blank. Measurement draws into a scratch frame.
1295	fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296		let margin = u16::from(width >= 50);
1297		let content_width = width.saturating_sub(margin * 2);
1298		if message == 2 {
1299			draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300			return EDIT_BOX_HEIGHT + 1;
1301		}
1302		let bottom = frame.size().height;
1303		let spans = Self::message_spans(message);
1304		// Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305		// wrapped row re-joins byte-for-byte in native selection.
1306		let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307		used.saturating_add(1)
1308	}
1309
1310	fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311		let mut spans = SmallVec::new();
1312		match message {
1313			0 => {
1314				spans.push(Span::new("Transcript rows are ", prose_style()));
1315				spans.push(Span::new("append-only", code_style()));
1316				spans.push(Span::new(
1317					": every line is painted once, becomes stable, and rides into native scrollback \
1318					 with any selection anchored to it.",
1319					prose_style(),
1320				));
1321			},
1322			1 => {
1323				spans.push(Span::new(
1324					"Only the bottom band repaints in place — the live shard panel, the activity \
1325					 shimmer, and the composer. Rows above it are never rewritten.",
1326					prose_style(),
1327				));
1328			},
1329			_ => {
1330				spans.push(Span::new(
1331					"On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332					 the stable transcript through a ",
1333					prose_style(),
1334				));
1335				spans.push(Span::new("DECSTBM top region", code_style()));
1336				spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337			},
1338		}
1339		spans
1340	}
1341
1342	/// Appends a finished shard's permanent one-line result.
1343	fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344		let margin = u16::from(width >= 50);
1345		let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346		let detail = fmts!("  workspace-{shard:03}.test.ts  [100%]");
1347		draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348			Span::new(prefix.as_str(), ink(GREEN)),
1349			Span::new(detail.as_str(), ink(MUTED)),
1350		]);
1351	}
1352
1353	/// Shimmering activity line above the editor. The spinner and timer
1354	/// live in the status bar's brand segment; this row only narrates.
1355	fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356		if y >= frame.size().height || frame.size().width < 4 {
1357			return;
1358		}
1359		let start = u16::from(frame.size().width >= 50);
1360		let mut column = start;
1361		let length = xutf::graphemes_str(WORKING_MESSAGE)
1362			.count()
1363			.saturating_add(xutf::graphemes_str(hint).count())
1364			.saturating_add(1);
1365		let length = u16::try_from(length).unwrap_or(u16::MAX);
1366		let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367		let right = frame.size().width.saturating_sub(1);
1368		draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369		draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370		draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371	}
1372
1373	/// The session title resting right-aligned in the air row between
1374	/// the working narration and the status band — against the visible
1375	/// right bound, inside any rail reservation — so the gap reads as
1376	/// session identity instead of dead space.
1377	fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378		let width = frame.size().width.saturating_sub(right_inset);
1379		let title_width = visible_width(SESSION_TITLE);
1380		if y >= frame.size().height || width < title_width.saturating_add(2) {
1381			return;
1382		}
1383		let x = width.saturating_sub(title_width.saturating_add(1));
1384		draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385	}
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391	if rect.width < 4 || rect.height < 4 {
1392		return;
1393	}
1394
1395	let content_x = rect.x + 2;
1396	let content_width = rect.width.saturating_sub(4);
1397	let header = [
1398		Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399		Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400	];
1401	draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402	let command = [
1403		Span::new("$ ", panel_ink(MUTED)),
1404		Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405		Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406	];
1407	draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413	frame: &mut Frame,
1414	rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415	label_scratch: &mut StrMut,
1416	rect: Rect,
1417	repaint_chrome: bool,
1418	emitted_shards: u16,
1419	animation_frame: u64,
1420	charset: Charset,
1421) -> bool {
1422	let mut changed = repaint_chrome;
1423	if repaint_chrome {
1424		draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425	}
1426	if rect.width < 4 || rect.height < 3 {
1427		return changed;
1428	}
1429
1430	if repaint_chrome {
1431		let title = [
1432			Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433			Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434		];
1435		draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436	}
1437	let content_x = rect.x + 2;
1438	let content_width = rect.width.saturating_sub(4);
1439	for row in 0..rect.height.saturating_sub(2) {
1440		let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441		let phase = (u64::from(row) + animation_frame) % 11;
1442		let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443			0 => (
1444				0,
1445				"⠼",
1446				"running",
1447				panel_ink(GREEN).bold(),
1448				(u64::from(row) * 17 + animation_frame * 7) % 100,
1449			),
1450			1..=7 => {
1451				(1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452			},
1453			_ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454		};
1455		let row_y = rect.y + 1 + row;
1456		let right = content_x
1457			.saturating_add(content_width)
1458			.min(frame.size().width);
1459		let cache = &mut rows[usize::from(row)];
1460		let prefix_changed = repaint_chrome
1461			|| !cache.prefix_valid
1462			|| cache.prefix_shard != shard
1463			|| cache.prefix_phase != prefix_phase;
1464		changed |= prefix_changed;
1465		let label_x = if prefix_changed {
1466			let prefix = fmts!(" {symbol} shard {shard:03} ");
1467			let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468			let next_x = content_x
1469				.saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470				.saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471				.saturating_add(2)
1472				.min(right);
1473			if !repaint_chrome && cache.label_x != next_x {
1474				clear_cached_label(frame, cache, row_y, right);
1475			}
1476			let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477				Span::new(prefix.as_str(), state_style),
1478				Span::new(state, state_style),
1479				Span::new("  ", panel_ink(FAINT)),
1480			]);
1481			cache.prefix_shard = shard;
1482			cache.prefix_phase = prefix_phase;
1483			cache.prefix_valid = true;
1484			next_x
1485		} else {
1486			cache.label_x
1487		};
1488		let moved = cache.label_x != label_x;
1489		let label_changed = repaint_chrome
1490			|| moved
1491			|| !cache.label_valid
1492			|| cache.label_shard != shard
1493			|| cache.label_progress != progress;
1494		changed |= label_changed;
1495		if label_changed {
1496			label_scratch.truncate(0);
1497			write!(label_scratch, "workspace-{shard:03}.test.ts  [{progress:>3}%]")
1498				.expect("shard label formatting is infallible");
1499			let resized = cache.label.len() != label_scratch.len();
1500			if !repaint_chrome && resized && !moved {
1501				clear_cached_label(frame, cache, row_y, right);
1502			}
1503			let width = right.saturating_sub(label_x);
1504			if repaint_chrome || moved || resized {
1505				frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506			} else {
1507				draw_ascii_changes(
1508					frame,
1509					label_x,
1510					row_y,
1511					width,
1512					cache.label.as_str(),
1513					label_scratch.as_str(),
1514					panel_ink(MUTED),
1515				);
1516			}
1517			std::mem::swap(&mut cache.label, label_scratch);
1518			cache.label_shard = shard;
1519			cache.label_progress = progress;
1520			cache.label_valid = true;
1521		}
1522		cache.label_x = label_x;
1523	}
1524	changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528	if cache.label.is_empty() {
1529		return;
1530	}
1531	let width = u16::try_from(cache.label.len())
1532		.unwrap_or(u16::MAX)
1533		.min(right.saturating_sub(cache.label_x));
1534	frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539	frame: &mut Frame,
1540	x: u16,
1541	y: u16,
1542	width: u16,
1543	previous: &str,
1544	next: &str,
1545	style: Style,
1546) {
1547	if width == 0 || previous == next {
1548		return;
1549	}
1550	if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551		frame.put_clipped(x, y, width, next, style);
1552		return;
1553	}
1554	let previous = previous.as_bytes();
1555	let next_bytes = next.as_bytes();
1556	let limit = previous.len().min(usize::from(width));
1557	let mut index = 0;
1558	while index < limit {
1559		while index < limit && previous[index] == next_bytes[index] {
1560			index += 1;
1561		}
1562		let start = index;
1563		while index < limit && previous[index] != next_bytes[index] {
1564			index += 1;
1565		}
1566		if start < index {
1567			let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568			frame.put_clipped(
1569				x.saturating_add(offset),
1570				y,
1571				u16::try_from(index - start).unwrap_or(u16::MAX),
1572				&next[start..index],
1573				style,
1574			);
1575		}
1576	}
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581	if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582		return;
1583	}
1584
1585	let title = [
1586		Span::new(" Live ", panel_ink(GREEN).bold()),
1587		Span::new("band · selection semantics ", panel_ink(CYAN)),
1588	];
1589	draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590	draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591		Span::new(charset.check(), panel_ink(GREEN).bold()),
1592		Span::new(" ", panel_ink(GREEN)),
1593		Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594	]);
1595	draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596		"  margin commits pin the band on kitty-class terminals",
1597		panel_ink(MUTED),
1598	)]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605	if frame.size().width < 4 {
1606		return;
1607	}
1608	let Some(view) = &submission.view else {
1609		for (offset, line) in submission.text.split('\n').enumerate() {
1610			let Ok(offset) = u16::try_from(offset) else {
1611				break;
1612			};
1613			let row = y.saturating_add(offset);
1614			if row >= frame.size().height {
1615				break;
1616			}
1617			let prompt = if offset == 0 { charset.cursor() } else { "  " };
1618			let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619			let width = frame
1620				.size()
1621				.width
1622				.saturating_sub(1)
1623				.saturating_sub(text_x.saturating_sub(1));
1624			draw_submission_text(frame, text_x, row, width, line, charset);
1625		}
1626		return;
1627	};
1628	frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629	frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
Source

pub const fn radio(self, selected: bool) -> &'static str

Radio mark for (selected).

Examples found in repository?
examples/chat/welcome.rs (line 412)
398fn draw_sessions(frame: &mut Frame, left: u16, top: u16, charset: Charset) {
399	let (_, _, _, _, _, vertical) = charset.border(Border::Round);
400	let mut glyph = [0_u8; 4];
401	let panel_x = left + 36;
402	frame.put(panel_x, top + 2, "RECENT SESSIONS", on_card(MUTED));
403	frame.put(left + CARD_COLS - 14, top + 2, "4 / LOCAL", on_card(FAINT));
404	for y in top + 4..=top + 10 {
405		frame.put(panel_x, y, vertical.encode_utf8(&mut glyph), on_card(FAINT.lerp(INDIGO, 0.18)));
406	}
407	for (index, (label, age)) in SESSIONS.iter().enumerate() {
408		let y = top + 4 + index as u16 * 2;
409		if index == 0 {
410			frame.fill(Rect::new(panel_x - 2, y, CARD_COLS - 35, 1), on_selected(TEXT));
411			frame.put(panel_x - 2, y, charset.rail(), on_selected(GREEN));
412			frame.put(panel_x, y, charset.radio(true), on_selected(GREEN));
413			frame.put(panel_x + 2, y, age, on_selected(GREEN));
414			frame.put(panel_x + 7, y, label, on_selected(TEXT_STRONG));
415		} else {
416			frame.put(panel_x, y, charset.radio(false), on_card(FAINT));
417			frame.put(panel_x + 2, y, age, on_card(FAINT));
418			frame.put(panel_x + 7, y, label, on_card(MUTED));
419		}
420	}
421}
Source

pub const fn grid(self) -> Grid

Grid chrome for cell-bordered tables: the square border strokes plus the tees and cross that Charset::border alone cannot provide.

Examples found in repository?
examples/chat/welcome.rs (line 324)
289	fn draw_card(&mut self, cols: u16, left: u16, top: u16, elapsed: f32, hover: f32) {
290		let full = cols == CARD_COLS;
291		let logo_left = if full {
292			left + 3
293		} else {
294			left + (cols - LOGO_COLS as u16) / 2
295		};
296		self.logo_origin = (logo_left, top + 2);
297		// Pointer-tracking border glow: the brand gradient sampled by angle
298		// around the card center (the disk's own palette), strongest near
299		// the pointer, scaled by the eased hover amount.
300		let pointer = self.pointer;
301		let center =
302			(f32::from(left) + f32::from(cols) / 2.0, f32::from(top) + f32::from(CARD_ROWS) / 2.0);
303		let edge_at = move |x: u16, y: u16| -> Style {
304			let Some((px, py)) = pointer.filter(|_| hover > 0.02) else {
305				return on_card(CARD_BORDER);
306			};
307			let dx = (f32::from(x) - f32::from(px)) * 0.5;
308			let dy = f32::from(y) - f32::from(py);
309			let glow = hover * (-(dx * dx + dy * dy) / 34.0).exp();
310			if glow < 0.02 {
311				return on_card(CARD_BORDER);
312			}
313			let angle = (f32::from(y) - center.1).atan2((f32::from(x) - center.0) * 0.5);
314			let brand = vec3_color(gradient(angle - elapsed * 0.5));
315			on_card(CARD_BORDER.lerp(brand, glow))
316		};
317		let frame = &mut self.frame;
318		frame.fill(Rect::new(left, top, cols, CARD_ROWS), on_card(TEXT));
319
320		let right = left + cols - 1;
321		let bottom = top + CARD_ROWS - 1;
322		let divider = bottom - 2;
323		let (tl, tr, bl, br, horizontal, vertical) = self.charset.border(Border::Round);
324		let grid = self.charset.grid();
325		let mut glyph = [0_u8; 4];
326		frame.put(left, top, tl.encode_utf8(&mut glyph), edge_at(left, top));
327		frame.put(right, top, tr.encode_utf8(&mut glyph), edge_at(right, top));
328		frame.put(left, divider, grid.middle.0.encode_utf8(&mut glyph), edge_at(left, divider));
329		frame.put(right, divider, grid.middle.2.encode_utf8(&mut glyph), edge_at(right, divider));
330		frame.put(left, bottom, bl.encode_utf8(&mut glyph), edge_at(left, bottom));
331		frame.put(right, bottom, br.encode_utf8(&mut glyph), edge_at(right, bottom));
332		for x in left + 1..right {
333			frame.put(x, top, horizontal.encode_utf8(&mut glyph), edge_at(x, top));
334			frame.put(x, divider, horizontal.encode_utf8(&mut glyph), edge_at(x, divider));
335			frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), edge_at(x, bottom));
336		}
337		for y in top + 1..bottom {
338			if y != divider {
339				frame.put(left, y, vertical.encode_utf8(&mut glyph), edge_at(left, y));
340				frame.put(right, y, vertical.encode_utf8(&mut glyph), edge_at(right, y));
341			}
342		}
343
344		frame.put(left + 2, top, self.title.as_str(), on_card(TEXT_STRONG));
345		if full {
346			frame.put(left + 34, top, " SESSION ORBIT ", on_card(FAINT));
347			draw_dust(frame, left, top, elapsed);
348			draw_sessions(frame, left, top, self.charset);
349			draw_beam(frame, left, top, elapsed);
350		}
351
352		blit_logo(frame, &self.logo, logo_left, top + 2, CARD_BG);
353
354		let footer = divider + 1;
355		frame.fill(Rect::new(left + 1, footer, cols - 2, 1), on_footer(TEXT));
356		if full {
357			frame.put(left + 3, divider, " SHORTCUTS ", on_card(FAINT));
358			let dot = fmts!(" {} ", self.charset.icon(Icon::Enabled));
359			let x = frame.put(left + cols - 21, top, &dot, on_card(GREEN));
360			frame.put(x, top, "rust-analyzer ", on_card(MUTED));
361			draw_full_hints(frame, left, footer);
362		} else {
363			draw_smol_hints(frame, left, cols, footer);
364		}
365	}
Source

pub const fn scrollbar(self) -> (&'static str, &'static str)

Scrollbar (track, thumb).

Examples found in repository?
examples/chat/demo.rs (line 381)
300	fn paint_picker(pc: &mut PaintCtx<'_>, rect: Rect, y: u16, editor: &Editor) {
301		let Some(picker) = editor.picker() else {
302			return;
303		};
304		let (start, suggestions) = picker.visible_suggestions();
305		let overflow = picker.len() > suggestions.len();
306		let row_right = rect
307			.x
308			.saturating_add(rect.width.saturating_sub(u16::from(overflow)));
309		let primary_width = suggestions
310			.iter()
311			.filter_map(|suggestion| match suggestion.display() {
312				SuggestionDisplay::Text(name) => Some(visible_width(name).saturating_add(2)),
313				SuggestionDisplay::Emoji { .. } => None,
314			})
315			.max()
316			.unwrap_or(12)
317			.clamp(12, 32);
318
319		for (offset, suggestion) in suggestions.iter().enumerate() {
320			let Ok(offset) = u16::try_from(offset) else {
321				break;
322			};
323			let row = y.saturating_add(offset);
324			if row >= pc.clip {
325				break;
326			}
327			let selected = start + usize::from(offset) == picker.selected();
328			let label = if selected { ink(GREEN) } else { ink(TEXT) };
329			let description = if selected { ink(GREEN) } else { ink(MUTED) };
330			pc.frame.put(
331				rect.x,
332				row,
333				if selected {
334					pc.ctx.charset.cursor()
335				} else {
336					"  "
337				},
338				label,
339			);
340			match suggestion.display() {
341				SuggestionDisplay::Text(name) => {
342					draw_line(
343						pc.frame,
344						rect.x.saturating_add(2),
345						row,
346						row_right.saturating_sub(rect.x.saturating_add(2)),
347						&[Span::new(name, label)],
348					);
349					if let Some(text) = suggestion.description()
350						&& rect.width > 40
351					{
352						let description_x = rect
353							.x
354							.saturating_add(2)
355							.saturating_add(primary_width)
356							.min(row_right);
357						draw_line(
358							pc.frame,
359							description_x,
360							row,
361							row_right.saturating_sub(description_x),
362							&[Span::new(text, description)],
363						);
364					}
365				},
366				SuggestionDisplay::Emoji { emoji, shortcode } => {
367					let mut column = pc.frame.put(rect.x.saturating_add(2), row, emoji, label);
368					column = pc.frame.put(column, row, "  ", label);
369					if shortcode.starts_with(':') {
370						pc.frame.put(column, row, shortcode, label);
371					} else {
372						column = pc.frame.put(column, row, ":", label);
373						column = pc.frame.put(column, row, shortcode, label);
374						pc.frame.put(column, row, ":", label);
375					}
376				},
377			}
378		}
379
380		if overflow && !suggestions.is_empty() {
381			let (track, thumb_glyph) = pc.ctx.charset.scrollbar();
382			let track_x = rect.x.saturating_add(rect.width.saturating_sub(1));
383			for offset in 0..suggestions.len() {
384				let Ok(offset) = u16::try_from(offset) else {
385					break;
386				};
387				pc.frame
388					.put(track_x, y.saturating_add(offset), track, ink(FAINT));
389			}
390			let thumb = picker
391				.selected()
392				.saturating_mul(suggestions.len().saturating_sub(1))
393				/ picker.len().saturating_sub(1);
394			pc.frame.put(
395				track_x,
396				y.saturating_add(u16::try_from(thumb).unwrap_or(u16::MAX)),
397				thumb_glyph,
398				ink(GREEN),
399			);
400		}
401	}
Source

pub const fn rail(self) -> &'static str

Left rail glyph for editors and <note> callouts.

Examples found in repository?
examples/chat/welcome.rs (line 411)
398fn draw_sessions(frame: &mut Frame, left: u16, top: u16, charset: Charset) {
399	let (_, _, _, _, _, vertical) = charset.border(Border::Round);
400	let mut glyph = [0_u8; 4];
401	let panel_x = left + 36;
402	frame.put(panel_x, top + 2, "RECENT SESSIONS", on_card(MUTED));
403	frame.put(left + CARD_COLS - 14, top + 2, "4 / LOCAL", on_card(FAINT));
404	for y in top + 4..=top + 10 {
405		frame.put(panel_x, y, vertical.encode_utf8(&mut glyph), on_card(FAINT.lerp(INDIGO, 0.18)));
406	}
407	for (index, (label, age)) in SESSIONS.iter().enumerate() {
408		let y = top + 4 + index as u16 * 2;
409		if index == 0 {
410			frame.fill(Rect::new(panel_x - 2, y, CARD_COLS - 35, 1), on_selected(TEXT));
411			frame.put(panel_x - 2, y, charset.rail(), on_selected(GREEN));
412			frame.put(panel_x, y, charset.radio(true), on_selected(GREEN));
413			frame.put(panel_x + 2, y, age, on_selected(GREEN));
414			frame.put(panel_x + 7, y, label, on_selected(TEXT_STRONG));
415		} else {
416			frame.put(panel_x, y, charset.radio(false), on_card(FAINT));
417			frame.put(panel_x + 2, y, age, on_card(FAINT));
418			frame.put(panel_x + 7, y, label, on_card(MUTED));
419		}
420	}
421}
Source

pub const fn spinner(self) -> Frames

Spinner animation frames for this tier.

Examples found in repository?
examples/footers.rs (line 133)
132	const fn spinner(&self) -> &'static str {
133		self.charset.spinner().at(self.elapsed)
134	}
More examples
Hide additional examples
examples/chat/demo.rs (line 581)
576	fn brand_segment(&self, now: Duration) -> Segment {
577		let work = self.work.borrow();
578		let brand = if work.working {
579			fmts!(
580				"{} {}",
581				self.charset.spinner().at(now),
582				elapsed_label(now.saturating_sub(work.since))
583			)
584		} else {
585			fmts!("{} omp", self.charset.icon(Icon::Omp))
586		};
587		Segment::new()
588			.label(brand)
589			.with(Prop::Fg, work.fade.sample(now))
590	}
591
592	fn model_segment(&self) -> Segment {
593		Segment::new()
594			.label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595			.with(Prop::Fg, GREEN)
596	}
597
598	fn git_segment(charset: Charset) -> Segment {
599		Segment::new()
600			.label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601			.with(Prop::Fg, CYAN)
602	}
603
604	fn context_segment(charset: Charset) -> Segment {
605		Segment::new()
606			.label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607			.with(Prop::Fg, GOLD)
608	}
609
610	fn cost_segment() -> Segment {
611		Segment::new()
612			.label("$60.07 (sub) + $8.65 (adv)")
613			.with(Prop::Fg, PURPLE)
614	}
615
616	/// The left band group: brand and model.
617	fn left_group(&self, now: Duration) -> Status {
618		Self::group()
619			.segment(self.brand_segment(now))
620			.segment(self.model_segment())
621	}
622
623	/// The right band group: branch, context, and cost.
624	fn right_group(charset: Charset) -> Status {
625		Self::group()
626			.with_str(Prop::Align, "right")
627			.segment(Self::git_segment(charset))
628			.segment(Self::context_segment(charset))
629			.segment(Self::cost_segment())
630	}
631
632	/// Every segment in one band, for panes too narrow to split.
633	fn combined(&self, now: Duration) -> Status {
634		Self::group()
635			.segment(self.brand_segment(now))
636			.segment(self.model_segment())
637			.segment(Self::git_segment(self.charset))
638			.segment(Self::context_segment(self.charset))
639			.segment(Self::cost_segment())
640	}
641}
642
643impl Component for DemoStatus {
644	fn props(&self) -> &Props {
645		&self.props
646	}
647
648	fn props_mut(&mut self) -> &mut Props {
649		&mut self.props
650	}
651
652	fn slot(&self) -> Slot {
653		self.slot
654	}
655
656	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657		self.combined(Duration::ZERO).measure(ctx)
658	}
659
660	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661		1
662	}
663
664	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665		let mut left = self.left_group(pc.now);
666		let (_, left_width) = left.measure(pc.ctx);
667		let (_, right_width) = self.right.measure(pc.ctx);
668		if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669			left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670			let dock = rect
671				.x
672				.saturating_add(rect.width)
673				.saturating_sub(right_width);
674			self
675				.right
676				.paint(pc, Rect::new(dock, rect.y, right_width, 1));
677		} else {
678			let mut combined = self.combined(pc.now);
679			combined.paint(pc, rect);
680		}
681		let work = self.work.borrow();
682		let fade_frame = work
683			.fade
684			.settles_at()
685			.min(pc.now.saturating_add(FADE_FRAME));
686		let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687			(true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688			(true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689			(false, false) => Some(fade_frame),
690			(false, true) => None,
691		};
692		if let Some(at) = deadline {
693			pc.wake(self.slot, at);
694		}
695	}
Source

pub const fn check(self) -> &'static str

Success / chosen mark.

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

Trait Implementations§

Source§

impl Clone for Charset

Source§

fn clone(&self) -> Charset

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

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

Performs copy-assignment from source. Read more
Source§

impl Copy for Charset

Source§

impl Debug for Charset

Source§

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

Formats the value using the given formatter. Read more
Source§

impl Default for Charset

Source§

fn default() -> Charset

Returns the “default value” for a type. Read more
Source§

impl Eq for Charset

Source§

impl PartialEq for Charset

Source§

fn eq(&self, other: &Charset) -> bool

Equality operator ==. Read more
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Inequality operator !=. Read more
Source§

impl StructuralPartialEq for Charset

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

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

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

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

Source§

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

Mutably borrows from an owned value. Read more
Source§

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

Source§

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

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

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

Source§

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

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

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

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

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
Source§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
Source§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

Source§

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

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

impl<Q, K> Equivalent<K> for Q
where Q: Eq + ?Sized, K: Borrow<Q> + ?Sized,

Source§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<Q, K> Equivalent<K> for Q
where Q: Eq + ?Sized, K: Borrow<Q> + ?Sized,

Source§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
Source§

impl<Q, K> Equivalent<K> for Q
where Q: Eq + ?Sized, K: Borrow<Q> + ?Sized,

Source§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

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

Source§

fn into(self) -> U

Calls U::from(self).

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

Source§

impl<T> IntoEither for T

Source§

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

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

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

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

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

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

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

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

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

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

Source§

type Error = Infallible

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

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

Performs the conversion.
Source§

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

Source§

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

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

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

Performs the conversion.