mirror of https://github.com/TaKO8Ki/gobang
Add SQL Editor component (#84)
* syntax text * add sql editor * remove sytaxx_text module * remove sytax_text * add a focus above key * add a event for movining focus and table component * add syntax text component * add a key for executing queries * add completion * add reserved words * update completion when key is delete or left/right * use reserved words in where clause * editor * add a macro for debugging * stop inserting a new line in the sentence * remove run key * enter to execute a query * fix tests for stateful paragraph * change const to letpull/137/head
parent
566f9ebb43
commit
4bcd4802fc
@ -0,0 +1,289 @@
|
||||
use super::{
|
||||
compute_character_width, CompletionComponent, Component, EventState, MovableComponent,
|
||||
StatefulDrawableComponent, TableComponent,
|
||||
};
|
||||
use crate::components::command::CommandInfo;
|
||||
use crate::config::KeyConfig;
|
||||
use crate::database::{ExecuteResult, Pool};
|
||||
use crate::event::Key;
|
||||
use crate::ui::stateful_paragraph::{ParagraphState, StatefulParagraph};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tui::{
|
||||
backend::Backend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
struct QueryResult {
|
||||
updated_rows: u64,
|
||||
query: String,
|
||||
}
|
||||
|
||||
impl QueryResult {
|
||||
fn result_str(&self) -> String {
|
||||
format!("Query OK, {} row affected", self.updated_rows)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Focus {
|
||||
Editor,
|
||||
Table,
|
||||
}
|
||||
|
||||
pub struct SqlEditorComponent {
|
||||
input: Vec<char>,
|
||||
input_cursor_position_x: u16,
|
||||
input_idx: usize,
|
||||
table: TableComponent,
|
||||
query_result: Option<QueryResult>,
|
||||
completion: CompletionComponent,
|
||||
key_config: KeyConfig,
|
||||
paragraph_state: ParagraphState,
|
||||
focus: Focus,
|
||||
}
|
||||
|
||||
impl SqlEditorComponent {
|
||||
pub fn new(key_config: KeyConfig) -> Self {
|
||||
Self {
|
||||
input: Vec::new(),
|
||||
input_idx: 0,
|
||||
input_cursor_position_x: 0,
|
||||
table: TableComponent::new(key_config.clone()),
|
||||
completion: CompletionComponent::new(key_config.clone(), "", true),
|
||||
focus: Focus::Editor,
|
||||
paragraph_state: ParagraphState::default(),
|
||||
query_result: None,
|
||||
key_config,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_completion(&mut self) {
|
||||
let input = &self
|
||||
.input
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i < &self.input_idx)
|
||||
.map(|(_, i)| i)
|
||||
.collect::<String>()
|
||||
.split(' ')
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
self.completion
|
||||
.update(input.last().unwrap_or(&String::new()));
|
||||
}
|
||||
|
||||
fn complete(&mut self) -> anyhow::Result<EventState> {
|
||||
if let Some(candidate) = self.completion.selected_candidate() {
|
||||
let mut input = Vec::new();
|
||||
let first = self
|
||||
.input
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i < &self.input_idx.saturating_sub(self.completion.word().len()))
|
||||
.map(|(_, c)| c.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
let last = self
|
||||
.input
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i >= &self.input_idx)
|
||||
.map(|(_, c)| c.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let is_last_word = last.first().map_or(false, |c| c == &" ".to_string());
|
||||
|
||||
let middle = if is_last_word {
|
||||
candidate
|
||||
.chars()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
} else {
|
||||
let mut c = candidate
|
||||
.chars()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
c.push(" ".to_string());
|
||||
c
|
||||
};
|
||||
|
||||
input.extend(first);
|
||||
input.extend(middle.clone());
|
||||
input.extend(last);
|
||||
|
||||
self.input = input.join("").chars().collect();
|
||||
self.input_idx += &middle.len();
|
||||
if is_last_word {
|
||||
self.input_idx += 1;
|
||||
}
|
||||
self.input_idx -= self.completion.word().len();
|
||||
self.input_cursor_position_x += middle
|
||||
.join("")
|
||||
.chars()
|
||||
.map(compute_character_width)
|
||||
.sum::<u16>();
|
||||
if is_last_word {
|
||||
self.input_cursor_position_x += " ".to_string().width() as u16
|
||||
}
|
||||
self.input_cursor_position_x -= self
|
||||
.completion
|
||||
.word()
|
||||
.chars()
|
||||
.map(compute_character_width)
|
||||
.sum::<u16>();
|
||||
self.update_completion();
|
||||
return Ok(EventState::Consumed);
|
||||
}
|
||||
Ok(EventState::NotConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulDrawableComponent for SqlEditorComponent {
|
||||
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) -> Result<()> {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(if matches!(self.focus, Focus::Table) {
|
||||
vec![Constraint::Length(7), Constraint::Min(1)]
|
||||
} else {
|
||||
vec![Constraint::Percentage(50), Constraint::Min(1)]
|
||||
})
|
||||
.split(area);
|
||||
|
||||
let editor = StatefulParagraph::new(self.input.iter().collect::<String>())
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
|
||||
f.render_stateful_widget(editor, layout[0], &mut self.paragraph_state);
|
||||
|
||||
if let Some(result) = self.query_result.as_ref() {
|
||||
let result = Paragraph::new(result.result_str())
|
||||
.block(Block::default().borders(Borders::ALL).style(
|
||||
if focused && matches!(self.focus, Focus::Editor) {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
},
|
||||
))
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(result, layout[1]);
|
||||
} else {
|
||||
self.table
|
||||
.draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?;
|
||||
}
|
||||
|
||||
if focused && matches!(self.focus, Focus::Editor) {
|
||||
f.set_cursor(
|
||||
(layout[0].x + 1)
|
||||
.saturating_add(
|
||||
self.input_cursor_position_x % layout[0].width.saturating_sub(2),
|
||||
)
|
||||
.min(area.right().saturating_sub(2)),
|
||||
(layout[0].y
|
||||
+ 1
|
||||
+ self.input_cursor_position_x / layout[0].width.saturating_sub(2))
|
||||
.min(layout[0].bottom()),
|
||||
)
|
||||
}
|
||||
|
||||
if focused && matches!(self.focus, Focus::Editor) {
|
||||
self.completion.draw(
|
||||
f,
|
||||
area,
|
||||
false,
|
||||
self.input_cursor_position_x % layout[0].width.saturating_sub(2) + 1,
|
||||
self.input_cursor_position_x / layout[0].width.saturating_sub(2),
|
||||
)?;
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SqlEditorComponent {
|
||||
fn commands(&self, _out: &mut Vec<CommandInfo>) {}
|
||||
|
||||
fn event(&mut self, key: Key) -> Result<EventState> {
|
||||
let input_str: String = self.input.iter().collect();
|
||||
|
||||
if key == self.key_config.focus_above && matches!(self.focus, Focus::Table) {
|
||||
self.focus = Focus::Editor
|
||||
} else if key == self.key_config.enter {
|
||||
return self.complete();
|
||||
}
|
||||
|
||||
match key {
|
||||
Key::Char(c) if matches!(self.focus, Focus::Editor) => {
|
||||
self.input.insert(self.input_idx, c);
|
||||
self.input_idx += 1;
|
||||
self.input_cursor_position_x += compute_character_width(c);
|
||||
self.update_completion();
|
||||
|
||||
return Ok(EventState::Consumed);
|
||||
}
|
||||
Key::Esc if matches!(self.focus, Focus::Editor) => self.focus = Focus::Table,
|
||||
Key::Delete | Key::Backspace if matches!(self.focus, Focus::Editor) => {
|
||||
if input_str.width() > 0 && !self.input.is_empty() && self.input_idx > 0 {
|
||||
let last_c = self.input.remove(self.input_idx - 1);
|
||||
self.input_idx -= 1;
|
||||
self.input_cursor_position_x -= compute_character_width(last_c);
|
||||
self.completion.update("");
|
||||
}
|
||||
|
||||
return Ok(EventState::Consumed);
|
||||
}
|
||||
Key::Left if matches!(self.focus, Focus::Editor) => {
|
||||
if !self.input.is_empty() && self.input_idx > 0 {
|
||||
self.input_idx -= 1;
|
||||
self.input_cursor_position_x = self
|
||||
.input_cursor_position_x
|
||||
.saturating_sub(compute_character_width(self.input[self.input_idx]));
|
||||
self.completion.update("");
|
||||
}
|
||||
return Ok(EventState::Consumed);
|
||||
}
|
||||
Key::Right if matches!(self.focus, Focus::Editor) => {
|
||||
if self.input_idx < self.input.len() {
|
||||
let next_c = self.input[self.input_idx];
|
||||
self.input_idx += 1;
|
||||
self.input_cursor_position_x += compute_character_width(next_c);
|
||||
self.completion.update("");
|
||||
}
|
||||
return Ok(EventState::Consumed);
|
||||
}
|
||||
key if matches!(self.focus, Focus::Table) => return self.table.event(key),
|
||||
_ => (),
|
||||
}
|
||||
return Ok(EventState::NotConsumed);
|
||||
}
|
||||
|
||||
async fn async_event(&mut self, key: Key, pool: &Box<dyn Pool>) -> Result<EventState> {
|
||||
if key == self.key_config.enter && matches!(self.focus, Focus::Editor) {
|
||||
let query = self.input.iter().collect();
|
||||
let result = pool.execute(&query).await?;
|
||||
match result {
|
||||
ExecuteResult::Read {
|
||||
headers,
|
||||
rows,
|
||||
database,
|
||||
table,
|
||||
} => {
|
||||
self.table.update(rows, headers, database, table);
|
||||
self.focus = Focus::Table;
|
||||
self.query_result = None;
|
||||
}
|
||||
ExecuteResult::Write { updated_rows } => {
|
||||
self.query_result = Some(QueryResult {
|
||||
updated_rows,
|
||||
query: query.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return Ok(EventState::Consumed);
|
||||
}
|
||||
|
||||
Ok(EventState::NotConsumed)
|
||||
}
|
||||
}
|
@ -0,0 +1,545 @@
|
||||
use easy_cast::Cast;
|
||||
use tui::text::StyledGrapheme;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const NBSP: &str = "\u{00a0}";
|
||||
|
||||
/// A state machine to pack styled symbols into lines.
|
||||
/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming
|
||||
/// iterators for that).
|
||||
pub trait LineComposer<'a> {
|
||||
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>;
|
||||
}
|
||||
|
||||
/// A state machine that wraps lines on word boundaries.
|
||||
pub struct WordWrapper<'a, 'b> {
|
||||
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||
max_line_width: u16,
|
||||
current_line: Vec<StyledGrapheme<'a>>,
|
||||
next_line: Vec<StyledGrapheme<'a>>,
|
||||
/// Removes the leading whitespace from lines
|
||||
trim: bool,
|
||||
}
|
||||
|
||||
impl<'a, 'b> WordWrapper<'a, 'b> {
|
||||
pub fn new(
|
||||
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||
max_line_width: u16,
|
||||
trim: bool,
|
||||
) -> WordWrapper<'a, 'b> {
|
||||
WordWrapper {
|
||||
symbols,
|
||||
max_line_width,
|
||||
current_line: vec![],
|
||||
next_line: vec![],
|
||||
trim,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> {
|
||||
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
|
||||
if self.max_line_width == 0 {
|
||||
return None;
|
||||
}
|
||||
std::mem::swap(&mut self.current_line, &mut self.next_line);
|
||||
self.next_line.truncate(0);
|
||||
|
||||
let mut current_line_width = self
|
||||
.current_line
|
||||
.iter()
|
||||
.map(|StyledGrapheme { symbol, .. }| -> u16 { symbol.width().cast() })
|
||||
.sum();
|
||||
|
||||
let mut symbols_to_last_word_end: usize = 0;
|
||||
let mut width_to_last_word_end: u16 = 0;
|
||||
let mut prev_whitespace = false;
|
||||
let mut symbols_exhausted = true;
|
||||
for StyledGrapheme { symbol, style } in &mut self.symbols {
|
||||
symbols_exhausted = false;
|
||||
let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP;
|
||||
|
||||
// Ignore characters wider that the total max width.
|
||||
if Cast::<u16>::cast(symbol.width()) > self.max_line_width
|
||||
// Skip leading whitespace when trim is enabled.
|
||||
|| self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break on newline and discard it.
|
||||
if symbol == "\n" {
|
||||
if prev_whitespace {
|
||||
current_line_width = width_to_last_word_end;
|
||||
self.current_line.truncate(symbols_to_last_word_end);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Mark the previous symbol as word end.
|
||||
if symbol_whitespace && !prev_whitespace {
|
||||
symbols_to_last_word_end = self.current_line.len();
|
||||
width_to_last_word_end = current_line_width;
|
||||
}
|
||||
|
||||
self.current_line.push(StyledGrapheme { symbol, style });
|
||||
current_line_width += Cast::<u16>::cast(symbol.width());
|
||||
|
||||
if current_line_width > self.max_line_width {
|
||||
// If there was no word break in the text, wrap at the end of the line.
|
||||
let (truncate_at, truncated_width) = if symbols_to_last_word_end == 0 {
|
||||
(self.current_line.len() - 1, self.max_line_width)
|
||||
} else {
|
||||
(self.current_line.len() - 1, width_to_last_word_end)
|
||||
};
|
||||
|
||||
// Push the remainder to the next line but strip leading whitespace:
|
||||
{
|
||||
let remainder = &self.current_line[truncate_at..];
|
||||
if !remainder.is_empty() {
|
||||
self.next_line.extend_from_slice(&remainder);
|
||||
}
|
||||
}
|
||||
self.current_line.truncate(truncate_at);
|
||||
current_line_width = truncated_width;
|
||||
break;
|
||||
}
|
||||
|
||||
prev_whitespace = symbol_whitespace;
|
||||
}
|
||||
|
||||
// Even if the iterator is exhausted, pass the previous remainder.
|
||||
if symbols_exhausted && self.current_line.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((&self.current_line[..], current_line_width))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A state machine that truncates overhanging lines.
|
||||
pub struct LineTruncator<'a, 'b> {
|
||||
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||
max_line_width: u16,
|
||||
current_line: Vec<StyledGrapheme<'a>>,
|
||||
/// Record the offet to skip render
|
||||
horizontal_offset: u16,
|
||||
}
|
||||
|
||||
impl<'a, 'b> LineTruncator<'a, 'b> {
|
||||
pub fn new(
|
||||
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||
max_line_width: u16,
|
||||
) -> LineTruncator<'a, 'b> {
|
||||
LineTruncator {
|
||||
symbols,
|
||||
max_line_width,
|
||||
horizontal_offset: 0,
|
||||
current_line: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) {
|
||||
self.horizontal_offset = horizontal_offset;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> {
|
||||
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
|
||||
if self.max_line_width == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.current_line.truncate(0);
|
||||
let mut current_line_width = 0;
|
||||
|
||||
let mut skip_rest = false;
|
||||
let mut symbols_exhausted = true;
|
||||
let mut horizontal_offset = self.horizontal_offset as usize;
|
||||
for StyledGrapheme { symbol, style } in &mut self.symbols {
|
||||
symbols_exhausted = false;
|
||||
|
||||
// Ignore characters wider that the total max width.
|
||||
if Cast::<u16>::cast(symbol.width()) > self.max_line_width {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break on newline and discard it.
|
||||
if symbol == "\n" {
|
||||
break;
|
||||
}
|
||||
|
||||
if current_line_width + Cast::<u16>::cast(symbol.width()) > self.max_line_width {
|
||||
// Exhaust the remainder of the line.
|
||||
skip_rest = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let symbol = if horizontal_offset == 0 {
|
||||
symbol
|
||||
} else {
|
||||
let w = symbol.width();
|
||||
if w > horizontal_offset {
|
||||
let t = trim_offset(symbol, horizontal_offset);
|
||||
horizontal_offset = 0;
|
||||
t
|
||||
} else {
|
||||
horizontal_offset -= w;
|
||||
""
|
||||
}
|
||||
};
|
||||
current_line_width += Cast::<u16>::cast(symbol.width());
|
||||
self.current_line.push(StyledGrapheme { symbol, style });
|
||||
}
|
||||
|
||||
if skip_rest {
|
||||
for StyledGrapheme { symbol, .. } in &mut self.symbols {
|
||||
if symbol == "\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if symbols_exhausted && self.current_line.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((&self.current_line[..], current_line_width))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This function will return a str slice which start at specified offset.
|
||||
/// As src is a unicode str, start offset has to be calculated with each character.
|
||||
fn trim_offset(src: &str, mut offset: usize) -> &str {
|
||||
let mut start = 0;
|
||||
for c in UnicodeSegmentation::graphemes(src, true) {
|
||||
let w = c.width();
|
||||
if w <= offset {
|
||||
offset -= w;
|
||||
start += c.len();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
&src[start..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
enum Composer {
|
||||
WordWrapper { trim: bool },
|
||||
LineTruncator,
|
||||
}
|
||||
|
||||
fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec<String>, Vec<u16>) {
|
||||
let style = Default::default();
|
||||
let mut styled =
|
||||
UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style });
|
||||
let mut composer: Box<dyn LineComposer> = match which {
|
||||
Composer::WordWrapper { trim } => {
|
||||
Box::new(WordWrapper::new(&mut styled, text_area_width, trim))
|
||||
}
|
||||
Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)),
|
||||
};
|
||||
let mut lines = vec![];
|
||||
let mut widths = vec![];
|
||||
while let Some((styled, width)) = composer.next_line() {
|
||||
let line = styled
|
||||
.iter()
|
||||
.map(|StyledGrapheme { symbol, .. }| *symbol)
|
||||
.collect::<String>();
|
||||
assert!(width <= text_area_width);
|
||||
lines.push(line);
|
||||
widths.push(width);
|
||||
}
|
||||
(lines, widths)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_one_line() {
|
||||
let width = 40;
|
||||
for i in 1..width {
|
||||
let text = "a".repeat(i);
|
||||
let (word_wrapper, _) =
|
||||
run_composer(Composer::WordWrapper { trim: true }, &text, width as u16);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16);
|
||||
let expected = vec![text];
|
||||
assert_eq!(word_wrapper, expected);
|
||||
assert_eq!(line_truncator, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_short_lines() {
|
||||
let width = 20;
|
||||
let text =
|
||||
"abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
|
||||
let wrapped: Vec<&str> = text.split('\n').collect();
|
||||
assert_eq!(word_wrapper, wrapped);
|
||||
assert_eq!(line_truncator, wrapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_long_word() {
|
||||
let width = 20;
|
||||
let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno";
|
||||
let (word_wrapper, _) =
|
||||
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
|
||||
|
||||
let wrapped = vec![
|
||||
&text[..width],
|
||||
&text[width..width * 2],
|
||||
&text[width * 2..width * 3],
|
||||
&text[width * 3..],
|
||||
];
|
||||
assert_eq!(
|
||||
word_wrapper, wrapped,
|
||||
"WordWrapper should detect the line cannot be broken on word boundary and \
|
||||
break it at line width limit."
|
||||
);
|
||||
assert_eq!(line_truncator, vec![&text[..width]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_long_sentence() {
|
||||
let width = 20;
|
||||
let text =
|
||||
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l m n o";
|
||||
let text_multi_space =
|
||||
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \
|
||||
m n o";
|
||||
let (word_wrapper_single_space, _) =
|
||||
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
|
||||
let (word_wrapper_multi_space, _) = run_composer(
|
||||
Composer::WordWrapper { trim: true },
|
||||
text_multi_space,
|
||||
width as u16,
|
||||
);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
|
||||
|
||||
assert_eq!(
|
||||
word_wrapper_single_space,
|
||||
vec![
|
||||
"abcd efghij klmnopab",
|
||||
"cd efgh ijklmnopabcd",
|
||||
"efg hijkl mnopab c d",
|
||||
" e f g h i j k l m n",
|
||||
" o",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
word_wrapper_multi_space,
|
||||
vec![
|
||||
"abcd efghij klmno",
|
||||
"pabcd efgh ijklm",
|
||||
"nopabcdefg hijkl mno",
|
||||
"pab c d e f g h i j ",
|
||||
"k l m n o"
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(line_truncator, vec![&text[..width]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_zero_width() {
|
||||
let width = 0;
|
||||
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
|
||||
let expected: Vec<&str> = Vec::new();
|
||||
assert_eq!(word_wrapper, expected);
|
||||
assert_eq!(line_truncator, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_max_line_width_of_1() {
|
||||
let width = 1;
|
||||
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
|
||||
let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
|
||||
assert_eq!(word_wrapper, expected);
|
||||
assert_eq!(line_truncator, vec!["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_max_line_width_of_1_double_width_characters() {
|
||||
let width = 1;
|
||||
let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\
|
||||
両端点では、";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
assert_eq!(word_wrapper, vec!["", "a", "a", "a"]);
|
||||
assert_eq!(line_truncator, vec!["", "a"]);
|
||||
}
|
||||
|
||||
/// Tests WordWrapper with words some of which exceed line length and some not.
|
||||
#[test]
|
||||
fn line_composer_word_wrapper_mixed_length() {
|
||||
let width = 20;
|
||||
let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
assert_eq!(
|
||||
word_wrapper,
|
||||
vec![
|
||||
"abcd efghij klmnopab",
|
||||
"cdefghijklmnopabcdef",
|
||||
"ghijkl mnopab cdefgh",
|
||||
"i j klmno"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_double_width_chars() {
|
||||
let width = 20;
|
||||
let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\
|
||||
では、";
|
||||
let (word_wrapper, word_wrapper_width) =
|
||||
run_composer(Composer::WordWrapper { trim: true }, &text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width);
|
||||
assert_eq!(line_truncator, vec!["コンピュータ上で文字"]);
|
||||
let wrapped = vec![
|
||||
"コンピュータ上で文字",
|
||||
"を扱う場合、典型的に",
|
||||
"は文字による通信を行",
|
||||
"う場合にその両端点で",
|
||||
"は、",
|
||||
];
|
||||
assert_eq!(word_wrapper, wrapped);
|
||||
assert_eq!(word_wrapper_width, vec![width, width, width, width, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_leading_whitespace_removal() {
|
||||
let width = 20;
|
||||
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]);
|
||||
assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]);
|
||||
}
|
||||
|
||||
/// Tests truncation of leading whitespace.
|
||||
#[test]
|
||||
fn line_composer_lots_of_spaces() {
|
||||
let width = 20;
|
||||
let text = " ";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
assert_eq!(word_wrapper, vec![""]);
|
||||
assert_eq!(line_truncator, vec![" "]);
|
||||
}
|
||||
|
||||
/// Tests an input starting with a letter, folowed by spaces - some of the behaviour is
|
||||
/// incidental.
|
||||
#[test]
|
||||
fn line_composer_char_plus_lots_of_spaces() {
|
||||
let width = 20;
|
||||
let text = "a ";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||
// What's happening below is: the first line gets consumed, trailing spaces discarded,
|
||||
// after 20 of which a word break occurs (probably shouldn't). The second line break
|
||||
// discards all whitespace. The result should probably be vec!["a"] but it doesn't matter
|
||||
// that much.
|
||||
assert_eq!(
|
||||
word_wrapper,
|
||||
vec![
|
||||
"a ",
|
||||
" ",
|
||||
" ",
|
||||
" "
|
||||
]
|
||||
);
|
||||
assert_eq!(line_truncator, vec!["a "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_word_wrapper_double_width_chars_mixed_with_spaces() {
|
||||
let width = 20;
|
||||
// Japanese seems not to use spaces but we should break on spaces anyway... We're using it
|
||||
// to test double-width chars.
|
||||
// You are more than welcome to add word boundary detection based of alterations of
|
||||
// hiragana and katakana...
|
||||
// This happens to also be a test case for mixed width because regular spaces are single width.
|
||||
let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、";
|
||||
let (word_wrapper, word_wrapper_width) =
|
||||
run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
assert_eq!(
|
||||
word_wrapper,
|
||||
vec![
|
||||
"コンピュ ータ上で文",
|
||||
"字を扱う場合、 典型",
|
||||
"的には文 字による 通",
|
||||
"信を行 う場合にその",
|
||||
"両端点では、"
|
||||
]
|
||||
);
|
||||
// Odd-sized lines have a space in them.
|
||||
assert_eq!(word_wrapper_width, vec![8, 14, 17, 6, 12]);
|
||||
}
|
||||
|
||||
/// Ensure words separated by nbsp are wrapped as if they were a single one.
|
||||
#[test]
|
||||
fn line_composer_word_wrapper_nbsp() {
|
||||
let width = 20;
|
||||
let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA AAAA", "\u{a0}AAA"]);
|
||||
|
||||
// Ensure that if the character was a regular space, it would be wrapped differently.
|
||||
let text_space = text.replace("\u{00a0}", " ");
|
||||
let (word_wrapper_space, _) =
|
||||
run_composer(Composer::WordWrapper { trim: true }, &text_space, width);
|
||||
assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", " AAA"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_word_wrapper_preserve_indentation() {
|
||||
let width = 20;
|
||||
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
|
||||
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_word_wrapper_preserve_indentation_with_wrap() {
|
||||
let width = 10;
|
||||
let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
|
||||
assert_eq!(
|
||||
word_wrapper,
|
||||
vec!["AAA AAA AA", "AAA AA AAA", "AAA", " B", " C", " D"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() {
|
||||
let width = 10;
|
||||
let text = " 4 Indent\n must wrap!";
|
||||
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
|
||||
assert_eq!(
|
||||
word_wrapper,
|
||||
vec![
|
||||
" ",
|
||||
" 4 Ind",
|
||||
"ent",
|
||||
" ",
|
||||
" mus",
|
||||
"t wrap!"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use easy_cast::Cast;
|
||||
use std::iter;
|
||||
use tui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Rect},
|
||||
style::Style,
|
||||
text::{StyledGrapheme, Text},
|
||||
widgets::{Block, StatefulWidget, Widget, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::reflow::{LineComposer, LineTruncator, WordWrapper};
|
||||
|
||||
const fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 {
|
||||
match alignment {
|
||||
Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2),
|
||||
Alignment::Right => text_area_width.saturating_sub(line_width),
|
||||
Alignment::Left => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StatefulParagraph<'a> {
|
||||
/// A block to wrap the widget in
|
||||
block: Option<Block<'a>>,
|
||||
/// Widget style
|
||||
style: Style,
|
||||
/// How to wrap the text
|
||||
wrap: Option<Wrap>,
|
||||
/// The text to display
|
||||
text: Text<'a>,
|
||||
/// Alignment of the text
|
||||
alignment: Alignment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ScrollPos {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
}
|
||||
|
||||
impl ScrollPos {
|
||||
pub const fn new(x: u16, y: u16) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub struct ParagraphState {
|
||||
/// Scroll
|
||||
scroll: ScrollPos,
|
||||
/// after all wrapping this is the amount of lines
|
||||
lines: u16,
|
||||
/// last visible height
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl ParagraphState {
|
||||
pub const fn lines(self) -> u16 {
|
||||
self.lines
|
||||
}
|
||||
|
||||
pub const fn height(self) -> u16 {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub const fn scroll(self) -> ScrollPos {
|
||||
self.scroll
|
||||
}
|
||||
|
||||
pub fn set_scroll(&mut self, scroll: ScrollPos) {
|
||||
self.scroll = scroll;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> StatefulParagraph<'a> {
|
||||
pub fn new<T>(text: T) -> Self
|
||||
where
|
||||
T: Into<Text<'a>>,
|
||||
{
|
||||
Self {
|
||||
block: None,
|
||||
style: Style::default(),
|
||||
wrap: None,
|
||||
text: text.into(),
|
||||
alignment: Alignment::Left,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_const_for_fn)]
|
||||
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||
self.block = Some(block);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn style(mut self, style: Style) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn wrap(mut self, wrap: Wrap) -> Self {
|
||||
self.wrap = Some(wrap);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn alignment(mut self, alignment: Alignment) -> Self {
|
||||
self.alignment = alignment;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> StatefulWidget for StatefulParagraph<'a> {
|
||||
type State = ParagraphState;
|
||||
|
||||
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
buf.set_style(area, self.style);
|
||||
let text_area = match self.block.take() {
|
||||
Some(b) => {
|
||||
let inner_area = b.inner(area);
|
||||
b.render(area, buf);
|
||||
inner_area
|
||||
}
|
||||
None => area,
|
||||
};
|
||||
|
||||
if text_area.height < 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let style = self.style;
|
||||
let mut styled = self.text.lines.iter().flat_map(|spans| {
|
||||
spans
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|span| span.styled_graphemes(style))
|
||||
// Required given the way composers work but might be refactored out if we change
|
||||
// composers to operate on lines instead of a stream of graphemes.
|
||||
.chain(iter::once(StyledGrapheme {
|
||||
symbol: "\n",
|
||||
style: self.style,
|
||||
}))
|
||||
});
|
||||
|
||||
let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap {
|
||||
Box::new(WordWrapper::new(&mut styled, text_area.width, trim))
|
||||
} else {
|
||||
let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width));
|
||||
if let Alignment::Left = self.alignment {
|
||||
line_composer.set_horizontal_offset(state.scroll.x);
|
||||
}
|
||||
line_composer
|
||||
};
|
||||
let mut y = 0;
|
||||
let mut end_reached = false;
|
||||
while let Some((current_line, current_line_width)) = line_composer.next_line() {
|
||||
if !end_reached && y >= state.scroll.y {
|
||||
let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
|
||||
for StyledGrapheme { symbol, style } in current_line {
|
||||
buf.get_mut(text_area.left() + x, text_area.top() + y - state.scroll.y)
|
||||
.set_symbol(if symbol.is_empty() {
|
||||
// If the symbol is empty, the last char which rendered last time will
|
||||
// leave on the line. It's a quick fix.
|
||||
" "
|
||||
} else {
|
||||
symbol
|
||||
})
|
||||
.set_style(*style);
|
||||
x += Cast::<u16>::cast(symbol.width());
|
||||
}
|
||||
}
|
||||
y += 1;
|
||||
if y >= text_area.height + state.scroll.y {
|
||||
end_reached = true;
|
||||
}
|
||||
}
|
||||
|
||||
state.lines = y;
|
||||
state.height = area.height;
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
use std::ops::Range;
|
||||
use syntect::{
|
||||
highlighting::{
|
||||
FontStyle, HighlightState, Highlighter, RangedHighlightIterator, Style, ThemeSet,
|
||||
},
|
||||
parsing::{ParseState, ScopeStack, SyntaxSet},
|
||||
};
|
||||
use tui::text::{Span, Spans};
|
||||
|
||||
struct SyntaxLine {
|
||||
items: Vec<(Style, usize, Range<usize>)>,
|
||||
}
|
||||
|
||||
pub struct SyntaxText {
|
||||
text: String,
|
||||
lines: Vec<SyntaxLine>,
|
||||
}
|
||||
|
||||
impl SyntaxText {
|
||||
pub fn new(text: String) -> Self {
|
||||
let syntax_set: SyntaxSet = SyntaxSet::load_defaults_nonewlines();
|
||||
let theme_set: ThemeSet = ThemeSet::load_defaults();
|
||||
|
||||
let mut state = ParseState::new(syntax_set.find_syntax_by_extension("sql").unwrap());
|
||||
let highlighter = Highlighter::new(&theme_set.themes["base16-eighties.dark"]);
|
||||
let mut syntax_lines: Vec<SyntaxLine> = Vec::new();
|
||||
let mut highlight_state = HighlightState::new(&highlighter, ScopeStack::new());
|
||||
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
let ops = state.parse_line(line, &syntax_set);
|
||||
let iter =
|
||||
RangedHighlightIterator::new(&mut highlight_state, &ops[..], line, &highlighter);
|
||||
|
||||
syntax_lines.push(SyntaxLine {
|
||||
items: iter
|
||||
.map(|(style, _, range)| (style, number, range))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
text,
|
||||
lines: syntax_lines,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert(&self) -> tui::text::Text<'_> {
|
||||
let mut result_lines: Vec<Spans> = Vec::with_capacity(self.lines.len());
|
||||
|
||||
for (syntax_line, line_content) in self.lines.iter().zip(self.text.lines()) {
|
||||
let mut line_span = Spans(Vec::with_capacity(syntax_line.items.len()));
|
||||
|
||||
for (style, _, range) in &syntax_line.items {
|
||||
let item_content = &line_content[range.clone()];
|
||||
let item_style = syntact_style_to_tui(style);
|
||||
|
||||
line_span.0.push(Span::styled(item_content, item_style));
|
||||
}
|
||||
|
||||
result_lines.push(line_span);
|
||||
}
|
||||
|
||||
result_lines.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a SyntaxText> for tui::text::Text<'a> {
|
||||
fn from(v: &'a SyntaxText) -> Self {
|
||||
let mut result_lines: Vec<Spans> = Vec::with_capacity(v.lines.len());
|
||||
|
||||
for (syntax_line, line_content) in v.lines.iter().zip(v.text.lines()) {
|
||||
let mut line_span = Spans(Vec::with_capacity(syntax_line.items.len()));
|
||||
|
||||
for (style, _, range) in &syntax_line.items {
|
||||
let item_content = &line_content[range.clone()];
|
||||
let item_style = syntact_style_to_tui(style);
|
||||
|
||||
line_span.0.push(Span::styled(item_content, item_style));
|
||||
}
|
||||
|
||||
result_lines.push(line_span);
|
||||
}
|
||||
|
||||
result_lines.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn syntact_style_to_tui(style: &Style) -> tui::style::Style {
|
||||
let mut res = tui::style::Style::default().fg(tui::style::Color::Rgb(
|
||||
style.foreground.r,
|
||||
style.foreground.g,
|
||||
style.foreground.b,
|
||||
));
|
||||
|
||||
if style.font_style.contains(FontStyle::BOLD) {
|
||||
res = res.add_modifier(tui::style::Modifier::BOLD);
|
||||
}
|
||||
if style.font_style.contains(FontStyle::ITALIC) {
|
||||
res = res.add_modifier(tui::style::Modifier::ITALIC);
|
||||
}
|
||||
if style.font_style.contains(FontStyle::UNDERLINE) {
|
||||
res = res.add_modifier(tui::style::Modifier::UNDERLINED);
|
||||
}
|
||||
|
||||
res
|
||||
}
|
Loading…
Reference in New Issue