返回 CodeWhale
artifacts.rs
1 //! A flat shelf of caller-reported results, without a filesystem or run loop.
2 //!
3 //! Names lead, actions remain discoverable, and kind, state and receipt facts
4 //! share a compact second row. The caller owns selection and what an action
5 //! does. Rows also implement [`ListRow`] for existing selectable host lists.
6
7 use std::borrow::Cow;
8
9 use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::Modifier,
13 text::{Line, Span},
14 };
15 use unicode_segmentation::UnicodeSegmentation;
16
17 use crate::{
18 ListRow, ListRowState, ListState, Paint, ReceiptValue, Role, State, StatusMark, Theme, glyphs,
19 text,
20 };
21
22 use super::workbench::row;
23
24 /// The kind reported by the host; no path, URL or command is inspected.
25 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
26 pub enum ArtifactKind {
27 File,
28 Review,
29 Run,
30 Link,
31 }
32
33 /// Localized shelf and kind words. State words come from [`StatusMark`].
34 #[derive(Clone, Debug, PartialEq, Eq)]
35 pub struct ArtifactWords {
36 pub file: Cow<'static, str>,
37 pub review: Cow<'static, str>,
38 pub run: Cow<'static, str>,
39 pub link: Cow<'static, str>,
40 pub empty: Cow<'static, str>,
41 pub omitted: Cow<'static, str>,
42 }
43
44 impl Default for ArtifactWords {
45 fn default() -> Self {
46 Self {
47 file: Cow::Borrowed("File"),
48 review: Cow::Borrowed("Review"),
49 run: Cow::Borrowed("Run"),
50 link: Cow::Borrowed("Link"),
51 empty: Cow::Borrowed("No artifacts reported"),
52 omitted: Cow::Borrowed("artifacts not fully shown"),
53 }
54 }
55 }
56
57 impl ArtifactWords {
58 fn kind(&self, kind: ArtifactKind) -> &str {
59 match kind {
60 ArtifactKind::File => &self.file,
61 ArtifactKind::Review => &self.review,
62 ArtifactKind::Run => &self.run,
63 ArtifactKind::Link => &self.link,
64 }
65 }
66 }
67
68 /// A resulting file, review, run or link over caller-owned facts.
69 ///
70 /// `detail` may be a purpose, a relative path, or a review summary. It is
71 /// display text, never a destination this component opens. An unknown receipt
72 /// remains unknown through [`ReceiptValue::render`], rather than becoming zero.
73 #[derive(Clone, Debug)]
74 pub struct Artifact<'a> {
75 pub name: Cow<'a, str>,
76 pub kind: ArtifactKind,
77 pub status: StatusMark,
78 pub detail: Option<Cow<'a, str>>,
79 pub action: Option<Cow<'a, str>>,
80 pub receipt: Option<(Cow<'a, str>, ReceiptValue)>,
81 pub words: Option<&'a ArtifactWords>,
82 }
83
84 impl<'a> Artifact<'a> {
85 #[must_use]
86 pub fn new(name: impl Into<Cow<'a, str>>, kind: ArtifactKind, state: State) -> Self {
87 Self {
88 name: name.into(),
89 kind,
90 status: StatusMark::new(state),
91 detail: None,
92 action: None,
93 receipt: None,
94 words: None,
95 }
96 }
97
98 #[must_use]
99 pub fn detail(mut self, detail: impl Into<Cow<'a, str>>) -> Self {
100 self.detail = Some(detail.into());
101 self
102 }
103
104 #[must_use]
105 pub fn action(mut self, action: impl Into<Cow<'a, str>>) -> Self {
106 self.action = Some(action.into());
107 self
108 }
109
110 #[must_use]
111 pub fn receipt(mut self, label: impl Into<Cow<'a, str>>, value: ReceiptValue) -> Self {
112 self.receipt = Some((label.into(), value));
113 self
114 }
115
116 #[must_use]
117 pub fn status_word(mut self, word: impl Into<Cow<'static, str>>) -> Self {
118 self.status = self.status.word(word);
119 self
120 }
121
122 #[must_use]
123 pub fn words(mut self, words: &'a ArtifactWords) -> Self {
124 self.words = Some(words);
125 self
126 }
127
128 fn draw(
129 &self,
130 area: Rect,
131 buf: &mut Buffer,
132 theme: &Theme,
133 selected: bool,
134 words: &ArtifactWords,
135 ) {
136 let area = area.intersection(buf.area);
137 if area.is_empty() {
138 return;
139 }
140 let words = self.words.unwrap_or(words);
141 let width = usize::from(area.width);
142 let name = safe(&self.name, theme.ascii());
143 let action = self
144 .action
145 .as_deref()
146 .map(|a| safe(a, theme.ascii()))
147 .filter(|a| !a.is_empty());
148 // The action gets at most a third of a useful row; the result's name
149 // keeps priority. Both cuts remain visible through the shared ellipsis.
150 let action = action
151 .filter(|_| width >= 12)
152 .map(|a| text::truncate(&a, width / 3, theme.ascii()).into_owned());
153 let action_width = action.as_deref().map_or(0, |a| text::width(a) + 2);
154 let name = text::truncate(&name, width.saturating_sub(action_width), theme.ascii());
155 let ink = if selected {
156 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD)
157 } else {
158 theme.fg(Role::Foreground)
159 };
160 row(
161 Rect { height: 1, ..area },
162 buf,
163 &Line::from(Span::styled(name.into_owned(), ink)),
164 );
165 if let Some(action) = action {
166 let width = u16::try_from(text::width(&action))
167 .unwrap_or(area.width)
168 .min(area.width);
169 row(
170 Rect {
171 x: area.right().saturating_sub(width),
172 width,
173 height: 1,
174 ..area
175 },
176 buf,
177 &Line::from(Span::styled(action, theme.fg(Role::Primary))),
178 );
179 }
180 if area.height < 2 {
181 return;
182 }
183 let separator = format!(" {} ", glyphs::pick(glyphs::NEUTRAL, theme.ascii()));
184 let mut facts: Vec<_> = self
185 .status
186 .spans(theme)
187 .into_iter()
188 .map(|s| Span::styled(safe(&s.content, theme.ascii()), s.style))
189 .collect();
190 facts.push(Span::styled(separator.clone(), theme.fg(Role::Dim)));
191 facts.push(Span::styled(
192 safe(words.kind(self.kind), theme.ascii()),
193 theme.fg(Role::Muted),
194 ));
195 if let Some((label, value)) = &self.receipt {
196 facts.push(Span::styled(separator.clone(), theme.fg(Role::Dim)));
197 let label = safe(label, theme.ascii());
198 if !label.is_empty() {
199 facts.push(Span::styled(format!("{label} "), theme.fg(Role::Muted)));
200 }
201 let role = if value.is_unknown() {
202 Role::Hint
203 } else {
204 Role::Foreground
205 };
206 facts.push(Span::styled(
207 safe(&value.render(theme.ascii()), theme.ascii()),
208 theme.fg(role),
209 ));
210 }
211 if let Some(detail) = &self.detail {
212 facts.push(Span::styled(separator, theme.fg(Role::Dim)));
213 facts.push(Span::styled(
214 safe(detail, theme.ascii()),
215 theme.fg(Role::Muted),
216 ));
217 }
218 row(
219 Rect {
220 y: area.y + 1,
221 height: 1,
222 ..area
223 },
224 buf,
225 &Line::from(fit(facts, width, theme)),
226 );
227 }
228 }
229
230 impl Paint for Artifact<'_> {
231 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
232 self.draw(area, buf, theme, false, &ArtifactWords::default());
233 }
234
235 fn height(&self, width: u16, _theme: &Theme) -> u16 {
236 if width == 0 { 0 } else { 2 }
237 }
238 }
239
240 impl ListRow for Artifact<'_> {
241 fn height(&self, _width: u16) -> u16 {
242 2
243 }
244
245 fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) {
246 self.draw(area, buf, theme, state.selected, &ArtifactWords::default());
247 }
248 }
249
250 /// A flat, selectable artifact shelf. Selection and scroll offset belong to
251 /// the caller; existing [`ListState`] supplies the viewport arithmetic.
252 ///
253 /// The omission rail counts supplied rows not completely visible vertically
254 /// plus `omitted_artifacts` already withheld by the host. Horizontal cuts carry
255 /// their own ellipsis. At a one-row viewport an omission count takes priority.
256 #[derive(Clone, Debug)]
257 pub struct ArtifactShelf<'a> {
258 pub artifacts: Vec<Artifact<'a>>,
259 pub selected: Option<usize>,
260 pub offset: usize,
261 pub focused: bool,
262 pub omitted_artifacts: usize,
263 pub words: Option<&'a ArtifactWords>,
264 }
265
266 impl<'a> ArtifactShelf<'a> {
267 #[must_use]
268 pub fn new(artifacts: Vec<Artifact<'a>>) -> Self {
269 Self {
270 artifacts,
271 selected: None,
272 offset: 0,
273 focused: false,
274 omitted_artifacts: 0,
275 words: None,
276 }
277 }
278
279 #[must_use]
280 pub fn selected(mut self, selected: usize) -> Self {
281 self.selected = Some(selected);
282 self
283 }
284
285 #[must_use]
286 pub fn offset(mut self, offset: usize) -> Self {
287 self.offset = offset;
288 self
289 }
290
291 #[must_use]
292 pub fn focused(mut self, focused: bool) -> Self {
293 self.focused = focused;
294 self
295 }
296
297 #[must_use]
298 pub fn omitted(mut self, omitted: usize) -> Self {
299 self.omitted_artifacts = omitted;
300 self
301 }
302
303 #[must_use]
304 pub fn words(mut self, words: &'a ArtifactWords) -> Self {
305 self.words = Some(words);
306 self
307 }
308
309 fn placement(&self, area: Rect) -> (usize, u16, bool) {
310 let selected = self.selected.filter(|&i| i < self.artifacts.len());
311 let offset_for = |height| {
312 if let Some(selected) = selected {
313 ListState {
314 selected,
315 offset: self.offset,
316 }
317 .visible_offset(self.artifacts.len(), height, |_| 2)
318 } else {
319 self.offset.min(self.artifacts.len().saturating_sub(1))
320 }
321 };
322 let offset = offset_for(area.height);
323 let overflow = self.omitted_artifacts > 0
324 || offset > 0
325 || self
326 .artifacts
327 .len()
328 .saturating_sub(offset)
329 .saturating_mul(2)
330 > usize::from(area.height);
331 let body = area.height.saturating_sub(u16::from(overflow));
332 let offset = if overflow { offset_for(body) } else { offset };
333 (offset, body, overflow)
334 }
335
336 /// The caller can route a pointer click to the same action as a key.
337 /// Pass the actual visible viewport, intersected with the paint buffer.
338 #[must_use]
339 pub fn row_at(&self, area: Rect, column: u16, row: u16) -> Option<usize> {
340 if area.is_empty() || !area.contains((column, row).into()) {
341 return None;
342 }
343 let (offset, body, _) = self.placement(area);
344 let y = row - area.y;
345 if y >= body {
346 return None;
347 }
348 let index = offset.saturating_add(usize::from(y / 2));
349 (index < self.artifacts.len()).then_some(index)
350 }
351 }
352
353 impl Paint for ArtifactShelf<'_> {
354 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
355 let area = area.intersection(buf.area);
356 if area.is_empty() {
357 return;
358 }
359 let default_words = ArtifactWords::default();
360 let words = self.words.unwrap_or(&default_words);
361 if self.artifacts.is_empty() && self.omitted_artifacts == 0 {
362 let empty = safe(&words.empty, theme.ascii());
363 row(
364 Rect { height: 1, ..area },
365 buf,
366 &Line::from(Span::styled(
367 text::truncate_words(&empty, usize::from(area.width), theme.ascii())
368 .into_owned(),
369 theme.fg(Role::Hint),
370 )),
371 );
372 return;
373 }
374 let (offset, body_height, overflow) = self.placement(area);
375 let gutter = area.width.min(2);
376 let mut painted = 0usize;
377 let mut y = 0u16;
378 for (index, artifact) in self.artifacts.iter().enumerate().skip(offset) {
379 if y >= body_height {
380 break;
381 }
382 let height = 2.min(body_height - y);
383 let row_area = Rect {
384 y: area.y + y,
385 height,
386 ..area
387 };
388 let selected = self.selected == Some(index);
389 if selected {
390 buf.set_style(row_area, theme.bg(Role::Selected));
391 let marker = glyphs::pick(glyphs::SELECTION, theme.ascii());
392 let role = if self.focused {
393 Role::Primary
394 } else {
395 Role::Muted
396 };
397 row(
398 Rect {
399 width: gutter,
400 height: 1,
401 ..row_area
402 },
403 buf,
404 &Line::from(Span::styled(marker, theme.fg(role))),
405 );
406 }
407 artifact.draw(
408 Rect {
409 x: row_area.x + gutter,
410 width: row_area.width - gutter,
411 ..row_area
412 },
413 buf,
414 theme,
415 selected,
416 words,
417 );
418 if height == 2 {
419 painted += 1;
420 }
421 y += height;
422 }
423 if overflow {
424 let hidden = self
425 .artifacts
426 .len()
427 .saturating_sub(painted)
428 .saturating_add(self.omitted_artifacts);
429 let omitted = safe(&format!("{hidden} {}", words.omitted), theme.ascii());
430 row(
431 Rect {
432 y: area.y + body_height,
433 height: 1,
434 ..area
435 },
436 buf,
437 &Line::from(Span::styled(
438 text::truncate_words(&omitted, usize::from(area.width), theme.ascii())
439 .into_owned(),
440 theme.fg(Role::Hint),
441 )),
442 );
443 }
444 }
445
446 fn height(&self, width: u16, _theme: &Theme) -> u16 {
447 if width == 0 {
448 return 0;
449 }
450 let rows = self
451 .artifacts
452 .len()
453 .saturating_mul(2)
454 .max(1)
455 .saturating_add(usize::from(self.omitted_artifacts > 0));
456 u16::try_from(rows).unwrap_or(u16::MAX)
457 }
458 }
459
460 fn safe(value: &str, ascii: bool) -> String {
461 let safe = text::display_safe(value);
462 if !ascii {
463 return safe.into_owned();
464 }
465 // ASCII-safe output never splits a combining sequence or a wide cluster.
466 safe.graphemes(true)
467 .map(|g| if g.is_ascii() { g } else { "?" })
468 .collect()
469 }
470
471 /// Apply the shared grapheme-safe cut without throwing away semantic styles.
472 fn fit(spans: Vec<Span<'static>>, width: usize, theme: &Theme) -> Vec<Span<'static>> {
473 let source: String = spans.iter().map(|s| s.content.as_ref()).collect();
474 let cut = text::truncate(&source, width, theme.ascii());
475 if cut == source {
476 return spans;
477 }
478 let prefix = source
479 .grapheme_indices(true)
480 .zip(cut.graphemes(true))
481 .take_while(|((_, a), b)| a == b)
482 .map(|((at, g), _)| at + g.len())
483 .last()
484 .unwrap_or(0);
485 let mut remaining = prefix;
486 let mut fitted = Vec::new();
487 for span in spans {
488 let take = remaining.min(span.content.len());
489 if take > 0 {
490 fitted.push(Span::styled(span.content[..take].to_owned(), span.style));
491 }
492 remaining -= take;
493 if remaining == 0 {
494 break;
495 }
496 }
497 if prefix < cut.len() {
498 fitted.push(Span::styled(cut[prefix..].to_owned(), theme.fg(Role::Hint)));
499 }
500 fitted
501 }
502
502 lines RUST