1use std::time::Duration;
2
3use pito_footer::{ConfirmBar, Footer, Hint, Notice, Words as Choice};
4use pito_header::{Breadcrumb, Drill, Header};
5use ratatui::{
6 Frame,
7 buffer::Buffer,
8 layout::{Alignment, Rect},
9 style::{Modifier, Style},
10 text::{Line, Text},
11 widgets::{Block, BorderType, Clear, Paragraph, Widget, Wrap},
12};
13
14use crate::head::{self, Focus, Item, Kind, Labels, Numbers, Row, Rule};
15use crate::label::Span;
16use crate::layout::{Edge, PAD, Spot};
17use crate::screen::{Phase, Screen};
18use crate::shell::{Role, Tui};
19use crate::text::cells;
20use crate::words::Words;
21
22const SIDES: u16 = 6;
23const LEAD: &str = " ยท ";
24const TOAST: u16 = 3;
25
26fn width(text: &str) -> u16 {
27 u16::try_from(cells(text)).unwrap_or(u16::MAX)
28}
29
30fn room(line: u16, title: &str) -> u16 {
31 let wide = width(title).saturating_add(2).min(line);
32 let side = ((line - wide) / 2).saturating_sub(2);
33 if side < SIDES { 0 } else { side - 2 }
34}
35
36pub fn message<'a>(frame: &mut Frame, area: Rect, text: impl Into<Text<'a>>) {
37 message_at(frame, area, text, Spot::THIRD);
38}
39
40pub fn message_at<'a>(frame: &mut Frame, area: Rect, text: impl Into<Text<'a>>, spot: Spot) {
41 let text = text.into();
42 let inner = area.width.saturating_sub(2 * PAD);
43 let x = area.x.saturating_add(PAD.min(area.width));
44 let lines: usize = text
45 .lines
46 .iter()
47 .map(|line| line.width().div_ceil(usize::from(inner.max(1))).max(1))
48 .sum();
49 let lines = u16::try_from(lines).unwrap_or(u16::MAX).min(area.height);
50 let top = match spot.y {
51 Edge::Start => 0,
52 Edge::Third => area.height / 3,
53 Edge::Middle => (area.height - lines) / 2,
54 Edge::End => area.height - lines,
55 };
56 let alignment = match spot.x {
57 Edge::Start => Alignment::Left,
58 Edge::End => Alignment::Right,
59 Edge::Third | Edge::Middle => Alignment::Center,
60 };
61 let place = Rect::new(x, area.y + top, inner, area.height - top);
62 let paragraph = Paragraph::new(text)
63 .alignment(alignment)
64 .wrap(Wrap { trim: true });
65 frame.render_widget(paragraph, place);
66}
67
68fn choice<'a>(words: &'a Words, hint: &'a str) -> Choice<'a> {
69 let choice = Choice::new(&words.yes, &words.no);
70 if hint.is_empty() {
71 choice
72 } else {
73 choice.hint(hint)
74 }
75}
76
77fn light(buffer: &mut Buffer, plain: &Buffer, accent: Style) {
78 let lit = accent.add_modifier(Modifier::BOLD);
79 let area = plain.area;
80 for y in area.top()..area.bottom() {
81 let changed = |x: &u16| {
82 let symbol = buffer[(*x, y)].symbol();
83 symbol != " " && symbol != plain[(*x, y)].symbol()
84 };
85 let first = (area.left()..area.right()).find(changed);
86 let last = (area.left()..area.right()).rev().find(changed);
87 let (Some(first), Some(last)) = (first, last) else {
88 continue;
89 };
90 for x in first..=last {
91 let cell = &mut buffer[(x, y)];
92 cell.modifier = Modifier::empty();
93 cell.set_style(lit);
94 }
95 }
96}
97
98fn spans(parts: &[(String, Style)]) -> Vec<Span> {
99 parts
100 .iter()
101 .map(|(text, style)| (text.clone().into(), *style))
102 .collect()
103}
104
105impl<E: Send + 'static> Tui<E> {
106 pub(crate) fn title_text(&self) -> String {
107 self.title.iter().map(|(text, _)| text.as_ref()).collect()
108 }
109
110 fn footer<'a>(
111 &'a self,
112 hints: &'a [Hint<'a>],
113 screen: &'a dyn Screen<E>,
114 again: Option<&'a str>,
115 width: u16,
116 ) -> Footer<'a> {
117 let hints_open = self.help.is_none_or(|help| help.open());
118 let notice = again
119 .map(|text| Notice::new(text, self.quit_tone))
120 .or_else(|| {
121 self.out
122 .said
123 .as_ref()
124 .map(|(text, tone)| Notice::new(text, *tone))
125 })
126 .or_else(|| screen.notice())
127 .or_else(|| screen.legend().filter(|_| hints_open).map(Notice::legend));
128 let words = &self.words;
129 let leave = if words.leave.is_empty() {
130 &words.choose
131 } else {
132 &words.leave
133 };
134 let mut asked = choice(words, &words.choose);
135 let mut leaving = choice(words, leave);
136 if let Some(text) = again.filter(|_| self.lit_again) {
137 asked = asked.hint(text);
138 leaving = leaving.hint(text);
139 }
140 let confirm = self.quit.bar(leaving).or_else(|| {
141 self.out
142 .asking
143 .as_ref()
144 .map(|asking| ConfirmBar::new(&asking.question, &asking.confirm, asked))
145 });
146 let input = screen
147 .input()
148 .map(|bar| again.map_or(bar, |text| bar.hint(text)));
149 let mut footer = Footer::new(hints)
150 .styles(self.drawn.footer())
151 .version(&self.name, &self.version)
152 .notice(notice)
153 .confirm(confirm)
154 .input(input);
155 if let Some(help) = &self.help
156 && !self.gated
157 {
158 footer = footer.help(help);
159 }
160 if self.version_fit {
161 let bare = footer.version("", "");
162 if again.is_some() || bare.height(width) < footer.height(width) {
163 footer = bare;
164 }
165 }
166 match self.footer_look {
167 Some(look) => look(footer, width),
168 None => footer,
169 }
170 }
171
172 fn sides(&self, index: usize) -> (&dyn Screen<E>, &dyn Screen<E>) {
173 let screen = self.screens[index].screen.as_ref();
174 let Some(app) = self.app_slot().map(|app| self.screens[app].screen.as_ref()) else {
175 return (screen, screen);
176 };
177 (app, screen)
178 }
179
180 fn left(&self, index: usize, room: u16) -> Vec<(String, Style)> {
181 let help = self
182 .help
183 .filter(|help| !help.open() && !self.gated)
184 .map(|_| self.words.help.as_ref())
185 .filter(|text| !text.is_empty());
186 let (app, screen) = self.sides(index);
187 for source in [app, screen] {
188 if let Some(left) = source.left(room, help) {
189 return left;
190 }
191 }
192 let taken = help.map_or(0, |text| width(text).saturating_add(width(LEAD)));
193 let mut lead = app.lead(room.saturating_sub(taken));
194 if lead.iter().all(|(text, _)| text.is_empty()) {
195 lead = screen.lead(room.saturating_sub(taken));
196 }
197 let mut left = Vec::new();
198 if let Some(text) = help {
199 left.push((text.to_string(), self.drawn.muted));
200 if lead.iter().any(|(text, _)| !text.is_empty()) {
201 left.push((LEAD.to_string(), self.drawn.muted));
202 }
203 }
204 left.extend(lead);
205 left
206 }
207
208 fn right(&self, index: usize, room: u16) -> Vec<(String, Style)> {
209 let (app, screen) = self.sides(index);
210 let parts = app.status_parts(room);
211 if parts.iter().any(|(text, _)| !text.is_empty()) {
212 return parts;
213 }
214 screen.status_parts(room)
215 }
216
217 pub(crate) fn header<'a>(
218 &'a self,
219 title: &'a str,
220 facts: &'a [(String, Style)],
221 left: &'a [(String, Style)],
222 status: &'a [(String, Style)],
223 ) -> Header<'a> {
224 let header = Header::new(&self.model.nav)
225 .styles(self.drawn.header())
226 .title(Some(title))
227 .left_pairs(left)
228 .right_pairs(status)
229 .facts_pairs(facts);
230 let header = match self.header_look {
231 Some(look) => look(header),
232 None => header,
233 };
234 if self.open.is_some() {
235 header.lit(false).drill(Drill::Rows).crumbs(false)
236 } else {
237 header
238 }
239 }
240
241 fn facts_row<'a>(&'a self, facts: &'a [(String, Style)]) -> Header<'a> {
242 self.header("", facts, &[], &[])
243 .title(None)
244 .tabs(false)
245 .crumbs(false)
246 .closing(false)
247 .notice(None)
248 }
249
250 pub(crate) fn tail(&self) -> Vec<(String, Style)> {
251 self.current()
252 .and_then(|index| self.screens.get(index))
253 .map(|slot| slot.screen.after_tabs())
254 .unwrap_or_default()
255 }
256
257 pub(crate) fn group_labels(&self) -> Labels<'_> {
258 let place = self.model.nav.place();
259 let palette = &self.drawn;
260 let items = self
261 .model
262 .groups
263 .iter()
264 .enumerate()
265 .map(|(group, heading)| Item {
266 label: &self.model.headings[*heading].label,
267 number: None,
268 on: group == place.group,
269 })
270 .collect();
271 let on = if self.focus == Focus::Groups {
272 palette.focus
273 } else {
274 palette.accent.add_modifier(Modifier::BOLD)
275 };
276 Labels {
277 items,
278 kind: Kind::Groups,
279 on,
280 off: palette.inactive,
281 tail: &[],
282 tail_style: Style::new(),
283 }
284 }
285
286 pub(crate) fn section_labels<'a>(&'a self, tail: &'a [(String, Style)]) -> Labels<'a> {
287 let palette = &self.drawn;
288 let place = self.model.nav.place();
289 let tabs = self.model.group_tabs();
290 let numbers = self
291 .head
292 .as_ref()
293 .map_or(Numbers::Across, |head| head.numbers);
294 let lone = self.head.as_ref().is_some_and(|head| head.lone) && tabs.len() == 1;
295 let offset = place.number.saturating_sub(place.section + 1);
296 let items = tabs
297 .iter()
298 .enumerate()
299 .map(|(at, tab)| Item {
300 label: &self.model.tabs[*tab].label,
301 number: match numbers {
302 _ if lone => None,
303 Numbers::Across => Some(offset + at + 1),
304 Numbers::InGroup => Some(at + 1),
305 Numbers::Off => None,
306 },
307 on: at == place.section,
308 })
309 .collect();
310 let lit = self.open.is_none();
311 let on = match (lit, self.focus) {
312 (false, _) => palette.inactive,
313 (true, Focus::Sections) => palette.focus,
314 (true, _) => palette.accent.add_modifier(Modifier::BOLD),
315 };
316 let tail_style = if self.focus == Focus::Slot {
317 palette.focus
318 } else {
319 Style::new()
320 };
321 Labels {
322 items,
323 kind: Kind::Sections,
324 on,
325 off: palette.inactive,
326 tail,
327 tail_style,
328 }
329 }
330
331 fn title_rule(
332 &self,
333 buf: &mut Buffer,
334 line: Rect,
335 title: &[Span],
336 left: &[(String, Style)],
337 right: &[(String, Style)],
338 ) {
339 Rule {
340 rule: self.drawn.rule,
341 base: self.drawn.title,
342 middle: title,
343 left,
344 right,
345 }
346 .draw(buf, line);
347 }
348
349 #[allow(clippy::too_many_arguments)]
350 fn stack(
351 &self,
352 buf: &mut Buffer,
353 line: Rect,
354 bottom: u16,
355 index: usize,
356 facts: &[(String, Style)],
357 left: &[(String, Style)],
358 status: &[(String, Style)],
359 ) -> (u16, Vec<(Row, Rect)>) {
360 let Some(head) = self.head.as_ref() else {
361 return (line.y, Vec::new());
362 };
363 let palette = &self.drawn;
364 let overlay = self
365 .open
366 .and_then(|open| match &self.screens.get(open)?.role {
367 Role::Overlay(nav, _) => Some(nav),
368 _ => None,
369 });
370 let nav = &self.model.nav;
371 let mut y = line.y;
372 let mut rows = Vec::new();
373 for row in &head.rows {
374 if y >= bottom {
375 break;
376 }
377 let at = Rect { y, ..line };
378 match row {
379 Row::Title => {
380 self.title_rule(buf, at, &self.title, left, status);
381 }
382 Row::Groups => {
383 if self.model.groups.len() < 2 {
384 continue;
385 }
386 self.group_labels().draw(buf, at);
387 rows.push((Row::Groups, at));
388 }
389 Row::Caption => {
390 let caption = self.screens[index]
391 .screen
392 .caption()
393 .map(|parts| spans(&parts));
394 let group = self.model.groups.get(nav.place().group).map(|heading| {
395 let name = self.model.headings[*heading].label.text().to_string();
396 vec![(name.into(), Style::new())]
397 });
398 let middle = caption.or(group).unwrap_or_default();
399 Rule {
400 rule: palette.rule,
401 base: palette.accent.add_modifier(Modifier::BOLD),
402 middle: &middle,
403 left: &[],
404 right: &[],
405 }
406 .draw(buf, at);
407 }
408 Row::Sections => {
409 if nav.count() == 0 {
410 continue;
411 }
412 if overlay.is_none() && nav.depth() > 0 {
413 Breadcrumb::new(nav)
414 .styles(palette.header())
415 .centred(true)
416 .render(at, buf);
417 } else {
418 let tail = self.screens[index].screen.after_tabs();
419 self.section_labels(&tail).draw(buf, at);
420 rows.push((Row::Sections, at));
421 }
422 }
423 Row::Facts => {
424 if facts.iter().all(|(text, _)| text.is_empty()) {
425 continue;
426 }
427 self.facts_row(facts).render(at, buf);
428 }
429 Row::Crumbs => {
430 if overlay.is_none() && nav.depth() > 0 {
431 continue;
432 }
433 Breadcrumb::new(overlay.unwrap_or(nav))
434 .styles(palette.header())
435 .render(at, buf);
436 }
437 Row::Blank => head::blank(buf, at, palette.base),
438 }
439 y += 1;
440 }
441 (y, rows)
442 }
443
444 fn time_glass(&mut self, index: usize) {
445 let moment = self.moment;
446 let (delay, least) = (self.delay, self.least);
447 let slot = &mut self.screens[index];
448 let label = match slot.screen.phase() {
449 Phase::Loading(label) => Some(label.into_owned()),
450 _ => None,
451 };
452 match label {
453 Some(label) => {
454 let since = *slot.since.get_or_insert(moment);
455 if slot.glass.is_none() && moment.saturating_duration_since(since) >= delay {
456 slot.glass = Some(moment);
457 }
458 slot.label = label;
459 }
460 None => {
461 slot.since = None;
462 if slot
463 .glass
464 .is_some_and(|shown| moment.saturating_duration_since(shown) >= least)
465 {
466 slot.glass = None;
467 }
468 }
469 }
470 }
471
472 fn draw_takeover(&self, frame: &mut Frame, area: Rect) {
473 let layout = self.layout;
474 let palette = self.drawn;
475 let width = area.width.saturating_sub(2 * layout.side);
476 let x = area.x + layout.side;
477 let bottom = area.bottom().saturating_sub(layout.bottom);
478 let top = (area.y + layout.top).min(bottom);
479 let Some((_, takeover)) = self.out.takeover.as_ref() else {
480 return;
481 };
482 if top < bottom {
483 Rule {
484 rule: palette.rule,
485 base: palette.title,
486 middle: &takeover.title,
487 left: &[],
488 right: &[],
489 }
490 .draw(frame.buffer_mut(), Rect::new(x, top, width, 1));
491 }
492 let again = self.quit.notice().filter(|text| !text.is_empty());
493 let mut floor = bottom;
494 let words = &self.words;
495 let leave = if words.leave.is_empty() {
496 &words.choose
497 } else {
498 &words.leave
499 };
500 let confirm = self.quit.bar(choice(words, leave));
501 if again.is_some() || confirm.is_some() {
502 let footer = Footer::new(&[])
503 .styles(palette.footer())
504 .notice(again.map(|text| Notice::new(text, self.quit_tone)))
505 .confirm(confirm)
506 .rule(false);
507 let height = footer.height(width).min(bottom.saturating_sub(top + 1));
508 floor = bottom - height;
509 frame.render_widget(footer, Rect::new(x, floor, width, height));
510 }
511 let elapsed = self.taken.map_or(Duration::ZERO, |since| {
512 self.moment.saturating_duration_since(since)
513 });
514 let below = top.saturating_add(1).min(floor);
515 let inner = area.width.saturating_sub(2 * layout.pad);
516 let place = Rect::new(area.x + layout.pad, below, inner, floor - below);
517 let hourglass = palette.hourglass(elapsed, &takeover.label, None);
518 frame.render_widget(hourglass, place);
519 }
520
521 fn draw_toasts(&self, frame: &mut Frame, area: Rect) {
522 let mut stacks: Vec<(Spot, u16)> = Vec::new();
523 for (until, toast) in &self.toasts {
524 if *until <= self.moment || area.is_empty() {
525 continue;
526 }
527 let wide = width(&toast.text).saturating_add(4).min(area.width);
528 let shift = match stacks.iter_mut().find(|(spot, _)| *spot == toast.spot) {
529 Some((_, count)) => {
530 *count += 1;
531 *count - 1
532 }
533 None => {
534 stacks.push((toast.spot, 1));
535 0
536 }
537 };
538 let mut place = toast.spot.place(area, wide, TOAST);
539 let by = shift.saturating_mul(TOAST);
540 place.y = match toast.spot.y {
541 Edge::End => place.y.saturating_sub(by).max(area.y),
542 _ => place.y.saturating_add(by),
543 };
544 if place.bottom() > area.bottom() || place.y < area.y {
545 continue;
546 }
547 let style = self.drawn.tone(toast.tone);
548 frame.render_widget(Clear, place);
549 let block = Block::bordered()
550 .border_type(BorderType::Rounded)
551 .border_style(style)
552 .style(self.drawn.base);
553 let inner = block.inner(place);
554 frame.render_widget(block, place);
555 let text = Line::styled(toast.text.to_string(), style).alignment(Alignment::Center);
556 frame.render_widget(Paragraph::new(text), inner);
557 }
558 }
559
560 pub fn draw(&mut self, frame: &mut Frame) {
561 let area = frame.area();
562 let palette = self.drawn;
563 frame.render_widget(Block::new().style(palette.base), area);
564 if area.width < self.min.0 || area.height < self.min.1 {
565 let line = Line::styled(self.words.too_small.as_ref(), palette.small);
566 message_at(frame, area, line, self.layout.small);
567 return;
568 }
569 self.follow();
570 self.word_quit();
571 self.rows.clear();
572 if self.out.takeover.is_some() {
573 self.draw_takeover(frame, area);
574 return;
575 }
576 let Some(index) = self.current() else {
577 return;
578 };
579 let moment = self.moment;
580 self.screens[index].screen.moment(moment);
581 self.time_glass(index);
582 let layout = self.layout;
583 let width = area.width.saturating_sub(2 * layout.side);
584 let bottom = area.bottom().saturating_sub(layout.bottom);
585 let title = self.title_text();
586 let screen = &self.screens[index].screen;
587 let facts = screen.facts();
588 let room = room(width, &title);
589 let left = self.left(index, room);
590 let status = self.right(index, room);
591 let line = Rect::new(area.x + layout.side, area.y + layout.top, width, 1);
592 let mut below;
593 if self.gated || self.head.is_some() {
594 let top = line.y.min(bottom);
595 if self.gated {
596 if top < bottom {
597 self.title_rule(frame.buffer_mut(), line, &self.title, &left, &status);
598 }
599 below = (top + 1).min(bottom);
600 } else {
601 let (y, rows) = self.stack(
602 frame.buffer_mut(),
603 line,
604 bottom,
605 index,
606 &facts,
607 &left,
608 &status,
609 );
610 below = y;
611 self.rows = rows;
612 }
613 self.top = Rect::default();
614 } else {
615 let header = self.header(&title, &facts, &left, &status);
616 let top = Rect::new(
617 line.x,
618 line.y,
619 width,
620 header.height().min(bottom.saturating_sub(line.y)),
621 );
622 let dropped = header.height() == header.facts_pairs(&[]).height();
623 frame.render_widget(header, top);
624 self.top = top;
625 below = top.bottom();
626 let overlay = self
627 .open
628 .and_then(|open| match &self.screens.get(open)?.role {
629 Role::Overlay(nav, _) => Some(nav),
630 _ => None,
631 });
632 if below < bottom && (overlay.is_some() || self.model.nav.depth() == 0) {
633 let nav = overlay.unwrap_or(&self.model.nav);
634 let rule = Breadcrumb::new(nav).styles(palette.header());
635 frame.render_widget(rule, Rect::new(top.x, below, width, 1));
636 below += 1;
637 } else if self.keep_facts && dropped && below < bottom {
638 let row = self.facts_row(&facts);
639 let height = row.height().min(bottom - below);
640 frame.render_widget(row, Rect::new(top.x, below, width, height));
641 below += height;
642 }
643 }
644 below = below.saturating_add(layout.head_gap).min(bottom);
645 let screen = &self.screens[index].screen;
646 let again = self.quit.notice().filter(|text| !text.is_empty());
647 let (mut hints, prompted) = match (self.out.modals.last(), &self.out.picking) {
648 (Some(top), _) => (top.modal.hints.clone(), true),
649 (None, Some(picking)) => (picking.pick.hints.clone(), true),
650 (None, None) => (screen.hints(), false),
651 };
652 if !self.gated {
653 let shown = self.hints.iter().filter(|hint| !prompted || hint.pinned);
654 hints.extend(shown.copied());
655 }
656 if again.is_some() && !self.quit_hints {
657 hints.clear();
658 }
659 let footer = self.footer(&hints, screen.as_ref(), again, width);
660 let height = footer.height(width).min(bottom.saturating_sub(below));
661 let floor_line = bottom.saturating_sub(height);
662 let place = Rect::new(line.x, floor_line, width, height);
663 frame.render_widget(footer, place);
664 if again.is_some()
665 && self.lit_again
666 && (screen.input().is_some() || self.out.asking.is_some())
667 {
668 let mut plain = Buffer::empty(place);
669 self.footer(&hints, screen.as_ref(), Some(""), width)
670 .render(place, &mut plain);
671 light(frame.buffer_mut(), &plain, palette.accent);
672 }
673 let inner = area.width.saturating_sub(2 * layout.pad);
674 let x = area.x + layout.pad;
675 let mut floor = floor_line;
676 let room = floor_line.saturating_sub(below);
677 let band = if self.gated { None } else { self.band.as_mut() };
678 if let Some(band) = band {
679 let rows = band
680 .height(room, moment)
681 .min(room.saturating_sub(layout.gap));
682 if rows > 0 {
683 floor = floor_line - rows;
684 band.draw(frame, Rect::new(x, floor, inner, rows), &palette, moment);
685 }
686 } else if let Some(board) = self.board().filter(|_| self.open.is_none() && !self.gated) {
687 let shown = board.banded(moment).len();
688 let rows = u16::try_from(shown).unwrap_or(u16::MAX).min(room / 2);
689 if rows > 0 {
690 floor = floor_line - rows;
691 board.band(frame, Rect::new(x, floor, inner, rows), &palette, moment);
692 }
693 }
694 if !self.gated
695 && let Some(band) = self.top_band.as_mut()
696 {
697 let room = floor.saturating_sub(below);
698 let rows = band
699 .height(room, moment)
700 .min(room.saturating_sub(layout.gap + 1));
701 if rows > 0 {
702 band.draw(frame, Rect::new(x, below, inner, rows), &palette, moment);
703 below += rows;
704 }
705 }
706 let content = Rect::new(x, below, inner, floor.saturating_sub(below + layout.gap));
707 self.content = content;
708 let slot = &self.screens[index];
709 let shown = slot
710 .glass
711 .map(|since| moment.saturating_duration_since(since));
712 let phase = slot.screen.phase();
713 match (shown, phase) {
714 (Some(elapsed), _) => {
715 let waiting = Some(self.words.waiting.as_ref());
716 let hourglass = palette.hourglass(elapsed, &slot.label, waiting);
717 frame.render_widget(hourglass, content);
718 }
719 (None, Phase::Loading(_)) => {}
720 (None, Phase::Trouble(lines)) => message(frame, content, lines.into_owned()),
721 (None, _) => self.screens[index].screen.draw(frame, content, &palette),
722 }
723 if let Some(picking) = self.out.picking.as_mut() {
724 picking.pick.draw(frame, content, &palette);
725 }
726 for opened in &mut self.out.modals {
727 opened.modal.draw(frame, content, &palette);
728 }
729 self.draw_toasts(frame, content);
730 }
731}