mirror of
https://github.com/FliegendeWurst/cursive.git
synced 2024-11-09 19:00:46 +00:00
Add initial TextArea
This commit is contained in:
parent
ede5c616f6
commit
75c365451f
@ -2,16 +2,17 @@
|
||||
authors = ["Alexandre Bury <alexandre.bury@gmail.com>"]
|
||||
description = "A TUI library based on ncurses-rs."
|
||||
documentation = "https://gyscos.github.io/Cursive/cursive/index.html"
|
||||
exclude = ["doc", "assets"]
|
||||
keywords = ["ncurses", "TUI"]
|
||||
license = "MIT"
|
||||
name = "cursive"
|
||||
readme = "Readme.md"
|
||||
repository = "https://github.com/gyscos/Cursive"
|
||||
version = "0.1.1"
|
||||
exclude = ["doc", "assets"]
|
||||
|
||||
[dependencies]
|
||||
ncurses = "5.80.0"
|
||||
odds = "0.2.14"
|
||||
toml = "0.1"
|
||||
unicode-segmentation = "0.1.2"
|
||||
unicode-width = "0.1.3"
|
||||
|
@ -5,10 +5,11 @@ use cursive::prelude::*;
|
||||
fn main() {
|
||||
let mut siv = Cursive::new();
|
||||
|
||||
// We can quit by pressing q
|
||||
siv.add_global_callback('q', |s| s.quit());
|
||||
// We can quit by pressing `q`
|
||||
siv.add_global_callback('q', Cursive::quit);
|
||||
|
||||
siv.add_layer(TextView::new("Hello World!\nPress q to quit the application."));
|
||||
siv.add_layer(TextView::new("Hello World!\n\
|
||||
Press q to quit the application."));
|
||||
|
||||
siv.run();
|
||||
}
|
||||
|
16
examples/text_area.rs
Normal file
16
examples/text_area.rs
Normal file
@ -0,0 +1,16 @@
|
||||
extern crate cursive;
|
||||
|
||||
use cursive::prelude::*;
|
||||
|
||||
fn main() {
|
||||
let mut siv = Cursive::new();
|
||||
|
||||
siv.add_layer(Dialog::empty()
|
||||
.title("Describe your issue")
|
||||
.padding((1,1,1,0))
|
||||
.content(BoxView::fixed_size((30, 5),
|
||||
TextArea::new().with_id("text")))
|
||||
.button("Ok", Cursive::quit));
|
||||
|
||||
siv.run();
|
||||
}
|
@ -51,6 +51,7 @@ extern crate ncurses;
|
||||
extern crate toml;
|
||||
extern crate unicode_segmentation;
|
||||
extern crate unicode_width;
|
||||
extern crate odds;
|
||||
|
||||
macro_rules! println_stderr(
|
||||
($($arg:tt)*) => { {
|
||||
|
@ -11,6 +11,6 @@ pub use event::{Event, Key};
|
||||
pub use view::{Identifiable, Selector, View};
|
||||
pub use views::{BoxView, Button, Checkbox, Dialog, EditView, FullView,
|
||||
IdView, KeyEventView, LinearLayout, ListView, Panel,
|
||||
ProgressBar, SelectView, TextView};
|
||||
ProgressBar, SelectView, TextArea, TextView};
|
||||
pub use vec::Vec2;
|
||||
pub use menu::MenuTree;
|
||||
|
@ -126,6 +126,7 @@ use toml;
|
||||
use B;
|
||||
|
||||
/// Text effect
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Effect {
|
||||
/// No effect
|
||||
Simple,
|
||||
|
@ -8,9 +8,16 @@ use utils::prefix_length;
|
||||
/// Given a long text and a width constraint, it iterates over
|
||||
/// substrings of the text, each within the constraint.
|
||||
pub struct LinesIterator<'a> {
|
||||
/// Content to iterate on.
|
||||
content: &'a str,
|
||||
start: usize,
|
||||
/// Current offset in the content.
|
||||
offset: usize,
|
||||
/// Available width. Don't output lines wider than that.
|
||||
width: usize,
|
||||
|
||||
/// If `true`, keep a blank cell at the end of lines
|
||||
/// when a whitespace or newline should be.
|
||||
show_spaces: bool,
|
||||
}
|
||||
|
||||
impl<'a> LinesIterator<'a> {
|
||||
@ -21,59 +28,96 @@ impl<'a> LinesIterator<'a> {
|
||||
LinesIterator {
|
||||
content: content,
|
||||
width: width,
|
||||
start: 0,
|
||||
offset: 0,
|
||||
show_spaces: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave a blank cell at the end of lines.
|
||||
///
|
||||
/// Unless a word had to be truncated, in which case
|
||||
/// it takes the entire width.
|
||||
pub fn show_spaces(mut self) -> Self {
|
||||
self.show_spaces = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a row of text within a `String`.
|
||||
///
|
||||
/// A row is made of an offset into a parent `String` and a length.
|
||||
/// A row is made of offsets into a parent `String`.
|
||||
/// The corresponding substring should take `width` cells when printed.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Row {
|
||||
/// Beginning of the row in the parent `String`.
|
||||
pub start: usize,
|
||||
/// Length of the row, in bytes.
|
||||
/// End of the row (excluded)
|
||||
pub end: usize,
|
||||
/// Width of the row, in cells.
|
||||
pub width: usize,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// Shift a row start and end by `offset`.
|
||||
pub fn shift(mut self, offset: usize) -> Self {
|
||||
self.start += offset;
|
||||
self.end += offset;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for LinesIterator<'a> {
|
||||
type Item = Row;
|
||||
|
||||
fn next(&mut self) -> Option<Row> {
|
||||
if self.start >= self.content.len() {
|
||||
if self.offset >= self.content.len() {
|
||||
// This is the end.
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = self.start;
|
||||
let content = &self.content[self.start..];
|
||||
// We start at the current offset.
|
||||
let start = self.offset;
|
||||
let content = &self.content[start..];
|
||||
|
||||
// Find the ideal line, in an infinitely wide world.
|
||||
// We'll make a line larger than that.
|
||||
let next = content.find('\n').unwrap_or(content.len());
|
||||
let content = &content[..next];
|
||||
|
||||
let allowed_width = if self.show_spaces {
|
||||
self.width - 1
|
||||
} else {
|
||||
self.width
|
||||
};
|
||||
|
||||
let line_width = content.width();
|
||||
if line_width <= self.width {
|
||||
if line_width <= allowed_width {
|
||||
// We found a newline before the allowed limit.
|
||||
// Break early.
|
||||
self.start += next + 1;
|
||||
// Advance the cursor to after the newline.
|
||||
self.offset += next + 1;
|
||||
return Some(Row {
|
||||
start: start,
|
||||
end: next + start,
|
||||
end: start + next,
|
||||
width: line_width,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep adding indivisible tokens
|
||||
// First attempt: only break on spaces.
|
||||
let prefix_length =
|
||||
match prefix_length(content.split(' '), self.width, " ") {
|
||||
match prefix_length(content.split(' '), allowed_width, " ") {
|
||||
// If this fail, fallback: only break on graphemes.
|
||||
// There's no whitespace to skip there.
|
||||
// And don't reserve the white space anymore.
|
||||
0 => prefix_length(content.graphemes(true), self.width, ""),
|
||||
other => {
|
||||
self.start += 1;
|
||||
// If it works, advance the cursor by 1
|
||||
// to jump the whitespace.
|
||||
// We don't want to add 1 to `prefix_length` though, it
|
||||
// would include the whitespace in the row.
|
||||
self.offset += 1;
|
||||
other
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if prefix_length == 0 {
|
||||
@ -82,7 +126,8 @@ impl<'a> Iterator for LinesIterator<'a> {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.start += prefix_length;
|
||||
// Advance the offset to the end of the line.
|
||||
self.offset += prefix_length;
|
||||
|
||||
Some(Row {
|
||||
start: start,
|
||||
@ -91,3 +136,11 @@ impl<'a> Iterator for LinesIterator<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_layout() {
|
||||
}
|
||||
}
|
||||
|
@ -200,6 +200,7 @@ impl EditView {
|
||||
// It means it'll just return a ref if no one else has a ref,
|
||||
// and it will clone it into `self.content` otherwise.
|
||||
Rc::make_mut(&mut self.content).insert(self.cursor, ch);
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
|
||||
/// Remove the character at the current cursor position.
|
||||
@ -317,7 +318,6 @@ impl View for EditView {
|
||||
// Find the byte index of the char at self.cursor
|
||||
|
||||
self.insert(ch);
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
// TODO: handle ctrl-key?
|
||||
Event::Key(Key::Home) => self.cursor = 0,
|
||||
|
@ -15,10 +15,11 @@ mod menubar;
|
||||
mod menu_popup;
|
||||
mod panel;
|
||||
mod progress_bar;
|
||||
mod shadow_view;
|
||||
mod select_view;
|
||||
mod shadow_view;
|
||||
mod sized_view;
|
||||
mod stack_view;
|
||||
mod text_area;
|
||||
mod text_view;
|
||||
mod tracked_view;
|
||||
|
||||
@ -41,5 +42,6 @@ pub use self::select_view::SelectView;
|
||||
pub use self::shadow_view::ShadowView;
|
||||
pub use self::sized_view::SizedView;
|
||||
pub use self::stack_view::StackView;
|
||||
pub use self::text_area::TextArea;
|
||||
pub use self::text_view::TextView;
|
||||
pub use self::tracked_view::TrackedView;
|
||||
|
325
src/views/text_area.rs
Normal file
325
src/views/text_area.rs
Normal file
@ -0,0 +1,325 @@
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use odds::vec::VecExt;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use {Printer, XY};
|
||||
use direction::Direction;
|
||||
use vec::Vec2;
|
||||
use event::{Event, EventResult, Key};
|
||||
use utils::{LinesIterator, Row, prefix_length};
|
||||
use view::{ScrollBase, SizeCache, View};
|
||||
use theme::{ColorStyle, Effect};
|
||||
|
||||
/// Multi-lines text editor.
|
||||
///
|
||||
/// A `TextArea` by itself doesn't have a well-defined size.
|
||||
/// You should wrap it in a `BoxView` to control its size.
|
||||
pub struct TextArea {
|
||||
// TODO: use a smarter data structure (rope?)
|
||||
content: String,
|
||||
/// Byte offsets within `content` representing text rows
|
||||
rows: Vec<Row>,
|
||||
|
||||
/// When `false`, we don't take any input.
|
||||
enabled: bool,
|
||||
|
||||
/// Base for scrolling features
|
||||
scrollbase: ScrollBase,
|
||||
|
||||
/// Cache to avoid re-computing layout on no-op events
|
||||
last_size: Option<XY<SizeCache>>,
|
||||
|
||||
/// Byte offset of the currently selected grapheme.
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
fn make_rows(text: &str, width: usize) -> Vec<Row> {
|
||||
LinesIterator::new(text, width)
|
||||
.show_spaces()
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
/// Creates a new, empty TextArea.
|
||||
pub fn new() -> Self {
|
||||
TextArea {
|
||||
content: String::new(),
|
||||
rows: vec![Row {
|
||||
start: 0,
|
||||
end: 0,
|
||||
width: 0,
|
||||
}],
|
||||
enabled: true,
|
||||
scrollbase: ScrollBase::new(),
|
||||
last_size: None,
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the content of the view.
|
||||
pub fn get_content(&self) -> &str {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Finds the row containing the grapheme at the given offset
|
||||
fn row_at(&self, offset: usize) -> usize {
|
||||
self.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take_while(|&(_, row)| row.start <= offset)
|
||||
.map(|(i, _)| i)
|
||||
.last()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Finds the row containing the cursor
|
||||
fn selected_row(&self) -> usize {
|
||||
self.row_at(self.cursor)
|
||||
}
|
||||
|
||||
fn page_up(&mut self) {
|
||||
for _ in 0..5 {
|
||||
self.move_up();
|
||||
}
|
||||
}
|
||||
|
||||
fn page_down(&mut self) {
|
||||
for _ in 0..5 {
|
||||
self.move_down();
|
||||
}
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let row_id = self.selected_row();
|
||||
if row_id == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let row = self.rows[row_id];
|
||||
// Number of cells to the left of the cursor
|
||||
let x = self.content[row.start..self.cursor].width();
|
||||
|
||||
let prev_row = self.rows[row_id - 1];
|
||||
let prev_text = &self.content[prev_row.start..prev_row.end];
|
||||
let offset = prefix_length(prev_text.graphemes(true), x, "");
|
||||
self.cursor = prev_row.start + offset;
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let row_id = self.selected_row();
|
||||
if row_id + 1 == self.rows.len() {
|
||||
return;
|
||||
}
|
||||
let row = self.rows[row_id];
|
||||
// Number of cells to the left of the cursor
|
||||
let x = self.content[row.start..self.cursor].width();
|
||||
|
||||
let next_row = self.rows[row_id + 1];
|
||||
let next_text = &self.content[next_row.start..next_row.end];
|
||||
let offset = prefix_length(next_text.graphemes(true), x, "");
|
||||
self.cursor = next_row.start + offset;
|
||||
}
|
||||
|
||||
/// Moves the cursor to the left.
|
||||
///
|
||||
/// Wraps the previous line if required.
|
||||
fn move_left(&mut self) {
|
||||
let len = {
|
||||
// We don't want to utf8-parse the entire content.
|
||||
// So restrict to the last row.
|
||||
let mut row = self.selected_row();
|
||||
if self.rows[row].start == self.cursor {
|
||||
row -= 1;
|
||||
}
|
||||
|
||||
let text = &self.content[self.rows[row].start..self.cursor];
|
||||
text.graphemes(true)
|
||||
.last()
|
||||
.unwrap()
|
||||
.len()
|
||||
};
|
||||
self.cursor -= len;
|
||||
}
|
||||
|
||||
/// Moves the cursor to the right.
|
||||
///
|
||||
/// Jumps to the next line is required.
|
||||
fn move_right(&mut self) {
|
||||
let len = self.content[self.cursor..]
|
||||
.graphemes(true)
|
||||
.next()
|
||||
.unwrap()
|
||||
.len();
|
||||
self.cursor += len;
|
||||
}
|
||||
|
||||
fn is_cache_valid(&self, size: Vec2) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn compute_rows(&mut self, size: Vec2) {
|
||||
let mut available = size.x;
|
||||
let content = format!("{} ", self.content);
|
||||
self.rows = make_rows(&content, available);
|
||||
if self.rows.len() > size.y {
|
||||
available -= 1;
|
||||
// Doh :(
|
||||
self.rows = make_rows(&content, available);
|
||||
}
|
||||
|
||||
|
||||
if !self.rows.is_empty() {
|
||||
// The last row probably contains a fake whitespace.
|
||||
// Unless... the whitespace was used as an implicit newline.
|
||||
// This means the last row ends in a newline-d whitespace.
|
||||
// How do we detect that?
|
||||
// By checking if the last row takes all the available width.
|
||||
if self.rows.last().unwrap().width != available {
|
||||
self.rows.last_mut().unwrap().end -= 1;
|
||||
}
|
||||
self.last_size = Some(SizeCache::build(size, size));
|
||||
}
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
if self.cursor != 0 {
|
||||
self.move_left();
|
||||
self.delete();
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&mut self) {
|
||||
let len = self.content[self.cursor..]
|
||||
.graphemes(true)
|
||||
.next()
|
||||
.unwrap()
|
||||
.len();
|
||||
let start = self.cursor;
|
||||
let end = self.cursor + len;
|
||||
for _ in self.content.drain(start..end) {}
|
||||
|
||||
let size = self.last_size.unwrap().map(|s| s.value);
|
||||
self.compute_rows(size);
|
||||
}
|
||||
|
||||
fn insert(&mut self, ch: char) {
|
||||
let cursor = self.cursor;
|
||||
self.content.insert(cursor, ch);
|
||||
|
||||
let shift = ch.len_utf8();
|
||||
let selected_row = self.selected_row();
|
||||
self.rows[selected_row].end += shift;
|
||||
|
||||
if selected_row < self.rows.len() {
|
||||
for row in &mut self.rows[1 + selected_row..] {
|
||||
row.start += shift;
|
||||
row.end += shift;
|
||||
}
|
||||
}
|
||||
|
||||
let size = self.last_size.unwrap().map(|s| s.value);
|
||||
self.compute_rows(size);
|
||||
self.cursor += shift;
|
||||
}
|
||||
}
|
||||
|
||||
impl View for TextArea {
|
||||
fn draw(&self, printer: &Printer) {
|
||||
printer.with_color(ColorStyle::Secondary, |printer| {
|
||||
let effect = if self.enabled {
|
||||
Effect::Reverse
|
||||
} else {
|
||||
Effect::Simple
|
||||
};
|
||||
|
||||
let w = if self.scrollbase.scrollable() {
|
||||
printer.size.x - 1
|
||||
} else {
|
||||
printer.size.x
|
||||
};
|
||||
printer.with_effect(effect, |printer| {
|
||||
for y in 0..printer.size.y {
|
||||
printer.print_hline((0, y), w, " ");
|
||||
}
|
||||
});
|
||||
|
||||
self.scrollbase.draw(printer, |printer, i| {
|
||||
let row = &self.rows[i];
|
||||
let text = &self.content[row.start..row.end];
|
||||
printer.with_effect(effect, |printer| {
|
||||
printer.print((0, 0), text);
|
||||
});
|
||||
|
||||
if printer.focused && i == self.selected_row() {
|
||||
let cursor_offset = self.cursor - row.start;
|
||||
let c = if cursor_offset == text.len() {
|
||||
"_"
|
||||
} else {
|
||||
text[cursor_offset..]
|
||||
.graphemes(true)
|
||||
.next()
|
||||
.expect("Found no char!")
|
||||
};
|
||||
let offset = text[..cursor_offset].width();
|
||||
printer.print((offset, 0), c);
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn on_event(&mut self, event: Event) -> EventResult {
|
||||
match event {
|
||||
Event::Char(ch) => self.insert(ch),
|
||||
Event::Key(Key::Enter) => self.insert('\n'),
|
||||
Event::Key(Key::Backspace) if self.cursor > 0 => self.backspace(),
|
||||
Event::Key(Key::Del) if self.cursor < self.content.len() => {
|
||||
self.delete()
|
||||
}
|
||||
|
||||
Event::Key(Key::End) => {
|
||||
let row = self.selected_row();
|
||||
self.cursor = self.rows[row].end;
|
||||
if row + 1 < self.rows.len() &&
|
||||
self.cursor == self.rows[row + 1].start {
|
||||
self.move_left();
|
||||
}
|
||||
}
|
||||
Event::Ctrl(Key::Home) => {
|
||||
self.cursor = 0
|
||||
}
|
||||
Event::Ctrl(Key::End) => {
|
||||
self.cursor = self.content.len()
|
||||
}
|
||||
Event::Key(Key::Home) => {
|
||||
self.cursor = self.rows[self.selected_row()].start
|
||||
}
|
||||
Event::Key(Key::Up) if self.selected_row() > 0 => self.move_up(),
|
||||
Event::Key(Key::Down) if self.selected_row() + 1 <
|
||||
self.rows.len() => self.move_down(),
|
||||
Event::Key(Key::PageUp) => self.page_up(),
|
||||
Event::Key(Key::PageDown) => self.page_down(),
|
||||
Event::Key(Key::Left) if self.cursor > 0 => self.move_left(),
|
||||
Event::Key(Key::Right) if self.cursor < self.content.len() => {
|
||||
self.move_right()
|
||||
}
|
||||
_ => return EventResult::Ignored,
|
||||
}
|
||||
|
||||
let focus = self.selected_row();
|
||||
self.scrollbase.scroll_to(focus);
|
||||
|
||||
EventResult::Consumed(None)
|
||||
}
|
||||
|
||||
fn take_focus(&mut self, _: Direction) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn layout(&mut self, size: Vec2) {
|
||||
self.last_size = Some(SizeCache::build(size, size));
|
||||
self.scrollbase.set_heights(size.y, self.rows.len());
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user