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
impl Charset
Sourcepub const fn icon(self, icon: Icon) -> &'static str
pub const fn icon(self, icon: Icon) -> &'static str
Resolves a semantic icon through this terminal’s capability tier.
Examples found in repository?
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
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}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 }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}Sourcepub fn icon_named(self, name: &str) -> Option<&'static str>
pub fn icon_named(self, name: &str) -> Option<&'static str>
Resolves a short icon name or qualified compatibility alias.
Sourcepub const fn border(
self,
border: Border,
) -> (char, char, char, char, char, char)
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?
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
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}Sourcepub const fn cursor(self) -> &'static str
pub const fn cursor(self) -> &'static str
Focus cursor prefix, two cells wide.
Examples found in repository?
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}Sourcepub const fn radio(self, selected: bool) -> &'static str
pub const fn radio(self, selected: bool) -> &'static str
Radio mark for (selected).
Examples found in repository?
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}Sourcepub const fn grid(self) -> Grid
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?
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 }Sourcepub const fn scrollbar(self) -> (&'static str, &'static str)
pub const fn scrollbar(self) -> (&'static str, &'static str)
Scrollbar (track, thumb).
Examples found in repository?
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 }Sourcepub const fn rail(self) -> &'static str
pub const fn rail(self) -> &'static str
Left rail glyph for editors and <note> callouts.
Examples found in repository?
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}Sourcepub const fn spinner(self) -> Frames
pub const fn spinner(self) -> Frames
Spinner animation frames for this tier.
Examples found in repository?
More examples
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 }Sourcepub const fn check(self) -> &'static str
pub const fn check(self) -> &'static str
Success / chosen mark.
Examples found in repository?
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§
impl Copy for Charset
impl Eq for Charset
impl StructuralPartialEq for Charset
Auto Trait Implementations§
impl Freeze for Charset
impl RefUnwindSafe for Charset
impl Send for Charset
impl Sync for Charset
impl Unpin for Charset
impl UnsafeUnpin for Charset
impl UnwindSafe for Charset
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can
then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.Source§fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be
further downcast into Rc<ConcreteType> where ConcreteType implements Trait.Source§fn as_any(&self) -> &(dyn Any + 'static)
fn as_any(&self) -> &(dyn Any + 'static)
&Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &Any’s vtable from &Trait’s.Source§fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.Source§impl<T> DowncastSync for T
impl<T> DowncastSync for T
Source§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
Source§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
Source§fn equivalent(&self, key: &K) -> bool
fn equivalent(&self, key: &K) -> bool
key and return true if they are equal.Source§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more