| 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 |