pub struct Ui { /* private fields */ }Expand description
A parsed, laid-out, retained component tree painting into a Frame.
Implementations§
Source§impl Ui
impl Ui
Sourcepub fn from_markup(
source: impl Into<Str>,
width: u16,
ctx: UiContext,
) -> Result<Self, ParseError>
pub fn from_markup( source: impl Into<Str>, width: u16, ctx: UiContext, ) -> Result<Self, ParseError>
Parses runtime markup and produces the first fully painted frame.
Prefer Ui::from_root with crate::dom! when the structure is
known at compile time; this path is for markup that only exists at
runtime (configuration, generated text, editable source).
§Errors
Returns ParseError for malformed markup.
Examples found in repository?
More examples
17fn build(source: &str, width: u16, ctx: &UiContext) -> Ui {
18 match Ui::from_markup(source, width, ctx.clone()) {
19 Ok(ui) => ui,
20 Err(error) => {
21 let message = error.to_string();
22 Ui::from_root(
23 dom! {
24 <box border=round bc=err title="parse error" pad="0 1">
25 <text fg=err>{message}</text>
26 </box>
27 },
28 width,
29 ctx.clone(),
30 )
31 },
32 }
33}Sourcepub fn from_root(root: impl IntoComponent, width: u16, ctx: UiContext) -> Self
pub fn from_root(root: impl IntoComponent, width: u16, ctx: UiContext) -> Self
Builds a retained UI directly from a component tree.
Examples found in repository?
More examples
17fn build(source: &str, width: u16, ctx: &UiContext) -> Ui {
18 match Ui::from_markup(source, width, ctx.clone()) {
19 Ok(ui) => ui,
20 Err(error) => {
21 let message = error.to_string();
22 Ui::from_root(
23 dom! {
24 <box border=round bc=err title="parse error" pad="0 1">
25 <text fg=err>{message}</text>
26 </box>
27 },
28 width,
29 ctx.clone(),
30 )
31 },
32 }
33}208fn build(query: &str, rows: u16, width: u16, ctx: &UiContext) -> Ui {
209 let list = entries();
210 let seed = Str::from(query);
211 let height = rows.saturating_add(1);
212 Ui::from_root(
213 dom! {
214 <box border=round title="Commands" pad-x=1>
215 <col>
216 <select id="commands" filter={seed} h={height}>
217 for entry in list {
218 <option value={entry.value} label={entry.label}>
219 <td><pre fg={entry.name_fg}>{entry.name}</pre></td>
220 <td truncate grow><pre fg={DIM}>{entry.detail}</pre></td>
221 if !entry.key.is_empty() {
222 <td align=end><pre fg={DIM}>{entry.key}</pre></td>
223 }
224 </option>
225 }
226 </select>
227 <text dim truncate>{HINT}</text>
228 </col>
229 </box>
230 },
231 width,
232 ctx.clone(),
233 )
234}170fn build(model: &str, ctx: &UiContext) -> Ui {
171 let files = FILES;
172 Ui::from_root(
173 dom! {
174 <row id="rail" h=24>
175 <hr/>
176 <col id="body" h=24 grow pad="0 1" gap=1>
177 <text bold fg={CYAN}>{"session"}</text>
178 <col>
179 <row gap=1>
180 <text fg={DIM} w=8>{"model"}</text>
181 <text id="model" truncate>{model}</text>
182 </row>
183 <row gap=1>
184 <text fg={DIM} w=8>{"elapsed"}</text>
185 <text id="elapsed">{"0:00"}</text>
186 </row>
187 <row gap=1>
188 <text fg={DIM} w=8>{"branch"}</text>
189 <text truncate>{"tui/seam-commits"}</text>
190 </row>
191 </col>
192 <hr/>
193 <text bold fg={CYAN}>{"files"}</text>
194 <select id="files" h={files.len() as u16}>
195 for (name, delta) in files {
196 <option value={name} label={name}>
197 <td grow truncate><pre>{name}</pre></td>
198 <td align=end><pre fg={GREEN}>{delta}</pre></td>
199 </option>
200 }
201 </select>
202 <spacer grow/>
203 <text dim truncate>{"ctrl+b rail · esc back"}</text>
204 </col>
205 </row>
206 },
207 WIDTH,
208 ctx.clone(),
209 )
210}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 }107fn build_ui(viewport: Size, context: UiContext) -> Ui {
108 let ids = PROVIDERS
109 .iter()
110 .enumerate()
111 .map(|(index, provider)| {
112 (format!("{ASSET_DIR}/{}.png", provider.id), u32::try_from(index + 1).unwrap())
113 })
114 .collect::<HashMap<_, _>>();
115 let elements = Elements::builder()
116 .with("logo", move |_: &str, props: Props, _: Vec<Cached>| {
117 let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
118 let id = ids.get(source).copied().unwrap_or(1);
119 Box::new(
120 Img::new()
121 .with_str(Prop::Src, source)
122 .with(Prop::W, 4_u16)
123 .kitty(id, 2, 4),
124 ) as Box<dyn omp_tui::Component>
125 })
126 .build();
127 let root = dom! {
128 <col gap=1>
129 <row gap=1>
130 <i:log-in/>
131 <text bold fg="accent..info">{"Choose a provider"}</text>
132 <text dim>{format!("{} providers", PROVIDERS.len())}</text>
133 </row>
134 <scroll id={SCROLL_ID} h={scroll_height(viewport)}>
135 <row wrap gap=1 justify=center>
136 for provider in PROVIDERS.iter() {
137 <box focus id={provider.id} w={CARD_W} border=round bc="muted..muted"
138 hover="#38bdf8..#c084fc" lift=1 anim=220 ease=in-out
139 align=center pad-x=1>
140 <logo src={format!("{ASSET_DIR}/{}.png", provider.id)}/>
141 <text bold truncate align=center>{provider.name}</text>
142 </box>
143 }
144 </row>
145 </scroll>
146 <row gap=2>
147 <text dim>{"↹/←→/↑↓ pick · ↵ login · wheel scroll · Ctrl-C quit"}</text>
148 <text id={HUD_ID} dim>{"repaint: 0 cells"}</text>
149 </row>
150 </col>
151 };
152 let context = UiContext { elements, ..context };
153 Ui::from_root(root, viewport.width, context)
154}Sourcepub const fn frame(&self) -> &Frame
pub const fn frame(&self) -> &Frame
The retained document frame.
Examples found in repository?
978 fn refresh_composer(&mut self) {
979 let width = self.editor_ui.frame().size().width;
980 if width > 0 {
981 self.editor_ui.resize(width);
982 }
983 }
984
985 /// Reserves `cols` at the right edge for a composited rail, so the
986 /// composer's right-docked chrome stays visible beside it. The next
987 /// render relayouts the editor at the narrowed width.
988 pub const fn set_right_inset(&mut self, cols: u16) {
989 self.right_inset = cols;
990 }
991
992 /// The width the composer may actually occupy at `viewport`.
993 fn composer_width(&self, viewport: Size) -> u16 {
994 viewport.width.saturating_sub(self.right_inset).max(1)
995 }
996
997 /// Updates the retained logical document and reports its repainted rows.
998 pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999 self.render_at(viewport, self.started_at.elapsed())
1000 }
1001
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }
1292
1293 /// Paints the n-th scripted message and returns rows used including
1294 /// the trailing blank. Measurement draws into a scratch frame.
1295 fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296 let margin = u16::from(width >= 50);
1297 let content_width = width.saturating_sub(margin * 2);
1298 if message == 2 {
1299 draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300 return EDIT_BOX_HEIGHT + 1;
1301 }
1302 let bottom = frame.size().height;
1303 let spans = Self::message_spans(message);
1304 // Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305 // wrapped row re-joins byte-for-byte in native selection.
1306 let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307 used.saturating_add(1)
1308 }
1309
1310 fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311 let mut spans = SmallVec::new();
1312 match message {
1313 0 => {
1314 spans.push(Span::new("Transcript rows are ", prose_style()));
1315 spans.push(Span::new("append-only", code_style()));
1316 spans.push(Span::new(
1317 ": every line is painted once, becomes stable, and rides into native scrollback \
1318 with any selection anchored to it.",
1319 prose_style(),
1320 ));
1321 },
1322 1 => {
1323 spans.push(Span::new(
1324 "Only the bottom band repaints in place — the live shard panel, the activity \
1325 shimmer, and the composer. Rows above it are never rewritten.",
1326 prose_style(),
1327 ));
1328 },
1329 _ => {
1330 spans.push(Span::new(
1331 "On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332 the stable transcript through a ",
1333 prose_style(),
1334 ));
1335 spans.push(Span::new("DECSTBM top region", code_style()));
1336 spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337 },
1338 }
1339 spans
1340 }
1341
1342 /// Appends a finished shard's permanent one-line result.
1343 fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344 let margin = u16::from(width >= 50);
1345 let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346 let detail = fmts!(" workspace-{shard:03}.test.ts [100%]");
1347 draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348 Span::new(prefix.as_str(), ink(GREEN)),
1349 Span::new(detail.as_str(), ink(MUTED)),
1350 ]);
1351 }
1352
1353 /// Shimmering activity line above the editor. The spinner and timer
1354 /// live in the status bar's brand segment; this row only narrates.
1355 fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356 if y >= frame.size().height || frame.size().width < 4 {
1357 return;
1358 }
1359 let start = u16::from(frame.size().width >= 50);
1360 let mut column = start;
1361 let length = xutf::graphemes_str(WORKING_MESSAGE)
1362 .count()
1363 .saturating_add(xutf::graphemes_str(hint).count())
1364 .saturating_add(1);
1365 let length = u16::try_from(length).unwrap_or(u16::MAX);
1366 let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367 let right = frame.size().width.saturating_sub(1);
1368 draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369 draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370 draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371 }
1372
1373 /// The session title resting right-aligned in the air row between
1374 /// the working narration and the status band — against the visible
1375 /// right bound, inside any rail reservation — so the gap reads as
1376 /// session identity instead of dead space.
1377 fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378 let width = frame.size().width.saturating_sub(right_inset);
1379 let title_width = visible_width(SESSION_TITLE);
1380 if y >= frame.size().height || width < title_width.saturating_add(2) {
1381 return;
1382 }
1383 let x = width.saturating_sub(title_width.saturating_add(1));
1384 draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385 }
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391 if rect.width < 4 || rect.height < 4 {
1392 return;
1393 }
1394
1395 let content_x = rect.x + 2;
1396 let content_width = rect.width.saturating_sub(4);
1397 let header = [
1398 Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399 Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400 ];
1401 draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402 let command = [
1403 Span::new("$ ", panel_ink(MUTED)),
1404 Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405 Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406 ];
1407 draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}More examples
307 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308 let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309 let tier = PerfTier::of(viewport.width);
310 if tier != self.tier {
311 self.tier = tier;
312 self.rebuild();
313 }
314 if rows != self.rows {
315 self.rows = rows;
316 // One query row plus the windowed list.
317 self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318 }
319 if self.ui.frame().size().width != viewport.width {
320 self.ui.resize(viewport.width);
321 }
322 Layer { frame: self.ui.frame(), options: &self.options, active: true }
323 }
324
325 /// Applies one surfaced [`UiEvent`] to picker state.
326 fn route(&mut self, event: UiEvent) -> PickerEvent {
327 match event {
328 UiEvent::Cancel => PickerEvent::Close,
329 UiEvent::Changed { value, .. } => value
330 .as_str()
331 .parse()
332 .map_or(PickerEvent::Consumed, PickerEvent::Pick),
333 UiEvent::Highlighted { value, .. } => {
334 self.show_detail(value.as_str().parse().ok());
335 PickerEvent::Consumed
336 },
337 UiEvent::Filtered { query, value, .. } => {
338 let wants_roles = query.starts_with('@');
339 self.query = query;
340 if wants_roles == (self.mode == Mode::Roles) {
341 self.show_detail(value.and_then(|value| value.as_str().parse().ok()));
342 } else {
343 self.mode = if wants_roles {
344 Mode::Roles
345 } else {
346 Mode::Models
347 };
348 self.rebuild();
349 }
350 PickerEvent::Consumed
351 },
352 UiEvent::None | UiEvent::Submit | UiEvent::Pressed(_) => PickerEvent::Consumed,
353 }
354 }
355
356 /// Rebuilds the retained tree for the current mode/tier, reseeding the
357 /// select's query so typing continuity survives the swap.
358 fn rebuild(&mut self) {
359 let width = self.ui.frame().size().width;
360 self.ui = build(self.mode, self.tier, self.current, &self.query, self.rows, width, &self.ctx);
361 let initial = match self.mode {
362 Mode::Models => Some(self.current),
363 // The role list keeps its configured order; detail follows the
364 // first row until the next cursor event.
365 Mode::Roles => ROLES.first().map(|role| role.model),
366 };
367 self.show_detail(initial);
368 }108 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109 let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110 let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111 if rows != self.rows {
112 self.rows = rows;
113 // One query row plus the windowed list.
114 self
115 .ui
116 .set_prop("commands", Prop::H, rows.saturating_add(1));
117 }
118 if self.ui.frame().size().width != width {
119 self.ui = build(&self.query, self.rows, width, &self.ctx);
120 }
121 self.options = self.options.width(Dim::Cells(width));
122 Layer { frame: self.ui.frame(), options: &self.options, active: true }
123 }138 pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139 if !self.visible(viewport) {
140 if self.focused {
141 self.blur();
142 }
143 return None;
144 }
145 if self.height != viewport.height {
146 self.height = viewport.height;
147 self.ui.set_prop("rail", Prop::H, viewport.height);
148 self.ui.set_prop("body", Prop::H, viewport.height);
149 }
150 let seconds = elapsed.as_secs();
151 if seconds != self.elapsed_seconds {
152 self.elapsed_seconds = seconds;
153 self.ui.set_text("elapsed", elapsed_label(seconds));
154 }
155 Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156 }42async fn main() -> io::Result<()> {
43 let path = std::env::args()
44 .nth(1)
45 .unwrap_or_else(|| "example.tml".into());
46 let source = std::fs::read_to_string(&path)?;
47
48 let mut ctx = None;
49 let mut app = AppOptions::new()
50 .quit([Key::Ctrl('c'), Key::Char('q'), Key::Esc])
51 .start(|env| {
52 let ui = build(&source, env.viewport.width, &env.ctx);
53 ctx = Some(env.ctx);
54 ui
55 })
56 .await?;
57 let ctx = ctx.expect("start ran the builder");
58
59 let handle = app.handle();
60 tokio::spawn(async move {
61 let mut seen = modified(path.as_ref());
62 loop {
63 tokio::time::sleep(Duration::from_millis(150)).await;
64 let stamp = modified(path.as_ref());
65 if stamp == seen {
66 continue;
67 }
68 seen = stamp;
69 let Ok(source) = std::fs::read_to_string(&path) else {
70 continue;
71 };
72 let ctx = ctx.clone();
73 handle.update(move |ui| *ui = build(&source, ui.frame().size().width, &ctx));
74 }
75 });
76
77 while app.next().await?.is_some() {}
78 Ok(())
79}Sourcepub fn set_context(&mut self, ctx: UiContext) -> bool
pub fn set_context(&mut self, ctx: UiContext) -> bool
Swaps the presentation context and refreshes the whole document.
Applies ctx to this tree and every stacked overlay, advances the
cache revision so geometry and render memos discard context-derived
output, then relays out and repaints. A context that compares equal
(appearance, charset, graphics, Jamo policy, theme, elements) is a
no-op returning false. The presentation clock is retained, and a
context without an image loader keeps the installed one. Structure
parsed from markup is retained: swapping elements affects future
parses only.
Sourcepub const fn height(&self) -> u16
pub const fn height(&self) -> u16
Document height in rows after the last layout.
Examples found in repository?
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 fn has_damage(&self) -> bool
pub fn has_damage(&self) -> bool
Whether a present is needed after the most recent mutation.
Sourcepub fn take_frame_damage(&mut self) -> bool
pub fn take_frame_damage(&mut self) -> bool
Consumes raw-frame damage after an embedder copies Ui::frame.
Examples found in repository?
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 }Sourcepub fn set_text(&mut self, id: &str, text: impl Into<Str>) -> bool
pub fn set_text(&mut self, id: &str, text: impl Into<Str>) -> bool
Replaces a named component’s text and refreshes the smallest safe region.
Examples found in repository?
130 pub fn set_model(&mut self, name: &str) {
131 self.ui.set_text("model", name);
132 }
133
134 /// The composited rail for this frame, laid out to the full viewport
135 /// height; `None` when toggled off or gated out by a small viewport.
136 /// Shrinking below the minimum blurs the rail so keys never route into
137 /// an invisible layer.
138 pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139 if !self.visible(viewport) {
140 if self.focused {
141 self.blur();
142 }
143 return None;
144 }
145 if self.height != viewport.height {
146 self.height = viewport.height;
147 self.ui.set_prop("rail", Prop::H, viewport.height);
148 self.ui.set_prop("body", Prop::H, viewport.height);
149 }
150 let seconds = elapsed.as_secs();
151 if seconds != self.elapsed_seconds {
152 self.elapsed_seconds = seconds;
153 self.ui.set_text("elapsed", elapsed_label(seconds));
154 }
155 Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156 }More examples
156fn show_stats(app: &mut App, chosen: Option<&str>) {
157 let caps = app.caps();
158 let stats = app.last_stats();
159 let pixels = caps.cell_px.map_or_else(
160 || "cell-px ?".to_owned(),
161 |(width, height)| format!("cell-px {width}×{height}"),
162 );
163 let login = chosen.map_or(String::new(), |id| format!("login: {id} · "));
164 app.ui_mut().set_text(
165 HUD_ID,
166 format!(
167 "{login}{} · {} · {} · repaint: {} cells",
168 graphics_label(caps.graphics),
169 caps.id,
170 pixels,
171 stats.changed_cells,
172 ),
173 );
174}379pub fn show_detail_on(ui: &mut Ui, model: Option<usize>) {
380 let facts = model.map_or_else(|| Str::new_static(" "), |index| facts(&MODELS[index]));
381 ui.set_text("facts", facts);
382 // Hide before show: the document must never transiently exceed its
383 // steady height — a raw-frame layer's retained frame keeps the
384 // high-water mark, which would leave a stale extra row.
385 for index in (0..MODELS.len()).filter(|&index| model != Some(index)) {
386 ui.set_visible(&fmts!("chips-{index}"), false);
387 }
388 if let Some(index) = model {
389 ui.set_visible(&fmts!("chips-{index}"), true);
390 }
391}112async fn main() -> io::Result<()> {
113 let mut app = AppOptions::new()
114 .mouse()
115 .quit([Key::Ctrl('c'), Key::Ctrl('q')])
116 .start(|env| build_ui(env.viewport, env.ctx))
117 .await?;
118 // The picker tab opens with the first model's details, like the chat
119 // overlay does.
120 picker::show_detail_on(app.ui_mut(), Some(0));
121
122 let mut synced = String::new();
123 let mut lab = anim::Lab::new();
124 let mut layers = Layers::default();
125 let mut next_step = tokio::time::Instant::now() + anim::AUTOPLAY_STEP;
126
127 loop {
128 let event = tokio::select! {
129 event = app.next() => match event? {
130 Some(event) => event,
131 None => break,
132 },
133 () = tokio::time::sleep_until(next_step) => {
134 if lab.autoplay && active_tab(app.ui()) == "Anim" {
135 lab.advance(app.ui_mut());
136 }
137 next_step += anim::AUTOPLAY_STEP;
138 continue;
139 },
140 };
141 match event {
142 AppEvent::Resized(viewport) => {
143 for pane in render::PANE_IDS {
144 app.ui_mut().set_height(pane, render::pane_height(viewport));
145 }
146 },
147 AppEvent::Key(key) => match active_tab(app.ui()).as_str() {
148 "Anim" => lab.handle_key(key, app.ui_mut()),
149 "Overlay" => match key {
150 Key::Ctrl('k') if layers.picker.is_none() => {
151 layers.picker = Some(overlay::show_picker(app.ui_mut()));
152 },
153 Key::Ctrl('g') => match layers.help.take() {
154 Some(id) => {
155 app.ui_mut().close_overlay(id);
156 },
157 None => layers.help = Some(overlay::show_help(app.ui_mut())),
158 },
159 _ => {},
160 },
161 _ => {},
162 },
163 // The Overlay tab's modal select committed a model.
164 AppEvent::Changed { id, value } if id == "model" => {
165 if let Some(overlay) = layers.picker.take() {
166 let label = overlay::MODELS
167 .iter()
168 .find(|(short, ..)| *short == value)
169 .map_or(value.as_str(), |(_, label, _)| label);
170 app.ui_mut().set_text("status", format!("model: {label}"));
171 app.ui_mut().close_overlay(overlay);
172 }
173 },
174 // The Picker tab's select moved: mirror the chat picker's
175 // facts-and-chips detail line.
176 AppEvent::Highlighted { id, value } if id == "models" => {
177 picker::show_detail_on(app.ui_mut(), value.as_str().parse().ok());
178 },
179 AppEvent::Filtered { id, value, .. } if id == "models" => {
180 let model = value.and_then(|value| value.as_str().parse().ok());
181 picker::show_detail_on(app.ui_mut(), model);
182 },
183 AppEvent::OverlayClosed(id) => {
184 if layers.picker == Some(id) {
185 layers.picker = None;
186 }
187 if layers.help == Some(id) {
188 layers.help = None;
189 }
190 },
191 _ => {},
192 }
193 render::sync_preview(app.ui_mut(), &mut synced);
194 // Reserve the Overlay tab's chords only while it is showing, so the
195 // focused composer can't spend Ctrl+K on kill-line — and the Live
196 // tab's editor keeps it.
197 let chords: &[Key] = if active_tab(app.ui()) == "Overlay" {
198 &[Key::Ctrl('k'), Key::Ctrl('g')]
199 } else {
200 &[]
201 };
202 app.set_hotkeys(chords.iter().copied());
203 }
204 Ok(())
205}Sourcepub fn set_prop(
&mut self,
id: &str,
prop: Prop,
value: impl Into<PropValue>,
) -> bool
pub fn set_prop( &mut self, id: &str, prop: Prop, value: impl Into<PropValue>, ) -> bool
Sets a named component’s property and refreshes the smallest safe
region. Size properties relayout the document; components with an
anim property tween toward the new value from whatever is on
screen. A matching value is a no-op. Returns false for an unknown
id.
§Panics
Panics when a textual value is invalid for prop, matching
[Props::set].
Examples found in repository?
101 fn race(&mut self, ui: &mut Ui) {
102 self.race_wide = !self.race_wide;
103 let target = if self.race_wide { "88%" } else { "12%" };
104 for (id, _) in BARS {
105 ui.set_prop(id, Prop::W, target);
106 }
107 }
108
109 fn mood(&mut self, ui: &mut Ui) {
110 self.mood = (self.mood + 1) % MOODS.len();
111 let (token, bg, text) = MOODS[self.mood];
112 ui.set_prop("mood", Prop::Bc, token);
113 ui.set_prop("mood", Prop::Bg, bg);
114 ui.set_prop("mood-text", Prop::Fg, token);
115 ui.set_text("mood-text", text);
116 }
117
118 fn palette(&mut self, ui: &mut Ui) {
119 self.palette = (self.palette + 1) % PALETTES.len();
120 ui.set_prop("hero", Prop::Bg, PALETTES[self.palette]);
121 }
122
123 fn sidebar(&mut self, ui: &mut Ui) {
124 self.sidebar_wide = !self.sidebar_wide;
125 ui.set_prop("sidebar", Prop::W, if self.sidebar_wide { 30_u16 } else { 14 });
126 }More examples
307 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308 let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309 let tier = PerfTier::of(viewport.width);
310 if tier != self.tier {
311 self.tier = tier;
312 self.rebuild();
313 }
314 if rows != self.rows {
315 self.rows = rows;
316 // One query row plus the windowed list.
317 self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318 }
319 if self.ui.frame().size().width != viewport.width {
320 self.ui.resize(viewport.width);
321 }
322 Layer { frame: self.ui.frame(), options: &self.options, active: true }
323 }108 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109 let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110 let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111 if rows != self.rows {
112 self.rows = rows;
113 // One query row plus the windowed list.
114 self
115 .ui
116 .set_prop("commands", Prop::H, rows.saturating_add(1));
117 }
118 if self.ui.frame().size().width != width {
119 self.ui = build(&self.query, self.rows, width, &self.ctx);
120 }
121 self.options = self.options.width(Dim::Cells(width));
122 Layer { frame: self.ui.frame(), options: &self.options, active: true }
123 }138 pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139 if !self.visible(viewport) {
140 if self.focused {
141 self.blur();
142 }
143 return None;
144 }
145 if self.height != viewport.height {
146 self.height = viewport.height;
147 self.ui.set_prop("rail", Prop::H, viewport.height);
148 self.ui.set_prop("body", Prop::H, viewport.height);
149 }
150 let seconds = elapsed.as_secs();
151 if seconds != self.elapsed_seconds {
152 self.elapsed_seconds = seconds;
153 self.ui.set_text("elapsed", elapsed_label(seconds));
154 }
155 Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156 }Sourcepub fn set_visible(&mut self, id: &str, visible: bool) -> bool
pub fn set_visible(&mut self, id: &str, visible: bool) -> bool
Shows or hides a named component and relayouts the document; a
hidden component skips layout, paint, focus, and hit-testing.
Prefer when= conditions for value-driven visibility — this is the
imperative counterpart for hosts driving visibility from app state
(a detail pane following a list cursor). Returns false for an
unknown id.
Examples found in repository?
379pub fn show_detail_on(ui: &mut Ui, model: Option<usize>) {
380 let facts = model.map_or_else(|| Str::new_static(" "), |index| facts(&MODELS[index]));
381 ui.set_text("facts", facts);
382 // Hide before show: the document must never transiently exceed its
383 // steady height — a raw-frame layer's retained frame keeps the
384 // high-water mark, which would leave a stale extra row.
385 for index in (0..MODELS.len()).filter(|&index| model != Some(index)) {
386 ui.set_visible(&fmts!("chips-{index}"), false);
387 }
388 if let Some(index) = model {
389 ui.set_visible(&fmts!("chips-{index}"), true);
390 }
391}Sourcepub fn tick(&mut self, now: Duration) -> bool
pub fn tick(&mut self, now: Duration) -> bool
Advances the deterministic presentation clock and repaints every component whose wake deadline has passed.
crate::App drives this clock in production; tests and custom hosts
can supply their own monotonic Duration. Returns whether anything
repainted.
Examples found in repository?
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 }Sourcepub fn next_wake(&self) -> Option<Duration>
pub fn next_wake(&self) -> Option<Duration>
Earliest pending animation deadline, if any component is animating.
crate::App schedules it; custom hosts may do the same.
Sourcepub fn invalidate(&mut self, id: &str) -> bool
pub fn invalidate(&mut self, id: &str) -> bool
Refreshes a named component whose externally shared state changed.
The out-of-band companion to event routing: components that read
application state through interior mutability cannot be reached by a
key or mouse path, so the owner mutates the state and invalidates the
component by id. Returns false for an unknown id.
Examples found in repository?
879 fn set_working(&mut self, working: bool, now: Duration) {
880 {
881 let mut work = self.work.borrow_mut();
882 if work.working == working {
883 return;
884 }
885 work.working = working;
886 work.since = now;
887 let target = if working { GREEN } else { MUTED };
888 work
889 .fade
890 .retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891 }
892 self.editor_ui.invalidate(STATUS_ID);
893 }
894
895 /// Reflects a session model switch in the status bar's model segment.
896 pub fn set_model(&mut self, name: &str) {
897 *self.model.borrow_mut() = Str::from(name);
898 self.editor_ui.invalidate(STATUS_ID);
899 }More examples
192async fn main() -> io::Result<()> {
193 let mut app = AppOptions::new()
194 .mouse()
195 .probe(Duration::from_millis(150))
196 .graphics_with(forced_from_args)
197 .start(|env| build_ui(env.viewport, env.ctx))
198 .await?;
199 if app.caps().graphics != Graphics::Cells {
200 for (index, provider) in PROVIDERS.iter().enumerate() {
201 let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202 app.renderer_mut().register_image(
203 u32::try_from(index + 1).expect("provider count fits image IDs"),
204 png,
205 )?;
206 }
207 app.ui_mut().invalidate(SCROLL_ID);
208 }
209 show_stats(&mut app, None);
210 let mut chosen: Option<String> = None;
211 while let Some(event) = app.next().await? {
212 match event {
213 AppEvent::Resized(viewport) => {
214 app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215 },
216 AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217 _ => {},
218 }
219 show_stats(&mut app, chosen.as_deref());
220 }
221 Ok(())
222}Sourcepub fn resize(&mut self, width: u16)
pub fn resize(&mut self, width: u16)
Relayouts and repaints everything at a new width.
Examples found in repository?
978 fn refresh_composer(&mut self) {
979 let width = self.editor_ui.frame().size().width;
980 if width > 0 {
981 self.editor_ui.resize(width);
982 }
983 }
984
985 /// Reserves `cols` at the right edge for a composited rail, so the
986 /// composer's right-docked chrome stays visible beside it. The next
987 /// render relayouts the editor at the narrowed width.
988 pub const fn set_right_inset(&mut self, cols: u16) {
989 self.right_inset = cols;
990 }
991
992 /// The width the composer may actually occupy at `viewport`.
993 fn composer_width(&self, viewport: Size) -> u16 {
994 viewport.width.saturating_sub(self.right_inset).max(1)
995 }
996
997 /// Updates the retained logical document and reports its repainted rows.
998 pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999 self.render_at(viewport, self.started_at.elapsed())
1000 }
1001
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 // Fires due animation wakes (the status bar's spinner and brand
1021 // fade) so the blit below picks up fresh retained pixels.
1022 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 // A viewport change starts a fresh renderer session: replay the
1026 // whole transcript log at the new width. Between rebuilds the log
1027 // is append-only and every drawn row is final, so selections over
1028 // transcript text stay anchored to it in every terminal.
1029 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 // Native scrollback is append-only, so the logical document may
1058 // never shrink while the seam is live: band rows that close again
1059 // (extra input lines) become blank padding that heals as the
1060 // transcript grows.
1061 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 // Paint the new transcript entries; rows above `transcript_rows`
1092 // are final and never repainted.
1093 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 // The live band repaints in place at the bottom of the document.
1102 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 /// Shards whose permanent result line has been appended by `elapsed`:
1165 /// two per emit tick, capped well inside `u16` document heights.
1166 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 /// Rows the bottom live band occupies: the shard panel, a blank
1172 /// separator, the activity row, the title air row, and the editor
1173 /// block.
1174 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 /// Rows `entry` will occupy at `width`, including its trailing blank.
1179 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 /// Paints one transcript entry at `y` and returns the rows it used.
1197 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 /// Paints `entry` into any frame at `y` and returns the rows it used,
1202 /// including the trailing blank.
1203 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 /// Composes exactly one viewport of throwaway resize-drag content at the
1224 /// new geometry: the live band anchors to the bottom, then transcript
1225 /// entries are walked backward and rewrapped at `viewport.width` until
1226 /// the screen is full — O(viewport) work per drag frame, with the
1227 /// topmost entry sliced when it only partially fits. Retained transcript
1228 /// state is untouched, so the settle rebuild replays full history
1229 /// exactly once.
1230 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 // The live band, laid out exactly like the retained document's.
1244 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 // Transcript tail, bottom-up above the band.
1269 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 // Slice the bottom rows of the partially visible entry.
1283 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }More examples
307 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308 let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309 let tier = PerfTier::of(viewport.width);
310 if tier != self.tier {
311 self.tier = tier;
312 self.rebuild();
313 }
314 if rows != self.rows {
315 self.rows = rows;
316 // One query row plus the windowed list.
317 self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318 }
319 if self.ui.frame().size().width != viewport.width {
320 self.ui.resize(viewport.width);
321 }
322 Layer { frame: self.ui.frame(), options: &self.options, active: true }
323 }Sourcepub fn set_height(&mut self, id: &str, height: u16) -> bool
pub fn set_height(&mut self, id: &str, height: u16) -> bool
Sets a named component’s fixed height.
Examples found in repository?
More examples
192async fn main() -> io::Result<()> {
193 let mut app = AppOptions::new()
194 .mouse()
195 .probe(Duration::from_millis(150))
196 .graphics_with(forced_from_args)
197 .start(|env| build_ui(env.viewport, env.ctx))
198 .await?;
199 if app.caps().graphics != Graphics::Cells {
200 for (index, provider) in PROVIDERS.iter().enumerate() {
201 let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202 app.renderer_mut().register_image(
203 u32::try_from(index + 1).expect("provider count fits image IDs"),
204 png,
205 )?;
206 }
207 app.ui_mut().invalidate(SCROLL_ID);
208 }
209 show_stats(&mut app, None);
210 let mut chosen: Option<String> = None;
211 while let Some(event) = app.next().await? {
212 match event {
213 AppEvent::Resized(viewport) => {
214 app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215 },
216 AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217 _ => {},
218 }
219 show_stats(&mut app, chosen.as_deref());
220 }
221 Ok(())
222}112async fn main() -> io::Result<()> {
113 let mut app = AppOptions::new()
114 .mouse()
115 .quit([Key::Ctrl('c'), Key::Ctrl('q')])
116 .start(|env| build_ui(env.viewport, env.ctx))
117 .await?;
118 // The picker tab opens with the first model's details, like the chat
119 // overlay does.
120 picker::show_detail_on(app.ui_mut(), Some(0));
121
122 let mut synced = String::new();
123 let mut lab = anim::Lab::new();
124 let mut layers = Layers::default();
125 let mut next_step = tokio::time::Instant::now() + anim::AUTOPLAY_STEP;
126
127 loop {
128 let event = tokio::select! {
129 event = app.next() => match event? {
130 Some(event) => event,
131 None => break,
132 },
133 () = tokio::time::sleep_until(next_step) => {
134 if lab.autoplay && active_tab(app.ui()) == "Anim" {
135 lab.advance(app.ui_mut());
136 }
137 next_step += anim::AUTOPLAY_STEP;
138 continue;
139 },
140 };
141 match event {
142 AppEvent::Resized(viewport) => {
143 for pane in render::PANE_IDS {
144 app.ui_mut().set_height(pane, render::pane_height(viewport));
145 }
146 },
147 AppEvent::Key(key) => match active_tab(app.ui()).as_str() {
148 "Anim" => lab.handle_key(key, app.ui_mut()),
149 "Overlay" => match key {
150 Key::Ctrl('k') if layers.picker.is_none() => {
151 layers.picker = Some(overlay::show_picker(app.ui_mut()));
152 },
153 Key::Ctrl('g') => match layers.help.take() {
154 Some(id) => {
155 app.ui_mut().close_overlay(id);
156 },
157 None => layers.help = Some(overlay::show_help(app.ui_mut())),
158 },
159 _ => {},
160 },
161 _ => {},
162 },
163 // The Overlay tab's modal select committed a model.
164 AppEvent::Changed { id, value } if id == "model" => {
165 if let Some(overlay) = layers.picker.take() {
166 let label = overlay::MODELS
167 .iter()
168 .find(|(short, ..)| *short == value)
169 .map_or(value.as_str(), |(_, label, _)| label);
170 app.ui_mut().set_text("status", format!("model: {label}"));
171 app.ui_mut().close_overlay(overlay);
172 }
173 },
174 // The Picker tab's select moved: mirror the chat picker's
175 // facts-and-chips detail line.
176 AppEvent::Highlighted { id, value } if id == "models" => {
177 picker::show_detail_on(app.ui_mut(), value.as_str().parse().ok());
178 },
179 AppEvent::Filtered { id, value, .. } if id == "models" => {
180 let model = value.and_then(|value| value.as_str().parse().ok());
181 picker::show_detail_on(app.ui_mut(), model);
182 },
183 AppEvent::OverlayClosed(id) => {
184 if layers.picker == Some(id) {
185 layers.picker = None;
186 }
187 if layers.help == Some(id) {
188 layers.help = None;
189 }
190 },
191 _ => {},
192 }
193 render::sync_preview(app.ui_mut(), &mut synced);
194 // Reserve the Overlay tab's chords only while it is showing, so the
195 // focused composer can't spend Ctrl+K on kill-line — and the Live
196 // tab's editor keeps it.
197 let chords: &[Key] = if active_tab(app.ui()) == "Overlay" {
198 &[Key::Ctrl('k'), Key::Ctrl('g')]
199 } else {
200 &[]
201 };
202 app.set_hotkeys(chords.iter().copied());
203 }
204 Ok(())
205}Sourcepub fn present<W: Write>(
&mut self,
renderer: &mut Renderer<W>,
viewport_height: u16,
stable_rows: u16,
) -> Result<PaintStats>
pub fn present<W: Write>( &mut self, renderer: &mut Renderer<W>, viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>
Presents the retained frame without copying it, compositing every visible overlay above the document for this viewport.
§Errors
Propagates the renderer’s contract and writer errors.
Sourcepub fn preview<W: Write>(
&mut self,
renderer: &mut Renderer<W>,
viewport_height: u16,
leading_sequence: &str,
) -> Result<PaintStats>
pub fn preview<W: Write>( &mut self, renderer: &mut Renderer<W>, viewport_height: u16, leading_sequence: &str, ) -> Result<PaintStats>
Paints the composited viewport as a throwaway frame, leaving the renderer’s committed history untouched.
Alternate-screen presentation: hosts holding the alternate screen —
for a modal overlay or a fullscreen scene — repaint with this on every
damage or geometry change, and leading_sequence lets the buffer
switch ride the same synchronized update (see
Terminal::stage_alt_enter).
Damage is consumed exactly like Ui::present.
§Errors
Propagates the renderer’s contract and writer errors.
Sourcepub fn compose_resize_tail(&mut self, viewport: Size) -> Option<Frame>
pub fn compose_resize_tail(&mut self, viewport: Size) -> Option<Frame>
Composes one screen of throwaway drag content at viewport without
relayouting the retained tree.
The root’s tail children (crate::Component::resize_tail) are
composed bottom-up at the new width until the viewport is full —
O(viewport) work per drag frame. Nested vertical stacks (including
the implicit markup root) are walked recursively without ever
computing their full height, and a leaf taller than the space left
is sliced to its bottom rows, so work stays bounded by the screen
rather than document history. None means the root has no tail fast
path; callers fall back to a full Ui::resize. Child placement is
transient: the deferred Ui::resize at settle re-places
everything.
Sourcepub fn handle_key(&mut self, key: Key) -> UiEvent
pub fn handle_key(&mut self, key: Key) -> UiEvent
Routes a key to the layer holding the keyboard — the topmost visible
modal overlay, else the non-modal layer focused through
Ui::focus_overlay or a click — falling back to the base tree’s
focused component with focus-ring fallback.
Examples found in repository?
More examples
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 }Sourcepub fn handle_paste(&mut self, text: &str) -> UiEvent
pub fn handle_paste(&mut self, text: &str) -> UiEvent
Routes sanitized paste text to the focused component; the returned
event mirrors Ui::handle_key.
Examples found in repository?
More examples
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 }Sourcepub fn handle_paste_raw(&mut self, text: &str) -> UiEvent
pub fn handle_paste_raw(&mut self, text: &str) -> UiEvent
Routes paste text for verbatim insertion ([Component::paste_raw]):
no drop classification, no large-paste collapse. Backs the
Ctrl+Shift+V clipboard fallback.
Sourcepub fn focus_first(&mut self)
pub fn focus_first(&mut self)
Moves focus to the first focusable component when nothing is focused yet, activating keyboard chrome.
The entry half of a raw-frame layer host’s keyboard hand-off;
Ui::blur is the exit half. Retained stacks get both through
Ui::focus_overlay and Ui::blur_overlay.
Examples found in repository?
87 pub fn toggle(&mut self) {
88 self.open = !self.open;
89 if self.open {
90 self.focused = true;
91 self.ui.focus_first();
92 } else {
93 self.blur();
94 }
95 }
96
97 /// Routes a key while the rail holds the keyboard; `Esc` hands it back.
98 pub fn handle_key(&mut self, key: Key) {
99 if self.ui.handle_key(key) == UiEvent::Cancel {
100 self.blur();
101 }
102 }
103
104 /// Routes a mouse report through the rail's band. A click inside takes
105 /// the keyboard, a click outside returns it; `false` means the gesture
106 /// was not consumed and belongs to the transcript.
107 pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108 if !self.open {
109 return false;
110 }
111 if self
112 .ui
113 .handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114 .is_some()
115 {
116 if kind == Mouse::Click && !self.focused {
117 self.focused = true;
118 self.ui.focus_first();
119 }
120 true
121 } else {
122 if kind == Mouse::Click {
123 self.blur();
124 }
125 false
126 }
127 }Sourcepub fn blur(&mut self)
pub fn blur(&mut self)
Clears this tree’s focus, removing focus chrome and the caret.
Raw-frame layer hosts call this when the keyboard returns to the document, so no stale chrome suggests typing still lands here.
Examples found in repository?
56 pub fn new(model: &str, ctx: &UiContext) -> Self {
57 let options = OverlayOptions::default()
58 .anchor(OverlayAnchor::Right)
59 .width(Dim::Cells(WIDTH))
60 .non_modal()
61 .min_viewport(MIN_VIEWPORT);
62 let mut ui = build(model, ctx);
63 // The rail starts without the keyboard: no focus chrome or frame
64 // cursor until `toggle` or a click hands it over.
65 ui.blur();
66 Self { ui, options, open: true, focused: false, elapsed_seconds: 0, height: 0 }
67 }
68
69 /// Whether the rail composites for `viewport`.
70 const fn visible(&self, viewport: Size) -> bool {
71 self.open && viewport.width >= MIN_VIEWPORT.width && viewport.height >= MIN_VIEWPORT.height
72 }
73
74 /// Columns the rail reserves at `viewport`: its full width while
75 /// composited, zero when toggled off or gated out. The composer docks
76 /// its right-aligned chrome against the remaining width.
77 pub const fn reserved(&self, viewport: Size) -> u16 {
78 if self.visible(viewport) { WIDTH } else { 0 }
79 }
80
81 /// Whether the rail currently holds the keyboard.
82 pub const fn focused(&self) -> bool {
83 self.focused
84 }
85
86 /// `Ctrl+B`: opening hands the rail the keyboard, closing returns it.
87 pub fn toggle(&mut self) {
88 self.open = !self.open;
89 if self.open {
90 self.focused = true;
91 self.ui.focus_first();
92 } else {
93 self.blur();
94 }
95 }
96
97 /// Routes a key while the rail holds the keyboard; `Esc` hands it back.
98 pub fn handle_key(&mut self, key: Key) {
99 if self.ui.handle_key(key) == UiEvent::Cancel {
100 self.blur();
101 }
102 }
103
104 /// Routes a mouse report through the rail's band. A click inside takes
105 /// the keyboard, a click outside returns it; `false` means the gesture
106 /// was not consumed and belongs to the transcript.
107 pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108 if !self.open {
109 return false;
110 }
111 if self
112 .ui
113 .handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114 .is_some()
115 {
116 if kind == Mouse::Click && !self.focused {
117 self.focused = true;
118 self.ui.focus_first();
119 }
120 true
121 } else {
122 if kind == Mouse::Click {
123 self.blur();
124 }
125 false
126 }
127 }
128
129 /// Reflects a session model switch in the rail's model row.
130 pub fn set_model(&mut self, name: &str) {
131 self.ui.set_text("model", name);
132 }
133
134 /// The composited rail for this frame, laid out to the full viewport
135 /// height; `None` when toggled off or gated out by a small viewport.
136 /// Shrinking below the minimum blurs the rail so keys never route into
137 /// an invisible layer.
138 pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139 if !self.visible(viewport) {
140 if self.focused {
141 self.blur();
142 }
143 return None;
144 }
145 if self.height != viewport.height {
146 self.height = viewport.height;
147 self.ui.set_prop("rail", Prop::H, viewport.height);
148 self.ui.set_prop("body", Prop::H, viewport.height);
149 }
150 let seconds = elapsed.as_secs();
151 if seconds != self.elapsed_seconds {
152 self.elapsed_seconds = seconds;
153 self.ui.set_text("elapsed", elapsed_label(seconds));
154 }
155 Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156 }
157
158 fn blur(&mut self) {
159 self.focused = false;
160 self.ui.blur();
161 }Sourcepub fn handle_mouse(&mut self, x: u16, y: u16, mouse: Mouse) -> UiEvent
pub fn handle_mouse(&mut self, x: u16, y: u16, mouse: Mouse) -> UiEvent
Routes a mouse gesture in document cell coordinates; visible overlays occlude the document within their bounds.
Sourcepub fn handle_mouse_as_layer(
&mut self,
options: &OverlayOptions,
viewport: Size,
x: u16,
y: u16,
mouse: Mouse,
) -> Option<UiEvent>
pub fn handle_mouse_as_layer( &mut self, options: &OverlayOptions, viewport: Size, x: u16, y: u16, mouse: Mouse, ) -> Option<UiEvent>
Routes a viewport-coordinate mouse gesture into this tree when it is
composited as a raw crate::Layer under options — the raw-frame
host counterpart of the overlay stack’s own routing
(crate::Renderer::present_overlaid instead of
Ui::show_overlay). The band is resolved exactly as the
compositor resolves it, coordinates are translated into this tree’s
local cells, and a drag that started inside stays captured. None
means the gesture fell outside the layer (a Move outside also
clears hover chrome).
Examples found in repository?
More examples
107 pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108 if !self.open {
109 return false;
110 }
111 if self
112 .ui
113 .handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114 .is_some()
115 {
116 if kind == Mouse::Click && !self.focused {
117 self.focused = true;
118 self.ui.focus_first();
119 }
120 true
121 } else {
122 if kind == Mouse::Click {
123 self.blur();
124 }
125 false
126 }
127 }Sourcepub fn values(&self) -> Value
pub fn values(&self) -> Value
Collects values from every visible component of the base tree;
overlay trees report through Ui::overlay.
Examples found in repository?
More examples
Sourcepub fn show_overlay(
&mut self,
root: impl IntoComponent,
options: OverlayOptions,
) -> OverlayId
pub fn show_overlay( &mut self, root: impl IntoComponent, options: OverlayOptions, ) -> OverlayId
Stacks an overlay tree above the document.
The overlay is its own retained Ui: address it through
Ui::overlay / Ui::overlay_mut for set_text, values, and
friends. Placement follows options against the viewport of each
Ui::present; the layer composites above the document and never
enters native terminal scrollback. Explicit z orders layers regardless
of creation order; later overlays stack on top among equal z. The
topmost visible modal overlay receives every key and paste until
closed or hidden; a non-modal layer (OverlayOptions::non_modal)
leaves the keyboard with the base tree until focused through
Ui::focus_overlay or a click inside its band.
§Panics
Panics when called on an overlay’s own tree: overlays stack on the
presenting Ui.
Examples found in repository?
35pub(crate) fn show_picker(ui: &mut Ui) -> OverlayId {
36 ui.show_overlay(
37 dom! {
38 <box border=round title="Switch Model">
39 <col gap=1>
40 <text dim>{"Session-only switch — role models stay unchanged"}</text>
41 <select id=model>
42 for (value, label, stats) in MODELS {
43 <option value={value} desc={stats}>{label}</option>
44 }
45 </select>
46 </col>
47 </box>
48 },
49 OverlayOptions::default()
50 .anchor(OverlayAnchor::Center)
51 .width(Dim::Pct(70))
52 .min_width(48)
53 .max_height(Dim::Pct(60))
54 .min_viewport(Size::new(40, 8)),
55 )
56}
57
58/// Opens the keybinding help layer.
59pub(crate) fn show_help(ui: &mut Ui) -> OverlayId {
60 ui.show_overlay(
61 dom! {
62 <box border=round title="Help">
63 <col>
64 <text>{"Ctrl+K switch model"}</text>
65 <text>{"Ctrl+G toggle this help"}</text>
66 <text>{"Esc close top layer"}</text>
67 <text>{"Ctrl+C quit"}</text>
68 </col>
69 </box>
70 },
71 OverlayOptions::default()
72 .anchor(OverlayAnchor::BottomRight)
73 .width(Dim::Cells(30))
74 .margin(OverlayMargin::uniform(1)),
75 )
76}Sourcepub fn close_overlay(&mut self, id: OverlayId) -> bool
pub fn close_overlay(&mut self, id: OverlayId) -> bool
Removes an overlay; the next present repaints the document beneath it.
Returns false for an unknown id.
Examples found in repository?
112async fn main() -> io::Result<()> {
113 let mut app = AppOptions::new()
114 .mouse()
115 .quit([Key::Ctrl('c'), Key::Ctrl('q')])
116 .start(|env| build_ui(env.viewport, env.ctx))
117 .await?;
118 // The picker tab opens with the first model's details, like the chat
119 // overlay does.
120 picker::show_detail_on(app.ui_mut(), Some(0));
121
122 let mut synced = String::new();
123 let mut lab = anim::Lab::new();
124 let mut layers = Layers::default();
125 let mut next_step = tokio::time::Instant::now() + anim::AUTOPLAY_STEP;
126
127 loop {
128 let event = tokio::select! {
129 event = app.next() => match event? {
130 Some(event) => event,
131 None => break,
132 },
133 () = tokio::time::sleep_until(next_step) => {
134 if lab.autoplay && active_tab(app.ui()) == "Anim" {
135 lab.advance(app.ui_mut());
136 }
137 next_step += anim::AUTOPLAY_STEP;
138 continue;
139 },
140 };
141 match event {
142 AppEvent::Resized(viewport) => {
143 for pane in render::PANE_IDS {
144 app.ui_mut().set_height(pane, render::pane_height(viewport));
145 }
146 },
147 AppEvent::Key(key) => match active_tab(app.ui()).as_str() {
148 "Anim" => lab.handle_key(key, app.ui_mut()),
149 "Overlay" => match key {
150 Key::Ctrl('k') if layers.picker.is_none() => {
151 layers.picker = Some(overlay::show_picker(app.ui_mut()));
152 },
153 Key::Ctrl('g') => match layers.help.take() {
154 Some(id) => {
155 app.ui_mut().close_overlay(id);
156 },
157 None => layers.help = Some(overlay::show_help(app.ui_mut())),
158 },
159 _ => {},
160 },
161 _ => {},
162 },
163 // The Overlay tab's modal select committed a model.
164 AppEvent::Changed { id, value } if id == "model" => {
165 if let Some(overlay) = layers.picker.take() {
166 let label = overlay::MODELS
167 .iter()
168 .find(|(short, ..)| *short == value)
169 .map_or(value.as_str(), |(_, label, _)| label);
170 app.ui_mut().set_text("status", format!("model: {label}"));
171 app.ui_mut().close_overlay(overlay);
172 }
173 },
174 // The Picker tab's select moved: mirror the chat picker's
175 // facts-and-chips detail line.
176 AppEvent::Highlighted { id, value } if id == "models" => {
177 picker::show_detail_on(app.ui_mut(), value.as_str().parse().ok());
178 },
179 AppEvent::Filtered { id, value, .. } if id == "models" => {
180 let model = value.and_then(|value| value.as_str().parse().ok());
181 picker::show_detail_on(app.ui_mut(), model);
182 },
183 AppEvent::OverlayClosed(id) => {
184 if layers.picker == Some(id) {
185 layers.picker = None;
186 }
187 if layers.help == Some(id) {
188 layers.help = None;
189 }
190 },
191 _ => {},
192 }
193 render::sync_preview(app.ui_mut(), &mut synced);
194 // Reserve the Overlay tab's chords only while it is showing, so the
195 // focused composer can't spend Ctrl+K on kill-line — and the Live
196 // tab's editor keeps it.
197 let chords: &[Key] = if active_tab(app.ui()) == "Overlay" {
198 &[Key::Ctrl('k'), Key::Ctrl('g')]
199 } else {
200 &[]
201 };
202 app.set_hotkeys(chords.iter().copied());
203 }
204 Ok(())
205}Sourcepub fn close_top_overlay(&mut self) -> Option<OverlayId>
pub fn close_top_overlay(&mut self) -> Option<OverlayId>
Removes the topmost layer (highest z, most recent among ties), if any.
This pops the stack regardless of modality; for dismissing the layer
that emitted a UiEvent::Cancel, use Ui::close_active_overlay —
the stack top may be a non-modal pane sitting above the modal that
routed the key.
Sourcepub fn close_active_overlay(&mut self) -> Option<OverlayId>
pub fn close_active_overlay(&mut self) -> Option<OverlayId>
Closes the layer currently receiving keys — the topmost visible modal overlay, else the focused non-modal pane — returning its id.
The manual-host counterpart of the crate::App cancel policy:
after a UiEvent::Cancel surfaces from the overlay stack, this
dismisses the layer that emitted it, even when a higher-z non-modal
pane stacks above it.
Temporarily hides or reshows an overlay without discarding its state.
Hiding the layer holding the keyboard returns keys to the base tree.
Returns false for an unknown id.
Whether an overlay is temporarily hidden; None for an unknown id.
Sourcepub fn overlay_mut(&mut self, id: OverlayId) -> Option<&mut Self>
pub fn overlay_mut(&mut self, id: OverlayId) -> Option<&mut Self>
Mutably borrows an overlay’s retained tree for set_text and friends.
Sourcepub fn has_overlay(&self) -> bool
pub fn has_overlay(&self) -> bool
Whether any modal overlay is currently visible (not hidden or gated).
While one is, crate::App holds the terminal’s alternate screen
(vim/less idiom): the whole composited viewport paints there with
mouse tracking active, and the untouched main screen restores when
the last visible modal overlay closes. Non-modal layers never hold:
they composite into the live inline viewport while the document
keeps committing to native scrollback beneath them.
Sourcepub fn top_overlay(&self) -> Option<OverlayId>
pub fn top_overlay(&self) -> Option<OverlayId>
Identity of the layer receiving keys — the topmost visible modal overlay, else the focused non-modal layer.
Sourcepub fn focus_overlay(&mut self, id: OverlayId) -> bool
pub fn focus_overlay(&mut self, id: OverlayId) -> bool
Directs keys and paste to a layer until it is blurred, closed, or hidden, or a modal overlay opens above it.
The layer’s focus ring activates so its chrome shows where typing
lands. Intended for non-modal layers — a modal overlay already
captures the keyboard while topmost. Returns false for an unknown
id.
Sourcepub fn blur_overlay(&mut self) -> Option<OverlayId>
pub fn blur_overlay(&mut self) -> Option<OverlayId>
Returns the keyboard to the base tree, clearing the previously focused layer’s own focus so no stale chrome (or hardware caret) suggests typing still lands there. Returns the layer that had key focus.
Sourcepub const fn focused_overlay(&self) -> Option<OverlayId>
pub const fn focused_overlay(&self) -> Option<OverlayId>
The non-modal layer holding the keyboard through
Ui::focus_overlay or a click, if any.
Auto Trait Implementations§
impl !Freeze for Ui
impl !RefUnwindSafe for Ui
impl !Send for Ui
impl !Sync for Ui
impl !UnwindSafe for Ui
impl Unpin for Ui
impl UnsafeUnpin for Ui
Blanket Implementations§
Source§impl<T> 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> 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> 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