use monospace_core::Glyph;
use monospace_diagram::{ShapeId, Text};
use monospace_editing::Command;
use rat_theme4::WidgetStyle;
use rat_theme4::theme::SalsaTheme;
use rat_widget::dialog_frame::{DialogFrame, DialogFrameState};
use rat_widget::event::{ButtonOutcome, HandleEvent, Outcome, Regular, TextOutcome};
use rat_widget::focus::{Focus, FocusBuilder, Navigation};
use rat_widget::text::HasScreenCursor;
use rat_widget::textarea::{TextArea, TextAreaState, TextWrap};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::{Constraint, Rect};
use ratatui_core::widgets::StatefulWidget;
use ratatui_crossterm::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui_widgets::block::Block;
use crate::overlay::Overlay;
use crate::vocabulary::{Action, Reply};
const MAX_WIDTH: u16 = 56;
const MAX_HEIGHT: u16 = 14;
#[derive(Debug)]
pub(crate) struct TextEditorState {
id: ShapeId,
frame: DialogFrameState,
text: TextAreaState,
}
impl TextEditorState {
pub(crate) fn new(id: ShapeId, text: Option<&Text>) -> Self {
let seed = text
.map(|text| {
text.lines()
.iter()
.map(|line| line.iter().map(Glyph::as_str).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let mut state = Self {
id,
frame: DialogFrameState::default(),
text: TextAreaState::default(),
};
state.frame.ok.focus.set(false);
state.frame.cancel.focus.set(false);
state.text.focus.set(true);
state.text.set_newline("\n");
state.text.set_text_wrap(TextWrap::Word(1));
state.text.set_text(seed);
let _ = state.text.select_all();
state
}
#[cfg(test)]
pub(crate) fn buffer(&self) -> String {
self.text.text()
}
#[cfg(test)]
pub(crate) fn selected_text(&self) -> String {
self.text.selected_text().into_owned()
}
fn title(&self) -> String {
format!("Text of Box #{}", self.id)
}
fn build_focus(&self) -> Focus {
let mut builder = FocusBuilder::default();
builder
.widget_navigate(&self.text, Navigation::Regular)
.widget(&self.frame.ok)
.widget(&self.frame.cancel);
builder.build()
}
}
pub(crate) fn handle(event: &Event, editor: &mut Overlay<TextEditorState>) -> Reply {
let Some(state) = editor.get_mut() else {
return Reply::Ignored;
};
let mut changed = state.build_focus().handle(event, Regular) == Outcome::Changed;
if is_tab(event) {
return if changed {
Reply::Repaint
} else {
Reply::Consumed
};
}
match state.text.handle(event, Regular) {
TextOutcome::TextChanged | TextOutcome::Changed => changed = true,
TextOutcome::Unchanged | TextOutcome::Continue => {}
}
if state.frame.cancel.handle(event, Regular) == ButtonOutcome::Pressed {
editor.close();
return Reply::Repaint;
}
if state.frame.ok.handle(event, Regular) == ButtonOutcome::Pressed {
let text = stored(&state.text.text());
let id = state.id;
editor.close();
return Reply::Act(Action::Command(Command::SetText { id, text }));
}
if is_escape(event) {
editor.close();
return Reply::Repaint;
}
if changed {
Reply::Repaint
} else {
Reply::Consumed
}
}
pub(crate) fn draw(
area: Rect,
buf: &mut Buffer,
editor: &mut Overlay<TextEditorState>,
theme: &SalsaTheme,
) -> Option<(u16, u16)> {
let state = editor.get_mut()?;
let pop_up = area.centered(Constraint::Max(MAX_WIDTH), Constraint::Max(MAX_HEIGHT));
DialogFrame::new()
.block(Block::bordered().title(state.title()))
.styles(crate::dialog_style::style(theme))
.left(Constraint::Length(2))
.right(Constraint::Length(2))
.top(Constraint::Length(0))
.bottom(Constraint::Length(0))
.render(pop_up, buf, &mut state.frame);
TextArea::new()
.styles(theme.style(WidgetStyle::TEXTAREA))
.render(state.frame.widget_area, buf, &mut state.text);
state.text.screen_cursor()
}
fn stored(buffer: &str) -> Option<Text> {
let mut normalized = String::with_capacity(buffer.len());
let mut characters = buffer.chars().peekable();
while let Some(character) = characters.next() {
match character {
'\r' => {
if characters.peek() == Some(&'\n') {
characters.next();
}
normalized.push('\n');
}
'\t' => normalized.push_str(" "),
'\n' => normalized.push('\n'),
other if other.is_control() => {}
other => normalized.push(other),
}
}
if normalized.chars().all(char::is_whitespace) {
None
} else {
Text::new(&normalized)
}
}
fn is_escape(event: &Event) -> bool {
matches!(
event,
Event::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press,
..
})
)
}
fn is_tab(event: &Event) -> bool {
matches!(
event,
Event::Key(KeyEvent {
code: KeyCode::Tab | KeyCode::BackTab,
kind: KeyEventKind::Press,
..
})
)
}
#[cfg(test)]
mod tests {
use super::*;
use monospace_core::ShapeId;
use ratatui_crossterm::crossterm::event::KeyModifiers;
use std::num::NonZeroU16;
fn id(ordinal: u16) -> ShapeId {
ShapeId::new(NonZeroU16::new(ordinal).expect("no test names zero"))
}
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn saved(reply: Reply) -> Option<Text> {
match reply {
Reply::Act(Action::Command(Command::SetText { text, .. })) => text,
other => panic!("the editor did not save: {other:?}"),
}
}
fn editor(id: ShapeId, text: Option<&Text>) -> Overlay<TextEditorState> {
Overlay::up(TextEditorState::new(id, text))
}
fn state_of(editor: &Overlay<TextEditorState>) -> &TextEditorState {
editor.get().expect("the text editor is up")
}
fn state_of_mut(editor: &mut Overlay<TextEditorState>) -> &mut TextEditorState {
editor.get_mut().expect("the text editor is up")
}
#[test]
fn the_editor_opens_with_all_the_text_selected() {
let state = editor(id(1), Some(&Text::new("one\ntwo").unwrap()));
assert_eq!(state_of(&state).buffer(), "one\ntwo");
assert_eq!(state_of(&state).selected_text(), "one\ntwo");
}
#[test]
fn saving_normalizes_control_characters() {
let mut state = editor(id(1), None);
state_of_mut(&mut state)
.text
.set_text("one\r\ntwo\tthree\x07");
let _ = handle(&key(KeyCode::Tab), &mut state);
let text = saved(handle(&key(KeyCode::Enter), &mut state))
.expect("the normalized text is not empty");
assert_eq!(text, Text::new("one\ntwo three").unwrap());
}
#[test]
fn saving_keeps_the_blank_lines() {
let mut state = editor(id(1), Some(&Text::new("one\n\ntwo").unwrap()));
let _ = handle(&key(KeyCode::Tab), &mut state);
assert_eq!(
saved(handle(&key(KeyCode::Enter), &mut state)),
Some(Text::new("one\n\ntwo").unwrap()),
"a blank line was folded away"
);
}
#[test]
fn saving_turns_a_lone_carriage_return_into_a_line_break() {
let mut state = editor(id(1), None);
state_of_mut(&mut state).text.set_text("one\rtwo");
let _ = handle(&key(KeyCode::Tab), &mut state);
assert_eq!(
saved(handle(&key(KeyCode::Enter), &mut state)),
Some(Text::new("one\ntwo").unwrap())
);
}
#[test]
fn saving_whitespace_only_stores_none() {
let mut state = editor(id(1), None);
state_of_mut(&mut state).text.set_text(" \n\t ");
let _ = handle(&key(KeyCode::Tab), &mut state);
assert_eq!(saved(handle(&key(KeyCode::Enter), &mut state)), None);
}
#[test]
fn a_wide_glyph_is_edited_in_two_columns_and_drawn_in_one_cell() {
let mut state = editor(id(1), None);
let _ = handle(&key(KeyCode::Char('漢')), &mut state);
assert_eq!(state_of(&state).buffer(), "漢");
let text = Text::new("漢").expect("the cluster is not a control character");
assert_eq!(text.width(), 1, "the model counts one glyph as one cell");
}
}