use super::backend::{CellStyle as BackendStyle, TerminalBackend};
use super::hints::HintPanel;
use super::text;
use crate::config::StyleColor;
use crate::ids::PaneId;
use crate::view::{CellKind, Frame, PaneView};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::{Color, Modifier, Style};
use std::io;
pub struct LocalView<'a> {
pub pane: PaneId,
pub view: &'a PaneView,
pub cursor: (u16, u16),
pub anchor: Option<(u16, u16)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notice {
pub text: String,
pub error: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub bar: Option<Color>,
pub bar_background: Option<Color>,
pub tab_active: Option<Color>,
pub separator: Option<Color>,
pub separator_focused: Option<Color>,
pub notice: Option<Color>,
}
impl From<&crate::config::Style> for Palette {
fn from(style: &crate::config::Style) -> Self {
Self {
bar: color(style.bar),
bar_background: color(style.bar_background),
tab_active: color(style.tab_active),
separator: color(style.separator),
separator_focused: color(style.separator_focused),
notice: color(style.notice),
}
}
}
impl Default for Palette {
fn default() -> Self {
Self::from(&crate::config::Style::default())
}
}
const fn color(value: StyleColor) -> Option<Color> {
Some(match value {
StyleColor::None => return None,
StyleColor::Default => Color::Reset,
StyleColor::Black => Color::Black,
StyleColor::Red => Color::Red,
StyleColor::Green => Color::Green,
StyleColor::Yellow => Color::Yellow,
StyleColor::Blue => Color::Blue,
StyleColor::Magenta => Color::Magenta,
StyleColor::Cyan => Color::Cyan,
StyleColor::White => Color::Gray,
StyleColor::BrightBlack => Color::DarkGray,
StyleColor::BrightRed => Color::LightRed,
StyleColor::BrightGreen => Color::LightGreen,
StyleColor::BrightYellow => Color::LightYellow,
StyleColor::BrightBlue => Color::LightBlue,
StyleColor::BrightMagenta => Color::LightMagenta,
StyleColor::BrightCyan => Color::LightCyan,
StyleColor::BrightWhite => Color::White,
})
}
fn styled(fg: Option<Color>) -> Style {
fg.map_or_else(Style::default, |fg| Style::default().fg(fg))
}
pub struct Composed {
pub buffer: Buffer,
pub cursor: Option<(u16, u16)>,
}
pub fn compose(
frame: &Frame,
local: Option<&LocalView<'_>>,
panel: Option<&HintPanel>,
notice: Option<&Notice>,
palette: &Palette,
rows: u16,
cols: u16,
) -> Composed {
let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows));
let mut cursor = None;
if rows == 0 || cols == 0 {
return Composed { buffer, cursor };
}
for entry in &frame.layout {
let content = Rect::new(
entry.rect.x,
entry.rect.y,
entry.rect.width,
entry.rect.height,
);
let focused = frame.focused == Some(entry.pane);
let Some(pane) = frame.pane(entry.pane) else {
continue;
};
let overlay = local.filter(|local| local.pane == entry.pane);
let view = overlay.map_or(pane, |local| local.view);
paint_pane(&mut buffer, content, view);
if let Some(local) = overlay {
paint_selection(&mut buffer, content, local);
if focused && local.cursor.0 < content.height && local.cursor.1 < content.width {
cursor = Some((
content.y.saturating_add(local.cursor.0),
content.x.saturating_add(local.cursor.1),
));
}
continue;
}
if focused
&& !pane.cursor.hidden
&& pane.exit.is_none()
&& pane.cursor.row < content.height
&& pane.cursor.column < content.width
{
cursor = Some((
content.y.saturating_add(pane.cursor.row),
content.x.saturating_add(pane.cursor.column),
));
}
if let Some(code) = pane.exit
&& !focused
{
paint_exit_marker(&mut buffer, content, code, palette);
}
}
paint_separators(&mut buffer, frame, palette);
paint_bar(&mut buffer, frame, notice, palette);
if let Some(panel) = panel {
let above_bar = Rect::new(0, 0, cols, rows.saturating_sub(1));
panel.paint(&mut buffer, above_bar);
if !panel.is_thin() || local.is_none() {
cursor = None;
}
}
Composed { buffer, cursor }
}
fn paint_exit_marker(buffer: &mut Buffer, content: Rect, code: u32, palette: &Palette) {
if content.height == 0 || content.width == 0 {
return;
}
let label = format!(" exit {code} ");
let label_width = text::width(&label);
if label_width > content.width {
return;
}
let x = content
.x
.saturating_add(content.width)
.saturating_sub(label_width);
let y = content.y.saturating_add(content.height).saturating_sub(1);
text::put(
buffer,
x,
y,
&label,
label_width,
styled(palette.bar)
.add_modifier(Modifier::DIM)
.add_modifier(Modifier::REVERSED),
);
}
fn paint_bar(buffer: &mut Buffer, frame: &Frame, notice: Option<&Notice>, palette: &Palette) {
let width = buffer.area.width;
let row = buffer.area.height.saturating_sub(1);
let base = palette
.bar_background
.map_or_else(|| styled(palette.bar), |bg| styled(palette.bar).bg(bg));
for col in 0..width {
if let Some(cell) = buffer.cell_mut((col, row)) {
cell.set_symbol(" ").set_style(base);
}
}
let active_width = frame
.tabs
.iter()
.find(|tab| Some(tab.id) == frame.active_tab)
.map_or(0, |tab| text::width(&tab.label).saturating_add(2));
let mut left = format!(" {} │", frame.workspace);
if text::width(&left).saturating_add(active_width) > width {
let allowed = width
.saturating_sub(active_width)
.max(width / 4)
.clamp(3, width.max(3));
left = format!(
" {} │",
text::head(&frame.workspace, allowed.saturating_sub(3))
);
}
let (right_text, right_style) = match notice {
Some(notice) => (
notice.text.clone(),
if notice.error {
base.fg(Color::Red)
} else {
palette.notice.map_or(base, |fg| base.fg(fg))
},
),
None => (focused_label(frame), base),
};
let left_width = text::width(&left).min(width);
let mut room = width.saturating_sub(left_width);
let allowance = room.saturating_sub(active_width.min(room));
let mut right_text = right_text;
if !right_text.is_empty() && text::width(&right_text).saturating_add(3) > allowance {
right_text = if allowance >= 4 {
if notice.is_some() {
text::head(&right_text, allowance.saturating_sub(3))
} else {
text::tail(&right_text, allowance.saturating_sub(3))
}
} else {
String::new()
};
}
let right_width = if right_text.is_empty() {
0
} else {
text::width(&right_text).saturating_add(3)
};
room = room.saturating_sub(right_width);
let mut x = 0_u16;
text::put(buffer, x, row, &left, left_width, base);
x = x.saturating_add(left_width);
for (label, active) in fit_tabs(frame, room) {
let style = if active {
palette
.tab_active
.map_or(base, |fg| base.fg(fg))
.add_modifier(Modifier::REVERSED)
} else {
base
};
let w = text::width(&label);
text::put(buffer, x, row, &label, w, style);
x = x.saturating_add(w);
}
if right_width > 0 && right_width <= width {
let start = width - right_width;
text::put(buffer, start, row, "│ ", 2, base);
text::put(
buffer,
start.saturating_add(2),
row,
&right_text,
right_width.saturating_sub(2),
right_style,
);
}
}
fn focused_label(frame: &Frame) -> String {
let Some(id) = frame.focused else {
return String::new();
};
let Some(pane) = frame.pane(id) else {
return id.to_string();
};
let title = crate::view::printable(&pane.title, usize::MAX);
let mut label = if title.is_empty() {
id.to_string()
} else {
format!("{id}: {title}")
};
if let Some(code) = pane.exit {
label.push_str(&format!(" (exit {code})"));
}
label
}
fn fit_tabs(frame: &Frame, room: u16) -> Vec<(String, bool)> {
let active = frame
.tabs
.iter()
.position(|tab| Some(tab.id) == frame.active_tab)
.unwrap_or(0);
let count = frame.tabs.len();
let mut chosen: Vec<Option<String>> = vec![None; count];
let mut used = 0_u16;
let mut order = Vec::with_capacity(count);
order.push(active);
for distance in 1..=count {
if let Some(index) = active.checked_sub(distance) {
order.push(index);
}
if active + distance < count {
order.push(active + distance);
}
}
for index in order {
let Some(tab) = frame.tabs.get(index) else {
continue;
};
let label = format!(" {} ", tab.label);
let w = text::width(&label);
let remaining = room.saturating_sub(used);
if w <= remaining {
used = used.saturating_add(w);
if let Some(slot) = chosen.get_mut(index) {
*slot = Some(label);
}
} else {
if remaining >= 4 {
let cut = text::head(&tab.label, remaining.saturating_sub(2));
if let Some(slot) = chosen.get_mut(index) {
*slot = Some(format!(" {cut} "));
}
}
break;
}
}
chosen
.into_iter()
.enumerate()
.filter_map(|(index, label)| label.map(|label| (label, index == active)))
.collect()
}
fn paint_pane(buffer: &mut Buffer, area: Rect, pane: &PaneView) {
for row in 0..area.height.min(pane.rows) {
for col in 0..area.width.min(pane.columns) {
let Some(source) = pane.cell(row, col) else {
continue;
};
if source.kind == CellKind::WideContinuation {
continue;
}
if source.kind == CellKind::WideLeading && col.saturating_add(1) >= area.width {
continue;
}
let Some(target) =
buffer.cell_mut((area.x.saturating_add(col), area.y.saturating_add(row)))
else {
continue;
};
target.set_symbol(if source.kind == CellKind::Blank {
" "
} else {
&source.text
});
target.set_style(cell_style(source.style));
}
}
}
fn paint_separators(buffer: &mut Buffer, frame: &Frame, palette: &Palette) {
if frame.layout.len() < 2 {
return;
}
let (mut left, mut top, mut right, mut bottom) = (u16::MAX, u16::MAX, 0_u16, 0_u16);
for entry in &frame.layout {
let rect = entry.rect;
if rect.width == 0 || rect.height == 0 {
continue;
}
left = left.min(rect.x);
top = top.min(rect.y);
right = right.max(rect.x.saturating_add(rect.width));
bottom = bottom.max(rect.y.saturating_add(rect.height));
}
if left >= right || top >= bottom {
return;
}
let right = right.min(buffer.area.width);
let bottom = bottom.min(buffer.area.height);
if left >= right || top >= bottom {
return;
}
let box_width = usize::from(right - left);
let box_height = usize::from(bottom - top);
let mut pane_cells = vec![false; box_width.saturating_mul(box_height)];
for entry in &frame.layout {
let rect = entry.rect;
for y in rect.y.max(top)..rect.y.saturating_add(rect.height).min(bottom) {
for x in rect.x.max(left)..rect.x.saturating_add(rect.width).min(right) {
let index = usize::from(y - top)
.saturating_mul(box_width)
.saturating_add(usize::from(x - left));
if let Some(cell) = pane_cells.get_mut(index) {
*cell = true;
}
}
}
}
let is_gap = |x: u16, y: u16| {
if x < left || x >= right || y < top || y >= bottom {
return false;
}
let index = usize::from(y - top)
.saturating_mul(box_width)
.saturating_add(usize::from(x - left));
pane_cells.get(index).is_some_and(|cell| !cell)
};
let focused = frame
.focused
.and_then(|id| frame.layout.iter().find(|entry| entry.pane == id))
.map(|entry| entry.rect);
let touches_focus = |x: u16, y: u16| {
focused.is_some_and(|rect| {
x.saturating_add(1) >= rect.x
&& x <= rect.x.saturating_add(rect.width)
&& y.saturating_add(1) >= rect.y
&& y <= rect.y.saturating_add(rect.height)
})
};
for y in top..bottom {
for x in left..right {
if !is_gap(x, y) {
continue;
}
let up = y > 0 && is_gap(x, y - 1);
let down = is_gap(x, y.saturating_add(1));
let l = x > 0 && is_gap(x - 1, y);
let r = is_gap(x.saturating_add(1), y);
let symbol = match (up, down, l, r) {
(true, true, true, true) => "┼",
(true, true, true, false) => "┤",
(true, true, false, true) => "├",
(false, true, true, true) => "┬",
(true, false, true, true) => "┴",
(true, false, false, true) => "└",
(true, false, true, false) => "┘",
(false, true, false, true) => "┌",
(false, true, true, false) => "┐",
(false, false, true, _) | (false, false, _, true) => "─",
_ => "│",
};
let style = if touches_focus(x, y) {
styled(palette.separator_focused).add_modifier(Modifier::BOLD)
} else {
styled(palette.separator)
};
if let Some(cell) = buffer.cell_mut((x, y)) {
cell.set_symbol(symbol).set_style(style);
}
}
}
}
fn paint_selection(buffer: &mut Buffer, content: Rect, local: &LocalView<'_>) {
let Some(anchor) = local.anchor else {
return;
};
if content.width == 0 || content.height == 0 {
return;
}
let clamp = |point: (u16, u16)| {
(
point.0.min(content.height - 1),
point.1.min(content.width - 1),
)
};
let cursor = clamp(local.cursor);
let anchor = clamp(anchor);
let (start, end) = if anchor <= cursor {
(anchor, cursor)
} else {
(cursor, anchor)
};
for row in start.0..=end.0 {
let first = if row == start.0 { start.1 } else { 0 };
let last = if row == end.0 {
end.1
} else {
content.width - 1
};
for column in first..=last {
if let Some(cell) = buffer.cell_mut((content.x + column, content.y + row)) {
cell.modifier.insert(Modifier::REVERSED);
}
}
}
}
fn cell_style(style: crate::view::CellStyle) -> Style {
let flags = [
(style.bold, Modifier::BOLD),
(style.dim, Modifier::DIM),
(style.italic, Modifier::ITALIC),
(style.underline, Modifier::UNDERLINED),
(style.inverse, Modifier::REVERSED),
];
let modifiers = flags
.into_iter()
.filter(|(set, _)| *set)
.fold(Modifier::empty(), |all, (_, flag)| all | flag);
Style::default()
.fg(rat_color(style.foreground))
.bg(rat_color(style.background))
.add_modifier(modifiers)
}
const fn rat_color(color: crate::view::Color) -> Color {
match color {
crate::view::Color::Default => Color::Reset,
crate::view::Color::Indexed(index) => Color::Indexed(index),
crate::view::Color::Rgb(red, green, blue) => Color::Rgb(red, green, blue),
}
}
pub fn paint<B: TerminalBackend>(
backend: &mut B,
previous: Option<&Buffer>,
next: &Buffer,
cursor: Option<(u16, u16)>,
) -> io::Result<()> {
backend.begin_frame()?;
let painted: io::Result<()> = (|| {
let empty = Buffer::empty(next.area);
let previous = previous.filter(|previous| previous.area == next.area);
if previous.is_none() {
backend.write_bytes(b"\x1b[2J")?;
}
let previous = previous.unwrap_or(&empty);
for (col, row, cell) in previous.diff(next) {
backend.move_to(row, col)?;
backend.set_style(backend_style(cell))?;
backend.print(cell.symbol())?;
}
if let Some((row, col)) = cursor {
backend.move_to(row, col)?;
backend.show_cursor()?;
} else {
backend.write_bytes(b"\x1b[?25l")?;
}
Ok(())
})();
let ended = backend.end_frame();
painted?;
ended?;
backend.flush()
}
fn backend_style(cell: &ratatui_core::buffer::Cell) -> BackendStyle {
BackendStyle {
fg: rat_to_vt(cell.fg),
bg: rat_to_vt(cell.bg),
bold: cell.modifier.contains(Modifier::BOLD),
dim: cell.modifier.contains(Modifier::DIM),
italic: cell.modifier.contains(Modifier::ITALIC),
underline: cell.modifier.contains(Modifier::UNDERLINED),
inverse: cell.modifier.contains(Modifier::REVERSED),
}
}
const fn rat_to_vt(color: Color) -> vt100::Color {
match color {
Color::Reset => vt100::Color::Default,
Color::Black => vt100::Color::Idx(0),
Color::Red => vt100::Color::Idx(1),
Color::Green => vt100::Color::Idx(2),
Color::Yellow => vt100::Color::Idx(3),
Color::Blue => vt100::Color::Idx(4),
Color::Magenta => vt100::Color::Idx(5),
Color::Cyan => vt100::Color::Idx(6),
Color::Gray => vt100::Color::Idx(7),
Color::DarkGray => vt100::Color::Idx(8),
Color::LightRed => vt100::Color::Idx(9),
Color::LightGreen => vt100::Color::Idx(10),
Color::LightYellow => vt100::Color::Idx(11),
Color::LightBlue => vt100::Color::Idx(12),
Color::LightMagenta => vt100::Color::Idx(13),
Color::LightCyan => vt100::Color::Idx(14),
Color::White => vt100::Color::Idx(15),
Color::Indexed(value) => vt100::Color::Idx(value),
Color::Rgb(r, g, b) => vt100::Color::Rgb(r, g, b),
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::panic)]
mod tests {
use super::*;
use crate::ids::TabId;
use crate::view::{PaneRect, TabEntry};
fn view(text: &[u8], rows: u16, cols: u16) -> PaneView {
let mut parser = vt100::Parser::new(rows, cols, 0);
parser.process(text);
PaneView::from_screen(parser.screen(), "shell", 0, None).unwrap_or_default()
}
fn frame(tabs: usize) -> Frame {
let mut frame = Frame {
workspace: "default".into(),
..Frame::default()
};
for index in 0..tabs {
frame.tabs.push(TabEntry {
id: TabId(index as u32 + 1),
label: format!("t{index}"),
});
}
frame.active_tab = Some(TabId(1));
frame.focused = Some(PaneId(1));
frame.layout.push(PaneRect {
pane: PaneId(1),
rect: crate::layout::Rect {
x: 0,
y: 0,
width: 10,
height: 4,
},
});
frame.panes.insert(PaneId(1), view(b"hello", 4, 10));
frame
}
fn split_frame() -> Frame {
let mut frame = frame(1);
frame.layout.clear();
frame.layout.push(PaneRect {
pane: PaneId(1),
rect: crate::layout::Rect {
x: 0,
y: 0,
width: 4,
height: 5,
},
});
frame.layout.push(PaneRect {
pane: PaneId(2),
rect: crate::layout::Rect {
x: 5,
y: 0,
width: 5,
height: 2,
},
});
frame.layout.push(PaneRect {
pane: PaneId(3),
rect: crate::layout::Rect {
x: 5,
y: 3,
width: 5,
height: 2,
},
});
frame.panes.insert(PaneId(1), view(b"aaaa", 5, 4));
frame.panes.insert(PaneId(2), view(b"bb", 2, 5));
frame.panes.insert(PaneId(3), view(b"cc", 2, 5));
frame
}
fn text(buffer: &Buffer) -> Vec<String> {
(0..buffer.area.height)
.map(|row| {
(0..buffer.area.width)
.map(|col| buffer.cell((col, row)).map_or(" ", |cell| cell.symbol()))
.collect::<String>()
})
.collect()
}
#[test]
fn bar_shows_workspace_tab_and_focused_pane_without_any_frame() {
let composed = compose(&frame(1), None, None, None, &Palette::default(), 5, 30);
let lines = text(&composed.buffer);
let bar = &lines[4];
assert!(bar.starts_with(" default │ t0 "), "{bar:?}");
assert!(bar.ends_with("│ 1: shell "), "{bar:?}");
assert_eq!(bar.chars().count(), 30);
assert!(lines[0].starts_with("hello"));
assert!(
!lines
.iter()
.any(|line| line.contains(['┌', '┐', '└', '┘', '│'].as_slice()) && line != bar)
);
assert_eq!(composed.cursor, Some((0, 5)));
let active = composed.buffer.cell((11, 4));
assert_eq!(active.map(|cell| cell.modifier), Some(Modifier::REVERSED));
assert_eq!(
active.map(|cell| cell.bg),
Some(Color::DarkGray),
"own background"
);
}
#[test]
fn bar_truncates_from_the_right_zone_first_and_keeps_the_current_tab() {
let mut frame = frame(3);
frame.active_tab = Some(TabId(2));
frame.tabs[1].label = "a-very-long-tab-label".into();
let composed = compose(&frame, None, None, None, &Palette::default(), 3, 24);
let line = &text(&composed.buffer)[2];
assert_eq!(line.trim_end(), " de… │ a-very-long-tab…");
assert!(
!line.contains("t0"),
"neighbours yield before the current tab: {line:?}"
);
}
#[test]
fn notices_replace_the_pane_title_in_the_bar() {
let notice = Notice {
text: "Copied 5 bytes".into(),
error: false,
};
let composed = compose(
&frame(1),
None,
None,
Some(¬ice),
&Palette::default(),
3,
40,
);
let line = &text(&composed.buffer)[2];
assert!(line.ends_with("│ Copied 5 bytes "), "{line:?}");
assert!(!line.contains("shell"));
}
#[test]
fn separators_join_between_panes_and_brighten_next_to_focus() {
let frame = split_frame();
let composed = compose(&frame, None, None, None, &Palette::default(), 6, 12);
let lines = text(&composed.buffer);
assert!(lines[0].starts_with("aaaa│bb"), "{:?}", lines[0]);
assert!(lines[2].starts_with(" ├─────"), "{:?}", lines[2]);
assert!(lines[3].starts_with(" │cc"), "{:?}", lines[3]);
assert_eq!(lines[5].trim_end(), " defa… │ t0", "bar on the last row");
assert!(lines.iter().all(|line| !line.contains('┌')));
let bold = |x: u16, y: u16| {
composed
.buffer
.cell((x, y))
.is_some_and(|cell| cell.modifier.contains(Modifier::BOLD))
};
assert!(bold(4, 0), "separator next to the focused pane is bold");
assert!(bold(4, 2), "the junction touches the focused pane too");
let mut other = frame;
other.focused = Some(PaneId(3));
let composed = compose(&other, None, None, None, &Palette::default(), 6, 12);
let bold_now = composed
.buffer
.cell((4, 0))
.is_some_and(|cell| cell.modifier.contains(Modifier::BOLD));
assert!(!bold_now, "far separator is dim when focus moved");
assert!(
composed
.buffer
.cell((6, 2))
.is_some_and(|cell| cell.modifier.contains(Modifier::BOLD)),
"the row separator above pane 3 is bold"
);
}
#[test]
fn tiny_and_zero_terminals_never_panic() {
let mut stale = split_frame();
if let Some(view) = stale.panes.get_mut(&PaneId(3)) {
view.exit = Some(1);
}
for (rows, cols) in [(0, 0), (1, 1), (2, 3), (1, 80), (24, 1), (2, 20), (3, 12)] {
let composed = compose(
&stale,
None,
Some(&HintPanel::bar("x")),
None,
&Palette::default(),
rows,
cols,
);
assert_eq!(composed.buffer.area.height, rows);
let mut backend = super::super::backend::CaptureBackend::new(rows, cols);
paint(&mut backend, None, &composed.buffer, composed.cursor).unwrap_or_default();
}
}
}