ktop 0.1.0

A fast Rust top/htop replacement with a Scrin TUI and Aisling effects.
Documentation
use std::io::{self, Stdout, Write};

use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::queue;
use crossterm::style::{
    Attribute, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
};
use crossterm::terminal::{
    self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
    LeaveAlternateScreen,
};

pub type Color = crossterm::style::Color;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rect {
    pub x: u16,
    pub y: u16,
    pub width: u16,
    pub height: u16,
}

impl Rect {
    pub fn right(self) -> u16 {
        self.x.saturating_add(self.width)
    }

    pub fn bottom(self) -> u16 {
        self.y.saturating_add(self.height)
    }

    fn contains(self, x: u16, y: u16) -> bool {
        x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Style {
    pub fg: Color,
    pub bg: Color,
    pub bold: bool,
    pub dim: bool,
    pub reverse: bool,
}

impl Style {
    pub fn new(fg: Color, bg: Color) -> Self {
        Self {
            fg,
            bg,
            bold: false,
            dim: false,
            reverse: false,
        }
    }

    pub fn fg(mut self, fg: Color) -> Self {
        self.fg = fg;
        self
    }

    pub fn bg(mut self, bg: Color) -> Self {
        self.bg = bg;
        self
    }

    pub fn bold(mut self) -> Self {
        self.bold = true;
        self
    }
}

impl Default for Style {
    fn default() -> Self {
        Self::new(
            Color::Rgb {
                r: 226,
                g: 232,
                b: 240,
            },
            Color::Black,
        )
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Cell {
    ch: char,
    style: Style,
}

#[derive(Clone)]
pub struct Frame {
    width: u16,
    height: u16,
    cells: Vec<Cell>,
}

impl Frame {
    pub fn new(width: u16, height: u16, style: Style) -> Self {
        let cell = Cell { ch: ' ', style };
        Self {
            width,
            height,
            cells: vec![cell; width as usize * height as usize],
        }
    }

    pub fn width(&self) -> u16 {
        self.width
    }

    pub fn height(&self) -> u16 {
        self.height
    }

    pub fn resize(&mut self, width: u16, height: u16, style: Style) {
        if self.width == width && self.height == height {
            self.clear(style);
            return;
        }

        self.width = width;
        self.height = height;
        self.cells = vec![Cell { ch: ' ', style }; width as usize * height as usize];
    }

    pub fn clear(&mut self, style: Style) {
        for cell in &mut self.cells {
            cell.ch = ' ';
            cell.style = style;
        }
    }

    pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
        if x >= self.width || y >= self.height {
            return;
        }
        let index = y as usize * self.width as usize + x as usize;
        if let Some(cell) = self.cells.get_mut(index) {
            *cell = Cell { ch, style };
        }
    }

    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 y >= self.height || width == 0 {
            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.chars().count() as u16;
        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.chars().count() as u16;
        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) {
        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(empty.fg).bold());
    }

    pub fn dim_outside(&mut self, rect: Rect, fg: Color, bg: Color) {
        for y in 0..self.height {
            for x in 0..self.width {
                if rect.contains(x, y) {
                    continue;
                }
                let index = y as usize * self.width as usize + x as usize;
                if let Some(cell) = self.cells.get_mut(index) {
                    cell.style.fg = fg;
                    cell.style.bg = bg;
                    cell.style.dim = true;
                    cell.style.bold = false;
                }
            }
        }
    }
}

pub struct Term {
    stdout: Stdout,
    previous: Option<Frame>,
}

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)> {
        terminal::size()
    }

    pub fn draw(&mut self, frame: &Frame) -> io::Result<()> {
        let full_redraw = self.previous.as_ref().map_or(true, |previous| {
            previous.width != frame.width || previous.height != frame.height
        });

        if full_redraw {
            queue!(self.stdout, Clear(ClearType::All))?;
        }

        let mut active_style = None;
        draw_runs(
            &mut self.stdout,
            frame,
            (!full_redraw).then_some(self.previous.as_ref()).flatten(),
            &mut active_style,
        )?;

        queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
        self.stdout.flush()?;
        self.previous = Some(frame.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();
    }
}

fn apply_style(stdout: &mut Stdout, style: Style) -> io::Result<()> {
    queue!(
        stdout,
        SetAttribute(Attribute::Reset),
        SetForegroundColor(style.fg),
        SetBackgroundColor(style.bg)
    )?;
    if style.bold {
        queue!(stdout, SetAttribute(Attribute::Bold))?;
    }
    if style.dim {
        queue!(stdout, SetAttribute(Attribute::Dim))?;
    }
    if style.reverse {
        queue!(stdout, SetAttribute(Attribute::Reverse))?;
    }
    Ok(())
}

fn draw_runs(
    stdout: &mut Stdout,
    frame: &Frame,
    previous: Option<&Frame>,
    active_style: &mut Option<Style>,
) -> io::Result<()> {
    for y in 0..frame.height {
        let mut x = 0;
        while x < frame.width {
            let index = y as usize * frame.width as usize + x as usize;
            let cell = frame.cells[index];
            let unchanged = previous
                .map(|previous| previous.cells[index] == cell)
                .unwrap_or(false);
            if unchanged {
                x += 1;
                continue;
            }

            let start = x;
            let style = cell.style;
            let mut text = String::new();
            while x < frame.width {
                let index = y as usize * frame.width as usize + x as usize;
                let cell = frame.cells[index];
                let unchanged = previous
                    .map(|previous| previous.cells[index] == cell)
                    .unwrap_or(false);
                if unchanged || cell.style != style {
                    break;
                }
                text.push(cell.ch);
                x += 1;
            }

            queue!(stdout, MoveTo(start, y))?;
            if *active_style != Some(style) {
                apply_style(stdout, style)?;
                *active_style = Some(style);
            }
            queue!(stdout, Print(text))?;
        }
    }

    Ok(())
}