use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block as TuiBlock, BorderType, Padding, Paragraph, Widget};
use crate::code::highlight::HLSpan;
use crate::ui::Groups;
pub struct RenderContext<'a> {
width: u16,
gutter: u16,
debugging: bool,
animation_frame: usize,
groups: &'a Groups,
}
impl<'a> RenderContext<'a> {
pub fn new(width: u16, gutter: u16, groups: &'a Groups) -> Self {
Self {
width,
gutter,
debugging: false,
animation_frame: 0,
groups,
}
}
pub fn debugging(mut self, debugging: bool) -> Self {
self.debugging = debugging;
self
}
pub fn animation_frame(mut self, frame: usize) -> Self {
self.animation_frame = frame;
self
}
pub fn is_debugging(&self) -> bool {
self.debugging
}
pub fn width(&self) -> u16 {
self.width
}
pub fn gutter(&self) -> u16 {
self.gutter
}
pub fn groups(&self) -> &'a Groups {
self.groups
}
pub fn spinner(&self) -> &'static str {
const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
FRAMES[self.animation_frame % FRAMES.len()]
}
}
const MARKER_MARGIN: u16 = 2;
const BORDER_MARGIN: u16 = 1;
const MARKER_TEXT_GAP: u16 = 2;
const BORDER_LABEL_GAP: u16 = 1;
const RIGHT_MARGIN: u16 = 3;
const MIN_BOX_W: u16 = 8;
fn marker_width(number: u32) -> u16 {
1 + number.max(1).ilog10() as u16 + 1
}
pub fn gutter_width(number: u32) -> u16 {
MARKER_MARGIN + marker_width(number) + MARKER_TEXT_GAP
}
pub fn prompt_gutter_width() -> u16 {
MARKER_MARGIN + 1 + MARKER_TEXT_GAP
}
pub fn border_label(area: Rect, buf: &mut Buffer, label: String, style: Style) {
let width = label.chars().count() as u16 + BORDER_LABEL_GAP;
Paragraph::new(Span::styled(format!("{label} "), style))
.render(Rect::new(area.x + MARKER_MARGIN, area.y, width, 1), buf);
}
#[derive(Clone, Copy)]
pub enum BoxLayout {
Text,
Output {
marker_gutter: u16,
content_gutter: u16,
},
}
pub fn gutter_span(label: &str, gutter: u16, style: Style) -> Span<'static> {
let trailing =
usize::from(gutter).saturating_sub(usize::from(MARKER_MARGIN) + label.chars().count());
let text = format!(
"{}{}{}",
" ".repeat(MARKER_MARGIN.into()),
label,
" ".repeat(trailing)
);
Span::styled(text, style)
}
#[derive(Clone, Copy)]
pub enum CellMarker {
Empty,
Number(u32),
Pending(&'static str),
Queued,
HardContinuation,
}
pub struct CodeBlockStyle {
base: Style,
column: Style,
}
impl CodeBlockStyle {
pub fn input(groups: &Groups) -> Self {
let base = groups.code();
Self {
base,
column: groups.muted().patch(base),
}
}
pub fn preview(groups: &Groups) -> Self {
let base = groups.transcript();
Self {
base,
column: groups.muted().patch(base),
}
}
pub fn error(groups: &Groups) -> Self {
let base = groups.code();
Self {
base,
column: groups.error().patch(base),
}
}
pub fn base(&self) -> Style {
self.base
}
pub fn column(&self, marker: CellMarker, width: u16) -> Vec<Span<'static>> {
let label = match marker {
CellMarker::Empty => String::new(),
CellMarker::Number(number) => format!("i{number}"),
CellMarker::Pending(spinner) => spinner.to_string(),
CellMarker::Queued => "…".to_string(),
CellMarker::HardContinuation => "".to_string(),
};
vec![gutter_span(&label, width, self.column)]
}
}
pub fn apply_spans(
abs_start: usize,
seg: &str,
spans: &[HLSpan],
base: Style,
groups: &Groups,
) -> Vec<Span<'static>> {
let seg_end = abs_start + seg.len();
let mut out = Vec::new();
let mut cur = abs_start;
for s in spans
.iter()
.filter(|s| s.range.start < seg_end && s.range.end > abs_start)
{
let st = s.range.start.max(abs_start);
let en = s.range.end.min(seg_end);
if st > cur {
out.push(Span::styled(
seg[cur - abs_start..st - abs_start].to_string(),
base,
));
}
out.push(Span::styled(
seg[st - abs_start..en - abs_start].to_string(),
base.patch(groups.macaulay(s.kind)),
));
cur = en;
}
if cur < seg_end {
out.push(Span::styled(seg[cur - abs_start..].to_string(), base));
}
if out.is_empty() {
out.push(Span::styled(seg.to_string(), base));
}
out
}
pub fn band(area: Rect, buf: &mut Buffer, lines: Vec<Line<'static>>, base: Style) {
Paragraph::new(lines).style(base).render(area, buf);
}
fn box_left_padding() -> u16 {
MARKER_TEXT_GAP + MARKER_MARGIN - BORDER_MARGIN
}
impl BoxLayout {
fn inset(self) -> u16 {
match self {
Self::Text => BORDER_MARGIN + marker_width(1) - 1,
Self::Output { marker_gutter, .. } => {
marker_gutter.saturating_sub(MARKER_MARGIN + MARKER_TEXT_GAP)
}
}
}
fn padding(self) -> Padding {
match self {
Self::Text => Padding::new(box_left_padding(), 1, 0, 0),
Self::Output { content_gutter, .. } => {
Padding::new(content_gutter.saturating_sub(self.inset() + 1), 2, 0, 0)
}
}
}
}
pub fn box_area(area: Rect, layout: BoxLayout) -> Rect {
let inset = layout.inset();
let width = area
.width
.saturating_sub(inset + RIGHT_MARGIN)
.max(MIN_BOX_W);
Rect::new(area.x + inset, area.y, width, area.height)
}
pub fn box_inner_area(area: Rect, layout: BoxLayout) -> Rect {
TuiBlock::bordered()
.padding(layout.padding())
.inner(box_area(area, layout))
}
pub fn box_text_width(context: &RenderContext<'_>, layout: BoxLayout) -> u16 {
box_inner_area(Rect::new(0, 0, context.width(), 0), layout).width
}
pub fn boxed(
area: Rect,
buf: &mut Buffer,
layout: BoxLayout,
border_style: Style,
title: Option<Line<'static>>,
bottom: Option<Line<'static>>,
body: Vec<Line<'static>>,
) {
let box_area = box_area(area, layout);
let mut block = TuiBlock::bordered()
.border_type(BorderType::Rounded)
.border_style(border_style)
.padding(layout.padding());
if let Some(t) = title {
block = block.title(t);
}
if let Some(b) = bottom {
block = block.title_bottom(b);
}
let inner = box_inner_area(area, layout);
block.render(box_area, buf);
Paragraph::new(body).render(inner, buf);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marker_gutter_tracks_the_number_width() {
assert_eq!(gutter_width(9), 6);
assert_eq!(gutter_width(99), 7);
assert_eq!(gutter_width(100), 8);
assert_eq!(gutter_width(1000), 9);
}
#[test]
fn markers_have_two_spaces_on_each_side() {
let narrow = gutter_span("i9", gutter_width(9), Style::default());
let wide = gutter_span("i99", gutter_width(99), Style::default());
assert_eq!(narrow.content.as_ref(), " i9 ");
assert_eq!(wide.content.as_ref(), " i99 ");
}
#[test]
fn output_border_crosses_the_second_last_marker_character() {
for number in [1u32, 42, 100, 5000] {
let layout = BoxLayout::Output {
marker_gutter: gutter_width(number),
content_gutter: gutter_width(5000),
};
assert_eq!(layout.inset(), BORDER_MARGIN + marker_width(number) - 1);
}
}
#[test]
fn box_body_starts_two_spaces_after_the_output_marker() {
for number in [1u32, 42, 100, 5000] {
let layout = BoxLayout::Output {
marker_gutter: gutter_width(number),
content_gutter: gutter_width(5000),
};
assert_eq!(
layout.inset() + 1 + layout.padding().left,
gutter_width(5000),
"misaligned at o{number}"
);
}
}
}