summaryrefslogtreecommitdiff
path: root/helix-term/src/application.rs
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term/src/application.rs')
-rw-r--r--helix-term/src/application.rs441
1 files changed, 441 insertions, 0 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs
new file mode 100644
index 00000000..d4756ef0
--- /dev/null
+++ b/helix-term/src/application.rs
@@ -0,0 +1,441 @@
+use clap::ArgMatches as Args;
+use helix_core::{indent::TAB_WIDTH, state::Mode, syntax::HighlightEvent, Position, Range, State};
+use helix_view::{commands, keymap, prompt::Prompt, Editor, View};
+
+use std::{
+ borrow::Cow,
+ io::{self, stdout, Stdout, Write},
+ path::PathBuf,
+ time::Duration,
+};
+
+use smol::prelude::*;
+
+use anyhow::Error;
+
+use crossterm::{
+ cursor,
+ cursor::position,
+ event::{self, read, Event, EventStream, KeyCode, KeyEvent},
+ execute, queue,
+ terminal::{self, disable_raw_mode, enable_raw_mode},
+};
+
+use tui::{
+ backend::CrosstermBackend,
+ buffer::Buffer as Surface,
+ layout::Rect,
+ style::{Color, Style},
+};
+
+const OFFSET: u16 = 6; // 5 linenr + 1 gutter
+
+type Terminal = tui::Terminal<CrosstermBackend<std::io::Stdout>>;
+
+static EX: smol::Executor = smol::Executor::new();
+
+pub struct Application {
+ editor: Editor,
+ prompt: Option<Prompt>,
+ should_close: bool,
+ terminal: Renderer,
+}
+
+struct Renderer {
+ size: (u16, u16),
+ terminal: Terminal,
+ surface: Surface,
+ cache: Surface,
+ text_color: Style,
+}
+
+impl Renderer {
+ pub fn new() -> 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) {
+ self.render_buffer(view, viewport);
+ self.render_statusline(view);
+ }
+
+ // TODO: ideally not &mut View but highlights require it because of cursor cache
+ pub fn render_buffer(&mut self, view: &mut View, viewport: Rect) {
+ 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, view.theme.get("ui.background"));
+
+ // TODO: inefficient, should feed chunks.iter() to tree_sitter.parse_with(|offset, pos|)
+ let source_code = view.state.doc().to_string();
+
+ let last_line = view.last_line();
+
+ let range = {
+ // calculate viewport byte ranges
+ let start = view.state.doc().line_to_byte(view.first_line);
+ let end = view.state.doc().line_to_byte(last_line)
+ + view.state.doc().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.state.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
+ .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.state.doc().byte_to_char(start);
+ let end = view.state.doc().byte_to_char(end); // <-- index 744, len 743
+
+ let text = view.state.doc().slice(start..end);
+
+ use helix_core::graphemes::{grapheme_width, RopeGraphemes};
+
+ let style = match spans.first() {
+ Some(span) => view.theme.get(view.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
+ };
+
+ // TODO: paint cursor heads except primary
+
+ self.surface
+ .set_string(OFFSET + visual_x, line, grapheme, style);
+
+ visual_x += width;
+ }
+ // if grapheme == "\t"
+
+ char_index += 1;
+ }
+ }
+ }
+ }
+ let style: Style = view.theme.get("ui.linenr");
+ let last_line = view.last_line();
+ for (i, line) in (view.first_line..last_line).enumerate() {
+ self.surface
+ .set_stringn(0, i as u16, format!("{:>5}", line + 1), 5, style);
+ }
+ }
+
+ pub fn render_statusline(&mut self, view: &View) {
+ let mode = match view.state.mode() {
+ Mode::Insert => "INS",
+ Mode::Normal => "NOR",
+ Mode::Goto => "GOTO",
+ Mode::Command => "COM", // command?
+ };
+ // statusline
+ self.surface.set_style(
+ Rect::new(0, self.size.1 - 2, self.size.0, 1),
+ view.theme.get("ui.statusline"),
+ );
+ self.surface
+ .set_string(1, self.size.1 - 2, mode, self.text_color);
+ }
+
+ pub fn render_prompt(&mut self, prompt: &Prompt) {
+ use tui::backend::Backend;
+ // render buffer text
+ self.surface
+ .set_string(1, self.size.1 - 1, String::from(":"), self.text_color);
+ self.surface
+ .set_string(2, self.size.1 - 1, &prompt.buffer, self.text_color);
+
+ // 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.state.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_loc)
+ } else {
+ if let Some(path) = view.state.path() {
+ self.surface.set_string(
+ 6,
+ self.size.1 - 1,
+ path.to_string_lossy(),
+ self.text_color,
+ );
+ }
+
+ let cursor = view.state.selection().cursor();
+
+ let mut pos = view
+ .screen_coords_at_pos(&view.state.doc().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 Application {
+ pub fn new(mut args: Args) -> Result<Self, Error> {
+ let terminal = Renderer::new()?;
+ let mut editor = Editor::new();
+
+ if let Some(file) = args.values_of_t::<PathBuf>("files").unwrap().pop() {
+ editor.open(file, terminal.size)?;
+ }
+
+ let mut app = Self {
+ editor,
+ terminal,
+ // TODO; move to state
+ prompt: None,
+ should_close: false,
+ };
+
+ Ok(app)
+ }
+
+ pub fn set_prompt(&mut self) {
+ // let commands = |input| match input {
+ // "q" => self.should_close = true,
+ // _ => (),
+ // };
+ // let prompt = Prompt::new(|input| None, commands);
+ // self.prompt = Some(prompt);
+ }
+
+ fn render(&mut self) {
+ let viewport = Rect::new(OFFSET, 0, self.terminal.size.0, self.terminal.size.1 - 2); // - 2 for statusline and prompt
+
+ if let Some(view) = &mut self.editor.view {
+ self.terminal.render_view(view, viewport);
+ }
+
+ if let Some(prompt) = &self.prompt {
+ self.terminal.render_prompt(prompt);
+ }
+
+ // TODO: drop unwrap
+ self.terminal.render_cursor(
+ self.editor.view.as_ref().unwrap(),
+ self.prompt.as_ref(),
+ viewport,
+ );
+ }
+
+ pub async fn event_loop(&mut self) {
+ let mut reader = EventStream::new();
+ let keymap = keymap::default();
+
+ self.set_prompt();
+ self.render();
+
+ loop {
+ // Handle key events
+ if self.should_close {
+ break;
+ }
+ let mut event = reader.next().await;
+ match event {
+ Some(Ok(Event::Resize(width, height))) => {
+ self.terminal.resize(width, height);
+
+ // TODO: simplistic ensure cursor in view for now
+ if let Some(view) = &mut self.editor.view {
+ view.size = self.terminal.size;
+ view.ensure_cursor_in_view()
+ };
+
+ self.render();
+ }
+ Some(Ok(Event::Key(event))) => {
+ // TODO: sequences (`gg`)
+ // TODO: handle count other than 1
+ if let Some(view) = &mut self.editor.view {
+ let keys = vec![event];
+ match view.state.mode() {
+ Mode::Insert => {
+ if let Some(command) = keymap[&Mode::Insert].get(&keys) {
+ command(view, 1);
+ } else if let KeyEvent {
+ code: KeyCode::Char(c),
+ ..
+ } = event
+ {
+ commands::insert::insert_char(view, c);
+ }
+ view.ensure_cursor_in_view();
+ }
+ Mode::Command => {
+ self.prompt.as_mut().unwrap().handle_input(event, view);
+ }
+ mode => {
+ if let Some(command) = keymap[&mode].get(&keys) {
+ command(view, 1);
+
+ // TODO: simplistic ensure cursor in view for now
+ view.ensure_cursor_in_view();
+ }
+ }
+ }
+ self.render();
+ }
+ }
+ Some(Ok(_)) => {
+ // unhandled event
+ }
+ Some(Err(x)) => panic!(x),
+ None => break,
+ }
+ }
+ }
+
+ pub async fn run(&mut self) -> Result<(), Error> {
+ enable_raw_mode()?;
+
+ let mut stdout = stdout();
+
+ execute!(stdout, terminal::EnterAlternateScreen)?;
+
+ // Exit the alternate screen and disable raw mode before panicking
+ let hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ execute!(std::io::stdout(), terminal::LeaveAlternateScreen);
+ disable_raw_mode();
+ hook(info);
+ }));
+
+ self.event_loop().await;
+
+ // reset cursor shape
+ write!(stdout, "\x1B[2 q");
+
+ execute!(stdout, terminal::LeaveAlternateScreen)?;
+
+ disable_raw_mode()?;
+
+ Ok(())
+ }
+}
+
+// TODO: language configs:
+// tabSize, fileExtension etc, mapping to tree sitter parser
+// themes:
+// map tree sitter highlights to color values
+//
+// TODO: expand highlight thing so we're able to render only viewport range
+// TODO: async: maybe pre-cache scopes as empty so we render all graphemes initially as regular
+////text until calc finishes
+// TODO: scope matching: biggest union match? [string] & [html, string], [string, html] & [ string, html]
+// can do this by sorting our theme matches based on array len (longest first) then stopping at the
+// first rule that matches (rule.all(|scope| scopes.contains(scope)))