commit 459201bc6531e62ae4decc62cca1b50d12b9adcb Author: Florian Dehau Date: Sun Oct 9 19:46:53 2016 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9d37c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..132d49c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "tui" +version = "0.1.0" +authors = ["Florian Dehau "] + +[dependencies] +termion = "1.1.1" +bitflags = "0.7" +cassowary = "0.2.0" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d5fb250 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +build: + cargo build +test: + cargo test +watch: + watchman-make -p 'src/**/*.rs' -t build -p 'test/**/*.rs' -t test diff --git a/examples/prototype.rs b/examples/prototype.rs new file mode 100644 index 0000000..924c6d3 --- /dev/null +++ b/examples/prototype.rs @@ -0,0 +1,94 @@ +extern crate tui; +extern crate termion; + +use std::thread; +use std::sync::mpsc; +use std::io::{Write, stdin}; + +use termion::event; +use termion::input::TermRead; + +use tui::Terminal; +use tui::widgets::{Widget, Block, Border}; +use tui::layout::{Group, Direction, Alignment, Size}; + +struct App { + name: String, + fetching: bool, +} + +enum Event { + Quit, + Redraw, +} + +fn main() { + + let mut app = App { + name: String::from("Test app"), + fetching: false, + }; + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || { + let tx = tx.clone(); + let stdin = stdin(); + for c in stdin.keys() { + let evt = c.unwrap(); + match evt { + event::Key::Char('q') => { + tx.send(Event::Quit).unwrap(); + break; + } + event::Key::Char('r') => { + tx.send(Event::Redraw).unwrap(); + } + _ => {} + } + } + }); + let mut terminal = Terminal::new().unwrap(); + terminal.clear(); + terminal.hide_cursor(); + loop { + draw(&mut terminal, &app); + let evt = rx.recv().unwrap(); + match evt { + Event::Quit => { + break; + } + Event::Redraw => {} + } + } + terminal.show_cursor(); +} + +fn draw(terminal: &mut Terminal, app: &App) { + + let ui = Group::default() + .direction(Direction::Vertical) + .alignment(Alignment::Left) + .chunks(&[Size::Fixed(3.0), Size::Percent(100.0), Size::Fixed(3.0)]) + .render(&terminal.area(), |chunks| { + vec![Block::default() + .borders(Border::TOP | Border::BOTTOM) + .title("Header") + .render(&chunks[0]), + Group::default() + .direction(Direction::Horizontal) + .alignment(Alignment::Left) + .chunks(&[Size::Percent(50.0), Size::Percent(50.0)]) + .render(&chunks[1], |chunks| { + vec![Block::default() + .borders(Border::ALL) + .title("Podcasts") + .render(&chunks[0]), + Block::default() + .borders(Border::ALL) + .title("Episodes") + .render(&chunks[1])] + }), + Block::default().borders(Border::ALL).title("Footer").render(&chunks[2])] + }); + terminal.render(&ui); +} diff --git a/src/buffer.rs b/src/buffer.rs new file mode 100644 index 0000000..39ca611 --- /dev/null +++ b/src/buffer.rs @@ -0,0 +1,150 @@ +use layout::Rect; +use style::Color; + +#[derive(Debug, Clone)] +pub struct Cell { + pub symbol: char, + pub fg: Color, + pub bg: Color, +} + +impl Default for Cell { + fn default() -> Cell { + Cell { + symbol: ' ', + fg: Color::White, + bg: Color::Black, + } + } +} + +#[derive(Debug, Clone)] +pub struct Buffer { + area: Rect, + content: Vec, +} + +impl Default for Buffer { + fn default() -> Buffer { + Buffer { + area: Default::default(), + content: Vec::new(), + } + } +} + +impl Buffer { + pub fn empty(area: Rect) -> Buffer { + let cell: Cell = Default::default(); + Buffer::filled(area, cell) + } + + pub fn filled(area: Rect, cell: Cell) -> Buffer { + let size = area.area() as usize; + let mut content = Vec::with_capacity(size); + for _ in 0..size { + content.push(cell.clone()); + } + Buffer { + area: area, + content: content, + } + } + + pub fn content(&self) -> &[Cell] { + &self.content + } + + pub fn area(&self) -> &Rect { + &self.area + } + + pub fn index_of(&self, x: u16, y: u16) -> usize { + let index = (y * self.area.width + x) as usize; + debug_assert!(index < self.content.len()); + index + } + + pub fn pos_of(&self, i: usize) -> (u16, u16) { + debug_assert!(self.area.width != 0); + (i as u16 % self.area.width, i as u16 / self.area.width) + } + + pub fn next_pos(&self, x: u16, y: u16) -> Option<(u16, u16)> { + let mut nx = x + 1; + let mut ny = y; + if nx >= self.area.width { + nx = 0; + ny = y + 1; + } + if ny >= self.area.height { + None + } else { + Some((nx, ny)) + } + } + + pub fn set(&mut self, x: u16, y: u16, cell: Cell) { + let i = self.index_of(x, y); + self.content[i] = cell; + } + + pub fn set_symbol(&mut self, x: u16, y: u16, symbol: char) { + let i = self.index_of(x, y); + self.content[i].symbol = symbol; + } + + pub fn set_string(&mut self, x: u16, y: u16, string: &str) { + let mut cursor = (x, y); + for c in string.chars() { + let index = self.index_of(cursor.0, cursor.1); + self.content[index].symbol = c; + match self.next_pos(cursor.0, cursor.1) { + Some(c) => { + cursor = c; + } + None => { + break; + } + } + } + } + + pub fn get(&self, x: u16, y: u16) -> &Cell { + let i = self.index_of(x, y); + &self.content[i] + } + + pub fn merge(&mut self, other: &Buffer) { + let area = self.area.union(&other.area); + let cell: Cell = Default::default(); + self.content.resize(area.area() as usize, cell.clone()); + + // Move original content to the appropriate space + let offset_x = self.area.x - area.x; + let offset_y = self.area.y - area.y; + let size = self.area.area() as usize; + for i in (0..size).rev() { + let (x, y) = self.pos_of(i); + // New index in content + let k = ((y + offset_y) * area.width + (x + offset_x)) as usize; + self.content[k] = self.content[i].clone(); + if i != k { + self.content[i] = cell.clone(); + } + } + + // Push content of the other buffer into this one (may erase previous + // data) + let offset_x = other.area.x - area.x; + let offset_y = other.area.y - area.y; + let size = other.area.area() as usize; + for i in 0..size { + let (x, y) = other.pos_of(i); + // New index in content + let k = ((y + offset_y) * area.width + (x + offset_x)) as usize; + self.content[k] = other.content[i].clone(); + } + self.area = area; + } +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..c2b8ba3 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,263 @@ +use std::cmp::{min, max}; +use std::collections::HashMap; + +use cassowary::{Solver, Variable, Constraint}; +use cassowary::WeightedRelation::*; +use cassowary::strength::{WEAK, MEDIUM, STRONG, REQUIRED}; + +use buffer::Buffer; + +pub enum Alignment { + Top, + Left, + Center, + Bottom, + Right, +} + +pub enum Direction { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy)] +pub struct Rect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl Default for Rect { + fn default() -> Rect { + Rect { + x: 0, + y: 0, + width: 0, + height: 0, + } + } +} + +impl Rect { + pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect { + Rect { + x: 0, + y: 0, + width: 0, + height: 0, + } + } + + pub fn area(&self) -> u16 { + self.width * self.height + } + + pub fn inner(&self, spacing: u16) -> Rect { + if self.width - spacing < 0 || self.height - spacing < 0 { + Rect::default() + } else { + Rect { + x: self.x + spacing, + y: self.y + spacing, + width: self.width - 2 * spacing, + height: self.height - 2 * spacing, + } + } + } + + pub fn union(&self, other: &Rect) -> Rect { + let x1 = min(self.x, other.x); + let y1 = min(self.y, other.y); + let x2 = max(self.x + self.width, other.x + other.width); + let y2 = max(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersection(&self, other: &Rect) -> Rect { + let x1 = max(self.x, other.x); + let y1 = max(self.y, other.y); + let x2 = min(self.x + self.width, other.x + other.width); + let y2 = min(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersects(&self, other: &Rect) -> bool { + self.x < other.x + other.width && self.x + self.width > other.x && + self.y < other.y + other.height && self.y + self.height > other.y + } +} + +#[derive(Debug, Clone)] +pub enum Size { + Fixed(f64), + Percent(f64), +} + +/// # Examples +/// ``` +/// extern crate tui; +/// use tui::layout::{Rect, Size, Alignment, Direction, split}; +/// +/// fn main() { +/// let chunks = split(&Rect{x: 2, y: 2, width: 10, height: 10}, Direction::Vertical, +/// Alignment::Left, &[Size::Fixed(5.0), Size::Percent(80.0)]); +/// } +/// +/// ``` +pub fn split(area: &Rect, dir: &Direction, align: &Alignment, sizes: &[Size]) -> Vec { + let mut solver = Solver::new(); + let mut vars: HashMap = HashMap::new(); + let elements = sizes.iter().map(|e| Element::new()).collect::>(); + let mut results = sizes.iter().map(|e| Rect::default()).collect::>(); + for (i, e) in elements.iter().enumerate() { + vars.insert(e.x, (i, 0)); + vars.insert(e.y, (i, 1)); + vars.insert(e.width, (i, 2)); + vars.insert(e.height, (i, 3)); + } + let mut constraints: Vec = Vec::new(); + if let Some(size) = sizes.first() { + constraints.push(match *dir { + Direction::Horizontal => elements[0].x | EQ(REQUIRED) | area.x as f64, + Direction::Vertical => elements[0].y | EQ(REQUIRED) | area.y as f64, + }) + } + if let Some(size) = sizes.last() { + let last = elements.last().unwrap(); + constraints.push(match *dir { + Direction::Horizontal => { + last.x + last.width | EQ(REQUIRED) | (area.x + area.width) as f64 + } + Direction::Vertical => { + last.y + last.height | EQ(REQUIRED) | (area.y + area.height) as f64 + } + }) + } + match *dir { + Direction::Horizontal => { + for pair in elements.windows(2) { + constraints.push(pair[0].x + pair[0].width | LE(REQUIRED) | pair[1].x); + } + for (i, size) in sizes.iter().enumerate() { + let cs = [elements[i].y | EQ(REQUIRED) | area.y as f64, + elements[i].height | EQ(REQUIRED) | area.height as f64, + match *size { + Size::Fixed(f) => elements[i].width | EQ(REQUIRED) | f, + Size::Percent(p) => { + elements[i].width | EQ(WEAK) | area.width as f64 * p / 100.0 + } + }]; + constraints.extend_from_slice(&cs); + } + } + Direction::Vertical => { + for pair in elements.windows(2) { + constraints.push(pair[0].y + pair[0].height | LE(REQUIRED) | pair[1].y); + } + for (i, size) in sizes.iter().enumerate() { + let cs = [elements[i].x | EQ(REQUIRED) | area.x as f64, + elements[i].width | EQ(REQUIRED) | area.width as f64, + match *size { + Size::Fixed(f) => elements[i].height | EQ(REQUIRED) | f, + Size::Percent(p) => { + elements[i].height | EQ(WEAK) | area.height as f64 * p / 100.0 + } + }]; + constraints.extend_from_slice(&cs); + } + } + } + solver.add_constraints(&constraints).unwrap(); + for &(var, value) in solver.fetch_changes() { + let (index, attr) = vars[&var]; + match attr { + 0 => { + results[index].x = value as u16; + } + 1 => { + results[index].y = value as u16; + } + 2 => { + results[index].width = value as u16; + } + 3 => { + results[index].height = value as u16; + } + _ => {} + } + } + results +} + +struct Element { + x: Variable, + y: Variable, + width: Variable, + height: Variable, +} + +impl Element { + fn new() -> Element { + Element { + x: Variable::new(), + y: Variable::new(), + width: Variable::new(), + height: Variable::new(), + } + } +} + +pub struct Group { + direction: Direction, + alignment: Alignment, + chunks: Vec, +} + +impl Default for Group { + fn default() -> Group { + Group { + direction: Direction::Horizontal, + alignment: Alignment::Left, + chunks: Vec::new(), + } + } +} + +impl Group { + pub fn direction(&mut self, direction: Direction) -> &mut Group { + self.direction = direction; + self + } + + pub fn alignment(&mut self, alignment: Alignment) -> &mut Group { + self.alignment = alignment; + self + } + + pub fn chunks(&mut self, chunks: &[Size]) -> &mut Group { + self.chunks = Vec::from(chunks); + self + } + pub fn render(&self, area: &Rect, f: F) -> Buffer + where F: Fn(&[Rect]) -> Vec + { + let chunks = split(area, &self.direction, &self.alignment, &self.chunks); + let results = f(&chunks); + let mut result = results[0].clone(); + for r in results.iter().skip(1) { + result.merge(&r); + } + result + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f236f18 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,18 @@ +extern crate termion; +#[macro_use] +extern crate bitflags; +extern crate cassowary; + +mod buffer; +pub mod terminal; +pub mod widgets; +pub mod style; +pub mod layout; + +pub use self::terminal::Terminal; + +#[cfg(test)] +mod tests { + #[test] + fn it_works() {} +} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..680bd92 --- /dev/null +++ b/src/style.rs @@ -0,0 +1,61 @@ +use termion; + +#[derive(Debug, Clone, Copy)] +pub enum Color { + Black, + Red, + Green, + Yellow, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightMagenta, + LightCyan, + White, + Rgb(u8, u8, u8), +} + +impl Color { + pub fn fg(&self) -> String { + match *self { + Color::Black => format!("{}", termion::color::Fg(termion::color::Black)), + Color::Red => format!("{}", termion::color::Fg(termion::color::Red)), + Color::Green => format!("{}", termion::color::Fg(termion::color::Green)), + Color::Yellow => format!("{}", termion::color::Fg(termion::color::Yellow)), + Color::Magenta => format!("{}", termion::color::Fg(termion::color::Magenta)), + Color::Cyan => format!("{}", termion::color::Fg(termion::color::Cyan)), + Color::Gray => format!("{}", termion::color::Fg(termion::color::Rgb(146, 131, 116))), + Color::DarkGray => format!("{}", termion::color::Fg(termion::color::Rgb(80, 73, 69))), + Color::LightRed => format!("{}", termion::color::Fg(termion::color::LightRed)), + Color::LightGreen => format!("{}", termion::color::Fg(termion::color::LightGreen)), + Color::LightYellow => format!("{}", termion::color::Fg(termion::color::LightYellow)), + Color::LightMagenta => format!("{}", termion::color::Fg(termion::color::LightMagenta)), + Color::LightCyan => format!("{}", termion::color::Fg(termion::color::LightCyan)), + Color::White => format!("{}", termion::color::Fg(termion::color::White)), + Color::Rgb(r, g, b) => format!("{}", termion::color::Fg(termion::color::Rgb(r, g, b))), + } + } + pub fn bg(&self) -> String { + match *self { + Color::Black => format!("{}", termion::color::Bg(termion::color::Black)), + Color::Red => format!("{}", termion::color::Bg(termion::color::Red)), + Color::Green => format!("{}", termion::color::Bg(termion::color::Green)), + Color::Yellow => format!("{}", termion::color::Bg(termion::color::Yellow)), + Color::Magenta => format!("{}", termion::color::Bg(termion::color::Magenta)), + Color::Cyan => format!("{}", termion::color::Bg(termion::color::Cyan)), + Color::Gray => format!("{}", termion::color::Bg(termion::color::Rgb(146, 131, 116))), + Color::DarkGray => format!("{}", termion::color::Bg(termion::color::Rgb(80, 73, 69))), + Color::LightRed => format!("{}", termion::color::Bg(termion::color::LightRed)), + Color::LightGreen => format!("{}", termion::color::Bg(termion::color::LightGreen)), + Color::LightYellow => format!("{}", termion::color::Bg(termion::color::LightYellow)), + Color::LightMagenta => format!("{}", termion::color::Bg(termion::color::LightMagenta)), + Color::LightCyan => format!("{}", termion::color::Bg(termion::color::LightCyan)), + Color::White => format!("{}", termion::color::Bg(termion::color::White)), + Color::Rgb(r, g, b) => format!("{}", termion::color::Bg(termion::color::Rgb(r, g, b))), + } + } +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..8719ca2 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,64 @@ +use std::iter; +use std::io; +use std::io::Write; + +use termion; +use termion::raw::{IntoRawMode, RawTerminal}; + +use buffer::Buffer; +use layout::Rect; + +pub struct Terminal { + stdout: RawTerminal, + width: u16, + height: u16, +} + +impl Terminal { + pub fn new() -> Result { + let terminal = try!(termion::terminal_size()); + let stdout = try!(io::stdout().into_raw_mode()); + Ok(Terminal { + stdout: stdout, + width: terminal.0, + height: terminal.1, + }) + } + + pub fn area(&self) -> Rect { + Rect { + x: 0, + y: 0, + width: self.width, + height: self.height, + } + } + + pub fn render(&mut self, buffer: &Buffer) { + for (i, cell) in buffer.content().iter().enumerate() { + let (lx, ly) = buffer.pos_of(i); + let (x, y) = (lx + buffer.area().x, ly + buffer.area().y); + write!(self.stdout, + "{}{}{}{}", + termion::cursor::Goto(x + 1, y + 1), + cell.fg.fg(), + cell.bg.bg(), + cell.symbol) + .unwrap(); + } + self.stdout.flush().unwrap(); + } + pub fn clear(&mut self) { + write!(self.stdout, "{}", termion::clear::All).unwrap(); + self.stdout.flush().unwrap(); + } + pub fn hide_cursor(&mut self) { + write!(self.stdout, "{}", termion::cursor::Hide).unwrap(); + self.stdout.flush().unwrap(); + } + + pub fn show_cursor(&mut self) { + write!(self.stdout, "{}", termion::cursor::Show).unwrap(); + self.stdout.flush().unwrap(); + } +} diff --git a/src/widgets/block.rs b/src/widgets/block.rs new file mode 100644 index 0000000..1cfa197 --- /dev/null +++ b/src/widgets/block.rs @@ -0,0 +1,94 @@ + +use buffer::Buffer; +use layout::Rect; +use style::Color; +use widgets::{Widget, Border, Line, vline, hline}; + +pub struct Block<'a> { + title: Option<&'a str>, + borders: Border::Flags, + border_fg: Color, + border_bg: Color, +} + +impl<'a> Default for Block<'a> { + fn default() -> Block<'a> { + Block { + title: None, + borders: Border::NONE, + border_fg: Color::White, + border_bg: Color::Black, + } + } +} + +impl<'a> Block<'a> { + pub fn title(&mut self, title: &'a str) -> &mut Block<'a> { + self.title = Some(title); + self + } + + pub fn borders(&mut self, flag: Border::Flags) -> &mut Block<'a> { + self.borders = flag; + self + } +} + +impl<'a> Widget for Block<'a> { + fn render(&self, area: &Rect) -> Buffer { + + let mut buf = Buffer::empty(*area); + + if area.area() == 0 { + return buf; + } + + if self.borders == Border::NONE { + return buf; + } + + // Sides + if self.borders.intersects(Border::LEFT) { + let line = vline(area.x, area.y, area.height, self.border_fg, self.border_bg); + buf.merge(&line); + } + if self.borders.intersects(Border::TOP) { + let line = hline(area.x, area.y, area.width, self.border_fg, self.border_bg); + buf.merge(&line); + } + if self.borders.intersects(Border::RIGHT) { + let line = vline(area.x + area.width - 1, + area.y, + area.height, + self.border_fg, + self.border_bg); + buf.merge(&line); + } + if self.borders.intersects(Border::BOTTOM) { + let line = hline(area.x, + area.y + area.height - 1, + area.width, + self.border_fg, + self.border_bg); + buf.merge(&line); + } + + // Corners + if self.borders.contains(Border::LEFT | Border::TOP) { + buf.set_symbol(0, 0, Line::TopLeft.get()); + } + if self.borders.contains(Border::RIGHT | Border::TOP) { + buf.set_symbol(area.width - 1, 0, Line::TopRight.get()); + } + if self.borders.contains(Border::BOTTOM | Border::LEFT) { + buf.set_symbol(0, area.height - 1, Line::BottomLeft.get()); + } + if self.borders.contains(Border::BOTTOM | Border::RIGHT) { + buf.set_symbol(area.width - 1, area.height - 1, Line::BottomRight.get()); + } + if let Some(ref title) = self.title { + buf.set_string(1, 0, &format!(" {} ", title)); + } + buf + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..cd59285 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,82 @@ +mod block; + +pub use self::block::Block; + +use buffer::{Buffer, Cell}; +use layout::Rect; +use style::Color; + +enum Line { + Horizontal, + Vertical, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + VerticalLeft, + VerticalRight, + HorizontalDown, + HorizontalUp, +} + +pub mod Border { + bitflags! { + pub flags Flags: u32 { + const NONE = 0b00000001, + const TOP = 0b00000010, + const RIGHT = 0b00000100, + const BOTTOM = 0b0001000, + const LEFT = 0b00010000, + const ALL = TOP.bits | RIGHT.bits | BOTTOM.bits | LEFT.bits, + } + } +} + +impl Line { + fn get<'a>(&self) -> char { + match *self { + Line::TopRight => '┐', + Line::Vertical => '│', + Line::Horizontal => '─', + Line::TopLeft => '┌', + Line::BottomRight => '┘', + Line::BottomLeft => '└', + Line::VerticalLeft => '┤', + Line::VerticalRight => '├', + Line::HorizontalDown => '┬', + Line::HorizontalUp => '┴', + } + } +} + + +fn hline<'a>(x: u16, y: u16, len: u16, fg: Color, bg: Color) -> Buffer { + Buffer::filled(Rect { + x: x, + y: y, + width: len, + height: 1, + }, + Cell { + symbol: Line::Horizontal.get(), + fg: fg, + bg: bg, + }) +} +fn vline<'a>(x: u16, y: u16, len: u16, fg: Color, bg: Color) -> Buffer { + Buffer::filled(Rect { + x: x, + y: y, + width: 1, + height: len, + }, + Cell { + symbol: Line::Vertical.get(), + fg: fg, + bg: bg, + }) +} + +pub trait Widget { + fn render(&self, area: &Rect) -> Buffer; +}