├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── 01_grid.rs ├── 02_item_list.rs ├── 03_double_panel.rs ├── 04_transfer_list.rs └── 05_edit_field.rs └── src ├── column.rs ├── curses.rs ├── dummy.rs ├── edit_field.rs ├── group.rs ├── item_list.rs ├── lib.rs ├── proxy.rs ├── row.rs ├── style.rs └── text.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build_and_test_unix: 6 | name: Rust project (Unix) 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: checkout 10 | uses: actions/checkout@v2 11 | - name: install dependencies 12 | run: | 13 | sudo apt-get update 14 | sudo apt-get install -qq libncurses-dev libncursesw5-dev 15 | - name: cargo build 16 | uses: actions-rs/cargo@v1 17 | with: 18 | command: build 19 | args: --release --all-features 20 | - name: cargo test 21 | uses: actions-rs/cargo@v1 22 | with: 23 | command: test 24 | - name: cargo clippy 25 | uses: actions-rs/cargo@v1 26 | with: 27 | command: clippy 28 | args: --all-targets 29 | - name: cargo fmt 30 | uses: actions-rs/cargo@v1 31 | with: 32 | command: fmt 33 | args: -- --check 34 | build_and_test_windows: 35 | name: Rust project (Windows) 36 | runs-on: windows-latest 37 | steps: 38 | - name: checkout 39 | uses: actions/checkout@v2 40 | - name: cargo build 41 | uses: actions-rs/cargo@v1 42 | with: 43 | command: build 44 | args: --release --all-features 45 | - name: cargo test 46 | uses: actions-rs/cargo@v1 47 | with: 48 | command: test 49 | - name: cargo clippy 50 | uses: actions-rs/cargo@v1 51 | with: 52 | command: clippy 53 | args: --all-targets 54 | - name: cargo fmt 55 | uses: actions-rs/cargo@v1 56 | with: 57 | command: fmt 58 | args: -- --check 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "cc" 5 | version = "1.0.65" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "95752358c8f7552394baf48cd82695b345628ad3f170d607de3ca03b8dacca15" 8 | 9 | [[package]] 10 | name = "libc" 11 | version = "0.2.80" 12 | source = "registry+https://github.com/rust-lang/crates.io-index" 13 | checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" 14 | 15 | [[package]] 16 | name = "ncurses" 17 | version = "5.99.0" 18 | source = "registry+https://github.com/rust-lang/crates.io-index" 19 | checksum = "15699bee2f37e9f8828c7b35b2bc70d13846db453f2d507713b758fabe536b82" 20 | dependencies = [ 21 | "cc", 22 | "libc", 23 | "pkg-config", 24 | ] 25 | 26 | [[package]] 27 | name = "pdcurses" 28 | version = "0.0.0" 29 | source = "git+https://github.com/et342/pdcurses-rs#83da8fdc26b1feab8cb571ce17ae1cde2e6c8108" 30 | dependencies = [ 31 | "cc", 32 | "libc", 33 | ] 34 | 35 | [[package]] 36 | name = "pkg-config" 37 | version = "0.3.19" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" 40 | 41 | [[package]] 42 | name = "rcui" 43 | version = "0.1.0" 44 | dependencies = [ 45 | "libc", 46 | "ncurses", 47 | "pdcurses", 48 | ] 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rcui" 3 | version = "0.1.0" 4 | authors = ["rexim "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | libc = "0.2.80" 11 | [target.'cfg(unix)'.dependencies] 12 | ncurses = { version = "5.99.0", features = ["wide"] } 13 | [target.'cfg(windows)'.dependencies] 14 | pdcurses = { git = "https://github.com/et342/pdcurses-rs", features = ["ncurses_compat"] } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Alexey Kutepov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/tsoding/rcui/workflows/CI/badge.svg)](https://github.com/tsoding/rcui/actions) 2 | 3 | # rcui 4 | 5 | Simple TUI framework in Rust. 6 | 7 | ## Example 8 | 9 | Item List with 100 elements and vim-like navigation 10 | 11 | ```rust 12 | use rcui::*; 13 | 14 | fn main() { 15 | Rcui::exec(Proxy::wrap( 16 | |list, context, event| match event { 17 | Event::KeyStroke(key) => match *key as u8 as char { 18 | 'q' => context.quit(), 19 | 'j' => list.down(), 20 | 'k' => list.up(), 21 | _ => {} 22 | } 23 | 24 | _ => {} 25 | }, 26 | ItemList::new((0..100).map(|x| format!("item-{:02}", x)).collect()), 27 | )); 28 | println!("Quitting gracefully uwu"); 29 | } 30 | ``` 31 | 32 | ## Quick Start 33 | 34 | ```console 35 | $ cargo run --example 01_grid 36 | $ cargo run --example 02_item_list 37 | ``` 38 | -------------------------------------------------------------------------------- /examples/01_grid.rs: -------------------------------------------------------------------------------- 1 | use rcui::*; 2 | 3 | fn text_cell(s: &str) -> Box { 4 | Box::new(Text { 5 | text: s.to_string(), 6 | halign: HAlign::Centre, 7 | valign: VAlign::Centre, 8 | }) 9 | } 10 | 11 | fn main() { 12 | Rcui::exec(Proxy::wrap( 13 | |origin, rcui, event| { 14 | if let Event::KeyStroke(key) = event { 15 | if *key as u8 as char == 'q' { 16 | rcui.quit(); 17 | } 18 | } 19 | origin.handle_event(rcui, event); 20 | }, 21 | Column::new(vec![ 22 | Cell::Fixed(3.0, text_cell("This is the Grid Example:")), 23 | Cell::Many( 24 | 3, 25 | Row::wrap(vec![ 26 | Cell::Many(1, text_cell("hello")), 27 | Cell::Many(1, text_cell("hello")), 28 | Cell::One(text_cell("hello")), 29 | ]), 30 | ), 31 | Cell::Many( 32 | 2, 33 | Row::wrap(vec![ 34 | Cell::One(text_cell("world")), 35 | Cell::One(text_cell("world")), 36 | Cell::One(text_cell("world")), 37 | ]), 38 | ), 39 | Cell::One(Row::wrap(vec![ 40 | Cell::One(text_cell("foo")), 41 | Cell::One(text_cell("foo")), 42 | Cell::One(text_cell("foo")), 43 | ])), 44 | Cell::One(Row::wrap(vec![ 45 | Cell::One(text_cell("bar")), 46 | Cell::One(text_cell("bar")), 47 | Cell::One(text_cell("bar")), 48 | ])), 49 | ]), 50 | )); 51 | 52 | println!("Quitting gracefully uwu"); 53 | } 54 | -------------------------------------------------------------------------------- /examples/02_item_list.rs: -------------------------------------------------------------------------------- 1 | use rcui::curses::*; 2 | use rcui::*; 3 | 4 | fn title(title: &str, widget: Box) -> Box { 5 | let mut title = Column::wrap(vec![ 6 | Cell::Fixed( 7 | 3.0, 8 | Box::new(Text { 9 | text: title.to_string(), 10 | halign: HAlign::Centre, 11 | valign: VAlign::Centre, 12 | }), 13 | ), 14 | Cell::One(widget), 15 | ]); 16 | title.group.focus = 1; 17 | title 18 | } 19 | 20 | fn main() { 21 | Rcui::exec(title( 22 | "jk to move up and down", 23 | Proxy::wrap( 24 | |list, context, event| { 25 | if let Event::KeyStroke(key) = event { 26 | match *key { 27 | KEY_NPAGE => list.page_down(), 28 | KEY_PPAGE => list.page_up(), 29 | key => match key as u8 as char { 30 | 'q' => context.quit(), 31 | 'j' => list.down(), 32 | 'k' => list.up(), 33 | _ => {} 34 | }, 35 | } 36 | } 37 | }, 38 | ItemList::new((0..100).map(|x| format!("item-{:02}", x)).collect()), 39 | ), 40 | )); 41 | println!("Quitting gracefully uwu"); 42 | } 43 | -------------------------------------------------------------------------------- /examples/03_double_panel.rs: -------------------------------------------------------------------------------- 1 | use rcui::curses::*; 2 | use rcui::*; 3 | 4 | fn item_list_controls(item_list: ItemList) -> Box>> { 5 | Proxy::wrap( 6 | |list, _, event| { 7 | if let Event::KeyStroke(key) = event { 8 | match *key { 9 | KEY_NPAGE => list.page_down(), 10 | KEY_PPAGE => list.page_up(), 11 | key => match key as u8 as char { 12 | 'j' => list.down(), 13 | 'k' => list.up(), 14 | _ => {} 15 | }, 16 | } 17 | } 18 | }, 19 | item_list, 20 | ) 21 | } 22 | 23 | fn title(title: &str, widget: Box) -> Box { 24 | let mut title = Column::wrap(vec![ 25 | Cell::Fixed( 26 | 3.0, 27 | Box::new(Text { 28 | text: title.to_string(), 29 | halign: HAlign::Centre, 30 | valign: VAlign::Centre, 31 | }), 32 | ), 33 | Cell::One(widget), 34 | ]); 35 | title.group.focus = 1; 36 | title 37 | } 38 | 39 | fn main() { 40 | let n = 100; 41 | let left_list = ItemList::new((0..n).map(|x| format!("foo-{}", x)).collect()); 42 | let right_list = ItemList::new((0..n).map(|x| format!("bar-{}", x)).collect()); 43 | Rcui::exec(title( 44 | "jk to move up and down, TAB to switch the focus", 45 | Proxy::wrap( 46 | |hbox, context, event| { 47 | if let Event::KeyStroke(key) = event { 48 | match *key as u8 as char { 49 | 'q' => context.quit(), 50 | '\t' => hbox.focus_next(), 51 | _ => hbox.handle_event(context, event), 52 | } 53 | } 54 | }, 55 | Row::new(vec![ 56 | Cell::One(item_list_controls(left_list)), 57 | Cell::One(item_list_controls(right_list)), 58 | ]), 59 | ), 60 | )); 61 | } 62 | -------------------------------------------------------------------------------- /examples/04_transfer_list.rs: -------------------------------------------------------------------------------- 1 | use rcui::curses::*; 2 | use rcui::*; 3 | 4 | struct AddItem { 5 | label: String, 6 | } 7 | 8 | fn item_list_controls(item_list: ItemList) -> Box>> { 9 | Proxy::wrap( 10 | |list, context, event| match event { 11 | Event::KeyStroke(key) => match *key { 12 | KEY_NPAGE => list.page_down(), 13 | KEY_PPAGE => list.page_up(), 14 | key => match key as u8 as char { 15 | 'j' => list.down(), 16 | 'k' => list.up(), 17 | '\n' => { 18 | if let Some(item) = list.remove() { 19 | context.push_event(Event::Custom(Box::new(AddItem { label: item }))); 20 | } 21 | } 22 | _ => {} 23 | }, 24 | }, 25 | Event::Custom(event) => { 26 | if let Some(add_item) = event.downcast_ref::() { 27 | list.insert_after(add_item.label.clone()); 28 | } 29 | } 30 | _ => {} 31 | }, 32 | item_list, 33 | ) 34 | } 35 | 36 | fn title(title: &str, widget: Box) -> Box { 37 | let mut title = Column::wrap(vec![ 38 | Cell::Fixed( 39 | 3.0, 40 | Box::new(Text { 41 | text: title.to_string(), 42 | halign: HAlign::Centre, 43 | valign: VAlign::Centre, 44 | }), 45 | ), 46 | Cell::One(widget), 47 | ]); 48 | title.group.focus = 1; 49 | title 50 | } 51 | 52 | fn main() { 53 | let n = 10; 54 | let left_list = ItemList::new((0..n).map(|x| format!("foo-{}", x)).collect()); 55 | let right_list = ItemList::new(Vec::::new()); 56 | 57 | Rcui::exec(title( 58 | "jk to move up and down, ENTER to transfer an element, TAB to switch the focus", 59 | Proxy::wrap( 60 | |row, context, event| match event { 61 | Event::KeyStroke(key) => match *key as u8 as char { 62 | 'q' => context.quit(), 63 | '\t' => row.focus_next(), 64 | _ => row.handle_event(context, event), 65 | }, 66 | 67 | Event::Custom(_) => { 68 | assert!(row.group.cells.len() == 2); 69 | row.group.cells[1 - row.group.focus] 70 | .get_widget_mut() 71 | .handle_event(context, event); 72 | } 73 | 74 | _ => {} 75 | }, 76 | Row::new(vec![ 77 | Cell::One(item_list_controls(left_list)), 78 | Cell::One(item_list_controls(right_list)), 79 | ]), 80 | ), 81 | )); 82 | } 83 | -------------------------------------------------------------------------------- /examples/05_edit_field.rs: -------------------------------------------------------------------------------- 1 | use rcui::curses::*; 2 | use rcui::*; 3 | 4 | fn main() { 5 | Rcui::exec(Proxy::wrap( 6 | |field, rcui, event| { 7 | if let Event::KeyStroke(key) = event { 8 | match *key { 9 | KEY_LEFT => field.left(), 10 | KEY_RIGHT => field.right(), 11 | KEY_DC => field.delete_front(), 12 | KEY_BACKSPACE => field.delete_back(), 13 | // TODO(#50): Replace KEY_F1 and KEY_F2 with Shift+Left and Shift+Right in 05_edit_field 14 | KEY_F1 => field.select_left(), 15 | KEY_F2 => field.select_right(), 16 | KEY_F3 => field.put_selection_to_clipboard(rcui), 17 | KEY_F4 => field.paste_from_clipboard(rcui), 18 | KEY_F5 => field.left_word(), 19 | KEY_F6 => field.right_word(), 20 | _ => { 21 | if *key as u8 as char == '\n' { 22 | rcui.quit() 23 | } else { 24 | field.handle_event(rcui, event) 25 | } 26 | } 27 | } 28 | } 29 | }, 30 | EditField::new(), 31 | )) 32 | } 33 | -------------------------------------------------------------------------------- /src/column.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub struct Column { 4 | pub group: Group, 5 | } 6 | 7 | impl Column { 8 | pub fn new(widgets: Vec) -> Self { 9 | Self { 10 | group: Group::new(widgets), 11 | } 12 | } 13 | 14 | pub fn wrap(widgets: Vec) -> Box { 15 | Box::new(Self::new(widgets)) 16 | } 17 | } 18 | 19 | impl Widget for Column { 20 | fn render(&mut self, context: &mut Rcui, rect: &Rect, active: bool) { 21 | let n = self.group.cells.len(); 22 | let cell_size = self.group.cell_size(rect.h); 23 | let mut y = rect.y; 24 | for i in 0..n { 25 | let widget_size = self.group.cells[i].size(cell_size); 26 | self.group.cells[i].get_widget_mut().render( 27 | context, 28 | &Rect { 29 | x: rect.x, 30 | y, 31 | w: rect.w, 32 | h: widget_size, 33 | }, 34 | active && i == self.group.focus, 35 | ); 36 | y += widget_size; 37 | } 38 | } 39 | 40 | fn handle_event(&mut self, context: &mut Rcui, event: &Event) { 41 | self.group.handle_event(context, event); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/curses.rs: -------------------------------------------------------------------------------- 1 | #[cfg(unix)] 2 | pub use ncurses::*; 3 | #[cfg(windows)] 4 | pub use pdcurses::*; 5 | -------------------------------------------------------------------------------- /src/dummy.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Default)] 4 | pub struct Dummy; 5 | 6 | impl Dummy { 7 | pub fn new() -> Self { 8 | Self {} 9 | } 10 | 11 | pub fn wrap() -> Box { 12 | Box::new(Self::new()) 13 | } 14 | } 15 | 16 | impl Widget for Dummy {} 17 | -------------------------------------------------------------------------------- /src/edit_field.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use std::cmp::{max, min, Ordering}; 3 | use std::ops::Range; 4 | 5 | #[derive(Default)] 6 | struct Cursor { 7 | position: usize, 8 | selection_offset: i32, 9 | } 10 | 11 | #[derive(Default)] 12 | pub struct EditField { 13 | text: Vec, 14 | buffer: Vec, 15 | cursor: Cursor, 16 | } 17 | 18 | // TODO(#46): EditField does not support multiple lines (newlines) 19 | // TODO(#47): EditField does not have a way to jump one word forward/backward 20 | // TODO(#48): Some sort of clipboard support for EditField 21 | 22 | impl EditField { 23 | pub fn new() -> Self { 24 | Self { 25 | text: Vec::new(), 26 | buffer: Vec::new(), 27 | cursor: Cursor { 28 | position: 0, 29 | selection_offset: 0, 30 | }, 31 | } 32 | } 33 | 34 | pub fn wrap() -> Box { 35 | Box::new(Self::new()) 36 | } 37 | 38 | pub fn put_selection_to_clipboard(&self, rcui: &mut Rcui) { 39 | if let Some(selection) = self.selection() { 40 | if let Some(text) = self.text.get(selection) { 41 | rcui.put_to_clipboard(text) 42 | } 43 | } 44 | } 45 | 46 | pub fn paste_from_clipboard(&mut self, rcui: &Rcui) { 47 | self.insert_chars(rcui.get_clipboard()) 48 | } 49 | 50 | fn selection(&self) -> Option> { 51 | let selection_border = 52 | (self.cursor.position as i32 + self.cursor.selection_offset) as usize; 53 | 54 | match self.cursor.selection_offset.cmp(&0) { 55 | Ordering::Less => { 56 | let selection_start = max(selection_border, 0); 57 | Some(selection_start..self.cursor.position) 58 | } 59 | Ordering::Equal => None, 60 | Ordering::Greater => { 61 | let selection_end = min(selection_border, self.text.len()); 62 | Some(self.cursor.position..selection_end) 63 | } 64 | } 65 | } 66 | 67 | fn delete_selection(&mut self, selection: Range) { 68 | self.cursor.position = selection.start; 69 | self.text.drain(selection); 70 | self.unselect() 71 | } 72 | 73 | fn unselect(&mut self) { 74 | self.cursor.selection_offset = 0 75 | } 76 | 77 | pub fn right_skip_while(&mut self, p: fn(char) -> bool) { 78 | while self.cursor.position < self.text.len() && p(self.text[self.cursor.position]) { 79 | self.right(); 80 | } 81 | } 82 | 83 | pub fn left_skip_while(&mut self, p: fn(char) -> bool) { 84 | while self.cursor.position > 0 85 | && (self.cursor.position >= self.text.len() || p(self.text[self.cursor.position])) 86 | { 87 | self.left(); 88 | } 89 | } 90 | 91 | pub fn right_word(&mut self) { 92 | self.right_skip_while(|x| x.is_whitespace()); 93 | self.right_skip_while(|x| !x.is_whitespace()); 94 | } 95 | 96 | pub fn left_word(&mut self) { 97 | self.left(); 98 | 99 | if self.cursor.position < self.text.len() && self.text[self.cursor.position].is_whitespace() 100 | { 101 | self.left_skip_while(|x| x.is_whitespace()); 102 | self.left_skip_while(|x| !x.is_whitespace()); 103 | } else { 104 | self.left_skip_while(|x| !x.is_whitespace()); 105 | } 106 | 107 | if self.cursor.position > 0 { 108 | self.right(); 109 | } 110 | } 111 | 112 | pub fn left(&mut self) { 113 | match self.selection() { 114 | None => { 115 | if self.cursor.position > 0 { 116 | self.cursor.position -= 1; 117 | } 118 | } 119 | Some(selection) => { 120 | self.cursor.position = selection.start; 121 | self.unselect(); 122 | } 123 | } 124 | } 125 | 126 | pub fn right(&mut self) { 127 | match self.selection() { 128 | None => { 129 | if self.cursor.position < self.text.len() { 130 | self.cursor.position += 1; 131 | } 132 | } 133 | Some(selection) => { 134 | self.cursor.position = selection.end; 135 | self.unselect(); 136 | } 137 | } 138 | } 139 | 140 | pub fn delete_back(&mut self) { 141 | match self.selection() { 142 | None => { 143 | if self.cursor.position > 0 { 144 | self.left(); 145 | self.text.remove(self.cursor.position); 146 | } 147 | } 148 | Some(selection) => { 149 | self.delete_selection(selection); 150 | } 151 | } 152 | } 153 | 154 | pub fn delete_front(&mut self) { 155 | match self.selection() { 156 | None => { 157 | if self.cursor.position < self.text.len() { 158 | self.text.remove(self.cursor.position); 159 | } 160 | } 161 | Some(selection) => { 162 | self.delete_selection(selection); 163 | } 164 | } 165 | } 166 | 167 | pub fn insert_chars(&mut self, cs: &[char]) { 168 | match self.selection() { 169 | None => {} 170 | Some(selection) => self.delete_selection(selection), 171 | } 172 | 173 | if self.cursor.position >= self.text.len() { 174 | for c in cs.iter() { 175 | if !c.is_control() { 176 | self.text.push(*c); 177 | self.cursor.position += 1; 178 | } 179 | } 180 | } else { 181 | for c in cs.iter() { 182 | if !c.is_control() { 183 | self.text.insert(self.cursor.position, *c); 184 | self.cursor.position += 1; 185 | } 186 | } 187 | } 188 | self.unselect() 189 | } 190 | 191 | pub fn select_left(&mut self) { 192 | if (self.cursor.position as i32 + self.cursor.selection_offset) > 0 { 193 | self.cursor.selection_offset -= 1; 194 | } 195 | } 196 | 197 | pub fn select_right(&mut self) { 198 | if (self.cursor.position as i32 + self.cursor.selection_offset) < self.text.len() as i32 { 199 | self.cursor.selection_offset += 1; 200 | } 201 | } 202 | } 203 | 204 | // TODO(#46): EditField does not support multiple lines (newlines) 205 | 206 | impl Widget for EditField { 207 | fn render(&mut self, _context: &mut Rcui, rect: &Rect, active: bool) { 208 | let x = rect.x.floor() as i32; 209 | let y = rect.y.floor() as i32; 210 | mv(y, x); 211 | // TODO(#35): EditField does not wrap during the rendering 212 | addstr(&self.text.iter().collect::()); 213 | if active { 214 | match self.selection() { 215 | None => { 216 | mv(y, x + self.cursor.position as i32); 217 | attron(COLOR_PAIR(style::CURSOR_PAIR)); 218 | if self.cursor.position >= self.text.len() { 219 | addstr(" "); 220 | } else { 221 | addstr(&self.text[self.cursor.position].to_string()); 222 | } 223 | attroff(COLOR_PAIR(style::CURSOR_PAIR)); 224 | } 225 | Some(selection) => { 226 | for position in selection { 227 | mv(y, x + position as i32); 228 | attron(COLOR_PAIR(style::SELECTION_PAIR)); 229 | if position >= self.text.len() { 230 | addstr(" "); 231 | } else { 232 | addstr(&self.text[position].to_string()); 233 | } 234 | attroff(COLOR_PAIR(style::SELECTION_PAIR)); 235 | } 236 | } 237 | } 238 | } 239 | } 240 | 241 | fn handle_event(&mut self, _context: &mut Rcui, event: &Event) { 242 | // TODO(#37): move the utf8 buffer mechanism to the main event loop 243 | if let Event::KeyStroke(key) = event { 244 | self.buffer.push(*key as u8); 245 | match String::from_utf8(self.buffer.clone()) { 246 | Ok(s) => { 247 | self.insert_chars(&s.chars().collect::>()); 248 | self.buffer.clear() 249 | } 250 | Err(_) => { 251 | if self.buffer.len() >= 4 { 252 | self.buffer.clear() 253 | } 254 | } 255 | } 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/group.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub enum Cell { 4 | One(Box), 5 | Many(usize, Box), 6 | Fixed(f32, Box), 7 | } 8 | 9 | impl Cell { 10 | #[allow(clippy::borrowed_box)] 11 | pub fn get_widget(&self) -> &Box { 12 | match self { 13 | Self::One(widget) => widget, 14 | Self::Many(_, widget) => widget, 15 | Self::Fixed(_, widget) => widget, 16 | } 17 | } 18 | 19 | pub fn get_widget_mut(&mut self) -> &mut Box { 20 | match self { 21 | Self::One(widget) => widget, 22 | Self::Many(_, widget) => widget, 23 | Self::Fixed(_, widget) => widget, 24 | } 25 | } 26 | 27 | pub fn size(&self, cell_size: f32) -> f32 { 28 | match self { 29 | Self::One(_) => cell_size, 30 | Self::Many(n, _) => cell_size * *n as f32, 31 | Self::Fixed(size, _) => *size, 32 | } 33 | } 34 | } 35 | 36 | pub struct Group { 37 | pub cells: Vec, 38 | pub focus: usize, 39 | } 40 | 41 | impl Group { 42 | pub fn new(cells: Vec) -> Self { 43 | Self { cells, focus: 0 } 44 | } 45 | 46 | pub fn wrap(cells: Vec) -> Box { 47 | Box::new(Self::new(cells)) 48 | } 49 | 50 | pub fn focus_next(&mut self) { 51 | if !self.cells.is_empty() { 52 | self.focus = (self.focus + 1) % self.cells.len() 53 | } 54 | } 55 | 56 | pub fn focus_prev(&mut self) { 57 | if !self.cells.is_empty() { 58 | if self.focus == 0 { 59 | self.focus = self.cells.len() - 1; 60 | } else { 61 | self.focus -= 1; 62 | } 63 | } 64 | } 65 | 66 | pub fn cell_size(&self, mut size: f32) -> f32 { 67 | let mut count = 0; 68 | 69 | for cell in self.cells.iter() { 70 | match cell { 71 | Cell::One(_) => count += 1, 72 | Cell::Many(n, _) => count += n, 73 | Cell::Fixed(s, _) => size -= s, 74 | } 75 | } 76 | 77 | size / count as f32 78 | } 79 | } 80 | 81 | impl Widget for Group { 82 | fn handle_event(&mut self, context: &mut Rcui, event: &Event) { 83 | if let Some(cell) = self.cells.get_mut(self.focus) { 84 | cell.get_widget_mut().handle_event(context, event); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/item_list.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | pub struct Window { 3 | pub offset: usize, 4 | pub height: usize, 5 | } 6 | 7 | pub struct ItemList { 8 | pub items: Vec, 9 | pub cursor: usize, 10 | pub window: Window, 11 | } 12 | 13 | impl ItemList { 14 | pub fn new(items: Vec) -> Self { 15 | Self { 16 | items, 17 | cursor: 0, 18 | window: Window { 19 | offset: 0, 20 | height: 0, 21 | }, 22 | } 23 | } 24 | 25 | pub fn wrap(items: Vec) -> Box { 26 | Box::new(Self::new(items)) 27 | } 28 | 29 | pub fn up(&mut self) { 30 | if self.cursor > 0 { 31 | self.cursor -= 1; 32 | } 33 | } 34 | 35 | pub fn page_up(&mut self) { 36 | for _ in 0..self.window.height { 37 | self.up(); 38 | 39 | if self.cursor == 0 { 40 | break; 41 | } 42 | } 43 | } 44 | 45 | pub fn down(&mut self) { 46 | let n = self.items.len(); 47 | if n > 0 && self.cursor < n - 1 { 48 | self.cursor += 1; 49 | } 50 | } 51 | 52 | pub fn page_down(&mut self) { 53 | for _ in 0..self.window.height { 54 | self.down(); 55 | 56 | if self.cursor == self.items.len() - 1 { 57 | break; 58 | } 59 | } 60 | } 61 | 62 | pub fn sync_window(&mut self, h: usize) { 63 | self.window.height = h; 64 | 65 | if self.cursor >= self.window.offset + h { 66 | self.window.offset = self.cursor - h + 1; 67 | } else if self.cursor < self.window.offset { 68 | self.window.offset = self.cursor; 69 | } 70 | } 71 | 72 | pub fn push(&mut self, item: T) { 73 | self.items.push(item) 74 | } 75 | 76 | pub fn insert_after(&mut self, item: T) { 77 | if self.cursor >= self.items.len() { 78 | self.items.push(item); 79 | self.cursor = 0; 80 | } else if self.cursor + 1 >= self.items.len() { 81 | self.items.push(item); 82 | self.cursor += 1; 83 | } else { 84 | self.items.insert(self.cursor + 1, item); 85 | self.cursor += 1; 86 | } 87 | } 88 | 89 | pub fn insert_before(&mut self, item: T) { 90 | if self.cursor >= self.items.len() { 91 | self.items.push(item); 92 | self.cursor = 0; 93 | } else { 94 | self.items.insert(self.cursor, item); 95 | } 96 | } 97 | 98 | pub fn remove(&mut self) -> Option { 99 | if !self.items.is_empty() { 100 | let item = self.items.remove(self.cursor); 101 | 102 | if !self.items.is_empty() && self.cursor >= self.items.len() { 103 | self.cursor = self.items.len() - 1; 104 | } 105 | 106 | Some(item) 107 | } else { 108 | None 109 | } 110 | } 111 | 112 | // TODO(#8): Operations to insert new items into the ItemList 113 | // TODO(#9): Operations to remove items from ItemList 114 | } 115 | 116 | impl Widget for ItemList { 117 | fn render(&mut self, _context: &mut Rcui, rect: &Rect, active: bool) { 118 | let h = rect.h.floor() as usize; 119 | if h > 0 { 120 | self.sync_window(h); 121 | for i in 0..h { 122 | if self.window.offset + i < self.items.len() { 123 | let selected = i + self.window.offset == self.cursor; 124 | let color_pair = if selected { 125 | if active { 126 | style::CURSOR_PAIR 127 | } else { 128 | style::INACTIVE_CURSOR_PAIR 129 | } 130 | } else { 131 | style::REGULAR_PAIR 132 | }; 133 | 134 | attron(COLOR_PAIR(color_pair)); 135 | let x = rect.x.floor() as i32; 136 | let y = (rect.y + i as f32).floor() as i32; 137 | let w = rect.w.floor() as usize; 138 | mv(y, x); 139 | let text = self.items[i + self.window.offset].to_string(); 140 | if text.len() >= w { 141 | addstr(text.get(..w).unwrap_or(&text)); 142 | } else { 143 | addstr(&text); 144 | for _ in 0..w - text.len() { 145 | addstr(" "); 146 | } 147 | } 148 | attroff(COLOR_PAIR(color_pair)); 149 | } 150 | } 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod column; 2 | pub mod curses; 3 | mod dummy; 4 | mod edit_field; 5 | mod group; 6 | mod item_list; 7 | mod proxy; 8 | mod row; 9 | pub mod style; 10 | mod text; 11 | 12 | use curses::CURSOR_VISIBILITY::*; 13 | use curses::*; 14 | use std::collections::VecDeque; 15 | use std::panic::{set_hook, take_hook}; 16 | 17 | pub use self::column::*; 18 | pub use self::dummy::*; 19 | pub use self::edit_field::*; 20 | pub use self::group::*; 21 | pub use self::item_list::*; 22 | pub use self::proxy::*; 23 | pub use self::row::*; 24 | pub use self::text::*; 25 | pub use std::any::Any; 26 | 27 | pub struct Rect { 28 | pub x: f32, 29 | pub y: f32, 30 | pub w: f32, 31 | pub h: f32, 32 | } 33 | 34 | pub enum Event { 35 | Quit, 36 | KeyStroke(i32), 37 | Custom(Box), 38 | } 39 | 40 | pub trait Widget { 41 | fn render(&mut self, _context: &mut Rcui, _rect: &Rect, _active: bool) {} 42 | fn handle_event(&mut self, _context: &mut Rcui, _event: &Event) {} 43 | } 44 | 45 | pub fn screen_rect() -> Rect { 46 | let mut w: i32 = 0; 47 | let mut h: i32 = 0; 48 | getmaxyx(stdscr(), &mut h, &mut w); 49 | Rect { 50 | x: 0.0, 51 | y: 0.0, 52 | w: w as f32, 53 | h: h as f32, 54 | } 55 | } 56 | 57 | pub struct Rcui { 58 | event_queue: VecDeque, 59 | clipboard: Vec, 60 | } 61 | 62 | impl Rcui { 63 | fn new() -> Self { 64 | Self { 65 | event_queue: VecDeque::new(), 66 | clipboard: Vec::new(), 67 | } 68 | } 69 | 70 | pub fn push_event(&mut self, event: Event) { 71 | self.event_queue.push_back(event); 72 | } 73 | 74 | // TODO(#36): no support for nested event loops via Rcui::exec() 75 | 76 | pub fn exec(mut ui: Box) { 77 | let mut context = Self::new(); 78 | 79 | unsafe { 80 | libc::setlocale(libc::LC_ALL, "en_US.UTF-8\0".as_ptr().cast()); 81 | } 82 | 83 | initscr(); 84 | keypad(stdscr(), true); 85 | timeout(10); 86 | 87 | style::init_style(); 88 | 89 | curs_set(CURSOR_INVISIBLE); 90 | 91 | set_hook(Box::new({ 92 | let default_hook = take_hook(); 93 | move |payload| { 94 | endwin(); 95 | default_hook(payload); 96 | } 97 | })); 98 | 99 | let mut quit = false; 100 | while !quit { 101 | #[cfg(windows)] 102 | if is_termresized() { 103 | resize_term(0, 0); 104 | } 105 | erase(); 106 | ui.render(&mut context, &screen_rect(), true); 107 | 108 | // Busy waiting on the key event 109 | let mut key = getch(); 110 | while key == ERR { 111 | key = getch(); 112 | } 113 | 114 | // Flushing everything we've got 115 | while key != ERR { 116 | context.push_event(Event::KeyStroke(key)); 117 | key = getch(); 118 | } 119 | 120 | // Handling all of the events from the queue 121 | while !context.event_queue.is_empty() { 122 | if let Some(event) = context.event_queue.pop_front() { 123 | if let Event::Quit = event { 124 | quit = true; 125 | } 126 | 127 | ui.handle_event(&mut context, &event); 128 | }; 129 | } 130 | } 131 | 132 | endwin(); 133 | } 134 | 135 | pub fn put_to_clipboard(&mut self, text: &[char]) { 136 | self.clipboard.clear(); 137 | self.clipboard.extend_from_slice(text); 138 | } 139 | 140 | pub fn get_clipboard(&self) -> &[char] { 141 | &self.clipboard 142 | } 143 | 144 | pub fn quit(&mut self) { 145 | self.push_event(Event::Quit); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/proxy.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub struct Proxy { 4 | pub origin: T, 5 | pub handler: fn(&mut T, &mut Rcui, &Event), 6 | } 7 | 8 | impl Proxy { 9 | pub fn new(handler: fn(&mut T, &mut Rcui, &Event), origin: T) -> Self { 10 | Self { origin, handler } 11 | } 12 | 13 | pub fn wrap(handler: fn(&mut T, &mut Rcui, &Event), origin: T) -> Box { 14 | Box::new(Self::new(handler, origin)) 15 | } 16 | } 17 | 18 | impl Widget for Proxy { 19 | fn render(&mut self, context: &mut Rcui, rect: &Rect, active: bool) { 20 | self.origin.render(context, rect, active); 21 | } 22 | 23 | fn handle_event(&mut self, context: &mut Rcui, event: &Event) { 24 | (self.handler)(&mut self.origin, context, event); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/row.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub struct Row { 4 | pub group: Group, 5 | } 6 | 7 | impl Row { 8 | pub fn new(cells: Vec) -> Self { 9 | Self { 10 | group: Group::new(cells), 11 | } 12 | } 13 | 14 | pub fn wrap(widgets: Vec) -> Box { 15 | Box::new(Self::new(widgets)) 16 | } 17 | 18 | pub fn focus_next(&mut self) { 19 | self.group.focus_next(); 20 | } 21 | 22 | pub fn focus_prev(&mut self) { 23 | self.group.focus_prev(); 24 | } 25 | } 26 | 27 | impl Widget for Row { 28 | fn render(&mut self, context: &mut Rcui, rect: &Rect, active: bool) { 29 | let n = self.group.cells.len(); 30 | let cell_size = self.group.cell_size(rect.w); 31 | let mut x = rect.x; 32 | for i in 0..n { 33 | let widget_size = self.group.cells[i].size(cell_size); 34 | self.group.cells[i].get_widget_mut().render( 35 | context, 36 | &Rect { 37 | x, 38 | y: rect.y, 39 | w: widget_size, 40 | h: rect.h, 41 | }, 42 | active && i == self.group.focus, 43 | ); 44 | x += widget_size; 45 | } 46 | } 47 | 48 | fn handle_event(&mut self, context: &mut Rcui, event: &Event) { 49 | self.group.handle_event(context, event); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/style.rs: -------------------------------------------------------------------------------- 1 | #[cfg(unix)] 2 | use ncurses::*; 3 | #[cfg(windows)] 4 | use pdcurses::*; 5 | 6 | pub const REGULAR_PAIR: i16 = 1; 7 | pub const CURSOR_PAIR: i16 = 2; 8 | pub const INACTIVE_CURSOR_PAIR: i16 = 3; 9 | pub const SELECTION_PAIR: i16 = 4; 10 | 11 | pub fn init_style() { 12 | start_color(); 13 | init_pair(REGULAR_PAIR, COLOR_WHITE, COLOR_BLACK); 14 | init_pair(CURSOR_PAIR, COLOR_BLACK, COLOR_WHITE); 15 | init_pair(INACTIVE_CURSOR_PAIR, COLOR_BLACK, COLOR_CYAN); 16 | init_pair(SELECTION_PAIR, COLOR_BLACK, COLOR_MAGENTA); 17 | } 18 | -------------------------------------------------------------------------------- /src/text.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[derive(Clone, Copy)] 4 | pub enum HAlign { 5 | Left, 6 | Centre, 7 | Right, 8 | } 9 | 10 | #[derive(Clone, Copy)] 11 | pub enum VAlign { 12 | Top, 13 | Centre, 14 | Bottom, 15 | } 16 | 17 | pub struct Text { 18 | pub text: String, 19 | pub halign: HAlign, 20 | pub valign: VAlign, 21 | } 22 | 23 | impl Text { 24 | pub fn new(text: &str) -> Self { 25 | Self { 26 | text: text.to_string(), 27 | halign: HAlign::Left, 28 | valign: VAlign::Top, 29 | } 30 | } 31 | 32 | pub fn wrap(text: &str) -> Box { 33 | Box::new(Self::new(text)) 34 | } 35 | } 36 | 37 | impl Widget for Text { 38 | fn render(&mut self, _context: &mut Rcui, rect: &Rect, _active: bool) { 39 | let s = self 40 | .text 41 | .get(..rect.w.floor() as usize) 42 | .unwrap_or(&self.text); 43 | let n = s.len(); 44 | let free_hspace = rect.w - n as f32; 45 | // TODO(#3): Text does not support wrapping around 46 | let free_vspace = rect.h - 1.0; 47 | 48 | let x = match self.halign { 49 | HAlign::Left => rect.x, 50 | HAlign::Centre => (rect.x + free_hspace * 0.5).floor(), 51 | HAlign::Right => (rect.x + free_hspace).floor(), 52 | } as i32; 53 | 54 | let y = match self.valign { 55 | VAlign::Top => rect.y, 56 | VAlign::Centre => (rect.y + free_vspace * 0.5).floor(), 57 | VAlign::Bottom => (rect.y + free_vspace).floor(), 58 | } as i32; 59 | 60 | mv(y, x); 61 | addstr(s); 62 | } 63 | } 64 | --------------------------------------------------------------------------------