aboutsummaryrefslogtreecommitdiff
path: root/helix-term
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term')
-rw-r--r--helix-term/Cargo.toml1
-rw-r--r--helix-term/src/application.rs579
-rw-r--r--helix-term/src/commands.rs735
-rw-r--r--helix-term/src/component.rs20
-rw-r--r--helix-term/src/compositor.rs155
-rw-r--r--helix-term/src/helix.log0
-rw-r--r--helix-term/src/keymap.rs217
-rw-r--r--helix-term/src/main.rs5
-rw-r--r--helix-term/src/terminal.rs221
-rw-r--r--helix-term/src/ui/editor.rs327
-rw-r--r--helix-term/src/ui/mod.rs14
-rw-r--r--helix-term/src/ui/prompt.rs217
12 files changed, 1954 insertions, 537 deletions
diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml
index c1560ee7..b8eea7c2 100644
--- a/helix-term/Cargo.toml
+++ b/helix-term/Cargo.toml
@@ -17,6 +17,7 @@ helix-view = { path = "../helix-view", features = ["term"]}
helix-lsp = { path = "../helix-lsp"}
anyhow = "1"
+once_cell = "1.4"
smol = "1"
num_cpus = "1"
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs
index 141779ec..dc37612a 100644
--- a/helix-term/src/application.rs
+++ b/helix-term/src/application.rs
@@ -1,17 +1,13 @@
use clap::ArgMatches as Args;
-use helix_core::{indent::TAB_WIDTH, syntax::HighlightEvent, Position, Range, State};
-use helix_view::{
- commands,
- document::Mode,
- keymap::{self, Keymaps},
- prompt::Prompt,
- Document, Editor, Theme, View,
-};
+
+use helix_view::{document::Mode, Document, Editor, Theme, View};
+
+use crate::compositor::Compositor;
+use crate::ui;
use log::{debug, info};
use std::{
- borrow::Cow,
io::{self, stdout, Stdout, Write},
path::PathBuf,
time::Duration,
@@ -22,366 +18,44 @@ use smol::prelude::*;
use anyhow::Error;
use crossterm::{
- cursor,
- event::{read, Event, EventStream, KeyCode, KeyEvent},
- execute, queue,
- terminal::{self, disable_raw_mode, enable_raw_mode},
+ event::{Event, EventStream},
+ execute, terminal,
};
-use tui::{
- backend::CrosstermBackend,
- buffer::Buffer as Surface,
- layout::Rect,
- style::{Color, Modifier, Style},
-};
-
-const OFFSET: u16 = 7; // 1 diagnostic + 5 linenr + 1 gutter
-
-type Terminal = tui::Terminal<CrosstermBackend<std::io::Stdout>>;
+use tui::{backend::CrosstermBackend, layout::Rect};
-const BASE_WIDTH: u16 = 30;
+type Terminal = crate::terminal::Terminal<CrosstermBackend<std::io::Stdout>>;
-pub struct Application<'a> {
+pub struct Application {
+ compositor: Compositor,
editor: Editor,
- prompt: Option<Prompt>,
- terminal: Renderer,
+ terminal: Terminal,
- keymap: Keymaps,
- executor: &'a smol::Executor<'a>,
+ executor: &'static smol::Executor<'static>,
language_server: helix_lsp::Client,
}
-struct Renderer {
- size: (u16, u16),
- terminal: Terminal,
- surface: Surface,
- cache: Surface,
- text_color: Style,
-}
-
-impl Renderer {
- pub fn new() -> Result<Self, Error> {
+impl Application {
+ pub fn new(mut args: Args, executor: &'static smol::Executor<'static>) -> Result<Self, Error> {
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
- let size = terminal::size().unwrap();
- let text_color: Style = Style::default().fg(Color::Rgb(219, 191, 239)); // lilac
-
- let area = Rect::new(0, 0, size.0, size.1);
-
- Ok(Self {
- size,
- terminal,
- surface: Surface::empty(area),
- cache: Surface::empty(area),
- text_color,
- })
- }
-
- pub fn resize(&mut self, width: u16, height: u16) {
- self.size = (width, height);
- let area = Rect::new(0, 0, width, height);
- self.surface = Surface::empty(area);
- self.cache = Surface::empty(area);
- }
-
- pub fn render_view(&mut self, view: &mut View, viewport: Rect, theme: &Theme) {
- self.render_buffer(view, viewport, theme);
- self.render_statusline(view, theme);
- }
-
- // TODO: ideally not &mut View but highlights require it because of cursor cache
- pub fn render_buffer(&mut self, view: &mut View, viewport: Rect, theme: &Theme) {
- let area = Rect::new(0, 0, self.size.0, self.size.1);
- self.surface.reset(); // reset is faster than allocating new empty surface
-
- // clear with background color
- self.surface.set_style(area, theme.get("ui.background"));
-
- // TODO: inefficient, should feed chunks.iter() to tree_sitter.parse_with(|offset, pos|)
- let source_code = view.doc.text().to_string();
-
- let last_line = view.last_line();
-
- let range = {
- // calculate viewport byte ranges
- let start = view.doc.text().line_to_byte(view.first_line);
- let end = view.doc.text().line_to_byte(last_line)
- + view.doc.text().line(last_line).len_bytes();
-
- start..end
- };
-
- // TODO: range doesn't actually restrict source, just highlight range
- // TODO: cache highlight results
- // TODO: only recalculate when state.doc is actually modified
- let highlights: Vec<_> = match view.doc.syntax.as_mut() {
- Some(syntax) => {
- syntax
- .highlight_iter(source_code.as_bytes(), Some(range), None, |_| None)
- .unwrap()
- .collect() // TODO: we collect here to avoid double borrow, fix later
- }
- None => vec![Ok(HighlightEvent::Source {
- start: range.start,
- end: range.end,
- })],
- };
- let mut spans = Vec::new();
- let mut visual_x = 0;
- let mut line = 0u16;
- let visible_selections: Vec<Range> = view
- .doc
- .state
- .selection()
- .ranges()
- .iter()
- // TODO: limit selection to one in viewport
- // .filter(|range| !range.is_empty()) // && range.overlaps(&Range::new(start, end + 1))
- .copied()
- .collect();
-
- 'outer: for event in highlights {
- match event.unwrap() {
- HighlightEvent::HighlightStart(span) => {
- spans.push(span);
- }
- HighlightEvent::HighlightEnd => {
- spans.pop();
- }
- HighlightEvent::Source { start, end } => {
- // TODO: filter out spans out of viewport for now..
-
- let start = view.doc.text().byte_to_char(start);
- let end = view.doc.text().byte_to_char(end); // <-- index 744, len 743
-
- let text = view.doc.text().slice(start..end);
-
- use helix_core::graphemes::{grapheme_width, RopeGraphemes};
-
- let style = match spans.first() {
- Some(span) => theme.get(theme.scopes()[span.0].as_str()),
- None => Style::default().fg(Color::Rgb(164, 160, 232)), // lavender
- };
-
- // TODO: we could render the text to a surface, then cache that, that
- // way if only the selection/cursor changes we can copy from cache
- // and paint the new cursor.
-
- let mut char_index = start;
-
- // iterate over range char by char
- for grapheme in RopeGraphemes::new(&text) {
- // TODO: track current char_index
-
- if grapheme == "\n" {
- visual_x = 0;
- line += 1;
-
- // TODO: with proper iter this shouldn't be necessary
- if line >= viewport.height {
- break 'outer;
- }
- } else if grapheme == "\t" {
- visual_x += (TAB_WIDTH as u16);
- } else {
- // Cow will prevent allocations if span contained in a single slice
- // which should really be the majority case
- let grapheme = Cow::from(grapheme);
- let width = grapheme_width(&grapheme) as u16;
-
- // TODO: this should really happen as an after pass
- let style = if visible_selections
- .iter()
- .any(|range| range.contains(char_index))
- {
- // cedar
- style.clone().bg(Color::Rgb(128, 47, 0))
- } else {
- style
- };
-
- let style = if visible_selections
- .iter()
- .any(|range| range.head == char_index)
- {
- style.clone().bg(Color::Rgb(255, 255, 255))
- } else {
- style
- };
-
- // ugh, improve with a traverse method
- // or interleave highlight spans with selection and diagnostic spans
- let style = if view.doc.diagnostics.iter().any(|diagnostic| {
- diagnostic.range.0 <= char_index && diagnostic.range.1 > char_index
- }) {
- style.clone().add_modifier(Modifier::UNDERLINED)
- } else {
- style
- };
-
- // TODO: paint cursor heads except primary
-
- self.surface
- .set_string(OFFSET + visual_x, line, grapheme, style);
-
- visual_x += width;
- }
-
- char_index += 1;
- }
- }
- }
- }
-
- let style: Style = theme.get("ui.linenr");
- let warning: Style = theme.get("warning");
- let last_line = view.last_line();
- for (i, line) in (view.first_line..last_line).enumerate() {
- if view.doc.diagnostics.iter().any(|d| d.line == line) {
- self.surface.set_stringn(0, i as u16, "●", 1, warning);
- }
-
- self.surface
- .set_stringn(1, i as u16, format!("{:>5}", line + 1), 5, style);
- }
- }
-
- pub fn render_statusline(&mut self, view: &View, theme: &Theme) {
- let mode = match view.doc.mode() {
- Mode::Insert => "INS",
- Mode::Normal => "NOR",
- Mode::Goto => "GOTO",
- };
- // statusline
- self.surface.set_style(
- Rect::new(0, self.size.1 - 2, self.size.0, 1),
- theme.get("ui.statusline"),
- );
- self.surface
- .set_string(1, self.size.1 - 2, mode, self.text_color);
-
- self.surface.set_string(
- self.size.0 - 10,
- self.size.1 - 2,
- format!("{}", view.doc.diagnostics.len()),
- self.text_color,
- );
- }
-
- pub fn render_prompt(&mut self, view: &View, prompt: &Prompt, theme: &Theme) {
- // completion
- if !prompt.completion.is_empty() {
- // TODO: find out better way of clearing individual lines of the screen
- let mut row = 0;
- let mut col = 0;
- let max_col = self.size.0 / BASE_WIDTH;
- let col_height = ((prompt.completion.len() as u16 + max_col - 1) / max_col);
-
- for i in (3..col_height + 3) {
- self.surface.set_string(
- 0,
- self.size.1 - i as u16,
- " ".repeat(self.size.0 as usize),
- self.text_color,
- );
- }
- self.surface.set_style(
- Rect::new(0, self.size.1 - col_height - 2, self.size.0, col_height),
- theme.get("ui.statusline"),
- );
- for (i, command) in prompt.completion.iter().enumerate() {
- let color = if prompt.completion_selection_index.is_some()
- && i == prompt.completion_selection_index.unwrap()
- {
- Style::default().bg(Color::Rgb(104, 060, 232))
- } else {
- self.text_color
- };
- self.surface.set_stringn(
- 1 + col * BASE_WIDTH,
- self.size.1 - col_height - 2 + row,
- &command,
- BASE_WIDTH as usize - 1,
- color,
- );
- row += 1;
- if row > col_height - 1 {
- row = 0;
- col += 1;
- }
- if col > max_col {
- break;
- }
- }
- }
- // render buffer text
- self.surface
- .set_string(1, self.size.1 - 1, &prompt.prompt, self.text_color);
- self.surface
- .set_string(2, self.size.1 - 1, &prompt.line, self.text_color);
- }
-
- pub fn draw(&mut self) {
- use tui::backend::Backend;
- // TODO: theres probably a better place for this
- self.terminal
- .backend_mut()
- .draw(self.cache.diff(&self.surface).into_iter());
- // swap the buffer
- std::mem::swap(&mut self.surface, &mut self.cache);
- }
-
- pub fn render_cursor(&mut self, view: &View, prompt: Option<&Prompt>, viewport: Rect) {
- let mut stdout = stdout();
- match view.doc.mode() {
- Mode::Insert => write!(stdout, "\x1B[6 q"),
- mode => write!(stdout, "\x1B[2 q"),
- };
- let pos = if let Some(prompt) = prompt {
- Position::new(self.size.0 as usize, 2 + prompt.cursor)
- } else {
- if let Some(path) = view.doc.path() {
- self.surface.set_string(
- 6,
- self.size.1 - 1,
- path.to_string_lossy(),
- self.text_color,
- );
- }
-
- let cursor = view.doc.state.selection().cursor();
-
- let mut pos = view
- .screen_coords_at_pos(&view.doc.text().slice(..), cursor)
- .expect("Cursor is out of bounds.");
- pos.col += viewport.x as usize;
- pos.row += viewport.y as usize;
- pos
- };
-
- execute!(stdout, cursor::MoveTo(pos.col as u16, pos.row as u16));
- }
-}
-
-impl<'a> Application<'a> {
- pub fn new(mut args: Args, executor: &'a smol::Executor<'a>) -> Result<Self, Error> {
- let terminal = Renderer::new()?;
let mut editor = Editor::new();
+ let size = terminal.size()?;
if let Some(file) = args.values_of_t::<PathBuf>("files").unwrap().pop() {
- editor.open(file, terminal.size)?;
+ editor.open(file, (size.width, size.height))?;
}
+ let mut compositor = Compositor::new();
+ compositor.push(Box::new(ui::EditorView::new()));
+
let language_server = helix_lsp::Client::start(&executor, "rust-analyzer", &[]);
let mut app = Self {
editor,
terminal,
- // TODO; move to state
- prompt: None,
+ compositor,
- //
- keymap: keymap::default(),
executor,
language_server,
};
@@ -390,27 +64,18 @@ impl<'a> Application<'a> {
}
fn render(&mut self) {
- let viewport = Rect::new(OFFSET, 0, self.terminal.size.0, self.terminal.size.1 - 2); // - 2 for statusline and prompt
-
- // SAFETY: we cheat around the view_mut() borrow because it doesn't allow us to also borrow
- // theme. Theme is immutable mutating view won't disrupt theme_ref.
- let theme_ref = unsafe { &*(&self.editor.theme as *const Theme) };
- if let Some(view) = self.editor.view_mut() {
- self.terminal.render_view(view, viewport, theme_ref);
- if let Some(prompt) = &self.prompt {
- if prompt.should_close {
- self.prompt = None;
- } else {
- self.terminal.render_prompt(view, prompt, theme_ref);
- }
- }
- }
+ let executor = &self.executor;
+ let editor = &mut self.editor;
+ let compositor = &self.compositor;
- self.terminal.draw();
+ let mut cx = crate::compositor::Context { editor, executor };
+ let area = self.terminal.size().unwrap();
- // TODO: drop unwrap
- self.terminal
- .render_cursor(self.editor.view().unwrap(), self.prompt.as_ref(), viewport);
+ compositor.render(area, self.terminal.current_buffer_mut(), &mut cx);
+ let pos = compositor.cursor_position(area, &mut cx);
+
+ self.terminal.draw();
+ self.terminal.set_cursor(pos.col as u16, pos.row as u16);
}
pub async fn event_loop(&mut self) {
@@ -418,10 +83,11 @@ impl<'a> Application<'a> {
// initialize lsp
self.language_server.initialize().await.unwrap();
- self.language_server
- .text_document_did_open(&self.editor.view().unwrap().doc)
- .await
- .unwrap();
+ // TODO: temp
+ // self.language_server
+ // .text_document_did_open(&cx.editor.view().unwrap().doc)
+ // .await
+ // .unwrap();
self.render();
@@ -433,7 +99,7 @@ impl<'a> Application<'a> {
use futures_util::{select, FutureExt};
select! {
event = reader.next().fuse() => {
- self.handle_terminal_events(event).await
+ self.handle_terminal_events(event)
}
call = self.language_server.incoming.next().fuse() => {
self.handle_language_server_message(call).await
@@ -442,151 +108,28 @@ impl<'a> Application<'a> {
}
}
- pub async fn handle_terminal_events(
- &mut self,
- event: Option<Result<Event, crossterm::ErrorKind>>,
- ) {
+ pub fn handle_terminal_events(&mut self, event: Option<Result<Event, crossterm::ErrorKind>>) {
+ let mut cx = crate::compositor::Context {
+ editor: &mut self.editor,
+ executor: &self.executor,
+ };
// Handle key events
- match event {
+ let should_redraw = match event {
Some(Ok(Event::Resize(width, height))) => {
- self.terminal.resize(width, height);
-
- // TODO: simplistic ensure cursor in view for now
- // TODO: loop over views
- if let Some(view) = self.editor.view_mut() {
- view.size = self.terminal.size;
- view.ensure_cursor_in_view()
- };
+ self.terminal.resize(Rect::new(0, 0, width, height));
- self.render();
- }
- Some(Ok(Event::Key(event))) => {
- // if there's a prompt, it takes priority
- if let Some(prompt) = &mut self.prompt {
- self.prompt
- .as_mut()
- .unwrap()
- .handle_input(event, &mut self.editor);
-
- self.render();
- } else if let Some(view) = self.editor.view_mut() {
- let keys = vec![event];
- // TODO: sequences (`gg`)
- // TODO: handle count other than 1
- match view.doc.mode() {
- Mode::Insert => {
- if let Some(command) = self.keymap[&Mode::Insert].get(&keys) {
- let mut cx = helix_view::commands::Context {
- view,
- executor: self.executor,
- count: 1,
- };
-
- command(&mut cx);
- } else if let KeyEvent {
- code: KeyCode::Char(c),
- ..
- } = event
- {
- let mut cx = helix_view::commands::Context {
- view,
- executor: self.executor,
- count: 1,
- };
- commands::insert::insert_char(&mut cx, c);
- }
- view.ensure_cursor_in_view();
- }
- Mode::Normal => {
- if let &[KeyEvent {
- code: KeyCode::Char(':'),
- ..
- }] = keys.as_slice()
- {
- let prompt = Prompt::new(
- ":".to_owned(),
- |_input: &str| {
- // TODO: i need this duplicate list right now to avoid borrow checker issues
- let command_list = vec![
- String::from("q"),
- String::from("aaa"),
- String::from("bbb"),
- String::from("ccc"),
- String::from("ddd"),
- String::from("eee"),
- String::from("averylongcommandaverylongcommandaverylongcommandaverylongcommandaverylongcommand"),
- String::from("q"),
- String::from("aaa"),
- String::from("bbb"),
- String::from("ccc"),
- String::from("ddd"),
- String::from("eee"),
- String::from("q"),
- String::from("aaa"),
- String::from("bbb"),
- String::from("ccc"),
- String::from("ddd"),
- String::from("eee"),
- String::from("q"),
- String::from("aaa"),
- String::from("bbb"),
- String::from("ccc"),
- String::from("ddd"),
- String::from("eee"),
- String::from("q"),
- String::from("aaa"),
- String::from("bbb"),
- String::from("ccc"),
- String::from("ddd"),
- String::from("eee"),
- ];
- command_list
- .into_iter()
- .filter(|command| command.contains(_input))
- .collect()
- }, // completion
- |editor: &mut Editor, input: &str| match input {
- "q" => editor.should_close = true,
- _ => (),
- },
- );
-
- self.prompt = Some(prompt);
-
- // HAXX: special casing for command mode
- } else if let Some(command) = self.keymap[&Mode::Normal].get(&keys) {
- let mut cx = helix_view::commands::Context {
- view,
- executor: self.executor,
- count: 1,
- };
- command(&mut cx);
-
- // TODO: simplistic ensure cursor in view for now
- view.ensure_cursor_in_view();
- }
- }
- mode => {
- if let Some(command) = self.keymap[&mode].get(&keys) {
- let mut cx = helix_view::commands::Context {
- view,
- executor: self.executor,
- count: 1,
- };
- command(&mut cx);
-
- // TODO: simplistic ensure cursor in view for now
- view.ensure_cursor_in_view();
- }
- }
- }
- self.render();
- }
+ self.compositor
+ .handle_event(Event::Resize(width, height), &mut cx)
}
- Some(Ok(Event::Mouse(_))) => (), // unhandled
+ Some(Ok(event)) => self.compositor.handle_event(event, &mut cx),
Some(Err(x)) => panic!(x),
None => panic!(),
};
+
+ if should_redraw {
+ self.render();
+ // calling render twice here fixes it for some reason
+ }
}
pub async fn handle_language_server_message(&mut self, call: Option<helix_lsp::Call>) {
@@ -599,11 +142,13 @@ impl<'a> Application<'a> {
match notification {
Notification::PublishDiagnostics(params) => {
let path = Some(params.uri.to_file_path().unwrap());
- let view = self
- .editor
- .views
- .iter_mut()
- .find(|view| view.doc.path == path);
+ let view: Option<&mut helix_view::View> = None;
+ // TODO:
+ // let view = self
+ // .editor
+ // .views
+ // .iter_mut()
+ // .find(|view| view.doc.path == path);
if let Some(view) = view {
let doc = view.doc.text().slice(..);
@@ -653,7 +198,7 @@ impl<'a> Application<'a> {
}
pub async fn run(&mut self) -> Result<(), Error> {
- enable_raw_mode()?;
+ terminal::enable_raw_mode()?;
let mut stdout = stdout();
@@ -663,7 +208,7 @@ impl<'a> Application<'a> {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
execute!(std::io::stdout(), terminal::LeaveAlternateScreen);
- disable_raw_mode();
+ terminal::disable_raw_mode();
hook(info);
}));
@@ -674,7 +219,7 @@ impl<'a> Application<'a> {
execute!(stdout, terminal::LeaveAlternateScreen)?;
- disable_raw_mode()?;
+ terminal::disable_raw_mode()?;
Ok(())
}
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
new file mode 100644
index 00000000..b345d2e8
--- /dev/null
+++ b/helix-term/src/commands.rs
@@ -0,0 +1,735 @@
+use helix_core::{
+ graphemes,
+ indent::TAB_WIDTH,
+ regex::Regex,
+ register, selection,
+ state::{Direction, Granularity, State},
+ ChangeSet, Range, Selection, Tendril, Transaction,
+};
+
+use once_cell::sync::Lazy;
+
+use crate::compositor::Compositor;
+use crate::ui::Prompt;
+
+use helix_view::{
+ document::Mode,
+ view::{View, PADDING},
+ Editor,
+};
+
+pub struct Context<'a, 'b> {
+ pub count: usize,
+ pub view: &'a mut View,
+ pub executor: &'a smol::Executor<'b>,
+
+ pub callback: Option<crate::compositor::Callback>,
+}
+
+/// A command is a function that takes the current state and a count, and does a side-effect on the
+/// state (usually by creating and applying a transaction).
+pub type Command = fn(cx: &mut Context);
+
+pub fn move_char_left(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .move_selection(Direction::Backward, Granularity::Character, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn move_char_right(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .move_selection(Direction::Forward, Granularity::Character, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn move_line_up(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .move_selection(Direction::Backward, Granularity::Line, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn move_line_down(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .move_selection(Direction::Forward, Granularity::Line, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn move_line_end(cx: &mut Context) {
+ let lines = selection_lines(&cx.view.doc.state);
+
+ let positions = lines
+ .into_iter()
+ .map(|index| {
+ // adjust all positions to the end of the line.
+
+ // Line end is pos at the start of next line - 1
+ // subtract another 1 because the line ends with \n
+ cx.view.doc.text().line_to_char(index + 1).saturating_sub(2)
+ })
+ .map(|pos| Range::new(pos, pos));
+
+ let selection = Selection::new(positions.collect(), 0);
+
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn move_line_start(cx: &mut Context) {
+ let lines = selection_lines(&cx.view.doc.state);
+
+ let positions = lines
+ .into_iter()
+ .map(|index| {
+ // adjust all positions to the start of the line.
+ cx.view.doc.text().line_to_char(index)
+ })
+ .map(|pos| Range::new(pos, pos));
+
+ let selection = Selection::new(positions.collect(), 0);
+
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn move_next_word_start(cx: &mut Context) {
+ let pos = cx.view.doc.state.move_pos(
+ cx.view.doc.selection().cursor(),
+ Direction::Forward,
+ Granularity::Word,
+ cx.count,
+ );
+
+ cx.view.doc.set_selection(Selection::point(pos));
+}
+
+pub fn move_prev_word_start(cx: &mut Context) {
+ let pos = cx.view.doc.state.move_pos(
+ cx.view.doc.selection().cursor(),
+ Direction::Backward,
+ Granularity::Word,
+ cx.count,
+ );
+
+ cx.view.doc.set_selection(Selection::point(pos));
+}
+
+pub fn move_next_word_end(cx: &mut Context) {
+ let pos = State::move_next_word_end(
+ &cx.view.doc.text().slice(..),
+ cx.view.doc.selection().cursor(),
+ cx.count,
+ );
+
+ cx.view.doc.set_selection(Selection::point(pos));
+}
+
+pub fn move_file_start(cx: &mut Context) {
+ cx.view.doc.set_selection(Selection::point(0));
+
+ cx.view.doc.mode = Mode::Normal;
+}
+
+pub fn move_file_end(cx: &mut Context) {
+ let text = &cx.view.doc.text();
+ let last_line = text.line_to_char(text.len_lines().saturating_sub(2));
+ cx.view.doc.set_selection(Selection::point(last_line));
+
+ cx.view.doc.mode = Mode::Normal;
+}
+
+pub fn check_cursor_in_view(view: &View) -> bool {
+ let cursor = view.doc.selection().cursor();
+ let line = view.doc.text().char_to_line(cursor);
+ let document_end = view.first_line + view.size.1.saturating_sub(1) as usize;
+
+ if (line > document_end.saturating_sub(PADDING)) | (line < view.first_line + PADDING) {
+ return false;
+ }
+ true
+}
+
+pub fn page_up(cx: &mut Context) {
+ if cx.view.first_line < PADDING {
+ return;
+ }
+
+ cx.view.first_line = cx.view.first_line.saturating_sub(cx.view.size.1 as usize);
+
+ if !check_cursor_in_view(cx.view) {
+ let text = cx.view.doc.text();
+ let pos = text.line_to_char(cx.view.last_line().saturating_sub(PADDING));
+ cx.view.doc.set_selection(Selection::point(pos));
+ }
+}
+
+pub fn page_down(cx: &mut Context) {
+ cx.view.first_line += cx.view.size.1 as usize + PADDING;
+
+ if cx.view.first_line < cx.view.doc.text().len_lines() {
+ let text = cx.view.doc.text();
+ let pos = text.line_to_char(cx.view.first_line as usize);
+ cx.view.doc.set_selection(Selection::point(pos));
+ }
+}
+
+pub fn half_page_up(cx: &mut Context) {
+ if cx.view.first_line < PADDING {
+ return;
+ }
+
+ cx.view.first_line = cx
+ .view
+ .first_line
+ .saturating_sub(cx.view.size.1 as usize / 2);
+
+ if !check_cursor_in_view(cx.view) {
+ let text = &cx.view.doc.text();
+ let pos = text.line_to_char(cx.view.last_line() - PADDING);
+ cx.view.doc.set_selection(Selection::point(pos));
+ }
+}
+
+pub fn half_page_down(cx: &mut Context) {
+ let lines = cx.view.doc.text().len_lines();
+ if cx.view.first_line < lines.saturating_sub(cx.view.size.1 as usize) {
+ cx.view.first_line += cx.view.size.1 as usize / 2;
+ }
+ if !check_cursor_in_view(cx.view) {
+ let text = cx.view.doc.text();
+ let pos = text.line_to_char(cx.view.first_line as usize);
+ cx.view.doc.set_selection(Selection::point(pos));
+ }
+}
+// avoid select by default by having a visual mode switch that makes movements into selects
+
+pub fn extend_char_left(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .extend_selection(Direction::Backward, Granularity::Character, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn extend_char_right(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .extend_selection(Direction::Forward, Granularity::Character, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn extend_line_up(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .extend_selection(Direction::Backward, Granularity::Line, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn extend_line_down(cx: &mut Context) {
+ let selection =
+ cx.view
+ .doc
+ .state
+ .extend_selection(Direction::Forward, Granularity::Line, cx.count);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn split_selection_on_newline(cx: &mut Context) {
+ let text = &cx.view.doc.text().slice(..);
+ // only compile the regex once
+ #[allow(clippy::trivial_regex)]
+ static REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\n").unwrap());
+ let selection = selection::split_on_matches(text, cx.view.doc.selection(), &REGEX);
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn select_line(cx: &mut Context) {
+ // TODO: count
+ let pos = cx.view.doc.selection().primary();
+ let text = cx.view.doc.text();
+ let line = text.char_to_line(pos.head);
+ let start = text.line_to_char(line);
+ let end = text.line_to_char(line + 1).saturating_sub(1);
+
+ cx.view.doc.set_selection(Selection::single(start, end));
+}
+
+pub fn delete_selection(cx: &mut Context) {
+ let transaction = Transaction::change_by_selection(&cx.view.doc.state, |range| {
+ (range.from(), range.to() + 1, None)
+ });
+ cx.view.doc.apply(&transaction);
+
+ append_changes_to_history(cx);
+}
+
+pub fn change_selection(cx: &mut Context) {
+ delete_selection(cx);
+ insert_mode(cx);
+}
+
+pub fn collapse_selection(cx: &mut Context) {
+ let selection = cx
+ .view
+ .doc
+ .selection()
+ .transform(|range| Range::new(range.head, range.head));
+
+ cx.view.doc.set_selection(selection);
+}
+
+pub fn flip_selections(cx: &mut Context) {
+ let selection = cx
+ .view
+ .doc
+ .selection()
+ .transform(|range| Range::new(range.head, range.anchor));
+
+ cx.view.doc.set_selection(selection);
+}
+
+fn enter_insert_mode(cx: &mut Context) {
+ cx.view.doc.mode = Mode::Insert;
+
+ append_changes_to_history(cx);
+}
+// inserts at the start of each selection
+pub fn insert_mode(cx: &mut Context) {
+ enter_insert_mode(cx);
+
+ let selection = cx
+ .view
+ .doc
+ .selection()
+ .transform(|range| Range::new(range.to(), range.from()));
+ cx.view.doc.set_selection(selection);
+}
+
+// inserts at the end of each selection
+pub fn append_mode(cx: &mut Context) {
+ enter_insert_mode(cx);
+ cx.view.doc.restore_cursor = true;
+
+ // TODO: as transaction
+ let text = &cx.view.doc.text().slice(..);
+ let selection = cx.view.doc.selection().transform(|range| {
+ // TODO: to() + next char
+ Range::new(
+ range.from(),
+ graphemes::next_grapheme_boundary(text, range.to()),
+ )
+ });
+ cx.view.doc.set_selection(selection);
+}
+
+// TODO: I, A, o and O can share a lot of the primitives.
+pub fn command_mode(cx: &mut Context) {
+ cx.callback = Some(Box::new(|compositor: &mut Compositor| {
+ let prompt = Prompt::new(
+ ":".to_owned(),
+ |_input: &str| {
+ // TODO: i need this duplicate list right now to avoid borrow checker issues
+ let command_list = vec![
+ String::from("q"),
+ String::from("aaa"),
+ String::from("bbb"),
+ String::from("ccc"),
+ String::from("ddd"),
+ String::from("eee"),
+ String::from("averylongcommandaverylongcommandaverylongcommandaverylongcommandaverylongcommand"),
+ String::from("q"),
+ String::from("aaa"),
+ String::from("bbb"),
+ String::from("ccc"),
+ String::from("ddd"),
+ String::from("eee"),
+ String::from("q"),
+ String::from("aaa"),
+ String::from("bbb"),
+ String::from("ccc"),
+ String::from("ddd"),
+ String::from("eee"),
+ String::from("q"),
+ String::from("aaa"),
+ String::from("bbb"),
+ String::from("ccc"),
+ String::from("ddd"),
+ String::from("eee"),
+ String::from("q"),
+ String::from("aaa"),
+ String::from("bbb"),
+ String::from("ccc"),
+ String::from("ddd"),
+ String::from("eee"),
+ ];
+ command_list
+ .into_iter()
+ .filter(|command| command.contains(_input))
+ .collect()
+ }, // completion
+ |editor: &mut Editor, input: &str| match input {
+ "q" => editor.should_close = true,
+ _ => (),
+ },
+ );
+ compositor.push(Box::new(prompt));
+ }));
+}
+
+// calculate line numbers for each selection range
+fn selection_lines(state: &State) -> Vec<usize> {
+ let mut lines = state
+ .selection
+ .ranges()
+ .iter()
+ .map(|range| state.doc.char_to_line(range.head))
+ .collect::<Vec<_>>();
+
+ lines.sort_unstable(); // sorting by usize so _unstable is preferred
+ lines.dedup();
+
+ lines
+}
+
+// I inserts at the start of each line with a selection
+pub fn prepend_to_line(cx: &mut Context) {
+ enter_insert_mode(cx);
+
+ move_line_start(cx);
+}
+
+// A inserts at the end of each line with a selection
+pub fn append_to_line(cx: &mut Context) {
+ enter_insert_mode(cx);
+
+ move_line_end(cx);
+}
+
+// o inserts a new line after each line with a selection
+pub fn open_below(cx: &mut Context) {
+ enter_insert_mode(cx);
+
+ let lines = selection_lines(&cx.view.doc.state);
+
+ let positions: Vec<_> = lines
+ .into_iter()
+ .map(|index| {
+ // adjust all positions to the end of the line/start of the next one.
+ cx.view.doc.text().line_to_char(index + 1)
+ })
+ .collect();
+
+ // TODO: use same logic as insert_newline for indentation
+ let changes = positions.iter().copied().map(|index|
+ // generate changes
+ (index, index, Some(Tendril::from_char('\n'))));
+
+ // TODO: count actually inserts "n" new lines and starts editing on all of them.
+ // TODO: append "count" newlines and modify cursors to those lines
+
+ let selection = Selection::new(
+ positions
+ .iter()
+ .copied()
+ .map(|pos| Range::new(pos, pos))
+ .collect(),
+ 0,
+ );
+
+ let transaction = Transaction::change(&cx.view.doc.state, changes).with_selection(selection);
+
+ cx.view.doc.apply(&transaction);
+}
+
+// O inserts a new line before each line with a selection
+
+fn append_changes_to_history(cx: &mut Context) {
+ if cx.view.doc.changes.is_empty() {
+ return;
+ }
+
+ let new_changeset = ChangeSet::new(cx.view.doc.text());
+ let changes = std::mem::replace(&mut cx.view.doc.changes, new_changeset);
+ // Instead of doing this messy merge we could always commit, and based on transaction
+ // annotations either add a new layer or compose into the previous one.
+ let transaction = Transaction::from(changes).with_selection(cx.view.doc.selection().clone());
+
+ // increment document version
+ // TODO: needs to happen on undo/redo too
+ cx.view.doc.version += 1;
+
+ // TODO: trigger lsp/documentDidChange with changes
+
+ // HAXX: we need to reconstruct the state as it was before the changes..
+ let old_state = std::mem::replace(&mut cx.view.doc.old_state, cx.view.doc.state.clone());
+ // TODO: take transaction by value?
+ cx.view
+ .doc
+ .history
+ .commit_revision(&transaction, &old_state);
+
+ // TODO: notify LSP of changes
+}
+
+pub fn normal_mode(cx: &mut Context) {
+ cx.view.doc.mode = Mode::Normal;
+
+ append_changes_to_history(cx);
+
+ // if leaving append mode, move cursor back by 1
+ if cx.view.doc.restore_cursor {
+ let text = &cx.view.doc.text().slice(..);
+ let selection = cx.view.doc.selection().transform(|range| {
+ Range::new(
+ range.from(),
+ graphemes::prev_grapheme_boundary(text, range.to()),
+ )
+ });
+ cx.view.doc.set_selection(selection);
+
+ cx.view.doc.restore_cursor = false;
+ }
+}
+
+pub fn goto_mode(cx: &mut Context) {
+ cx.view.doc.mode = Mode::Goto;
+}
+
+// NOTE: Transactions in this module get appended to history when we switch back to normal mode.
+pub mod insert {
+ use super::*;
+ // TODO: insert means add text just before cursor, on exit we should be on the last letter.
+ pub fn insert_char(cx: &mut Context, c: char) {
+ let c = Tendril::from_char(c);
+ let transaction = Transaction::insert(&cx.view.doc.state, c);
+
+ cx.view.doc.apply(&transaction);
+ }
+
+ pub fn insert_tab(cx: &mut Context) {
+ insert_char(cx, '\t');
+ }
+
+ pub fn insert_newline(cx: &mut Context) {
+ let transaction = Transaction::change_by_selection(&cx.view.doc.state, |range| {
+ let indent_level = helix_core::indent::suggested_indent_for_pos(
+ cx.view.doc.syntax.as_ref(),
+ &cx.view.doc.state,
+ range.head,
+ );
+ let indent = " ".repeat(TAB_WIDTH).repeat(indent_level);
+ let mut text = String::with_capacity(1 + indent.len());
+ text.push('\n');
+ text.push_str(&indent);
+ (range.head, range.head, Some(text.into()))
+ });
+ cx.view.doc.apply(&transaction);
+ }
+
+ // TODO: handle indent-aware delete
+ pub fn delete_char_backward(cx: &mut Context) {
+ let text = &cx.view.doc.text().slice(..);
+ let transaction = Transaction::change_by_selection(&cx.view.doc.state, |range| {
+ (
+ graphemes::nth_prev_grapheme_boundary(text, range.head, cx.count),
+ range.head,
+ None,
+ )
+ });
+ cx.view.doc.apply(&transaction);
+ }
+
+ pub fn delete_char_forward(cx: &mut Context) {
+ let text = &cx.view.doc.text().slice(..);
+ let transaction = Transaction::change_by_selection(&cx.view.doc.state, |range| {
+ (
+ range.head,
+ graphemes::nth_next_grapheme_boundary(text, range.head, cx.count),
+ None,
+ )
+ });
+ cx.view.doc.apply(&transaction);
+ }
+}
+
+pub fn insert_char_prompt(prompt: &mut Prompt, c: char) {
+ prompt.insert_char(c);
+}
+
+// Undo / Redo
+
+pub fn undo(cx: &mut Context) {
+ if let Some(revert) = cx.view.doc.history.undo() {
+ cx.view.doc.version += 1;
+ cx.view.doc.apply(&revert);
+ }
+
+ // TODO: each command could simply return a Option<transaction>, then the higher level handles storing it?
+}
+
+pub fn redo(cx: &mut Context) {
+ if let Some(transaction) = cx.view.doc.history.redo() {
+ cx.view.doc.version += 1;
+ cx.view.doc.apply(&transaction);
+ }
+}
+
+// Yank / Paste
+
+pub fn yank(cx: &mut Context) {
+ // TODO: should selections be made end inclusive?
+ let values = cx
+ .view
+ .doc
+ .state
+ .selection()
+ .fragments(&cx.view.doc.text().slice(..))
+ .map(|cow| cow.into_owned())
+ .collect();
+
+ // TODO: allow specifying reg
+ let reg = '"';
+ register::set(reg, values);
+}
+
+pub fn paste(cx: &mut Context) {
+ // TODO: allow specifying reg
+ let reg = '"';
+ if let Some(values) = register::get(reg) {
+ let repeat = std::iter::repeat(
+ values
+ .last()
+ .map(|value| Tendril::from_slice(value))
+ .unwrap(),
+ );
+
+ // TODO: if any of values ends \n it's linewise paste
+ //
+ // p => paste after
+ // P => paste before
+ // alt-p => paste every yanked selection after selected text
+ // alt-P => paste every yanked selection before selected text
+ // R => replace selected text with yanked text
+ // alt-R => replace selected text with every yanked text
+ //
+ // append => insert at next line
+ // insert => insert at start of line
+ // replace => replace
+ // default insert
+
+ let linewise = values.iter().any(|value| value.ends_with('\n'));
+
+ let mut values = values.into_iter().map(Tendril::from).chain(repeat);
+
+ let transaction = if linewise {
+ // paste on the next line
+ // TODO: can simply take a range + modifier and compute the right pos without ifs
+ let text = cx.view.doc.text();
+ Transaction::change_by_selection(&cx.view.doc.state, |range| {
+ let line_end = text.line_to_char(text.char_to_line(range.head) + 1);
+ (line_end, line_end, Some(values.next().unwrap()))
+ })
+ } else {
+ Transaction::change_by_selection(&cx.view.doc.state, |range| {
+ (range.head + 1, range.head + 1, Some(values.next().unwrap()))
+ })
+ };
+
+ cx.view.doc.apply(&transaction);
+ append_changes_to_history(cx);
+ }
+}
+
+fn get_lines(view: &View) -> Vec<usize> {
+ let mut lines = Vec::new();
+
+ // Get all line numbers
+ for range in view.doc.selection().ranges() {
+ let start = view.doc.text().char_to_line(range.from());
+ let end = view.doc.text().char_to_line(range.to());
+
+ for line in start..=end {
+ lines.push(line)
+ }
+ }
+ lines.sort_unstable(); // sorting by usize so _unstable is preferred
+ lines.dedup();
+ lines
+}
+
+pub fn indent(cx: &mut Context) {
+ let lines = get_lines(cx.view);
+
+ // Indent by one level
+ let indent = Tendril::from(" ".repeat(TAB_WIDTH));
+
+ let transaction = Transaction::change(
+ &cx.view.doc.state,
+ lines.into_iter().map(|line| {
+ let pos = cx.view.doc.text().line_to_char(line);
+ (pos, pos, Some(indent.clone()))
+ }),
+ );
+ cx.view.doc.apply(&transaction);
+ append_changes_to_history(cx);
+}
+
+pub fn unindent(cx: &mut Context) {
+ let lines = get_lines(cx.view);
+ let mut changes = Vec::with_capacity(lines.len());
+
+ for line_idx in lines {
+ let line = cx.view.doc.text().line(line_idx);
+ let mut width = 0;
+
+ for ch in line.chars() {
+ match ch {
+ ' ' => width += 1,
+ '\t' => width = (width / TAB_WIDTH + 1) * TAB_WIDTH,
+ _ => break,
+ }
+
+ if width >= TAB_WIDTH {
+ break;
+ }
+ }
+
+ if width > 0 {
+ let start = cx.view.doc.text().line_to_char(line_idx);
+ changes.push((start, start + width, None))
+ }
+ }
+
+ let transaction = Transaction::change(&cx.view.doc.state, changes.into_iter());
+
+ cx.view.doc.apply(&transaction);
+ append_changes_to_history(cx);
+}
+
+pub fn indent_selection(_cx: &mut Context) {
+ // loop over each line and recompute proper indentation
+ unimplemented!()
+}
+
+//
+
+pub fn save(cx: &mut Context) {
+ // Spawns an async task to actually do the saving. This way we prevent blocking.
+
+ // TODO: handle save errors somehow?
+ cx.executor.spawn(cx.view.doc.save()).detach();
+}
diff --git a/helix-term/src/component.rs b/helix-term/src/component.rs
deleted file mode 100644
index 08d6c620..00000000
--- a/helix-term/src/component.rs
+++ /dev/null
@@ -1,20 +0,0 @@
-// IDEA: render to a cache buffer, then if not changed, copy the buf into the parent
-type Surface = ();
-pub trait Component {
- /// Process input events, return true if handled.
- fn process_event(&mut self, event: crossterm::event::Event, args: ()) -> bool;
- /// Should redraw? Useful for saving redraw cycles if we know component didn't change.
- fn should_update(&self) -> bool {
- true
- }
-
- fn render(&mut self, surface: &mut Surface, args: ());
-}
-
-// HStack / VStack
-// focus by component id: each View/Editor gets it's own incremental id at create
-// Component: View(Arc<State>) -> multiple views can point to same state
-// id 0 = prompt?
-// when entering to prompt, it needs to direct Commands to last focus window
-// -> prompt.trigger(focus_id), on_leave -> focus(focus_id)
-// popups on another layer
diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs
new file mode 100644
index 00000000..2e65f02a
--- /dev/null
+++ b/helix-term/src/compositor.rs
@@ -0,0 +1,155 @@
+// Features:
+// Tracks currently focused component which receives all input
+// Event loop is external as opposed to cursive-rs
+// Calls render on the component and translates screen coords to local component coords
+//
+// TODO:
+// Q: where is the Application state stored? do we store it into an external static var?
+// A: probably makes sense to initialize the editor into a `static Lazy<>` global var.
+//
+// Q: how do we composit nested structures? There should be sub-components/views
+//
+// Each component declares it's own size constraints and gets fitted based on it's parent.
+// Q: how does this work with popups?
+// cursive does compositor.screen_mut().add_layer_at(pos::absolute(x, y), <component>)
+
+use crossterm::event::Event;
+use helix_core::Position;
+use smol::Executor;
+use tui::buffer::Buffer as Surface;
+use tui::layout::Rect;
+
+pub type Callback = Box<dyn Fn(&mut Compositor)>;
+
+// --> EventResult should have a callback that takes a context with methods like .popup(),
+// .prompt() etc. That way we can abstract it from the renderer.
+// Q: How does this interact with popups where we need to be able to specify the rendering of the
+// popup?
+// A: It could just take a textarea.
+//
+// If Compositor was specified in the callback that's then problematic because of
+
+// Cursive-inspired
+pub enum EventResult {
+ Ignored,
+ Consumed(Option<Callback>),
+}
+
+use helix_view::{Editor, View};
+// shared with commands.rs
+pub struct Context<'a> {
+ pub editor: &'a mut Editor,
+ pub executor: &'static smol::Executor<'static>,
+}
+
+pub trait Component {
+ /// Process input events, return true if handled.
+ fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult;
+ // , args: ()
+
+ /// Should redraw? Useful for saving redraw cycles if we know component didn't change.
+ fn should_update(&self) -> bool {
+ true
+ }
+
+ fn render(&self, area: Rect, frame: &mut Surface, ctx: &mut Context);
+
+ fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option<Position> {
+ None
+ }
+}
+
+// struct Editor { };
+
+// For v1:
+// Child views are something each view needs to handle on it's own for now, positioning and sizing
+// options, focus tracking. In practice this is simple: we only will need special solving for
+// splits etc
+
+// impl Editor {
+// fn render(&mut self, surface: &mut Surface, args: ()) {
+// // compute x, y, w, h rects for sub-views!
+// // get surface area
+// // get constraints for textarea, statusbar
+// // -> cassowary-rs
+
+// // first render textarea
+// // then render statusbar
+// }
+// }
+
+// usecases to consider:
+// - a single view with subviews (textarea + statusbar)
+// - a popup panel / dialog with it's own interactions
+// - an autocomplete popup that doesn't change focus
+
+//fn main() {
+// let root = Editor::new();
+// let compositor = Compositor::new();
+
+// compositor.push(root);
+
+// // pos: clip to bottom of screen
+// compositor.push_at(pos, Prompt::new(
+// ":",
+// (),
+// |input: &str| match input {}
+// )); // TODO: this Prompt needs to somehow call compositor.pop() on close, but it can't refer to parent
+// // Cursive solves this by allowing to return a special result on process_event
+// // that's either Ignore | Consumed(Opt<C>) where C: fn (Compositor) -> ()
+
+// // TODO: solve popup focus: we want to push autocomplete popups on top of the current layer
+// // but retain the focus where it was. The popup will also need to update as we type into the
+// // textarea. It should also capture certain input, such as tab presses etc
+// //
+// // 1) This could be faked by the top layer pushing down edits into the previous layer.
+// // 2) Alternatively,
+//}
+
+pub struct Compositor {
+ layers: Vec<Box<dyn Component>>,
+}
+
+impl Compositor {
+ pub fn new() -> Self {
+ Self { layers: Vec::new() }
+ }
+
+ pub fn push(&mut self, layer: Box<dyn Component>) {
+ self.layers.push(layer);
+ }
+
+ pub fn pop(&mut self) {
+ self.layers.pop();
+ }
+
+ pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool {
+ // TODO: custom focus
+ if let Some(layer) = self.layers.last_mut() {
+ return match layer.handle_event(event, cx) {
+ EventResult::Consumed(Some(callback)) => {
+ callback(self);
+ true
+ }
+ EventResult::Consumed(None) => true,
+ EventResult::Ignored => false,
+ };
+ }
+ false
+ }
+
+ pub fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ for layer in &self.layers {
+ layer.render(area, surface, cx)
+ }
+ }
+
+ pub fn cursor_position(&self, area: Rect, cx: &mut Context) -> Position {
+ for layer in self.layers.iter().rev() {
+ if let Some(pos) = layer.cursor_position(area, cx) {
+ return pos;
+ }
+ }
+ panic!("No layer returned a position!");
+ }
+}
diff --git a/helix-term/src/helix.log b/helix-term/src/helix.log
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/helix-term/src/helix.log
diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs
new file mode 100644
index 00000000..af46f7a4
--- /dev/null
+++ b/helix-term/src/keymap.rs
@@ -0,0 +1,217 @@
+use crate::commands::{self, Command};
+use helix_core::hashmap;
+use helix_view::document::Mode;
+use std::collections::HashMap;
+
+// Kakoune-inspired:
+// mode = {
+// normal = {
+// q = record_macro
+// w = (next) word
+// W = next WORD
+// e = end of word
+// E = end of WORD
+// r =
+// t = 'till char
+// y = yank
+// u = undo
+// U = redo
+// i = insert
+// I = INSERT (start of line)
+// o = open below (insert on new line below)
+// O = open above (insert on new line above)
+// p = paste (before cursor)
+// P = PASTE (after cursor)
+// ` =
+// [ = select to text object start (alt = select whole object)
+// ] = select to text object end
+// { = extend to inner object start
+// } = extend to inner object end
+// a = append
+// A = APPEND (end of line)
+// s = split
+// S = select
+// d = delete()
+// f = find_char()
+// g = goto (gg, G, gc, gd, etc)
+//
+// h = move_char_left(n)
+// j = move_line_down(n)
+// k = move_line_up(n)
+// l = move_char_right(n)
+// : = command line
+// ; = collapse selection to cursor
+// " = use register
+// ` = convert case? (to lower) (alt = swap case)
+// ~ = convert to upper case
+// . = repeat last command
+// \ = disable hook?
+// / = search
+// > = indent
+// < = deindent
+// % = select whole buffer (in vim = jump to matching bracket)
+// * = search pattern in selection
+// ( = rotate main selection backward
+// ) = rotate main selection forward
+// - = trim selections? (alt = merge contiguous sel together)
+// @ = convert tabs to spaces
+// & = align cursor
+// ? = extend to next given regex match (alt = to prev)
+//
+// in kakoune these are alt-h alt-l / gh gl
+// select from curs to begin end / move curs to begin end
+// 0 = start of line
+// ^ = start of line (first non blank char)
+// $ = end of line
+//
+// z = save selections
+// Z = restore selections
+// x = select line
+// X = extend line
+// c = change selected text
+// C = copy selection?
+// v = view menu (viewport manipulation)
+// b = select to previous word start
+// B = select to previous WORD start
+//
+//
+//
+//
+//
+//
+// = = align?
+// + =
+// }
+//
+// gd = goto definition
+// gr = goto reference
+// }
+
+// #[cfg(feature = "term")]
+pub use crossterm::event::{KeyCode, KeyEvent as Key, KeyModifiers as Modifiers};
+
+// TODO: could be trie based
+pub type Keymap = HashMap<Vec<Key>, Command>;
+pub type Keymaps = HashMap<Mode, Keymap>;
+
+macro_rules! key {
+ ($ch:expr) => {
+ Key {
+ code: KeyCode::Char($ch),
+ modifiers: Modifiers::NONE,
+ }
+ };
+}
+
+macro_rules! shift {
+ ($ch:expr) => {
+ Key {
+ code: KeyCode::Char($ch),
+ modifiers: Modifiers::SHIFT,
+ }
+ };
+}
+
+macro_rules! ctrl {
+ ($ch:expr) => {
+ Key {
+ code: KeyCode::Char($ch),
+ modifiers: Modifiers::CONTROL,
+ }
+ };
+}
+
+// macro_rules! alt {
+// ($ch:expr) => {
+// Key {
+// code: KeyCode::Char($ch),
+// modifiers: Modifiers::ALT,
+// }
+// };
+// }
+
+pub fn default() -> Keymaps {
+ hashmap!(
+ Mode::Normal =>
+ // as long as you cast the first item, rust is able to infer the other cases
+ hashmap!(
+ vec![key!('h')] => commands::move_char_left as Command,
+ vec![key!('j')] => commands::move_line_down,
+ vec![key!('k')] => commands::move_line_up,
+ vec![key!('0')] => commands::move_line_start,
+ vec![key!('$')] => commands::move_line_end,
+ vec![key!('l')] => commands::move_char_right,
+ vec![shift!('H')] => commands::extend_char_left,
+ vec![shift!('J')] => commands::extend_line_down,
+ vec![shift!('K')] => commands::extend_line_up,
+ vec![shift!('L')] => commands::extend_char_right,
+ vec![key!('w')] => commands::move_next_word_start,
+ vec![key!('b')] => commands::move_prev_word_start,
+ vec![key!('e')] => commands::move_next_word_end,
+ vec![key!('g')] => commands::goto_mode,
+ vec![key!('i')] => commands::insert_mode,
+ vec![shift!('I')] => commands::prepend_to_line,
+ vec![key!('a')] => commands::append_mode,
+ vec![shift!('A')] => commands::append_to_line,
+ vec![key!('o')] => commands::open_below,
+ vec![key!('d')] => commands::delete_selection,
+ vec![key!('c')] => commands::change_selection,
+ vec![key!('s')] => commands::split_selection_on_newline,
+ vec![key!(';')] => commands::collapse_selection,
+ // TODO should be alt(;)
+ vec![key!('%')] => commands::flip_selections,
+ vec![key!('x')] => commands::select_line,
+ vec![key!('u')] => commands::undo,
+ vec![shift!('U')] => commands::redo,
+ vec![key!('y')] => commands::yank,
+ vec![key!('p')] => commands::paste,
+ vec![key!('>')] => commands::indent,
+ vec![key!('<')] => commands::unindent,
+ vec![key!(':')] => commands::command_mode,
+ vec![Key {
+ code: KeyCode::Esc,
+ modifiers: Modifiers::NONE
+ }] => commands::normal_mode,
+ vec![Key {
+ code: KeyCode::PageUp,
+ modifiers: Modifiers::NONE
+ }] => commands::page_up,
+ vec![Key {
+ code: KeyCode::PageDown,
+ modifiers: Modifiers::NONE
+ }] => commands::page_down,
+ vec![ctrl!('u')] => commands::half_page_up,
+ vec![ctrl!('d')] => commands::half_page_down,
+ ),
+ Mode::Insert => hashmap!(
+ vec![Key {
+ code: KeyCode::Esc,
+ modifiers: Modifiers::NONE
+ }] => commands::normal_mode as Command,
+ vec![Key {
+ code: KeyCode::Backspace,
+ modifiers: Modifiers::NONE
+ }] => commands::insert::delete_char_backward,
+ vec![Key {
+ code: KeyCode::Delete,
+ modifiers: Modifiers::NONE
+ }] => commands::insert::delete_char_forward,
+ vec![Key {
+ code: KeyCode::Enter,
+ modifiers: Modifiers::NONE
+ }] => commands::insert::insert_newline,
+ vec![Key {
+ code: KeyCode::Tab,
+ modifiers: Modifiers::NONE
+ }] => commands::insert::insert_tab,
+ ),
+ Mode::Goto => hashmap!(
+ vec![Key {
+ code: KeyCode::Esc,
+ modifiers: Modifiers::NONE
+ }] => commands::normal_mode as Command,
+ vec![key!('g')] => commands::move_file_start as Command,
+ vec![key!('e')] => commands::move_file_end as Command,
+ ),
+ )
+}
diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs
index 9378d3ee..f350b4c1 100644
--- a/helix-term/src/main.rs
+++ b/helix-term/src/main.rs
@@ -1,6 +1,11 @@
#![allow(unused)]
mod application;
+mod commands;
+mod compositor;
+mod keymap;
+mod terminal;
+mod ui;
use application::Application;
diff --git a/helix-term/src/terminal.rs b/helix-term/src/terminal.rs
new file mode 100644
index 00000000..e40343bd
--- /dev/null
+++ b/helix-term/src/terminal.rs
@@ -0,0 +1,221 @@
+use std::io;
+use tui::{
+ backend::Backend,
+ buffer::Buffer,
+ layout::Rect,
+ widgets::{StatefulWidget, Widget},
+};
+
+#[derive(Debug, Clone, PartialEq)]
+/// UNSTABLE
+enum ResizeBehavior {
+ Fixed,
+ Auto,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+/// UNSTABLE
+pub struct Viewport {
+ area: Rect,
+ resize_behavior: ResizeBehavior,
+}
+
+impl Viewport {
+ /// UNSTABLE
+ pub fn fixed(area: Rect) -> Viewport {
+ Viewport {
+ area,
+ resize_behavior: ResizeBehavior::Fixed,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+/// Options to pass to [`Terminal::with_options`]
+pub struct TerminalOptions {
+ /// Viewport used to draw to the terminal
+ pub viewport: Viewport,
+}
+
+/// Interface to the terminal backed by Termion
+#[derive(Debug)]
+pub struct Terminal<B>
+where
+ B: Backend,
+{
+ backend: B,
+ /// Holds the results of the current and previous draw calls. The two are compared at the end
+ /// of each draw pass to output the necessary updates to the terminal
+ buffers: [Buffer; 2],
+ /// Index of the current buffer in the previous array
+ current: usize,
+ /// Whether the cursor is currently hidden
+ hidden_cursor: bool,
+ /// Viewport
+ viewport: Viewport,
+}
+
+impl<B> Drop for Terminal<B>
+where
+ B: Backend,
+{
+ fn drop(&mut self) {
+ // Attempt to restore the cursor state
+ if self.hidden_cursor {
+ if let Err(err) = self.show_cursor() {
+ eprintln!("Failed to show the cursor: {}", err);
+ }
+ }
+ }
+}
+
+impl<B> Terminal<B>
+where
+ B: Backend,
+{
+ /// Wrapper around Terminal initialization. Each buffer is initialized with a blank string and
+ /// default colors for the foreground and the background
+ pub fn new(backend: B) -> io::Result<Terminal<B>> {
+ let size = backend.size()?;
+ Terminal::with_options(
+ backend,
+ TerminalOptions {
+ viewport: Viewport {
+ area: size,
+ resize_behavior: ResizeBehavior::Auto,
+ },
+ },
+ )
+ }
+
+ /// UNSTABLE
+ pub fn with_options(backend: B, options: TerminalOptions) -> io::Result<Terminal<B>> {
+ Ok(Terminal {
+ backend,
+ buffers: [
+ Buffer::empty(options.viewport.area),
+ Buffer::empty(options.viewport.area),
+ ],
+ current: 0,
+ hidden_cursor: false,
+ viewport: options.viewport,
+ })
+ }
+
+ // /// Get a Frame object which provides a consistent view into the terminal state for rendering.
+ // pub fn get_frame(&mut self) -> Frame<B> {
+ // Frame {
+ // terminal: self,
+ // cursor_position: None,
+ // }
+ // }
+
+ pub fn current_buffer_mut(&mut self) -> &mut Buffer {
+ &mut self.buffers[self.current]
+ }
+
+ pub fn backend(&self) -> &B {
+ &self.backend
+ }
+
+ pub fn backend_mut(&mut self) -> &mut B {
+ &mut self.backend
+ }
+
+ /// Obtains a difference between the previous and the current buffer and passes it to the
+ /// current backend for drawing.
+ pub fn flush(&mut self) -> io::Result<()> {
+ let previous_buffer = &self.buffers[1 - self.current];
+ let current_buffer = &self.buffers[self.current];
+ let updates = previous_buffer.diff(current_buffer);
+ self.backend.draw(updates.into_iter())
+ }
+
+ /// Updates the Terminal so that internal buffers match the requested size. Requested size will
+ /// be saved so the size can remain consistent when rendering.
+ /// This leads to a full clear of the screen.
+ pub fn resize(&mut self, area: Rect) -> io::Result<()> {
+ self.buffers[self.current].resize(area);
+ self.buffers[1 - self.current].resize(area);
+ self.viewport.area = area;
+ self.clear()
+ }
+
+ /// Queries the backend for size and resizes if it doesn't match the previous size.
+ pub fn autoresize(&mut self) -> io::Result<()> {
+ if self.viewport.resize_behavior == ResizeBehavior::Auto {
+ let size = self.size()?;
+ if size != self.viewport.area {
+ self.resize(size)?;
+ }
+ };
+ Ok(())
+ }
+
+ /// Synchronizes terminal size, calls the rendering closure, flushes the current internal state
+ /// and prepares for the next draw call.
+ pub fn draw(&mut self) -> io::Result<()> {
+ // // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
+ // // and the terminal (if growing), which may OOB.
+ // self.autoresize()?;
+
+ // let mut frame = self.get_frame();
+ // f(&mut frame);
+ // // We can't change the cursor position right away because we have to flush the frame to
+ // // stdout first. But we also can't keep the frame around, since it holds a &mut to
+ // // Terminal. Thus, we're taking the important data out of the Frame and dropping it.
+ // let cursor_position = frame.cursor_position;
+
+ // Draw to stdout
+ self.flush()?;
+
+ // match cursor_position {
+ // None => self.hide_cursor()?,
+ // Some((x, y)) => {
+ // self.show_cursor()?;
+ // self.set_cursor(x, y)?;
+ // }
+ // }
+
+ // Swap buffers
+ self.buffers[1 - self.current].reset();
+ self.current = 1 - self.current;
+
+ // Flush
+ self.backend.flush()?;
+ Ok(())
+ }
+
+ pub fn hide_cursor(&mut self) -> io::Result<()> {
+ self.backend.hide_cursor()?;
+ self.hidden_cursor = true;
+ Ok(())
+ }
+
+ pub fn show_cursor(&mut self) -> io::Result<()> {
+ self.backend.show_cursor()?;
+ self.hidden_cursor = false;
+ Ok(())
+ }
+
+ pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
+ self.backend.get_cursor()
+ }
+
+ pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
+ self.backend.set_cursor(x, y)
+ }
+
+ /// Clear the terminal and force a full redraw on the next draw call.
+ pub fn clear(&mut self) -> io::Result<()> {
+ self.backend.clear()?;
+ // Reset the back buffer to make sure the next update will redraw everything.
+ self.buffers[1 - self.current].reset();
+ Ok(())
+ }
+
+ /// Queries the real size of the backend.
+ pub fn size(&self) -> io::Result<Rect> {
+ self.backend.size()
+ }
+}
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
new file mode 100644
index 00000000..ceb5a442
--- /dev/null
+++ b/helix-term/src/ui/editor.rs
@@ -0,0 +1,327 @@
+use crate::commands;
+use crate::compositor::{Component, Compositor, Context, EventResult};
+use crate::keymap::{self, Keymaps};
+use crate::ui::text_color;
+
+use helix_core::{indent::TAB_WIDTH, syntax::HighlightEvent, Position, Range, State};
+use helix_view::{document::Mode, Document, Editor, Theme, View};
+use std::borrow::Cow;
+
+use crossterm::{
+ cursor,
+ event::{read, Event, EventStream, KeyCode, KeyEvent},
+};
+use tui::{
+ backend::CrosstermBackend,
+ buffer::Buffer as Surface,
+ layout::Rect,
+ style::{Color, Modifier, Style},
+};
+
+pub struct EditorView {
+ keymap: Keymaps,
+}
+
+const OFFSET: u16 = 7; // 1 diagnostic + 5 linenr + 1 gutter
+
+impl EditorView {
+ pub fn new() -> Self {
+ Self {
+ keymap: keymap::default(),
+ }
+ }
+ pub fn render_view(
+ &self,
+ view: &mut View,
+ viewport: Rect,
+ surface: &mut Surface,
+ theme: &Theme,
+ ) {
+ let area = Rect::new(OFFSET, 0, viewport.width - OFFSET, viewport.height - 2); // - 2 for statusline and prompt
+ self.render_buffer(view, area, surface, theme);
+ let area = Rect::new(0, viewport.height - 2, viewport.width, 1);
+ self.render_statusline(view, area, surface, theme);
+ }
+
+ // TODO: ideally not &mut View but highlights require it because of cursor cache
+ pub fn render_buffer(
+ &self,
+ view: &mut View,
+ viewport: Rect,
+ surface: &mut Surface,
+ theme: &Theme,
+ ) {
+ // clear with background color
+ surface.set_style(viewport, theme.get("ui.background"));
+
+ // TODO: inefficient, should feed chunks.iter() to tree_sitter.parse_with(|offset, pos|)
+ let source_code = view.doc.text().to_string();
+
+ let last_line = view.last_line();
+
+ let range = {
+ // calculate viewport byte ranges
+ let start = view.doc.text().line_to_byte(view.first_line);
+ let end = view.doc.text().line_to_byte(last_line)
+ + view.doc.text().line(last_line).len_bytes();
+
+ start..end
+ };
+
+ // TODO: range doesn't actually restrict source, just highlight range
+ // TODO: cache highlight results
+ // TODO: only recalculate when state.doc is actually modified
+ let highlights: Vec<_> = match view.doc.syntax.as_mut() {
+ Some(syntax) => {
+ syntax
+ .highlight_iter(source_code.as_bytes(), Some(range), None, |_| None)
+ .unwrap()
+ .collect() // TODO: we collect here to avoid double borrow, fix later
+ }
+ None => vec![Ok(HighlightEvent::Source {
+ start: range.start,
+ end: range.end,
+ })],
+ };
+ let mut spans = Vec::new();
+ let mut visual_x = 0;
+ let mut line = 0u16;
+ let visible_selections: Vec<Range> = view
+ .doc
+ .state
+ .selection()
+ .ranges()
+ .iter()
+ // TODO: limit selection to one in viewport
+ // .filter(|range| !range.is_empty()) // && range.overlaps(&Range::new(start, end + 1))
+ .copied()
+ .collect();
+
+ 'outer: for event in highlights {
+ match event.unwrap() {
+ HighlightEvent::HighlightStart(span) => {
+ spans.push(span);
+ }
+ HighlightEvent::HighlightEnd => {
+ spans.pop();
+ }
+ HighlightEvent::Source { start, end } => {
+ // TODO: filter out spans out of viewport for now..
+
+ let start = view.doc.text().byte_to_char(start);
+ let end = view.doc.text().byte_to_char(end); // <-- index 744, len 743
+
+ let text = view.doc.text().slice(start..end);
+
+ use helix_core::graphemes::{grapheme_width, RopeGraphemes};
+
+ let style = match spans.first() {
+ Some(span) => theme.get(theme.scopes()[span.0].as_str()),
+ None => Style::default().fg(Color::Rgb(164, 160, 232)), // lavender
+ };
+
+ // TODO: we could render the text to a surface, then cache that, that
+ // way if only the selection/cursor changes we can copy from cache
+ // and paint the new cursor.
+
+ let mut char_index = start;
+
+ // iterate over range char by char
+ for grapheme in RopeGraphemes::new(&text) {
+ // TODO: track current char_index
+
+ if grapheme == "\n" {
+ visual_x = 0;
+ line += 1;
+
+ // TODO: with proper iter this shouldn't be necessary
+ if line >= viewport.height {
+ break 'outer;
+ }
+ } else if grapheme == "\t" {
+ visual_x += (TAB_WIDTH as u16);
+ } else {
+ // Cow will prevent allocations if span contained in a single slice
+ // which should really be the majority case
+ let grapheme = Cow::from(grapheme);
+ let width = grapheme_width(&grapheme) as u16;
+
+ // TODO: this should really happen as an after pass
+ let style = if visible_selections
+ .iter()
+ .any(|range| range.contains(char_index))
+ {
+ // cedar
+ style.clone().bg(Color::Rgb(128, 47, 0))
+ } else {
+ style
+ };
+
+ let style = if visible_selections
+ .iter()
+ .any(|range| range.head == char_index)
+ {
+ style.clone().bg(Color::Rgb(255, 255, 255))
+ } else {
+ style
+ };
+
+ // ugh, improve with a traverse method
+ // or interleave highlight spans with selection and diagnostic spans
+ let style = if view.doc.diagnostics.iter().any(|diagnostic| {
+ diagnostic.range.0 <= char_index && diagnostic.range.1 > char_index
+ }) {
+ style.clone().add_modifier(Modifier::UNDERLINED)
+ } else {
+ style
+ };
+
+ // TODO: paint cursor heads except primary
+
+ surface.set_string(
+ viewport.x + visual_x,
+ viewport.y + line,
+ grapheme,
+ style,
+ );
+
+ visual_x += width;
+ }
+
+ char_index += 1;
+ }
+ }
+ }
+ }
+
+ let style: Style = theme.get("ui.linenr");
+ let warning: Style = theme.get("warning");
+ let last_line = view.last_line();
+ for (i, line) in (view.first_line..last_line).enumerate() {
+ if view.doc.diagnostics.iter().any(|d| d.line == line) {
+ surface.set_stringn(0, i as u16, "●", 1, warning);
+ }
+
+ surface.set_stringn(1, i as u16, format!("{:>5}", line + 1), 5, style);
+ }
+ }
+
+ pub fn render_statusline(
+ &self,
+ view: &View,
+ viewport: Rect,
+ surface: &mut Surface,
+ theme: &Theme,
+ ) {
+ let text_color = text_color();
+ let mode = match view.doc.mode() {
+ Mode::Insert => "INS",
+ Mode::Normal => "NOR",
+ Mode::Goto => "GOTO",
+ };
+ // statusline
+ surface.set_style(
+ Rect::new(0, viewport.y, viewport.width, 1),
+ theme.get("ui.statusline"),
+ );
+ surface.set_string(1, viewport.y, mode, text_color);
+
+ if let Some(path) = view.doc.path() {
+ surface.set_string(6, viewport.y, path.to_string_lossy(), text_color);
+ }
+
+ surface.set_string(
+ viewport.width - 10,
+ viewport.y,
+ format!("{}", view.doc.diagnostics.len()),
+ text_color,
+ );
+ }
+}
+
+impl Component for EditorView {
+ fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
+ match event {
+ Event::Resize(width, height) => {
+ // TODO: simplistic ensure cursor in view for now
+ // TODO: loop over views
+ if let Some(view) = cx.editor.view_mut() {
+ view.size = (width, height);
+ view.ensure_cursor_in_view()
+ };
+ EventResult::Consumed(None)
+ }
+ Event::Key(event) => {
+ if let Some(view) = cx.editor.view_mut() {
+ let keys = vec![event];
+ // TODO: sequences (`gg`)
+ let mode = view.doc.mode();
+ // TODO: handle count other than 1
+ let mut cx = commands::Context {
+ view,
+ executor: cx.executor,
+ count: 1,
+ callback: None,
+ };
+
+ match mode {
+ Mode::Insert => {
+ if let Some(command) = self.keymap[&Mode::Insert].get(&keys) {
+ command(&mut cx);
+ } else if let KeyEvent {
+ code: KeyCode::Char(c),
+ ..
+ } = event
+ {
+ commands::insert::insert_char(&mut cx, c);
+ }
+ }
+ mode => {
+ if let Some(command) = self.keymap[&mode].get(&keys) {
+ command(&mut cx);
+
+ // TODO: simplistic ensure cursor in view for now
+ }
+ }
+ }
+ // appease borrowck
+ let callback = cx.callback.take();
+
+ view.ensure_cursor_in_view();
+
+ EventResult::Consumed(callback)
+ } else {
+ EventResult::Ignored
+ }
+ }
+ Event::Mouse(_) => EventResult::Ignored,
+ }
+ }
+
+ fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ // SAFETY: we cheat around the view_mut() borrow because it doesn't allow us to also borrow
+ // theme. Theme is immutable mutating view won't disrupt theme_ref.
+ let theme_ref = unsafe { &*(&cx.editor.theme as *const Theme) };
+ if let Some(view) = cx.editor.view_mut() {
+ self.render_view(view, area, surface, theme_ref);
+ }
+
+ // TODO: drop unwrap
+ }
+
+ fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option<Position> {
+ // match view.doc.mode() {
+ // Mode::Insert => write!(stdout, "\x1B[6 q"),
+ // mode => write!(stdout, "\x1B[2 q"),
+ // };
+ let view = ctx.editor.view().unwrap();
+ let cursor = view.doc.state.selection().cursor();
+
+ let mut pos = view
+ .screen_coords_at_pos(&view.doc.text().slice(..), cursor)
+ .expect("Cursor is out of bounds.");
+ pos.col += area.x as usize + OFFSET as usize;
+ pos.row += area.y as usize;
+ Some(pos)
+ }
+}
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
new file mode 100644
index 00000000..bc79e09c
--- /dev/null
+++ b/helix-term/src/ui/mod.rs
@@ -0,0 +1,14 @@
+mod editor;
+mod prompt;
+
+pub use editor::EditorView;
+pub use prompt::Prompt;
+
+pub use tui::layout::Rect;
+pub use tui::style::{Color, Modifier, Style};
+
+// TODO: temp
+#[inline(always)]
+pub fn text_color() -> Style {
+ Style::default().fg(Color::Rgb(219, 191, 239)) // lilac
+}
diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs
new file mode 100644
index 00000000..ce00a129
--- /dev/null
+++ b/helix-term/src/ui/prompt.rs
@@ -0,0 +1,217 @@
+use crate::compositor::{Component, Compositor, Context, EventResult};
+use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
+use helix_core::Position;
+use helix_view::Editor;
+use helix_view::Theme;
+use std::string::String;
+
+pub struct Prompt {
+ pub prompt: String,
+ pub line: String,
+ pub cursor: usize,
+ pub completion: Vec<String>,
+ pub should_close: bool,
+ pub completion_selection_index: Option<usize>,
+ completion_fn: Box<dyn FnMut(&str) -> Vec<String>>,
+ callback_fn: Box<dyn FnMut(&mut Editor, &str)>,
+}
+
+impl Prompt {
+ pub fn new(
+ prompt: String,
+ mut completion_fn: impl FnMut(&str) -> Vec<String> + 'static,
+ callback_fn: impl FnMut(&mut Editor, &str) + 'static,
+ ) -> Prompt {
+ Prompt {
+ prompt,
+ line: String::new(),
+ cursor: 0,
+ completion: completion_fn(""),
+ should_close: false,
+ completion_selection_index: None,
+ completion_fn: Box::new(completion_fn),
+ callback_fn: Box::new(callback_fn),
+ }
+ }
+
+ pub fn insert_char(&mut self, c: char) {
+ self.line.insert(self.cursor, c);
+ self.cursor += 1;
+ self.completion = (self.completion_fn)(&self.line);
+ self.exit_selection();
+ }
+
+ pub fn move_char_left(&mut self) {
+ if self.cursor > 0 {
+ self.cursor -= 1;
+ }
+ }
+
+ pub fn move_char_right(&mut self) {
+ if self.cursor < self.line.len() {
+ self.cursor += 1;
+ }
+ }
+
+ pub fn move_start(&mut self) {
+ self.cursor = 0;
+ }
+
+ pub fn move_end(&mut self) {
+ self.cursor = self.line.len();
+ }
+
+ pub fn delete_char_backwards(&mut self) {
+ if self.cursor > 0 {
+ self.line.remove(self.cursor - 1);
+ self.cursor -= 1;
+ self.completion = (self.completion_fn)(&self.line);
+ }
+ self.exit_selection();
+ }
+
+ pub fn change_completion_selection(&mut self) {
+ if self.completion.is_empty() {
+ return;
+ }
+ let index =
+ self.completion_selection_index.map(|i| i + 1).unwrap_or(0) % self.completion.len();
+ self.completion_selection_index = Some(index);
+ self.line = self.completion[index].clone();
+ }
+ pub fn exit_selection(&mut self) {
+ self.completion_selection_index = None;
+ }
+}
+
+use tui::{
+ buffer::Buffer as Surface,
+ layout::Rect,
+ style::{Color, Modifier, Style},
+};
+
+const BASE_WIDTH: u16 = 30;
+use crate::ui::text_color;
+
+impl Prompt {
+ pub fn render_prompt(&self, area: Rect, surface: &mut Surface, theme: &Theme) {
+ let text_color = text_color();
+ // completion
+ if !self.completion.is_empty() {
+ // TODO: find out better way of clearing individual lines of the screen
+ let mut row = 0;
+ let mut col = 0;
+ let max_col = area.width / BASE_WIDTH;
+ let col_height = ((self.completion.len() as u16 + max_col - 1) / max_col);
+
+ for i in (3..col_height + 3) {
+ surface.set_string(
+ 0,
+ area.height - i as u16,
+ " ".repeat(area.width as usize),
+ text_color,
+ );
+ }
+ surface.set_style(
+ Rect::new(0, area.height - col_height - 2, area.width, col_height),
+ theme.get("ui.statusline"),
+ );
+ for (i, command) in self.completion.iter().enumerate() {
+ let color = if self.completion_selection_index.is_some()
+ && i == self.completion_selection_index.unwrap()
+ {
+ Style::default().bg(Color::Rgb(104, 060, 232))
+ } else {
+ text_color
+ };
+ surface.set_stringn(
+ 1 + col * BASE_WIDTH,
+ area.height - col_height - 2 + row,
+ &command,
+ BASE_WIDTH as usize - 1,
+ color,
+ );
+ row += 1;
+ if row > col_height - 1 {
+ row = 0;
+ col += 1;
+ }
+ if col > max_col {
+ break;
+ }
+ }
+ }
+ // render buffer text
+ surface.set_string(1, area.height - 1, &self.prompt, text_color);
+ surface.set_string(2, area.height - 1, &self.line, text_color);
+ }
+}
+
+impl Component for Prompt {
+ fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
+ let event = match event {
+ Event::Key(event) => event,
+ _ => return EventResult::Ignored,
+ };
+
+ match event {
+ KeyEvent {
+ code: KeyCode::Char(c),
+ modifiers: KeyModifiers::NONE,
+ } => self.insert_char(c),
+ KeyEvent {
+ code: KeyCode::Esc, ..
+ } => {
+ return EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| {
+ // remove the layer
+ compositor.pop();
+ })));
+ }
+ KeyEvent {
+ code: KeyCode::Right,
+ ..
+ } => self.move_char_right(),
+ KeyEvent {
+ code: KeyCode::Left,
+ ..
+ } => self.move_char_left(),
+ KeyEvent {
+ code: KeyCode::Char('e'),
+ modifiers: KeyModifiers::CONTROL,
+ } => self.move_end(),
+ KeyEvent {
+ code: KeyCode::Char('a'),
+ modifiers: KeyModifiers::CONTROL,
+ } => self.move_start(),
+ KeyEvent {
+ code: KeyCode::Backspace,
+ modifiers: KeyModifiers::NONE,
+ } => self.delete_char_backwards(),
+ KeyEvent {
+ code: KeyCode::Enter,
+ ..
+ } => (self.callback_fn)(cx.editor, &self.line),
+ KeyEvent {
+ code: KeyCode::Tab, ..
+ } => self.change_completion_selection(),
+ KeyEvent {
+ code: KeyCode::Char('q'),
+ modifiers: KeyModifiers::CONTROL,
+ } => self.exit_selection(),
+ _ => (),
+ };
+
+ EventResult::Consumed(None)
+ }
+
+ fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ self.render_prompt(area, surface, &cx.editor.theme)
+ }
+
+ fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option<Position> {
+ Some(Position::new(
+ area.height as usize - 1,
+ area.x as usize + 2 + self.cursor,
+ ))
+ }
+}