use std::io::{self, Stdout, Write};
use ::scrin::core::buffer::{Buffer, Cell};
use ::scrin::style::Modifier;
use ::scrin::{write_diff_to, write_full_to};
use crossterm::cursor::{Hide, Show};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::queue;
use crossterm::style::{Attribute, ResetColor, SetAttribute};
use crossterm::terminal::{
self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
LeaveAlternateScreen,
};
pub use ::scrin::{Color, Rect, Style};
pub const MAX_FRAME_WIDTH: u16 = 512;
pub const MAX_FRAME_HEIGHT: u16 = 256;
pub const MAX_FRAME_CELLS: usize = MAX_FRAME_WIDTH as usize * MAX_FRAME_HEIGHT as usize;
pub struct Frame {
buffer: Buffer,
has_wide_glyphs: bool,
}
impl Frame {
pub fn new(width: u16, height: u16, style: Style) -> Self {
let (width, height) = capped_dimensions(width, height);
let mut frame = Self {
buffer: Buffer::new(width as usize, height as usize),
has_wide_glyphs: false,
};
frame.clear(style);
frame
}
pub fn width(&self) -> u16 {
self.buffer.width().min(u16::MAX as usize) as u16
}
pub fn height(&self) -> u16 {
self.buffer.height().min(u16::MAX as usize) as u16
}
pub fn resize(&mut self, width: u16, height: u16, style: Style) {
let (width, height) = capped_dimensions(width, height);
if self.width() == width && self.height() == height {
self.clear(style);
return;
}
self.buffer = Buffer::new(width as usize, height as usize);
self.clear(style);
}
pub fn clear(&mut self, style: Style) {
let area = Rect::new(0, 0, self.width(), self.height());
if simple_fill_style(style) {
self.buffer.fill_styled_fast_ascii(area, ' ', style);
} else {
self.fill_rect(area, ' ', style);
}
self.has_wide_glyphs = false;
}
pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
if x >= self.width() || y >= self.height() {
return;
}
let cell = styled_cell(ch, style);
if ch.is_ascii() && !self.has_wide_glyphs {
let index = y as usize * self.buffer.width() + x as usize;
self.buffer.cells_mut()[index] = cell;
return;
}
if !ch.is_ascii() {
self.has_wide_glyphs = true;
}
self.buffer.set(x as usize, y as usize, cell);
}
pub fn text(&mut self, x: u16, y: u16, text: &str, style: Style) {
self.text_fit(x, y, self.width().saturating_sub(x), text, style);
}
pub fn text_fit(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) {
if x >= self.width() || y >= self.height() || width == 0 {
return;
}
if text.is_ascii() && simple_text_style(style) {
let width = width.min(self.width().saturating_sub(x)) as usize;
let text = &text[..text.len().min(width)];
if !self.has_wide_glyphs {
write_ascii_text(&mut self.buffer, x, y, text, style);
} else {
self.buffer
.set_str_styled(x as usize, y as usize, text, style);
}
return;
}
for (offset, ch) in text.chars().take(width as usize).enumerate() {
self.set(x.saturating_add(offset as u16), y, ch, style);
}
}
pub fn text_right(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) {
let len = text_width(text);
let start = x.saturating_add(width.saturating_sub(len.min(width)));
self.text_fit(start, y, width.min(len), text, style);
}
pub fn text_center(&mut self, rect: Rect, y: u16, text: &str, style: Style) {
if rect.width == 0 {
return;
}
let len = text_width(text);
let start = rect.x.saturating_add(rect.width.saturating_sub(len) / 2);
self.text_fit(start, y, rect.width.min(len), text, style);
}
pub fn fill_rect(&mut self, rect: Rect, ch: char, style: Style) {
if simple_fill_style(style) && ch.is_ascii() {
let Some(rect) = clipped_rect(rect, self.width(), self.height()) else {
return;
};
if self.has_wide_glyphs {
self.buffer.fill(rect, ch, style_fg(style), style.bg);
return;
}
if self.buffer.fill_styled_fast_ascii(rect, ch, style) {
return;
}
}
for y in rect.y..rect.bottom().min(self.height()) {
for x in rect.x..rect.right().min(self.width()) {
self.set(x, y, ch, style);
}
}
}
pub fn border(&mut self, rect: Rect, style: Style, title: Option<&str>) {
if rect.width < 2 || rect.height < 2 {
return;
}
let right = rect
.right()
.saturating_sub(1)
.min(self.width().saturating_sub(1));
let bottom = rect
.bottom()
.saturating_sub(1)
.min(self.height().saturating_sub(1));
for x in rect.x..=right {
self.set(x, rect.y, '-', style);
self.set(x, bottom, '-', style);
}
for y in rect.y..=bottom {
self.set(rect.x, y, '|', style);
self.set(right, y, '|', style);
}
self.set(rect.x, rect.y, '+', style);
self.set(right, rect.y, '+', style);
self.set(rect.x, bottom, '+', style);
self.set(right, bottom, '+', style);
if let Some(title) = title {
let label = format!(" {title} ");
self.text_fit(
rect.x.saturating_add(2),
rect.y,
rect.width.saturating_sub(4),
&label,
style.bold(),
);
}
}
pub fn bar(&mut self, rect: Rect, ratio: f32, filled: Style, empty: Style, label: &str) {
if rect.width == 0 || rect.height == 0 {
return;
}
let filled_width =
((rect.width as f32 * ratio.clamp(0.0, 1.0)).round() as u16).min(rect.width);
if filled_width > 0 {
self.fill_rect(
Rect {
width: filled_width,
..rect
},
' ',
filled,
);
}
if filled_width < rect.width {
self.fill_rect(
Rect {
x: rect.x.saturating_add(filled_width),
width: rect.width.saturating_sub(filled_width),
..rect
},
' ',
empty,
);
}
self.text_center(rect, rect.y, label, filled.fg(style_fg(empty)).bold());
}
pub fn dim_outside(&mut self, rect: Rect, fg: Color, bg: Color) {
let width = self.buffer.width();
let height = self.buffer.height().min(u16::MAX as usize) as u16;
let width_u16 = width.min(u16::MAX as usize) as u16;
let dim_fg = fg.dim(0.35);
let cells = self.buffer.cells_mut();
for y in 0..height {
for x in 0..width_u16 {
if rect_contains(rect, x, y) {
continue;
}
let index = y as usize * width + x as usize;
if let Some(cell) = cells.get_mut(index) {
cell.fg = dim_fg;
cell.bg = Some(bg);
cell.bold = false;
cell.italic = false;
cell.underlined = false;
}
}
}
}
}
fn styled_cell(ch: char, style: Style) -> Cell {
let mut fg = style.fg.unwrap_or(Color::WHITE);
if style.dim || style.add_modifier.contains(Modifier::DIM) {
fg = fg.dim(0.35);
}
let mut cell = Cell::new(ch, fg, style.bg);
cell.bold = style.bold || style.add_modifier.contains(Modifier::BOLD);
cell.italic = style.italic || style.add_modifier.contains(Modifier::ITALIC);
cell.underlined = style.underlined || style.add_modifier.contains(Modifier::UNDERLINED);
if style.sub_modifier.contains(Modifier::BOLD) {
cell.bold = false;
}
if style.sub_modifier.contains(Modifier::ITALIC) {
cell.italic = false;
}
if style.sub_modifier.contains(Modifier::UNDERLINED) {
cell.underlined = false;
}
cell
}
fn style_fg(style: Style) -> Color {
style.fg.unwrap_or(Color::WHITE)
}
fn text_width(text: &str) -> u16 {
if text.is_ascii() {
text.len().min(u16::MAX as usize) as u16
} else {
text.chars().count().min(u16::MAX as usize) as u16
}
}
fn simple_text_style(style: Style) -> bool {
!style.dim
&& !style.italic
&& !style.underlined
&& style.sub_modifier == Modifier::NONE
&& !style.add_modifier.contains(Modifier::DIM)
&& !style.add_modifier.contains(Modifier::ITALIC)
&& !style.add_modifier.contains(Modifier::UNDERLINED)
&& !style.add_modifier.contains(Modifier::REVERSED)
&& !style.add_modifier.contains(Modifier::CROSSED_OUT)
}
fn simple_fill_style(style: Style) -> bool {
simple_text_style(style) && !style.bold && !style.add_modifier.contains(Modifier::BOLD)
}
fn clipped_rect(rect: Rect, width: u16, height: u16) -> Option<Rect> {
let right = rect.right().min(width);
let bottom = rect.bottom().min(height);
(rect.x < right && rect.y < bottom)
.then(|| Rect::new(rect.x, rect.y, right - rect.x, bottom - rect.y))
}
fn write_ascii_text(buffer: &mut Buffer, x: u16, y: u16, text: &str, style: Style) {
let width = buffer.width();
let start = y as usize * width + x as usize;
let cells = buffer.cells_mut();
let mut cell = styled_cell(' ', style);
for (offset, byte) in text.bytes().enumerate() {
cell.ch = byte as char;
cells[start + offset] = cell;
}
}
fn rect_contains(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
}
fn capped_dimensions(width: u16, height: u16) -> (u16, u16) {
let width = width.clamp(1, MAX_FRAME_WIDTH);
let height = height.clamp(1, MAX_FRAME_HEIGHT);
let max_height = (MAX_FRAME_CELLS / width as usize).max(1) as u16;
(width, height.min(max_height))
}
pub struct Term {
stdout: Stdout,
previous: Option<Buffer>,
}
impl Term {
pub fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, Hide)?;
Ok(Self {
stdout,
previous: None,
})
}
pub fn size(&self) -> io::Result<(u16, u16)> {
let (width, height) = terminal::size()?;
Ok(capped_dimensions(width, height))
}
pub fn draw(&mut self, frame: &Frame) -> io::Result<()> {
let full_redraw = self.previous.as_ref().map_or(true, |previous| {
previous.width() != frame.buffer.width() || previous.height() != frame.buffer.height()
});
let width = frame.width();
let height = frame.height();
if full_redraw {
queue!(self.stdout, Clear(ClearType::All))?;
write_full_to(&mut self.stdout, &frame.buffer, width, height)?;
} else if let Some(previous) = &self.previous {
write_diff_to(
&mut self.stdout,
previous,
&frame.buffer,
width,
height,
None,
)?;
}
self.stdout.flush()?;
self.previous = Some(frame.buffer.clone());
Ok(())
}
}
impl Drop for Term {
fn drop(&mut self) {
let _ = execute!(
self.stdout,
ResetColor,
SetAttribute(Attribute::Reset),
Show,
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_dimensions_are_capped() {
let frame = Frame::new(u16::MAX, u16::MAX, Style::new());
assert!(frame.width() <= MAX_FRAME_WIDTH);
assert!(frame.height() <= MAX_FRAME_HEIGHT);
assert!(frame.buffer.cells().len() <= MAX_FRAME_CELLS);
}
#[test]
fn resize_keeps_frame_capped() {
let mut frame = Frame::new(10, 10, Style::new());
frame.resize(u16::MAX, u16::MAX, Style::new());
assert!(frame.width() <= MAX_FRAME_WIDTH);
assert!(frame.height() <= MAX_FRAME_HEIGHT);
assert!(frame.buffer.cells().len() <= MAX_FRAME_CELLS);
}
#[test]
fn text_fit_caps_ascii_fast_path() {
let mut frame = Frame::new(8, 1, Style::new());
frame.text_fit(0, 0, 3, "abcdef", Style::new().fg(Color::CYAN).bold());
assert_eq!(frame.buffer.to_plain_string(), "abc ");
assert!(frame.buffer.get(0, 0).unwrap().bold);
assert_eq!(frame.buffer.get(0, 0).unwrap().fg, Color::CYAN);
}
#[test]
fn text_fit_ignores_out_of_bounds_start() {
let mut frame = Frame::new(4, 1, Style::new());
frame.text_fit(4, 0, 4, "nope", Style::new().fg(Color::RED));
assert_eq!(frame.buffer.to_plain_string(), " ");
}
#[test]
fn fill_rect_fast_path_clips_to_frame() {
let mut frame = Frame::new(4, 2, Style::new());
frame.fill_rect(Rect::new(2, 0, 8, 2), 'x', Style::new().fg(Color::GREEN));
assert_eq!(frame.buffer.to_plain_string(), " xx\n xx");
}
#[test]
fn dim_outside_preserves_existing_symbols() {
let mut frame = Frame::new(4, 1, Style::new());
frame.text(0, 0, "ABCD", Style::new().fg(Color::WHITE));
frame.dim_outside(Rect::new(1, 0, 2, 1), Color::GRAY, Color::BLACK);
assert_eq!(frame.buffer.to_plain_string(), "ABCD");
assert_eq!(frame.buffer.get(0, 0).unwrap().bg, Some(Color::BLACK));
assert_eq!(frame.buffer.get(1, 0).unwrap().bg, None);
}
#[test]
fn clear_resets_wide_glyph_skip_metadata() {
let mut frame = Frame::new(4, 1, Style::new());
frame.set(0, 0, 'δΈ', Style::new().fg(Color::WHITE));
assert!(frame.buffer.is_skip(1, 0));
frame.clear(Style::new().fg(Color::WHITE));
assert_eq!(frame.buffer.to_plain_string(), " ");
assert!(!frame.buffer.is_skip(1, 0));
}
}