| 1 | //! A quiet inbox for work that needs a person's answer. |
| 2 | //! |
| 3 | //! Hosts supply every request, identity, state, priority, timestamp and action. |
| 4 | //! Priority changes display order only. Selection refers to the original input |
| 5 | //! index, so sorting never changes which request a host means to open. This |
| 6 | //! component neither grants permission nor dispatches an action. |
| 7 | |
| 8 | use std::{borrow::Cow, cmp::Reverse}; |
| 9 | |
| 10 | use ratatui::{ |
| 11 | buffer::Buffer, |
| 12 | layout::Rect, |
| 13 | style::Modifier, |
| 14 | text::{Line, Span}, |
| 15 | widgets::{Clear, Widget}, |
| 16 | }; |
| 17 | use unicode_segmentation::UnicodeSegmentation; |
| 18 | |
| 19 | use crate::{EmptyState, KeyHints, ListState, Paint, Role, State, StatusMark, Theme, glyphs, text}; |
| 20 | |
| 21 | use super::workbench::row; |
| 22 | |
| 23 | /// Localized inbox copy. States use the item's [`StatusMark`] word. |
| 24 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 25 | pub struct AttentionWords { |
| 26 | pub title: Cow<'static, str>, |
| 27 | /// Shown after the number of caller-supplied items. |
| 28 | pub requests: Cow<'static, str>, |
| 29 | /// Shown after the count, including partially visible requests. |
| 30 | pub hidden: Cow<'static, str>, |
| 31 | pub clipped: Cow<'static, str>, |
| 32 | pub empty_title: Cow<'static, str>, |
| 33 | pub empty_detail: Cow<'static, str>, |
| 34 | } |
| 35 | |
| 36 | const ENGLISH: AttentionWords = AttentionWords { |
| 37 | title: Cow::Borrowed("Needs you"), |
| 38 | requests: Cow::Borrowed("waiting"), |
| 39 | hidden: Cow::Borrowed("not fully shown"), |
| 40 | clipped: Cow::Borrowed("Open the full request before answering"), |
| 41 | empty_title: Cow::Borrowed("No requests waiting"), |
| 42 | empty_detail: Cow::Borrowed("Questions and decisions appear here."), |
| 43 | }; |
| 44 | |
| 45 | impl Default for AttentionWords { |
| 46 | fn default() -> Self { |
| 47 | ENGLISH.clone() |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// A request with project and agent context. Its action is display copy; |
| 52 | /// the host owns the underlying decision and all keyboard/pointer handlers. |
| 53 | #[derive(Clone, Debug)] |
| 54 | pub struct AttentionItem<'a> { |
| 55 | pub project: Cow<'a, str>, |
| 56 | pub agent: Cow<'a, str>, |
| 57 | pub request: Cow<'a, str>, |
| 58 | pub status: StatusMark, |
| 59 | /// Greater values appear first; equal priorities preserve caller order. |
| 60 | pub priority: u8, |
| 61 | pub timestamp: Option<Cow<'a, str>>, |
| 62 | pub action: Option<Cow<'a, str>>, |
| 63 | /// Shortcuts appear only on the selected request, alongside its action. |
| 64 | pub hints: Option<&'a KeyHints>, |
| 65 | pub selected: bool, |
| 66 | pub focused: bool, |
| 67 | /// Keep the inbox concise. Further request rows get an explicit cue, |
| 68 | /// and the action rail is withheld until the full request is available. |
| 69 | pub request_rows: u16, |
| 70 | pub words: Option<&'a AttentionWords>, |
| 71 | } |
| 72 | |
| 73 | impl<'a> AttentionItem<'a> { |
| 74 | #[must_use] |
| 75 | pub fn new( |
| 76 | project: impl Into<Cow<'a, str>>, |
| 77 | agent: impl Into<Cow<'a, str>>, |
| 78 | request: impl Into<Cow<'a, str>>, |
| 79 | state: State, |
| 80 | ) -> Self { |
| 81 | Self { |
| 82 | project: project.into(), |
| 83 | agent: agent.into(), |
| 84 | request: request.into(), |
| 85 | status: StatusMark::new(state), |
| 86 | priority: 0, |
| 87 | timestamp: None, |
| 88 | action: None, |
| 89 | hints: None, |
| 90 | selected: false, |
| 91 | focused: false, |
| 92 | request_rows: 3, |
| 93 | words: None, |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | #[must_use] |
| 98 | pub fn priority(mut self, priority: u8) -> Self { |
| 99 | self.priority = priority; |
| 100 | self |
| 101 | } |
| 102 | |
| 103 | #[must_use] |
| 104 | pub fn timestamp(mut self, timestamp: impl Into<Cow<'a, str>>) -> Self { |
| 105 | self.timestamp = Some(timestamp.into()); |
| 106 | self |
| 107 | } |
| 108 | |
| 109 | #[must_use] |
| 110 | pub fn action(mut self, action: impl Into<Cow<'a, str>>) -> Self { |
| 111 | self.action = Some(action.into()); |
| 112 | self |
| 113 | } |
| 114 | |
| 115 | #[must_use] |
| 116 | pub fn hints(mut self, hints: &'a KeyHints) -> Self { |
| 117 | self.hints = Some(hints); |
| 118 | self |
| 119 | } |
| 120 | |
| 121 | #[must_use] |
| 122 | pub fn selected(mut self, selected: bool) -> Self { |
| 123 | self.selected = selected; |
| 124 | self |
| 125 | } |
| 126 | |
| 127 | #[must_use] |
| 128 | pub fn focused(mut self, focused: bool) -> Self { |
| 129 | self.focused = focused; |
| 130 | self |
| 131 | } |
| 132 | |
| 133 | #[must_use] |
| 134 | pub fn request_rows(mut self, rows: u16) -> Self { |
| 135 | self.request_rows = rows.max(1); |
| 136 | self |
| 137 | } |
| 138 | |
| 139 | #[must_use] |
| 140 | pub fn status_word(mut self, word: impl Into<Cow<'static, str>>) -> Self { |
| 141 | self.status = self.status.word(word); |
| 142 | self |
| 143 | } |
| 144 | |
| 145 | #[must_use] |
| 146 | pub fn with_words(mut self, words: &'a AttentionWords) -> Self { |
| 147 | self.words = Some(words); |
| 148 | self |
| 149 | } |
| 150 | |
| 151 | fn words(&self) -> &AttentionWords { |
| 152 | self.words.unwrap_or(&ENGLISH) |
| 153 | } |
| 154 | |
| 155 | fn plan(&self, width: u16, theme: &Theme, selected: bool) -> Vec<Line<'static>> { |
| 156 | if width == 0 { |
| 157 | return Vec::new(); |
| 158 | } |
| 159 | let content_width = width.saturating_sub(gutter(width)); |
| 160 | let project = safe(&self.project); |
| 161 | let agent = safe(&self.agent); |
| 162 | let identity = if agent.is_empty() { |
| 163 | project |
| 164 | } else { |
| 165 | format!("{project} / {agent}") |
| 166 | }; |
| 167 | let timestamp = self |
| 168 | .timestamp |
| 169 | .as_deref() |
| 170 | .map(safe) |
| 171 | .filter(|value| !value.is_empty()); |
| 172 | let mut state = self.status.spans(theme); |
| 173 | let status_width: usize = state.iter().map(Span::width).sum(); |
| 174 | if let Some(timestamp) = timestamp { |
| 175 | state.push(Span::raw(" ")); |
| 176 | state.push(Span::styled(timestamp, theme.fg(Role::Muted))); |
| 177 | } |
| 178 | let state_width: usize = state.iter().map(Span::width).sum(); |
| 179 | let mut complete = status_width <= usize::from(content_width); |
| 180 | let mut lines = Vec::new(); |
| 181 | let strong = theme.fg(Role::Foreground).add_modifier(Modifier::BOLD); |
| 182 | if content_width >= 52 && state_width + 4 < usize::from(content_width) { |
| 183 | let identity_width = usize::from(content_width).saturating_sub(state_width + 2); |
| 184 | let identity_fit = text::truncate(&identity, identity_width, theme.ascii()); |
| 185 | complete &= identity_fit == identity; |
| 186 | let used = text::width(&identity_fit); |
| 187 | let mut header = vec![ |
| 188 | Span::styled(identity_fit.into_owned(), strong), |
| 189 | Span::raw( |
| 190 | " ".repeat(usize::from(content_width).saturating_sub(used + state_width)), |
| 191 | ), |
| 192 | ]; |
| 193 | header.extend(state); |
| 194 | lines.push(Line::from(header)); |
| 195 | } else { |
| 196 | let status = Line::from(state); |
| 197 | // Metadata can recede at narrow widths, but an identity that is |
| 198 | // clipped must never sit above an available decision shortcut. |
| 199 | lines.push(status); |
| 200 | let identity_fit = text::truncate(&identity, usize::from(content_width), theme.ascii()); |
| 201 | complete &= identity_fit == identity; |
| 202 | lines.push(Line::from(Span::styled(identity_fit.into_owned(), strong))); |
| 203 | } |
| 204 | let request = wrap(&self.request, content_width, theme.ascii()); |
| 205 | let keep = usize::from(self.request_rows.max(1)); |
| 206 | complete &= request.complete && request.lines.len() <= keep; |
| 207 | lines.extend( |
| 208 | request |
| 209 | .lines |
| 210 | .into_iter() |
| 211 | .take(keep) |
| 212 | .map(|line| Line::from(Span::styled(line, theme.fg(Role::Foreground)))), |
| 213 | ); |
| 214 | if complete { |
| 215 | let mut actions = Vec::new(); |
| 216 | if let Some(action) = self |
| 217 | .action |
| 218 | .as_deref() |
| 219 | .map(safe) |
| 220 | .filter(|value| !value.is_empty()) |
| 221 | { |
| 222 | actions.extend( |
| 223 | wrap(&action, content_width, theme.ascii()) |
| 224 | .lines |
| 225 | .into_iter() |
| 226 | .map(|line| { |
| 227 | Line::from(Span::styled( |
| 228 | line, |
| 229 | theme.fg(if selected { Role::Primary } else { Role::Muted }), |
| 230 | )) |
| 231 | }), |
| 232 | ); |
| 233 | } |
| 234 | if selected && let Some(hints) = self.hints { |
| 235 | let hint_lines = hints.lines(content_width, theme); |
| 236 | if actions.len() == 1 |
| 237 | && hint_lines.len() == 1 |
| 238 | && actions[0].width() + hint_lines[0].width() + 3 <= usize::from(content_width) |
| 239 | { |
| 240 | let gap = usize::from(content_width) |
| 241 | .saturating_sub(actions[0].width() + hint_lines[0].width()); |
| 242 | actions[0].spans.push(Span::raw(" ".repeat(gap))); |
| 243 | actions[0].spans.extend(hint_lines[0].spans.clone()); |
| 244 | } else { |
| 245 | actions.extend(hint_lines); |
| 246 | } |
| 247 | } |
| 248 | lines.extend(actions); |
| 249 | } else { |
| 250 | lines.push(clipped_line(self.words(), content_width, theme)); |
| 251 | } |
| 252 | lines |
| 253 | } |
| 254 | |
| 255 | fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme, selected: bool, focused: bool) { |
| 256 | if area.is_empty() { |
| 257 | return; |
| 258 | } |
| 259 | Clear.render(area, buf); |
| 260 | buf.set_style( |
| 261 | area, |
| 262 | theme.bg(if selected { |
| 263 | Role::Selected |
| 264 | } else { |
| 265 | Role::Background |
| 266 | }), |
| 267 | ); |
| 268 | let gutter = gutter(area.width); |
| 269 | let content = Rect { |
| 270 | x: area.x.saturating_add(gutter), |
| 271 | width: area.width.saturating_sub(gutter), |
| 272 | ..area |
| 273 | }; |
| 274 | let mut lines = self.plan(area.width, theme, selected); |
| 275 | if lines.len() > usize::from(area.height) { |
| 276 | if area.height == 1 { |
| 277 | lines.truncate(1); |
| 278 | } else { |
| 279 | lines.truncate(usize::from(area.height.saturating_sub(1))); |
| 280 | lines.push(clipped_line(self.words(), content.width, theme)); |
| 281 | } |
| 282 | } |
| 283 | for (index, line) in lines.iter().take(usize::from(area.height)).enumerate() { |
| 284 | let y = area.y.saturating_add(rows(index)); |
| 285 | if gutter > 0 { |
| 286 | let mark = if index == 0 && selected { |
| 287 | glyphs::SELECTION |
| 288 | } else { |
| 289 | "▏" |
| 290 | }; |
| 291 | let rail = if selected && focused { |
| 292 | Role::Primary |
| 293 | } else { |
| 294 | Role::BorderStrong |
| 295 | }; |
| 296 | buf.set_stringn( |
| 297 | area.x, |
| 298 | y, |
| 299 | glyphs::pick(mark, theme.ascii()), |
| 300 | 1, |
| 301 | theme.fg(rail), |
| 302 | ); |
| 303 | } |
| 304 | row( |
| 305 | Rect { |
| 306 | y, |
| 307 | height: 1, |
| 308 | ..content |
| 309 | }, |
| 310 | buf, |
| 311 | line, |
| 312 | ); |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | impl Paint for AttentionItem<'_> { |
| 318 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 319 | self.draw( |
| 320 | area.intersection(buf.area), |
| 321 | buf, |
| 322 | theme, |
| 323 | self.selected, |
| 324 | self.focused, |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | fn height(&self, width: u16, theme: &Theme) -> u16 { |
| 329 | rows(self.plan(width, theme, self.selected).len()) |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | /// An inbox with stable caller priority, original-index selection and an |
| 334 | /// honest overflow rail. Hosts own navigation, ordering inputs and opening. |
| 335 | #[derive(Clone, Debug)] |
| 336 | pub struct AttentionQueue<'a> { |
| 337 | pub items: &'a [AttentionItem<'a>], |
| 338 | /// Original index in `items`, never an index in the sorted presentation. |
| 339 | pub selected: Option<usize>, |
| 340 | pub focused: bool, |
| 341 | /// Preferred offset in priority order; selection remains in view. |
| 342 | pub offset: usize, |
| 343 | pub words: Option<&'a AttentionWords>, |
| 344 | } |
| 345 | |
| 346 | impl<'a> AttentionQueue<'a> { |
| 347 | #[must_use] |
| 348 | pub fn new(items: &'a [AttentionItem<'a>]) -> Self { |
| 349 | Self { |
| 350 | items, |
| 351 | selected: None, |
| 352 | focused: false, |
| 353 | offset: 0, |
| 354 | words: None, |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | #[must_use] |
| 359 | pub fn selected(mut self, index: usize) -> Self { |
| 360 | self.selected = Some(index); |
| 361 | self |
| 362 | } |
| 363 | |
| 364 | #[must_use] |
| 365 | pub fn focused(mut self, focused: bool) -> Self { |
| 366 | self.focused = focused; |
| 367 | self |
| 368 | } |
| 369 | |
| 370 | #[must_use] |
| 371 | pub fn offset(mut self, offset: usize) -> Self { |
| 372 | self.offset = offset; |
| 373 | self |
| 374 | } |
| 375 | |
| 376 | #[must_use] |
| 377 | pub fn with_words(mut self, words: &'a AttentionWords) -> Self { |
| 378 | self.words = Some(words); |
| 379 | self |
| 380 | } |
| 381 | |
| 382 | /// Original item indices in display order. Priority is a presentation |
| 383 | /// input, with no urgency or lifecycle inferred from state or age. |
| 384 | #[must_use] |
| 385 | pub fn order(&self) -> Vec<usize> { |
| 386 | let mut order: Vec<_> = (0..self.items.len()).collect(); |
| 387 | order.sort_by_key(|index| (Reverse(self.items[*index].priority), *index)); |
| 388 | order |
| 389 | } |
| 390 | |
| 391 | fn words(&self) -> &AttentionWords { |
| 392 | self.words.unwrap_or(&ENGLISH) |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | impl Paint for AttentionQueue<'_> { |
| 397 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 398 | let area = area.intersection(buf.area); |
| 399 | if area.is_empty() { |
| 400 | return; |
| 401 | } |
| 402 | Clear.render(area, buf); |
| 403 | buf.set_style(area, theme.bg(Role::Background)); |
| 404 | let words = self.words(); |
| 405 | paint_heading( |
| 406 | words, |
| 407 | self.items.len(), |
| 408 | Rect { height: 1, ..area }, |
| 409 | buf, |
| 410 | theme, |
| 411 | ); |
| 412 | if area.height < 3 { |
| 413 | return; |
| 414 | } |
| 415 | for x in area.x..area.right() { |
| 416 | buf[(x, area.y.saturating_add(1))] |
| 417 | .set_symbol(glyphs::pick("─", theme.ascii())) |
| 418 | .set_style(theme.fg(Role::Border)); |
| 419 | } |
| 420 | let body = Rect { |
| 421 | y: area.y.saturating_add(2), |
| 422 | height: area.height.saturating_sub(2), |
| 423 | ..area |
| 424 | }; |
| 425 | if self.items.is_empty() { |
| 426 | paint_empty(words, body, buf, theme); |
| 427 | return; |
| 428 | } |
| 429 | let order = self.order(); |
| 430 | let selected = self.selected.filter(|index| *index < self.items.len()); |
| 431 | let heights: Vec<_> = order |
| 432 | .iter() |
| 433 | .map(|index| { |
| 434 | rows( |
| 435 | self.items[*index] |
| 436 | .plan(body.width, theme, selected == Some(*index)) |
| 437 | .len(), |
| 438 | ) |
| 439 | }) |
| 440 | .collect(); |
| 441 | let total = heights |
| 442 | .iter() |
| 443 | .map(|height| usize::from(*height)) |
| 444 | .sum::<usize>() |
| 445 | .saturating_add(order.len().saturating_sub(1)); |
| 446 | let clipped = total > usize::from(body.height) || self.offset > 0; |
| 447 | let available = body.height.saturating_sub(u16::from(clipped)); |
| 448 | let rank = selected.and_then(|selected| order.iter().position(|index| *index == selected)); |
| 449 | let offset = if let Some(rank) = rank { |
| 450 | ListState { |
| 451 | selected: rank, |
| 452 | offset: self.offset, |
| 453 | } |
| 454 | .visible_offset(order.len(), available, |index| { |
| 455 | heights[index].saturating_add(u16::from(index + 1 < order.len())) |
| 456 | }) |
| 457 | } else { |
| 458 | self.offset.min(order.len().saturating_sub(1)) |
| 459 | }; |
| 460 | let mut used = 0u16; |
| 461 | let mut complete = 0usize; |
| 462 | for rank in offset..order.len() { |
| 463 | if used >= available { |
| 464 | break; |
| 465 | } |
| 466 | let index = order[rank]; |
| 467 | let height = heights[rank].min(available.saturating_sub(used)); |
| 468 | self.items[index].draw( |
| 469 | Rect { |
| 470 | y: body.y.saturating_add(used), |
| 471 | height, |
| 472 | ..body |
| 473 | }, |
| 474 | buf, |
| 475 | theme, |
| 476 | selected == Some(index), |
| 477 | self.focused, |
| 478 | ); |
| 479 | complete += usize::from(height == heights[rank]); |
| 480 | used = used.saturating_add(height).saturating_add(1); |
| 481 | } |
| 482 | if clipped && complete < self.items.len() { |
| 483 | let hidden = self.items.len().saturating_sub(complete); |
| 484 | row( |
| 485 | Rect { |
| 486 | y: body.bottom().saturating_sub(1), |
| 487 | height: 1, |
| 488 | ..body |
| 489 | }, |
| 490 | buf, |
| 491 | &Line::from(Span::styled( |
| 492 | format!("{hidden} {}", safe(&words.hidden)), |
| 493 | theme.fg(Role::Muted), |
| 494 | )), |
| 495 | ); |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | fn height(&self, width: u16, theme: &Theme) -> u16 { |
| 500 | if width == 0 { |
| 501 | return 0; |
| 502 | } |
| 503 | if self.items.is_empty() { |
| 504 | return 5; |
| 505 | } |
| 506 | let content = self |
| 507 | .items |
| 508 | .iter() |
| 509 | .enumerate() |
| 510 | .map(|(index, item)| item.plan(width, theme, self.selected == Some(index)).len()) |
| 511 | .sum::<usize>(); |
| 512 | rows( |
| 513 | content |
| 514 | .saturating_add(self.items.len().saturating_sub(1)) |
| 515 | .saturating_add(2), |
| 516 | ) |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | // Heading's fit is private; keep its title priority and quiet meta, using |
| 521 | // the shared buffer-row writer even at the last representable coordinate. |
| 522 | fn paint_heading( |
| 523 | words: &AttentionWords, |
| 524 | count: usize, |
| 525 | area: Rect, |
| 526 | buf: &mut Buffer, |
| 527 | theme: &Theme, |
| 528 | ) { |
| 529 | let width = usize::from(area.width); |
| 530 | let title = safe(&words.title); |
| 531 | let meta = format!("{count} {}", safe(&words.requests)); |
| 532 | let full = text::width(&title) |
| 533 | .saturating_add(text::width(&meta)) |
| 534 | .saturating_add(2) |
| 535 | <= width; |
| 536 | let budget = if full { |
| 537 | text::width(&meta) |
| 538 | } else if width >= 12 { |
| 539 | text::width(&meta).min(width / 3) |
| 540 | } else { |
| 541 | 0 |
| 542 | }; |
| 543 | let meta = if full || budget >= 4 { |
| 544 | text::truncate(&meta, budget, theme.ascii()).into_owned() |
| 545 | } else { |
| 546 | String::new() |
| 547 | }; |
| 548 | let room = width.saturating_sub(if meta.is_empty() { |
| 549 | 0 |
| 550 | } else { |
| 551 | text::width(&meta) + 2 |
| 552 | }); |
| 553 | let title = text::truncate(&title, room, theme.ascii()).into_owned(); |
| 554 | let gap = width.saturating_sub(text::width(&title) + text::width(&meta)); |
| 555 | row( |
| 556 | area, |
| 557 | buf, |
| 558 | &Line::from(vec![ |
| 559 | Span::styled( |
| 560 | title, |
| 561 | theme.fg(Role::Foreground).add_modifier(Modifier::BOLD), |
| 562 | ), |
| 563 | Span::raw(" ".repeat(if meta.is_empty() { 0 } else { gap })), |
| 564 | Span::styled(meta, theme.fg(Role::Muted)), |
| 565 | ]), |
| 566 | ); |
| 567 | } |
| 568 | |
| 569 | fn paint_empty(words: &AttentionWords, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 570 | if area.right() != u16::MAX && area.bottom() != u16::MAX { |
| 571 | EmptyState::new(words.empty_title.clone()) |
| 572 | .body(words.empty_detail.clone()) |
| 573 | .paint(area, buf, theme); |
| 574 | return; |
| 575 | } |
| 576 | // The general empty component uses centered terminal widgets. At the |
| 577 | // saturated edge, retain its title/body hierarchy with safe row writes. |
| 578 | let title = text::truncate( |
| 579 | &safe(&words.empty_title), |
| 580 | usize::from(area.width), |
| 581 | theme.ascii(), |
| 582 | ) |
| 583 | .into_owned(); |
| 584 | let body = wrap(&words.empty_detail, area.width, theme.ascii()); |
| 585 | let mut lines = vec![Line::styled( |
| 586 | title, |
| 587 | theme.fg(Role::Foreground).add_modifier(Modifier::BOLD), |
| 588 | )]; |
| 589 | lines.extend( |
| 590 | body.lines |
| 591 | .into_iter() |
| 592 | .take(3) |
| 593 | .map(|line| Line::styled(line, theme.fg(Role::Muted))), |
| 594 | ); |
| 595 | lines.truncate(usize::from(area.height)); |
| 596 | let top = area |
| 597 | .y |
| 598 | .saturating_add(area.height.saturating_sub(rows(lines.len())) / 2); |
| 599 | for (index, mut line) in lines.into_iter().enumerate() { |
| 600 | let inset = usize::from(area.width).saturating_sub(line.width()) / 2; |
| 601 | line.spans.insert(0, Span::raw(" ".repeat(inset))); |
| 602 | row( |
| 603 | Rect { |
| 604 | y: top.saturating_add(rows(index)), |
| 605 | height: 1, |
| 606 | ..area |
| 607 | }, |
| 608 | buf, |
| 609 | &line, |
| 610 | ); |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | fn safe(value: &str) -> String { |
| 615 | text::display_safe(value).into_owned() |
| 616 | } |
| 617 | fn rows(count: usize) -> u16 { |
| 618 | u16::try_from(count).unwrap_or(u16::MAX) |
| 619 | } |
| 620 | fn gutter(width: u16) -> u16 { |
| 621 | if width >= 6 { 2 } else { 0 } |
| 622 | } |
| 623 | |
| 624 | fn clipped_line(words: &AttentionWords, width: u16, theme: &Theme) -> Line<'static> { |
| 625 | let words = safe(&words.clipped); |
| 626 | Line::from(Span::styled( |
| 627 | text::truncate_words(&words, usize::from(width), theme.ascii()).into_owned(), |
| 628 | theme.fg(Role::Attention), |
| 629 | )) |
| 630 | } |
| 631 | |
| 632 | // Keep authored line breaks and word spacing; long words break only between |
| 633 | // whole Unicode graphemes. A grapheme wider than the viewport gets a cut mark. |
| 634 | struct Wrapped { |
| 635 | lines: Vec<String>, |
| 636 | complete: bool, |
| 637 | } |
| 638 | |
| 639 | fn wrap(value: &str, width: u16, ascii: bool) -> Wrapped { |
| 640 | let mut result = Wrapped { |
| 641 | lines: Vec::new(), |
| 642 | complete: true, |
| 643 | }; |
| 644 | if value.is_empty() || width == 0 { |
| 645 | return result; |
| 646 | } |
| 647 | let width = usize::from(width); |
| 648 | for logical in value.split('\n') { |
| 649 | let logical = safe(logical); |
| 650 | let graphemes: Vec<_> = logical |
| 651 | .graphemes(true) |
| 652 | .map(|value| { |
| 653 | if text::width(value) > width { |
| 654 | result.complete = false; |
| 655 | Cow::Owned(text::truncate(value, width, ascii).into_owned()) |
| 656 | } else { |
| 657 | Cow::Borrowed(value) |
| 658 | } |
| 659 | }) |
| 660 | .collect(); |
| 661 | if graphemes.is_empty() { |
| 662 | result.lines.push(String::new()); |
| 663 | } |
| 664 | let mut start = 0; |
| 665 | while start < graphemes.len() { |
| 666 | let mut end = start; |
| 667 | let mut cells = 0; |
| 668 | let mut boundary = None; |
| 669 | while end < graphemes.len() && cells + text::width(&graphemes[end]) <= width { |
| 670 | cells += text::width(&graphemes[end]); |
| 671 | if graphemes[end].chars().all(char::is_whitespace) { |
| 672 | boundary = Some(end + 1); |
| 673 | } |
| 674 | end += 1; |
| 675 | } |
| 676 | if end < graphemes.len() |
| 677 | && let Some(boundary) = boundary |
| 678 | { |
| 679 | end = boundary; |
| 680 | } |
| 681 | result.lines.push( |
| 682 | graphemes[start..end] |
| 683 | .iter() |
| 684 | .map(|value| value.as_ref()) |
| 685 | .collect(), |
| 686 | ); |
| 687 | start = end; |
| 688 | } |
| 689 | } |
| 690 | result |
| 691 | } |
| 692 |