返回 CodeWhale
display.rs
1 //! Gallery entries for package Display: the receipt, diff, tree and
2 //! progress. The toast's entries live in `toast.rs`.
3
4 use std::ops::Range;
5
6 use ratatui::{buffer::Buffer, layout::Rect};
7
8 use super::Entry;
9 use crate::{
10 CountBar, Diff, DiffGutter, DiffWrap, Paint, Receipt, ReceiptColumn, ReceiptDensity,
11 ReceiptTable, ReceiptValue, Role, State, Theme, TreeNode, TreeState, WorkflowTree,
12 parse_unified,
13 };
14 use std::time::Duration;
15
16 use crate::Cost;
17
18 // ---------------------------------------------------------------------------
19 // Receipts
20 // ---------------------------------------------------------------------------
21
22 fn columns() -> Vec<ReceiptColumn> {
23 vec![
24 ReceiptColumn::new("Time").essential(),
25 ReceiptColumn::new("Tokens"),
26 ReceiptColumn::new("Cost").essential(),
27 ]
28 }
29
30 fn secs(s: u64) -> ReceiptValue {
31 ReceiptValue::Duration(Some(Duration::from_secs(s)))
32 }
33
34 /// Language text is outside the ASCII charter, but an ASCII-safe frame must
35 /// stay ASCII, so the fixtures swap in a Latin line there.
36 fn words(ascii: bool, text: &'static str, latin: &'static str) -> &'static str {
37 if ascii { latin } else { text }
38 }
39
40 fn rows(ascii: bool) -> Vec<Receipt> {
41 vec![
42 Receipt::new(State::Done, "Edited summary.md").values([
43 secs(4),
44 ReceiptValue::Tokens(Some(1_240)),
45 ReceiptValue::Cost(Some(Cost::usd_cents(1))),
46 ]),
47 Receipt::new(State::Failed, "Ran cargo test")
48 .values([
49 secs(72),
50 ReceiptValue::Tokens(Some(12_400)),
51 ReceiptValue::Cost(None),
52 ])
53 .opens(),
54 Receipt::new(State::Done, "Read 14 files").values([
55 ReceiptValue::Duration(Some(Duration::from_millis(850))),
56 ReceiptValue::Tokens(None),
57 ReceiptValue::Cost(Some(Cost::usd_cents(0))),
58 ]),
59 Receipt::new(State::Working, "Writing CHANGELOG.md").values([
60 secs(38),
61 ReceiptValue::Tokens(Some(96_300)),
62 ReceiptValue::Cost(Some(Cost::new(382_000, "$").estimate())),
63 ]),
64 Receipt::new(
65 State::Stopped,
66 words(
67 ascii,
68 "整理发布说明,翻译成中文",
69 "Translate the release notes",
70 ),
71 )
72 .values([
73 secs(3_720),
74 ReceiptValue::Tokens(Some(1_950_000)),
75 ReceiptValue::Cost(Some(Cost::usd_cents(1_240))),
76 ]),
77 ]
78 }
79
80 fn receipt_row(area: Rect, buf: &mut Buffer, theme: &Theme) {
81 Receipt::new(State::Done, "Edited summary.md")
82 .values([
83 secs(4),
84 ReceiptValue::Tokens(Some(1_240)),
85 ReceiptValue::Cost(Some(Cost::usd_cents(1))),
86 ])
87 .opens()
88 .paint(area, buf, theme);
89 }
90
91 fn table(density: Option<ReceiptDensity>, ascii: bool) -> ReceiptTable {
92 let table = ReceiptTable::new(columns(), rows(ascii))
93 .label_header("Action")
94 .totals();
95 match density {
96 Some(d) => table.density(d),
97 None => table,
98 }
99 }
100
101 fn receipt_table(area: Rect, buf: &mut Buffer, theme: &Theme) {
102 table(None, theme.ascii()).paint(area, buf, theme);
103 }
104
105 fn receipt_table_compact(area: Rect, buf: &mut Buffer, theme: &Theme) {
106 table(Some(ReceiptDensity::Compact), theme.ascii()).paint(area, buf, theme);
107 }
108
109 fn receipt_table_minimal(area: Rect, buf: &mut Buffer, theme: &Theme) {
110 table(Some(ReceiptDensity::Minimal), theme.ascii()).paint(area, buf, theme);
111 }
112
113 fn receipt_table_clipped(area: Rect, buf: &mut Buffer, theme: &Theme) {
114 table(None, theme.ascii()).paint(area, buf, theme);
115 }
116
117 // ---------------------------------------------------------------------------
118 // Diff
119 // ---------------------------------------------------------------------------
120
121 const PATCH: &str = concat!(
122 "diff --git a/src/lib.rs b/src/lib.rs\n",
123 "index 3f2a1c9..8b7d4e0 100644\n",
124 "--- a/src/lib.rs\n",
125 "+++ b/src/lib.rs\n",
126 "@@ -10,5 +10,6 @@ fn main() {\n",
127 " fn main() {\n",
128 " let name = \"whale\";\n",
129 "- let depth = 1;\n",
130 "+ let depth = 2;\n",
131 "+ let pods = 3;\n",
132 " println!(\"{name}\");\n",
133 " }\n",
134 "@@ -40,3 +41,3 @@ impl Pod {\n",
135 " \tfn describe(&self) -> String {\n",
136 "-\t\tformat!(\"a pod of {} whales swimming together in the deep blue ocean at night\", self.count)\n",
137 "+\t\tformat!(\"a pod of {} whales swimming together in the deep blue ocean at dawn\", self.count)\n",
138 " \t}\n",
139 );
140
141 /// A hunk of language text, for terminals that can show it.
142 const CJK_HUNK: &str = concat!(
143 "@@ -70,2 +71,2 @@ impl Pod {\n",
144 "- let note = \"鲸鱼在深海里游动\";\n",
145 "+ let note = \"鲸鱼在深蓝的海里游动\";\n",
146 " }\n",
147 );
148
149 fn diff_lines<'a>(
150 patch: &'a str,
151 old: &'a [Range<usize>],
152 new: &'a [Range<usize>],
153 ) -> Vec<crate::DiffLine<'a>> {
154 let mut lines = parse_unified(patch);
155 for line in &mut lines {
156 if line.text.contains("at night") {
157 line.emphasis = old;
158 } else if line.text.contains("at dawn") {
159 line.emphasis = new;
160 }
161 }
162 lines
163 }
164
165 /// Where `word` sits in the text of the patch line containing `marker`
166 /// (the text starts after the sign).
167 fn word_range(marker: &str, word: &str) -> Vec<Range<usize>> {
168 let text = PATCH
169 .lines()
170 .find(|l| l.contains(marker))
171 .map_or("", |l| &l[1..]);
172 let at = text.find(word).unwrap_or(0);
173 std::iter::once(at..at + word.len()).collect()
174 }
175
176 fn paint_diff(area: Rect, buf: &mut Buffer, theme: &Theme, build: impl Fn(Diff) -> Diff) {
177 let old = word_range("at night", "night");
178 let new = word_range("at dawn", "dawn");
179 let patch = if theme.ascii() {
180 PATCH.to_string()
181 } else {
182 format!("{PATCH}{CJK_HUNK}")
183 };
184 build(Diff::new(diff_lines(&patch, &old, &new))).paint(area, buf, theme);
185 }
186
187 fn diff_truncated(area: Rect, buf: &mut Buffer, theme: &Theme) {
188 paint_diff(area, buf, theme, |d| d);
189 }
190
191 fn diff_wrapped(area: Rect, buf: &mut Buffer, theme: &Theme) {
192 paint_diff(area, buf, theme, |d| d.wrap(DiffWrap::Wrap));
193 }
194
195 fn diff_no_numbers(area: Rect, buf: &mut Buffer, theme: &Theme) {
196 paint_diff(area, buf, theme, |d| d.gutter(DiffGutter::Off).scroll(4));
197 }
198
199 /// A stand-in for a caller's highlighter: keywords in `Primary`, string
200 /// literals in `Muted`.
201 fn keywords(line: &str) -> Vec<(Range<usize>, Role)> {
202 let mut out = Vec::new();
203 for word in ["fn", "let", "impl", "format!", "println!"] {
204 let mut from = 0;
205 while let Some(at) = line[from..].find(word) {
206 let start = from + at;
207 out.push((start..start + word.len(), Role::Primary));
208 from = start + word.len();
209 }
210 }
211 if let (Some(open), Some(close)) = (line.find('"'), line.rfind('"'))
212 && close > open
213 {
214 out.push((open..close + 1, Role::Muted));
215 }
216 out
217 }
218
219 fn diff_highlighted(area: Rect, buf: &mut Buffer, theme: &Theme) {
220 paint_diff(area, buf, theme, |d| d.highlight(keywords).scroll(4));
221 }
222
223 // ---------------------------------------------------------------------------
224 // Tree
225 // ---------------------------------------------------------------------------
226
227 fn run() -> Vec<TreeNode> {
228 vec![
229 TreeNode::new(State::Working, "Release notes")
230 .detail("4m 06s")
231 .receipt("$0.38")
232 .children([
233 TreeNode::new(State::Done, "Gather")
234 .detail("3 agents")
235 .receipt("1m 12s")
236 .collapsed(true)
237 .children([
238 TreeNode::new(State::Done, "reader-1").receipt("40 s"),
239 TreeNode::new(State::Done, "reader-2").receipt("52 s"),
240 TreeNode::new(State::Done, "reader-3").receipt("1m 12s"),
241 ]),
242 TreeNode::new(State::Working, "Draft")
243 .detail("2 of 4 agents")
244 .children([
245 TreeNode::new(State::Working, "writer-1")
246 .detail("Editing CHANGELOG.md")
247 .receipt("38 s"),
248 TreeNode::new(State::Failed, "writer-2")
249 .detail("tests did not pass")
250 .opens(),
251 TreeNode::new(State::Ready, "writer-3"),
252 ]),
253 TreeNode::new(State::Ready, "Review"),
254 ]),
255 ]
256 }
257
258 fn workflow_tree(area: Rect, buf: &mut Buffer, theme: &Theme) {
259 let nodes = run();
260 WorkflowTree::new(&nodes).summary().paint(area, buf, theme);
261 }
262
263 fn workflow_tree_selected(area: Rect, buf: &mut Buffer, theme: &Theme) {
264 let nodes = run();
265 let mut state = TreeState::new();
266 state.selected = 4;
267 WorkflowTree::new(&nodes)
268 .state(&state)
269 .summary()
270 .paint(area, buf, theme);
271 }
272
273 fn workflow_tree_clipped(area: Rect, buf: &mut Buffer, theme: &Theme) {
274 let nodes = run();
275 let mut state = TreeState::new();
276 state.selected = 1;
277 WorkflowTree::new(&nodes)
278 .state(&state)
279 .summary()
280 .paint(area, buf, theme);
281 }
282
283 fn workflow_tree_long(area: Rect, buf: &mut Buffer, theme: &Theme) {
284 let ascii = theme.ascii();
285 let nodes = vec![
286 TreeNode::new(
287 State::NeedsYou,
288 words(
289 ascii,
290 "整理发布说明并翻译成中文,然后提交审阅",
291 "Tidy the release notes, translate them, then send for review",
292 ),
293 )
294 .detail(words(
295 ascii,
296 "等待你批准运行 cargo publish,这一步不能撤销",
297 "Waiting for you to approve cargo publish, which cannot be undone",
298 ))
299 .child(TreeNode::new(
300 State::Unknown,
301 "review-with-a-very-long-agent-name",
302 )),
303 ];
304 WorkflowTree::new(&nodes).paint(area, buf, theme);
305 }
306
307 // ---------------------------------------------------------------------------
308 // Progress
309 // ---------------------------------------------------------------------------
310
311 fn count_bars(area: Rect, buf: &mut Buffer, theme: &Theme) {
312 let bars = [
313 CountBar::new(3, 5).label("phases"),
314 CountBar::new(0, 5).label("files"),
315 CountBar::new(5, 5).label("phases").state(State::Done),
316 CountBar::new(7, 5).label("checks").state(State::NeedsYou),
317 CountBar::unknown(12).label("files"),
318 CountBar::new(0, 0).label("tasks").state(State::Ready),
319 ];
320 for (row, bar) in bars.iter().enumerate() {
321 let Some(y) = area
322 .y
323 .checked_add(row as u16)
324 .filter(|y| *y < area.bottom())
325 else {
326 break;
327 };
328 bar.paint(Rect::new(area.x, y, area.width, 1), buf, theme);
329 }
330 }
331
332 pub(crate) fn entries() -> Vec<Entry> {
333 vec![
334 Entry {
335 name: "receipt-row",
336 width: 60,
337 height: 1,
338 draw: receipt_row,
339 },
340 Entry {
341 name: "receipt-table",
342 width: 80,
343 height: 8,
344 draw: receipt_table,
345 },
346 Entry {
347 name: "receipt-table-compact",
348 width: 56,
349 height: 7,
350 draw: receipt_table_compact,
351 },
352 Entry {
353 name: "receipt-table-minimal",
354 width: 40,
355 height: 7,
356 draw: receipt_table_minimal,
357 },
358 Entry {
359 name: "receipt-table-clipped",
360 width: 80,
361 height: 5,
362 draw: receipt_table_clipped,
363 },
364 Entry {
365 name: "diff",
366 width: 80,
367 height: 22,
368 draw: diff_truncated,
369 },
370 Entry {
371 name: "diff-wrapped",
372 width: 48,
373 height: 26,
374 draw: diff_wrapped,
375 },
376 Entry {
377 name: "diff-no-numbers",
378 width: 60,
379 height: 10,
380 draw: diff_no_numbers,
381 },
382 Entry {
383 name: "diff-highlighted",
384 width: 80,
385 height: 12,
386 draw: diff_highlighted,
387 },
388 Entry {
389 name: "workflow-tree",
390 width: 64,
391 height: 9,
392 draw: workflow_tree,
393 },
394 Entry {
395 name: "workflow-tree-selected",
396 width: 64,
397 height: 9,
398 draw: workflow_tree_selected,
399 },
400 Entry {
401 name: "workflow-tree-clipped",
402 width: 64,
403 height: 6,
404 draw: workflow_tree_clipped,
405 },
406 Entry {
407 name: "workflow-tree-long",
408 width: 48,
409 height: 3,
410 draw: workflow_tree_long,
411 },
412 Entry {
413 name: "count-bars",
414 width: 60,
415 height: 6,
416 draw: count_bars,
417 },
418 ]
419 }
420
420 lines RUST