返回 CodeWhale
surface.rs
1 //! Panels with depth, and the one horizon.
2 //!
3 //! Depth is elevation: raised things sit nearer the surface, the stage and
4 //! things put away sit deeper. Where the terminal paints grounds, depth is a
5 //! ground; where it cannot (16 colors, `NO_COLOR`, an unmeasured ground, or a
6 //! depth where two grounds quantize to one color), depth becomes an edge,
7 //! because a fill nobody can see separates nothing.
8 //!
9 //! Replaces the engine's three modal treatments (`render_modal_surface` with
10 //! its shadow, `render_underwater_surface` with its two rules, and hand-rolled
11 //! `Clear` blocks) and lifts `centered_modal_area` (`crates/tui/src/tui/
12 //! views/mod.rs`, `Hmbown/CodeWhale` `58b1dd3dd`).
13
14 use std::borrow::Cow;
15
16 use ratatui::{
17 buffer::Buffer,
18 layout::Rect,
19 style::{Modifier, Style},
20 symbols::border,
21 text::{Line, Span},
22 widgets::{Block, Borders, Clear, Paragraph, Widget},
23 };
24
25 use crate::{KeyHints, Paint, Role, Theme, color, glyphs, text};
26
27 /// How far from the surface a panel sits.
28 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29 pub enum Depth {
30 /// Rails and things put away.
31 Deep,
32 /// The stage: full-screen sheets. No edge; a two-cell gutter.
33 Stage,
34 /// A card on the stage.
35 Raised,
36 /// A decision over everything else: always edged in `BorderStrong`.
37 Overlay,
38 }
39
40 impl Depth {
41 #[must_use]
42 pub const fn ground(self) -> Role {
43 match self {
44 Depth::Deep => Role::Sidebar,
45 Depth::Stage => Role::Background,
46 Depth::Raised | Depth::Overlay => Role::Surface,
47 }
48 }
49 }
50
51 /// A titled panel. [`Panel::draw`] paints it and returns the content area.
52 #[derive(Clone, Debug)]
53 pub struct Panel<'a> {
54 pub title: Option<Cow<'a, str>>,
55 /// Muted text at the right of the title row: a count, "changed 2 min ago".
56 pub aside: Option<Cow<'a, str>>,
57 pub depth: Depth,
58 /// Light the edge in `Primary`: this panel has the keyboard.
59 pub focused: bool,
60 pub hints: Option<&'a KeyHints>,
61 }
62
63 impl<'a> Panel<'a> {
64 #[must_use]
65 pub fn new(depth: Depth) -> Self {
66 Self {
67 title: None,
68 aside: None,
69 depth,
70 focused: false,
71 hints: None,
72 }
73 }
74
75 #[must_use]
76 pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
77 self.title = Some(title.into());
78 self
79 }
80
81 #[must_use]
82 pub fn aside(mut self, aside: impl Into<Cow<'a, str>>) -> Self {
83 self.aside = Some(aside.into());
84 self
85 }
86
87 #[must_use]
88 pub fn focused(mut self, focused: bool) -> Self {
89 self.focused = focused;
90 self
91 }
92
93 #[must_use]
94 pub fn hints(mut self, hints: &'a KeyHints) -> Self {
95 self.hints = Some(hints);
96 self
97 }
98
99 fn edged(&self, theme: &Theme) -> bool {
100 match self.depth {
101 Depth::Overlay => true,
102 // Light 256-color `Surface` and `Background` are both white.
103 Depth::Raised => !theme.grounds_differ(Role::Surface, Role::Background),
104 Depth::Deep | Depth::Stage => false,
105 }
106 }
107
108 /// Where everything goes in `area`, without painting. [`Panel::draw`]
109 /// paints from this, and [`Panel::body`] and [`Dialog`] size from it, so
110 /// the three cannot disagree.
111 fn layout(&self, area: Rect, theme: &Theme) -> PanelLayout {
112 let edged = self.edged(theme);
113 let mut inner = area;
114 if edged {
115 inner = Block::default().borders(Borders::ALL).inner(area);
116 }
117
118 let gutter = match self.depth {
119 Depth::Stage if inner.width >= 24 => 2,
120 _ if inner.width >= 8 => 1,
121 _ => 0,
122 };
123 let vpad = u16::from(edged && inner.height >= 6);
124 inner = Rect {
125 x: inner.x + gutter,
126 y: inner.y + vpad,
127 width: inner.width.saturating_sub(gutter * 2),
128 height: inner.height.saturating_sub(vpad * 2),
129 };
130
131 let mut title_row = None;
132 if self.title.is_some() && inner.height > 0 {
133 title_row = Some(Rect { height: 1, ..inner });
134 let used = 1 + u16::from(inner.height >= 6);
135 inner.y += used.min(inner.height);
136 inner.height = inner.height.saturating_sub(used);
137 }
138
139 let mut rail = None;
140 if let Some(hints) = self.hints {
141 let lines = hints.lines(inner.width, theme);
142 let h = u16::try_from(lines.len())
143 .unwrap_or(u16::MAX)
144 .min(inner.height);
145 if h > 0 {
146 let rect = Rect {
147 y: inner.bottom() - h,
148 height: h,
149 ..inner
150 };
151 rail = Some((rect, lines));
152 let gap = u16::from(inner.height >= h + 4);
153 inner.height = inner.height.saturating_sub(h + gap);
154 }
155 }
156 PanelLayout {
157 edged,
158 title_row,
159 rail,
160 body: inner,
161 }
162 }
163
164 /// The content area [`Panel::draw`] returns for `area`, without
165 /// painting: for sizing a popup to what goes in it.
166 #[must_use]
167 pub fn body(&self, area: Rect, theme: &Theme) -> Rect {
168 if area.is_empty() {
169 return area;
170 }
171 self.layout(area, theme).body
172 }
173
174 /// Paint the panel and return the area left for content.
175 pub fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect {
176 let area = area.intersection(buf.area);
177 if area.is_empty() {
178 return area;
179 }
180 Clear.render(area, buf);
181 let ground = theme.bg(self.depth.ground());
182 buf.set_style(area, ground);
183
184 let layout = self.layout(area, theme);
185 if layout.edged {
186 let edge_role = if self.focused {
187 Role::Primary
188 } else if self.depth == Depth::Overlay {
189 Role::BorderStrong
190 } else {
191 Role::Border
192 };
193 let set = if theme.ascii() {
194 border::Set {
195 top_left: "+",
196 top_right: "+",
197 bottom_left: "+",
198 bottom_right: "+",
199 vertical_left: "|",
200 vertical_right: "|",
201 horizontal_top: "-",
202 horizontal_bottom: "-",
203 }
204 } else {
205 border::PLAIN
206 };
207 Block::default()
208 .borders(Borders::ALL)
209 .border_set(set)
210 .border_style(theme.fg(edge_role).patch(ground))
211 .render(area, buf);
212 }
213
214 if let (Some(title), Some(row)) = (&self.title, layout.title_row) {
215 let title = text::display_safe(title);
216 // The title keeps its room: an aside that cannot sit beside it
217 // is cut to a third of the row, or dropped.
218 let row_w = usize::from(row.width);
219 let aside = self.aside.as_deref().map(text::display_safe).and_then(|a| {
220 if text::width(&title) + 2 + text::width(&a) <= row_w {
221 Some(a.into_owned())
222 } else {
223 let cut = text::truncate(&a, row_w / 3, theme.ascii());
224 (row_w / 3 >= 4 && !cut.is_empty()).then(|| cut.into_owned())
225 }
226 });
227 let aside_w = aside.as_deref().map_or(0, |a| text::width(a) + 2);
228 let title_w = row_w.saturating_sub(aside_w);
229 let title = text::truncate(&title, title_w, theme.ascii());
230 Line::from(Span::styled(
231 title.into_owned(),
232 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
233 ))
234 .render(row, buf);
235 if let Some(aside) = aside
236 && aside_w > 0
237 && aside_w <= usize::from(row.width)
238 {
239 Line::from(Span::styled(aside, theme.fg(Role::Muted)))
240 .right_aligned()
241 .render(row, buf);
242 }
243 }
244
245 if let Some((rect, lines)) = layout.rail {
246 Paragraph::new(lines).render(rect, buf);
247 }
248 layout.body
249 }
250 }
251
252 /// The geometry of a [`Panel`] in an area.
253 struct PanelLayout {
254 edged: bool,
255 title_row: Option<Rect>,
256 rail: Option<(Rect, Vec<Line<'static>>)>,
257 body: Rect,
258 }
259
260 impl Paint for Panel<'_> {
261 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
262 self.draw(area, buf, theme);
263 }
264 }
265
266 /// A centered popup rect: starts from the preferred size, never exceeds the
267 /// frame (keeping a one-cell margin when it can), and never drops below the
268 /// minimum unless the frame is smaller. From the engine's
269 /// `centered_modal_area` (#3732).
270 #[must_use]
271 pub fn centered(
272 area: Rect,
273 preferred_width: u16,
274 preferred_height: u16,
275 min_width: u16,
276 min_height: u16,
277 ) -> Rect {
278 if area.is_empty() {
279 return area;
280 }
281 let avail_width = area.width.saturating_sub(2).max(1);
282 let avail_height = area.height.saturating_sub(2).max(1);
283 let width = preferred_width.clamp(min_width.min(avail_width), avail_width);
284 let height = preferred_height.clamp(min_height.min(avail_height), avail_height);
285 Rect {
286 x: area.x + area.width.saturating_sub(width) / 2,
287 y: area.y + area.height.saturating_sub(height) / 2,
288 width,
289 height,
290 }
291 }
292
293 /// How wide a [`Dialog`] wants to be before the terminal clamps it.
294 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
295 pub enum DialogWidth {
296 /// A yes-or-no question: 48 cells.
297 Narrow,
298 /// Most decisions: 60 cells.
299 #[default]
300 Standard,
301 /// Detail worth reading before deciding: 72 cells.
302 Wide,
303 }
304
305 impl DialogWidth {
306 /// The preferred width in cells.
307 #[must_use]
308 pub const fn cells(self) -> u16 {
309 match self {
310 DialogWidth::Narrow => 48,
311 DialogWidth::Standard => 60,
312 DialogWidth::Wide => 72,
313 }
314 }
315 }
316
317 /// `preferred` cells of a `total`-cell axis, leaving `margin` cells of
318 /// backdrop when the axis can spare them, never below `floor` unless the
319 /// axis itself is smaller, and never more than the axis.
320 fn fit_axis(total: u16, preferred: u16, margin: u16, floor: u16) -> u16 {
321 preferred.min(total.saturating_sub(margin).max(floor.min(total)))
322 }
323
324 /// A decision, centered over everything else: confirmations, approvals,
325 /// destructive actions. Always edged in `BorderStrong`, so it stays legible
326 /// where grounds do not paint (16 colors, `NO_COLOR`), with no shadow and no
327 /// rounded corners. Replaces the engine's modal surface with its shadow and
328 /// the hand-rolled `Clear` blocks.
329 ///
330 /// ```
331 /// # use codewhale_ratatui::{Dialog, DialogWidth, KeyHint, KeyHints, Theme};
332 /// # use ratatui::{buffer::Buffer, layout::Rect};
333 /// # let (theme, area) = (Theme::detect(), Rect::new(0, 0, 80, 24));
334 /// # let mut buf = Buffer::empty(area);
335 /// let hints = KeyHints::new(vec![KeyHint::new("y", "stop"), KeyHint::new("n", "keep running")]);
336 /// let body = Dialog::new()
337 /// .title("Stop the running workflow?")
338 /// .width(DialogWidth::Narrow)
339 /// .body_rows(2)
340 /// .hints(&hints)
341 /// .draw(area, &mut buf, &theme);
342 /// // paint the question into `body`
343 /// # let _ = body;
344 /// ```
345 #[derive(Clone, Debug, Default)]
346 pub struct Dialog<'a> {
347 pub title: Option<Cow<'a, str>>,
348 pub width: DialogWidth,
349 /// Rows of body the caller will fill. The dialog is as tall as that
350 /// needs, with padding and a blank row between its parts, then clamped
351 /// to the terminal. The body it returns has at least this many rows
352 /// unless the terminal is too short.
353 pub body_rows: u16,
354 pub hints: Option<&'a KeyHints>,
355 }
356
357 impl<'a> Dialog<'a> {
358 /// The narrowest a dialog gets while the terminal can still spare it a
359 /// margin.
360 const MIN_WIDTH: u16 = 20;
361 const MIN_HEIGHT: u16 = 5;
362
363 #[must_use]
364 pub fn new() -> Self {
365 Self::default()
366 }
367
368 #[must_use]
369 pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
370 self.title = Some(title.into());
371 self
372 }
373
374 #[must_use]
375 pub fn width(mut self, width: DialogWidth) -> Self {
376 self.width = width;
377 self
378 }
379
380 #[must_use]
381 pub fn body_rows(mut self, rows: u16) -> Self {
382 self.body_rows = rows;
383 self
384 }
385
386 #[must_use]
387 pub fn hints(mut self, hints: &'a KeyHints) -> Self {
388 self.hints = Some(hints);
389 self
390 }
391
392 fn panel(&self) -> Panel<'a> {
393 Panel {
394 title: self.title.clone(),
395 aside: None,
396 depth: Depth::Overlay,
397 focused: false,
398 hints: self.hints,
399 }
400 }
401
402 fn width_in(&self, area_width: u16) -> u16 {
403 fit_axis(area_width, self.width.cells(), 4, Self::MIN_WIDTH)
404 }
405
406 /// Rows that leave `body_rows` of body at `width`, before clamping.
407 /// Starts from the roomy layout (padding, a blank row under the title and
408 /// above the hints) and grows until the panel really leaves that body.
409 fn rows_for(&self, width: u16, theme: &Theme) -> u16 {
410 let panel = self.panel();
411 let want = self.body_rows.max(1);
412 let hint_rows = self.hints.map_or(0, |h| h.height(width, theme));
413 let chrome = 4
414 + if self.title.is_some() { 2 } else { 0 }
415 + if hint_rows > 0 { hint_rows + 1 } else { 0 };
416 let start = want.saturating_add(chrome);
417 let limit = start.saturating_add(8);
418 (start..=limit)
419 .find(|&h| panel.body(Rect::new(0, 0, width, h), theme).height >= want)
420 .unwrap_or(limit)
421 }
422
423 /// Where the dialog goes in `area`: centered, at most the width asked
424 /// for, never wider or taller than `area`, with a two-cell margin at the
425 /// sides and one row above and below when `area` can spare them.
426 #[must_use]
427 pub fn rect(&self, area: Rect, theme: &Theme) -> Rect {
428 let width = self.width_in(area.width);
429 let rows = self.rows_for(width, theme);
430 let height = fit_axis(area.height, rows, 2, Self::MIN_HEIGHT);
431 Rect {
432 x: area.x + (area.width - width) / 2,
433 y: area.y + (area.height - height) / 2,
434 width,
435 height,
436 }
437 }
438
439 /// Paint the dialog over whatever is in `area` and return the body area
440 /// for the caller to fill.
441 pub fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect {
442 let area = area.intersection(buf.area);
443 if area.is_empty() {
444 return area;
445 }
446 self.panel().draw(self.rect(area, theme), buf, theme)
447 }
448 }
449
450 impl Paint for Dialog<'_> {
451 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
452 self.draw(area, buf, theme);
453 }
454
455 /// Rows the dialog wants at `width`, before the terminal clamps it.
456 fn height(&self, width: u16, theme: &Theme) -> u16 {
457 self.rows_for(self.width_in(width), theme)
458 }
459 }
460
461 /// The edge a [`Sheet`] is anchored to.
462 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
463 pub enum SheetEdge {
464 #[default]
465 Bottom,
466 Top,
467 Left,
468 Right,
469 }
470
471 impl SheetEdge {
472 /// Whether the sheet's size is a height (it spans the width).
473 #[must_use]
474 pub const fn spans_width(self) -> bool {
475 matches!(self, SheetEdge::Top | SheetEdge::Bottom)
476 }
477 }
478
479 /// A raised surface anchored to one edge of the screen: settings, managers
480 /// and pickers that slide in over the work. The ground separates it where
481 /// grounds paint; where they do not (16 colors, `NO_COLOR`, a 256-color
482 /// ground that collapses) [`Panel`] draws an edge, so a sheet is never an
483 /// unmarked patch of text. Replaces the engine's underwater surface, whose
484 /// two rules broke the one-horizon rule.
485 #[derive(Clone, Debug, Default)]
486 pub struct Sheet<'a> {
487 pub title: Option<Cow<'a, str>>,
488 /// Muted text at the right of the title row.
489 pub aside: Option<Cow<'a, str>>,
490 pub edge: SheetEdge,
491 /// Rows (top and bottom) or columns (left and right) wanted; half the
492 /// screen when unset.
493 pub size: Option<u16>,
494 /// The most rows or columns the sheet takes, however much is wanted.
495 pub max_size: Option<u16>,
496 pub focused: bool,
497 pub hints: Option<&'a KeyHints>,
498 }
499
500 impl<'a> Sheet<'a> {
501 /// A sheet anchored to the bottom edge.
502 #[must_use]
503 pub fn new() -> Self {
504 Self::default()
505 }
506
507 #[must_use]
508 pub fn edge(mut self, edge: SheetEdge) -> Self {
509 self.edge = edge;
510 self
511 }
512
513 #[must_use]
514 pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
515 self.title = Some(title.into());
516 self
517 }
518
519 #[must_use]
520 pub fn aside(mut self, aside: impl Into<Cow<'a, str>>) -> Self {
521 self.aside = Some(aside.into());
522 self
523 }
524
525 #[must_use]
526 pub fn size(mut self, size: u16) -> Self {
527 self.size = Some(size);
528 self
529 }
530
531 #[must_use]
532 pub fn max_size(mut self, max: u16) -> Self {
533 self.max_size = Some(max);
534 self
535 }
536
537 #[must_use]
538 pub fn focused(mut self, focused: bool) -> Self {
539 self.focused = focused;
540 self
541 }
542
543 #[must_use]
544 pub fn hints(mut self, hints: &'a KeyHints) -> Self {
545 self.hints = Some(hints);
546 self
547 }
548
549 /// Where the sheet goes in `area`: against its edge, across the whole
550 /// other axis, and as large as asked for, no larger than `max_size` or
551 /// `area`.
552 #[must_use]
553 pub fn rect(&self, area: Rect) -> Rect {
554 let total = if self.edge.spans_width() {
555 area.height
556 } else {
557 area.width
558 };
559 let size = self
560 .size
561 .unwrap_or_else(|| total.div_ceil(2))
562 .min(self.max_size.unwrap_or(u16::MAX))
563 .min(total);
564 match self.edge {
565 SheetEdge::Bottom => Rect {
566 y: area.bottom() - size,
567 height: size,
568 ..area
569 },
570 SheetEdge::Top => Rect {
571 height: size,
572 ..area
573 },
574 SheetEdge::Left => Rect {
575 width: size,
576 ..area
577 },
578 SheetEdge::Right => Rect {
579 x: area.right() - size,
580 width: size,
581 ..area
582 },
583 }
584 }
585
586 fn panel(&self) -> Panel<'a> {
587 Panel {
588 title: self.title.clone(),
589 aside: self.aside.clone(),
590 depth: Depth::Raised,
591 focused: self.focused,
592 hints: self.hints,
593 }
594 }
595
596 /// Paint the sheet over whatever is in `area` and return the body area
597 /// for the caller to fill.
598 pub fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect {
599 let area = area.intersection(buf.area);
600 if area.is_empty() {
601 return area;
602 }
603 self.panel().draw(self.rect(area), buf, theme)
604 }
605 }
606
607 impl Paint for Sheet<'_> {
608 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
609 self.draw(area, buf, theme);
610 }
611 }
612
613 /// The horizon: one full-width rule, drawn once per frame, above the place
614 /// where the person types. Sugimoto's seascapes, not a table border.
615 ///
616 /// On a truecolor ground we painted, its ends fade into it, so it reads as a
617 /// horizon rather than a box edge. Everywhere else it is a plain `Border`
618 /// line; ASCII-safe terminals draw `-`.
619 #[derive(Clone, Debug, Default)]
620 pub struct HorizonRule<'a> {
621 /// Muted words at the left: what the space below is for.
622 pub label: Option<Cow<'a, str>>,
623 /// Muted words at the right.
624 pub aside: Option<Cow<'a, str>>,
625 }
626
627 impl<'a> HorizonRule<'a> {
628 #[must_use]
629 pub fn new() -> Self {
630 Self::default()
631 }
632
633 #[must_use]
634 pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
635 self.label = Some(label.into());
636 self
637 }
638
639 #[must_use]
640 pub fn aside(mut self, aside: impl Into<Cow<'a, str>>) -> Self {
641 self.aside = Some(aside.into());
642 self
643 }
644 }
645
646 impl Paint for HorizonRule<'_> {
647 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
648 let area = area.intersection(buf.area);
649 if area.is_empty() {
650 return;
651 }
652 let row = Rect { height: 1, ..area };
653 let rule = glyphs::pick("─", theme.ascii());
654 let line_style = theme.fg(Role::Border);
655 // Fade only into a ground we painted: after `without_base_ground` the
656 // ground is the terminal's own, whose color we do not know.
657 let fade = theme.depth() == color::ColorDepth::TrueColor && theme.paints_base_ground();
658 let width = row.width;
659 let ramp = (width / 8).min(6);
660 for i in 0..width {
661 let mut style = line_style;
662 if fade && ramp > 0 {
663 let from_edge = i.min(width - 1 - i);
664 if from_edge < ramp {
665 let alpha = f32::from(from_edge + 1) / f32::from(ramp + 1);
666 style = Style::default().fg(color::blend(
667 theme.token(Role::Border),
668 theme.token(Role::Background),
669 alpha,
670 ));
671 }
672 }
673 buf[(row.x + i, row.y)].set_symbol(rule).set_style(style);
674 }
675 let muted = theme.fg(Role::Muted);
676 if let Some(label) = &self.label {
677 let label = text::display_safe(label);
678 let budget = usize::from(width.saturating_sub(ramp.max(2) * 2 + 4)) / 2;
679 let label = format!(" {} ", text::truncate(&label, budget, theme.ascii()));
680 let x = row.x + ramp.max(2).min(width);
681 buf.set_stringn(x, row.y, &label, usize::from(row.right() - x), muted);
682 }
683 if let Some(aside) = &self.aside {
684 let aside = text::display_safe(aside);
685 let budget = usize::from(width.saturating_sub(ramp.max(2) * 2 + 4)) / 2;
686 let aside = format!(" {} ", text::truncate(&aside, budget, theme.ascii()));
687 let w = u16::try_from(text::width(&aside)).unwrap_or(u16::MAX);
688 let x = row.right().saturating_sub(w + ramp.max(2));
689 if x > row.x {
690 buf.set_stringn(x, row.y, &aside, usize::from(w), muted);
691 }
692 }
693 }
694 }
695
696 #[cfg(test)]
697 mod tests {
698 use super::*;
699
700 #[test]
701 fn centered_clamps_to_the_frame() {
702 let area = Rect::new(0, 0, 80, 24);
703 assert_eq!(centered(area, 68, 10, 44, 8), Rect::new(6, 7, 68, 10));
704 let small = Rect::new(0, 0, 30, 6);
705 let r = centered(small, 68, 10, 44, 8);
706 assert!(r.width <= 28 && r.height <= 4);
707 }
708
709 /// A raised card must be told from the stage in every profile: by its
710 /// ground where the two grounds differ, by an edge everywhere else.
711 #[test]
712 fn a_raised_panel_is_always_distinguishable() {
713 use crate::testing::{Profile, render, text};
714 for profile in Profile::ALL {
715 for theme in [profile.theme(), profile.theme().without_base_ground()] {
716 let buf = render(12, 4, |area, buf| {
717 Panel::new(Depth::Raised).draw(area, buf, &theme);
718 });
719 let edged = text(&buf).starts_with(['┌', '+']);
720 let grounded = theme.grounds_differ(Role::Surface, Role::Background);
721 assert!(
722 edged || grounded,
723 "{}: raised panel has neither an edge nor its own ground",
724 profile.name()
725 );
726 }
727 }
728 let light256 = Profile::Light256.theme();
729 assert!(!light256.grounds_differ(Role::Surface, Role::Background));
730 }
731
732 #[test]
733 fn the_horizon_does_not_fade_into_a_ground_it_did_not_paint() {
734 use crate::testing::{Profile, render};
735 let theme = Profile::DarkTrue.theme().without_base_ground();
736 let buf = render(40, 1, |area, buf| {
737 HorizonRule::new().paint(area, buf, &theme)
738 });
739 for x in 0..40 {
740 assert_eq!(
741 buf[(x, 0)].fg,
742 theme.color(Role::Border).unwrap(),
743 "col {x}"
744 );
745 }
746 let painted = Profile::DarkTrue.theme();
747 let buf = render(40, 1, |area, buf| {
748 HorizonRule::new().paint(area, buf, &painted);
749 });
750 assert_ne!(buf[(0, 0)].fg, painted.color(Role::Border).unwrap());
751 }
752 }
753
753 lines RUST