From b847fcae52692fd234a33dc0e00c1326a5520077 Mon Sep 17 00:00:00 2001 From: Willians Faria Date: Mon, 6 May 2024 20:53:59 -0300 Subject: [PATCH] feat: editor rendering and cursor motions --- reqtui/src/text_object.rs | 1 + reqtui/src/text_object/cursor.rs | 59 +++++ reqtui/src/text_object/text_object.rs | 29 ++- tui/src/app.rs | 2 +- tui/src/components.rs | 14 +- .../components/api_explorer/api_explorer.rs | 49 ++-- tui/src/components/api_explorer/req_editor.rs | 241 ++++++++++++++---- tui/src/components/dashboard/dashboard.rs | 22 +- tui/src/components/terminal_too_small.rs | 2 + tui/src/screen_manager.rs | 34 +-- tui/tests/dashboard.rs | 2 +- 11 files changed, 336 insertions(+), 119 deletions(-) create mode 100644 reqtui/src/text_object/cursor.rs diff --git a/reqtui/src/text_object.rs b/reqtui/src/text_object.rs index abbd5af..c96231c 100644 --- a/reqtui/src/text_object.rs +++ b/reqtui/src/text_object.rs @@ -1,3 +1,4 @@ +pub mod cursor; #[allow(clippy::module_inception)] mod text_object; diff --git a/reqtui/src/text_object/cursor.rs b/reqtui/src/text_object/cursor.rs new file mode 100644 index 0000000..9740ef9 --- /dev/null +++ b/reqtui/src/text_object/cursor.rs @@ -0,0 +1,59 @@ +use std::ops::Add; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Cursor { + // row/col, this is where the cursor is displayed onscreen, we use these fields to determine + // where to do text operations. + row: usize, + col: usize, + // expand row/col are used to store where the cursor was when moving to a smaller line, so we + // can restore it to the position it was if the line length allows + expand_row: usize, + expand_col: usize, +} + +impl Cursor { + pub fn move_left(&mut self, amount: usize) { + self.col = self.col.saturating_sub(amount); + self.expand_col = self.expand_col.saturating_sub(amount); + } + + pub fn move_down(&mut self, amount: usize) { + self.row = self.row.add(amount); + self.expand_row = self.expand_row.add(amount); + } + + pub fn move_up(&mut self, amount: usize) { + self.row = self.row.saturating_sub(amount); + self.expand_row = self.expand_row.saturating_sub(amount); + } + + pub fn move_right(&mut self, amount: usize) { + self.col = self.col.add(amount); + self.expand_col = self.expand_col.add(amount); + } + + pub fn move_to_newline_start(&mut self) { + self.col = 0; + self.expand_col = 0; + self.row = self.row.add(1); + self.expand_row = self.expand_row.add(1); + } + + pub fn move_to_col(&mut self, col: usize) { + self.col = col; + self.expand_col = col; + } + + pub fn row(&self) -> usize { + self.row + } + + pub fn col(&self) -> usize { + self.col + } + + pub fn readable_position(&self) -> (usize, usize) { + (self.col.add(1), self.row.add(1)) + } +} diff --git a/reqtui/src/text_object/text_object.rs b/reqtui/src/text_object/text_object.rs index 684ddec..6dd149a 100644 --- a/reqtui/src/text_object/text_object.rs +++ b/reqtui/src/text_object/text_object.rs @@ -1,3 +1,6 @@ +use std::ops::Sub; + +use crate::text_object::cursor::Cursor; use ropey::Rope; #[derive(Debug, Clone, PartialEq)] @@ -5,7 +8,7 @@ pub struct Readonly; #[derive(Debug, Clone, PartialEq)] pub struct Write; -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct TextObject { content: Rope, state: std::marker::PhantomData, @@ -39,6 +42,30 @@ impl TextObject { } } +impl TextObject { + pub fn insert_char(&mut self, c: char, cursor: &Cursor) { + let line = self.content.line_to_char(cursor.row()); + let col_offset = line + cursor.col(); + self.content.insert_char(col_offset, c); + } + + pub fn erase_previous_char(&mut self, cursor: &Cursor) { + let line = self.content.line_to_char(cursor.row()); + let col_offset = line + cursor.col(); + self.content + .try_remove(col_offset.saturating_sub(1)..col_offset) + .ok(); + } + + pub fn current_line(&self, cursor: &Cursor) -> Option<&str> { + self.content.line(cursor.row()).as_str() + } + + pub fn len_lines(&self) -> usize { + self.content.len_lines() + } +} + impl std::fmt::Display for TextObject { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.content.to_string()) diff --git a/tui/src/app.rs b/tui/src/app.rs index e982dd4..9d03a65 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -1,5 +1,5 @@ use crate::{ - components::Component, + components::{Component, Eventful}, event_pool::{Event, EventPool}, screen_manager::ScreenManager, }; diff --git a/tui/src/components.rs b/tui/src/components.rs index a53879e..5c3cb50 100644 --- a/tui/src/components.rs +++ b/tui/src/components.rs @@ -12,9 +12,7 @@ use crossterm::event::KeyEvent; use ratatui::{layout::Rect, Frame}; use tokio::sync::mpsc::UnboundedSender; -pub trait Component { - fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()>; - +pub trait Eventful { fn handle_event(&mut self, event: Option) -> anyhow::Result> { let action = match event { Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, @@ -24,13 +22,17 @@ pub trait Component { Ok(action) } - #[allow(unused_variables)] - fn resize(&mut self, new_size: Rect) {} - #[allow(unused_variables)] fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { Ok(None) } +} + +pub trait Component { + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()>; + + #[allow(unused_variables)] + fn resize(&mut self, new_size: Rect) {} #[allow(unused_variables)] fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { diff --git a/tui/src/components/api_explorer/api_explorer.rs b/tui/src/components/api_explorer/api_explorer.rs index df5bb40..8c1a5fb 100644 --- a/tui/src/components/api_explorer/api_explorer.rs +++ b/tui/src/components/api_explorer/api_explorer.rs @@ -4,7 +4,7 @@ use crate::components::{ res_viewer::{ResViewer, ResViewerState, ResViewerTabs}, sidebar::{Sidebar, SidebarState}, }, - Component, + Component, Eventful, }; use anyhow::Context; use crossterm::event::{KeyCode, KeyEvent}; @@ -38,12 +38,6 @@ enum VisitNode { Prev, } -#[derive(PartialEq, Debug)] -enum EditorMode { - Insert, - Normal, -} - #[derive(Debug, PartialEq)] enum PaneFocus { Sidebar, @@ -71,7 +65,6 @@ pub struct ApiExplorer<'a> { editor: ReqEditor<'a>, editor_tab: ReqEditorTabs, editor_body_scroll: usize, - editor_mode: EditorMode, responses_map: HashMap, } @@ -107,7 +100,6 @@ impl<'a> ApiExplorer<'a> { editor: ReqEditor::new(colors, selected_request.clone()), editor_tab: ReqEditorTabs::Request, editor_body_scroll: 0, - editor_mode: EditorMode::Normal, hovered_request, selected_request, @@ -190,26 +182,11 @@ impl<'a> ApiExplorer<'a> { } fn handle_editor_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - match key_event.code { - KeyCode::Char(c) => { - if let Some(req) = self.selected_request.as_mut() { - req.borrow_mut() - .body - .as_mut() - .map(|body| body.push(c)) - .or_else(|| { - req.borrow_mut().body = Some(c.to_string()); - Some(()) - }); - - tracing::debug!("{:#?}", self.selected_request); - }; - } - KeyCode::Enter => {} - _ => {} - }; - - Ok(None) + if key_event.code.eq(&KeyCode::Enter) && self.selected_pane.is_none() { + self.selected_pane = Some(PaneFocus::Editor); + return Ok(None); + } + self.editor.handle_key_event(key_event) } fn draw_background(&self, size: Rect, frame: &mut Frame) { @@ -311,13 +288,27 @@ impl Component for ApiExplorer<'_> { self.draw_req_uri(frame); self.draw_sidebar(frame); + if self + .selected_pane + .as_ref() + .is_some_and(|pane| pane.eq(&PaneFocus::Editor)) + { + let editor_position = self.layout.req_editor; + let cursor = self.editor.cursor(); + let row_with_offset = editor_position.y.add(cursor.row() as u16).add(3); + let col_with_offset = editor_position.x.add(cursor.col() as u16).add(1); + frame.set_cursor(col_with_offset, row_with_offset); + } + Ok(()) } fn resize(&mut self, new_size: Rect) { self.layout = build_layout(new_size); } +} +impl Eventful for ApiExplorer<'_> { fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { if let KeyCode::Tab = key_event.code { match (&self.focused_pane, &self.selected_pane) { diff --git a/tui/src/components/api_explorer/req_editor.rs b/tui/src/components/api_explorer/req_editor.rs index 37c4827..3884c5a 100644 --- a/tui/src/components/api_explorer/req_editor.rs +++ b/tui/src/components/api_explorer/req_editor.rs @@ -1,3 +1,5 @@ +use crate::components::Eventful; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, @@ -11,16 +13,31 @@ use ratatui::{ use reqtui::{ schema::types::Request, syntax::highlighter::{ColorInfo, HIGHLIGHTER}, - text_object::{TextObject, Write}, + text_object::{cursor::Cursor, TextObject, Write}, }; use std::{ cell::RefCell, fmt::Display, - ops::{Add, Deref}, + ops::{Add, Deref, Div, Mul, Sub}, rc::Rc, }; use tree_sitter::Tree; +#[derive(PartialEq, Debug, Clone)] +enum EditorMode { + Insert, + Normal, +} + +impl std::fmt::Display for EditorMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Normal => f.write_str("NORMAL"), + Self::Insert => f.write_str("INSERT"), + } + } +} + #[derive(Debug, Default, Clone)] pub enum ReqEditorTabs { #[default] @@ -92,7 +109,36 @@ pub struct ReqEditor<'a> { request: Option>>, body: TextObject, tree: Option, + cursor: Cursor, styled_display: Vec>, + editor_mode: EditorMode, +} + +fn build_styled_content( + content: String, + tree: Option<&Tree>, + colors: &colors::Colors, +) -> Vec> { + let highlights = HIGHLIGHTER + .read() + .unwrap() + .apply(&content, tree, &colors.tokens); + let mut styled_display: Vec = vec![]; + let mut current_line: Vec = vec![]; + + content.chars().enumerate().for_each(|(i, c)| match c { + '\n' => { + styled_display.push(current_line.clone().into()); + current_line.clear(); + } + _ => current_line.push(build_stylized_line(c, i, &highlights)), + }); + + if !content.ends_with('\n') { + styled_display.push(current_line.into()); + } + + styled_display } impl<'a> ReqEditor<'a> { @@ -107,26 +153,8 @@ impl<'a> ReqEditor<'a> { (TextObject::default(), None) }; - let body_str = body.to_string(); - let highlights = - HIGHLIGHTER - .read() - .unwrap() - .apply(&body_str, tree.as_ref(), &colors.tokens); - let mut styled_display: Vec = vec![]; - let mut current_line: Vec = vec![]; - - body_str.chars().enumerate().for_each(|(i, c)| match c { - '\n' => { - styled_display.push(current_line.clone().into()); - current_line.clear(); - } - _ => current_line.push(build_stylized_line(c, i, &highlights)), - }); - - if !body_str.ends_with('\n') { - styled_display.push(current_line.into()); - } + let content = body.to_string(); + let styled_display = build_styled_content(content, tree.as_ref(), colors); Self { colors, @@ -134,18 +162,65 @@ impl<'a> ReqEditor<'a> { body, tree, styled_display, + cursor: Cursor::default(), + editor_mode: EditorMode::Normal, } } + fn draw_statusline(&self, buf: &mut Buffer, size: Rect) { + let cursor_pos = self.cursor.readable_position(); + + let mut mode = Span::from(format!(" {} ", self.editor_mode)); + let mut cursor = Span::from(format!(" {}:{} ", cursor_pos.0, cursor_pos.1)); + + let mut percentage = Span::from(format!( + " {}% ", + (cursor_pos.1 as f64) + .div(self.body.len_lines() as f64) + .mul(100.0) as usize + )); + + let content_len = mode + .content + .len() + .add(cursor.content.len()) + .add(percentage.content.len()); + + let padding = Span::from(" ".repeat(size.width.sub(content_len as u16).into())) + .bg(self.colors.primary.hover); + + match self.editor_mode { + EditorMode::Insert => { + mode = mode + .fg(self.colors.normal.black) + .bg(self.colors.normal.green); + cursor = cursor + .fg(self.colors.normal.black) + .bg(self.colors.normal.green); + percentage = percentage + .fg(self.colors.normal.green) + .bg(self.colors.normal.black); + } + EditorMode::Normal => { + mode = mode + .fg(self.colors.normal.black) + .bg(self.colors.bright.blue); + cursor = cursor + .fg(self.colors.normal.black) + .bg(self.colors.bright.blue); + percentage = percentage + .fg(self.colors.bright.blue) + .bg(self.colors.normal.blue); + } + }; + + Paragraph::new(Line::from(vec![mode, padding, percentage, cursor])).render(size, buf); + } + fn draw_editor(&self, state: &mut ReqEditorState, buf: &mut Buffer, size: Rect) { - let [request_pane, scrollbar_pane] = build_preview_layout(size); + let [request_pane, statusline_pane] = build_preview_layout(size); - self.draw_scrollbar( - self.styled_display.len(), - *state.body_scroll, - buf, - scrollbar_pane, - ); + self.draw_statusline(buf, statusline_pane); if state .body_scroll @@ -185,23 +260,6 @@ impl<'a> ReqEditor<'a> { Ok(()) } - fn draw_scrollbar( - &self, - total_ines: usize, - current_scroll: usize, - buf: &mut Buffer, - size: Rect, - ) { - let mut scrollbar_state = ScrollbarState::new(total_ines).position(current_scroll); - - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .style(Style::default().fg(self.colors.normal.red)) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - scrollbar.render(size, buf, &mut scrollbar_state); - } - fn draw_tabs(&self, buf: &mut Buffer, state: &ReqEditorState, size: Rect) { let tabs = Tabs::new(["Request", "Headers", "Query", "Auth"]) .style(Style::default().fg(self.colors.bright.black)) @@ -227,6 +285,10 @@ impl<'a> ReqEditor<'a> { block.render(size, buf); } + + pub fn cursor(&self) -> &Cursor { + &self.cursor + } } impl<'a> StatefulWidget for ReqEditor<'a> { @@ -234,13 +296,86 @@ impl<'a> StatefulWidget for ReqEditor<'a> { fn render(self, size: Rect, buf: &mut Buffer, state: &mut Self::State) { let layout = build_layout(size); - self.draw_container(size, buf, state); self.draw_tabs(buf, state, layout.tabs_pane); self.draw_current_tab(state, buf, layout.content_pane).ok(); } } +impl Eventful for ReqEditor<'_> { + fn handle_key_event( + &mut self, + key_event: KeyEvent, + ) -> anyhow::Result> { + match (&self.editor_mode, key_event.code, key_event.modifiers) { + (EditorMode::Insert, KeyCode::Char(c), KeyModifiers::NONE) => { + self.body.insert_char(c, &self.cursor); + self.cursor.move_right(1); + self.tree = HIGHLIGHTER.write().unwrap().parse(&self.body.to_string()); + // if let Some(tree) = self.tree.as_mut() {} + } + (EditorMode::Insert, KeyCode::Enter, KeyModifiers::NONE) => { + self.body.insert_char('\n', &self.cursor); + self.cursor.move_to_newline_start(); + self.tree = HIGHLIGHTER.write().unwrap().parse(&self.body.to_string()); + // if let Some(tree) = self.tree.as_mut() {} + } + (EditorMode::Insert, KeyCode::Backspace, KeyModifiers::NONE) => { + match (self.cursor.col(), self.cursor.row()) { + (0, 0) => {} + (0, _) => { + self.body.erase_previous_char(&self.cursor); + self.cursor.move_up(1); + + let current_line = self + .body + .current_line(&self.cursor) + .expect("cursor should never be on a non-existing row"); + + self.cursor + .move_to_col(current_line.len().saturating_sub(3)); + + self.tree = HIGHLIGHTER.write().unwrap().parse(&self.body.to_string()); + } + (_, _) => { + self.body.erase_previous_char(&self.cursor); + self.cursor.move_left(1); + self.tree = HIGHLIGHTER.write().unwrap().parse(&self.body.to_string()); + } + } + } + (EditorMode::Normal, KeyCode::Char('h'), KeyModifiers::NONE) + | (EditorMode::Insert, KeyCode::Left, KeyModifiers::NONE) => { + self.cursor.move_left(1); + } + (EditorMode::Normal, KeyCode::Char('j'), KeyModifiers::NONE) + | (EditorMode::Insert, KeyCode::Down, KeyModifiers::NONE) => { + self.cursor.move_down(1); + } + (EditorMode::Normal, KeyCode::Char('k'), KeyModifiers::NONE) + | (EditorMode::Insert, KeyCode::Up, KeyModifiers::NONE) => { + self.cursor.move_up(1); + } + (EditorMode::Normal, KeyCode::Char('l'), KeyModifiers::NONE) + | (EditorMode::Insert, KeyCode::Right, KeyModifiers::NONE) => { + self.cursor.move_right(1); + } + (EditorMode::Normal, KeyCode::Char('i'), KeyModifiers::NONE) => { + self.editor_mode = EditorMode::Insert; + } + (EditorMode::Insert, KeyCode::Esc, KeyModifiers::NONE) => { + self.editor_mode = EditorMode::Normal; + } + _ => {} + }; + + self.styled_display = + build_styled_content(self.body.to_string(), self.tree.as_ref(), self.colors); + + Ok(None) + } +} + fn build_layout(size: Rect) -> ReqEditorLayout { let size = Rect::new( size.x.add(1), @@ -265,16 +400,12 @@ fn build_layout(size: Rect) -> ReqEditorLayout { } fn build_preview_layout(size: Rect) -> [Rect; 2] { - let [request_pane, _, scrollbar_pane] = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Length(1), - ]) + let [request_pane, statusline_pane] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(1)]) .areas(size); - [request_pane, scrollbar_pane] + [request_pane, statusline_pane] } fn build_stylized_line(c: char, i: usize, colors: &[ColorInfo]) -> Span<'static> { diff --git a/tui/src/components/dashboard/dashboard.rs b/tui/src/components/dashboard/dashboard.rs index d152a03..7ba0be0 100644 --- a/tui/src/components/dashboard/dashboard.rs +++ b/tui/src/components/dashboard/dashboard.rs @@ -5,7 +5,7 @@ use crate::components::{ schema_list::{SchemaList, SchemaListState}, }, error_popup::ErrorPopup, - Component, + Component, Eventful, }; use reqtui::{command::Command, schema::types::Schema}; @@ -538,6 +538,17 @@ impl Component for Dashboard<'_> { Ok(()) } + fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { + self.command_sender = Some(sender.clone()); + Ok(()) + } + + fn resize(&mut self, new_size: Rect) { + self.layout = build_layout(new_size); + } +} + +impl Eventful for Dashboard<'_> { fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { match self.pane_focus { PaneFocus::List => self.handle_list_key_event(key_event), @@ -551,15 +562,6 @@ impl Component for Dashboard<'_> { } } } - - fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { - self.command_sender = Some(sender.clone()); - Ok(()) - } - - fn resize(&mut self, new_size: Rect) { - self.layout = build_layout(new_size); - } } fn build_layout(size: Rect) -> DashboardLayout { diff --git a/tui/src/components/terminal_too_small.rs b/tui/src/components/terminal_too_small.rs index d3a80cb..ec57c09 100644 --- a/tui/src/components/terminal_too_small.rs +++ b/tui/src/components/terminal_too_small.rs @@ -7,6 +7,8 @@ use ratatui::{ Frame, }; +use super::Eventful; + pub struct TerminalTooSmall<'a> { colors: &'a colors::Colors, } diff --git a/tui/src/screen_manager.rs b/tui/src/screen_manager.rs index cee5c1d..5fc7d15 100644 --- a/tui/src/screen_manager.rs +++ b/tui/src/screen_manager.rs @@ -1,7 +1,7 @@ use crate::{ components::{ api_explorer::ApiExplorer, dashboard::Dashboard, terminal_too_small::TerminalTooSmall, - Component, + Component, Eventful, }, event_pool::Event, }; @@ -101,6 +101,22 @@ impl Component for ScreenManager<'_> { Ok(()) } + fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { + self.dashboard.register_command_handler(sender.clone())?; + Ok(()) + } + + fn resize(&mut self, new_size: Rect) { + self.size = new_size; + self.dashboard.resize(new_size); + + if let Some(e) = self.api_explorer.as_mut() { + e.resize(new_size) + } + } +} + +impl Eventful for ScreenManager<'_> { fn handle_event(&mut self, event: Option) -> anyhow::Result> { if let Some(Event::Key(KeyEvent { code: KeyCode::Char('c'), @@ -118,21 +134,7 @@ impl Component for ScreenManager<'_> { .context("should never be able to switch to editor screen without having a schema")? .handle_event(event), Screens::Dashboard => self.dashboard.handle_event(event), - Screens::TerminalTooSmall => self.terminal_too_small.handle_event(event), - } - } - - fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { - self.dashboard.register_command_handler(sender.clone())?; - Ok(()) - } - - fn resize(&mut self, new_size: Rect) { - self.size = new_size; - self.dashboard.resize(new_size); - - if let Some(e) = self.api_explorer.as_mut() { - e.resize(new_size) + Screens::TerminalTooSmall => Ok(None), } } } diff --git a/tui/tests/dashboard.rs b/tui/tests/dashboard.rs index ca50e9a..f4df826 100644 --- a/tui/tests/dashboard.rs +++ b/tui/tests/dashboard.rs @@ -6,7 +6,7 @@ use std::{ io::Write, }; use tempfile::{tempdir, TempDir}; -use tui::components::{dashboard::Dashboard, Component}; +use tui::components::{dashboard::Dashboard, Component, Eventful}; fn setup_temp_schemas(amount: usize) -> (TempDir, String) { let tmp_data_dir = tempdir().expect("Failed to create temp data dir");