diff --git a/book/src/editor.md b/book/src/editor.md index 7f1de68bb..26588845c 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -63,6 +63,7 @@ | `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable" | `clipboard-provider` | Which API to use for clipboard interaction. One of `pasteboard` (MacOS), `wayland`, `x-clip`, `x-sel`, `win-32-yank`, `termux`, `tmux`, `windows`, `termcode`, `none`, or a custom command set. | Platform and environment specific. | | `editor-config` | Whether to read settings from [EditorConfig](https://editorconfig.org) files | `true` | +| `welcome-screen` | Whether to enable the welcome screen | `true` | ### `[editor.clipboard-provider]` Section diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index cf09aac0d..6fd7b410b 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -215,11 +215,11 @@ impl Application { editor.new_file(Action::VerticalSplit); } } else if stdin().is_tty() || cfg!(feature = "integration") { - editor.new_file(Action::VerticalSplit); + editor.new_file_welcome(); } else { editor .new_file_from_stdin(Action::VerticalSplit) - .unwrap_or_else(|_| editor.new_file(Action::VerticalSplit)); + .unwrap_or_else(|_| editor.new_file_welcome()); } #[cfg(windows)] diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..788ab3d04 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -22,6 +22,7 @@ use helix_core::{ unicode::width::UnicodeWidthStr, visual_offset_from_block, Change, Position, Range, Selection, Transaction, }; +use helix_loader::VERSION_AND_GIT_HASH; use helix_view::{ annotations::diagnostics::DiagnosticFilter, document::{Mode, SCRATCH_BUFFER_NAME}, @@ -31,9 +32,12 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{mem::take, num::NonZeroUsize, ops, path::PathBuf, rc::Rc}; +use std::{mem::take, num::NonZeroUsize, ops, path::PathBuf, rc::Rc, sync::LazyLock}; -use tui::{buffer::Buffer as Surface, text::Span}; +use tui::{ + buffer::Buffer as Surface, + text::{Span, Spans}, +}; pub struct EditorView { pub keymaps: Keymaps, @@ -74,6 +78,261 @@ impl EditorView { &mut self.spinners } + pub fn render_welcome(theme: &Theme, view: &View, surface: &mut Surface, is_colorful: bool) { + /// Logo for Helix + const LOGO_STR: &str = "\ +** +***** :: + ******** ::::: + **::::::: + ::::::::***= +::::::: ==== +:::: ======= +:---======== + =======-- +===== -------- +== ----- + --"; + + /// Size of the maximum line of the logo + static LOGO_WIDTH: LazyLock = LazyLock::new(|| { + LOGO_STR + .lines() + .max_by(|line, other| line.len().cmp(&other.len())) + .unwrap_or("") + .len() as u16 + }); + + /// Use when true color is not supported + static LOGO_NO_COLOR: LazyLock> = LazyLock::new(|| { + LOGO_STR + .lines() + .map(|line| Spans(vec![Span::raw(line)])) + .collect() + }); + + /// The logo is colored using Helix's colors + static LOGO_WITH_COLOR: LazyLock> = LazyLock::new(|| { + LOGO_STR + .lines() + .map(|line| { + line.chars() + .map(|ch| match ch { + '*' | ':' | '=' | '-' => Span::styled( + ch.to_string(), + Style::new().fg(match ch { + // Dark purple + '*' => Color::Rgb(112, 107, 200), + // Dark blue + ':' => Color::Rgb(132, 221, 234), + // Bright purple + '=' => Color::Rgb(153, 123, 200), + // Bright blue + '-' => Color::Rgb(85, 197, 228), + _ => unreachable!(), + }), + ), + ' ' => Span::raw(" "), + _ => unreachable!("logo should only contain '*', ':', '=', '-' or ' '"), + }) + .collect() + }) + .collect() + }); + + /// How much space to put between the help text and the logo + const LOGO_LEFT_PADDING: u16 = 6; + + // Shift the help text to the right by this amount, to add space + // for the logo + static HELP_X_LOGO_OFFSET: LazyLock = + LazyLock::new(|| *LOGO_WIDTH / 2 + LOGO_LEFT_PADDING / 2); + + #[derive(PartialEq, PartialOrd, Eq, Ord)] + enum AlignLine { + Left, + Center, + } + use AlignLine::*; + + let logo = if is_colorful { + &LOGO_WITH_COLOR + } else { + &LOGO_NO_COLOR + }; + + let empty_line = || (Spans::from(""), Left); + + let raw_help_lines: [(Spans, AlignLine); 12] = [ + ( + vec![ + Span::raw("helix "), + Span::styled(VERSION_AND_GIT_HASH, theme.get("comment")), + ] + .into(), + Center, + ), + empty_line(), + ( + Span::styled( + "A post-modern modal text editor", + theme.get("ui.text").add_modifier(Modifier::ITALIC), + ) + .into(), + Center, + ), + empty_line(), + ( + vec![ + Span::styled(":tutor", theme.get("markup.raw")), + Span::styled("", theme.get("comment")), + Span::raw(" learn helix"), + ] + .into(), + Left, + ), + ( + vec![ + Span::styled(":theme", theme.get("markup.raw")), + Span::styled("", theme.get("comment")), + Span::raw(" choose a theme"), + ] + .into(), + Left, + ), + ( + vec![ + Span::styled("e", theme.get("markup.raw")), + Span::raw(" file explorer"), + ] + .into(), + Left, + ), + ( + vec![ + Span::styled("?", theme.get("markup.raw")), + Span::raw(" see all commands"), + ] + .into(), + Left, + ), + ( + vec![ + Span::styled(":quit", theme.get("markup.raw")), + Span::styled("", theme.get("comment")), + Span::raw(" quit helix"), + ] + .into(), + Left, + ), + empty_line(), + ( + vec![ + Span::styled("docs: ", theme.get("ui.text")), + Span::styled("docs.helix-editor.com", theme.get("markup.link.url")), + ] + .into(), + Center, + ), + empty_line(), + ]; + + debug_assert!( + raw_help_lines.len() >= LOGO_STR.lines().count(), + "help lines get chained with lines of logo. if there are not \ + enough help lines, logo will be cut off. add `empty_line()`s if necessary" + ); + + let mut help_lines = Vec::with_capacity(raw_help_lines.len()); + let mut len_of_longest_left_align = 0; + let mut len_of_longest_center_align = 0; + + for (spans, align) in raw_help_lines { + let width = spans.width(); + match align { + Left => len_of_longest_left_align = len_of_longest_left_align.max(width), + Center => len_of_longest_center_align = len_of_longest_center_align.max(width), + } + help_lines.push((spans, align)); + } + + let len_of_longest_left_align = len_of_longest_left_align as u16; + + // the y-coordinate where we start drawing the welcome screen + let start_drawing_at_y = + view.area.y + (view.area.height / 2).saturating_sub(help_lines.len() as u16 / 2); + + // x-coordinate of the center of the viewport + let x_view_center = view.area.x + view.area.width / 2; + + // the x-coordinate where we start drawing the `AlignLine::Left` lines + // +2 to make the text look like more balanced relative to the center of the help + let start_drawing_left_align_at_x = + view.area.x + (view.area.width / 2).saturating_sub(len_of_longest_left_align / 2) + 2; + + let are_any_left_aligned_lines_overflowing_x = + (start_drawing_left_align_at_x + len_of_longest_left_align) > view.area.width; + + let are_any_center_aligned_lines_overflowing_x = + len_of_longest_center_align as u16 > view.area.width; + + let is_help_x_overflowing = + are_any_left_aligned_lines_overflowing_x || are_any_center_aligned_lines_overflowing_x; + + // we want `>=` so it does not get drawn over the status line + // (essentially, it WON'T be marked as "overflowing" if the help + // fully fits vertically in the viewport without touching the status line) + let is_help_y_overflowing = (help_lines.len() as u16) >= view.area.height; + + // Not enough space to render the help text even without the logo. Render nothing. + if is_help_x_overflowing || is_help_y_overflowing { + return; + } + + // At this point we know that there is enough vertical + // and horizontal space to render the help text + + let width_of_help_with_logo = *LOGO_WIDTH + LOGO_LEFT_PADDING + len_of_longest_left_align; + + // If there is not enough space to show LOGO + HELP, then don't show the logo at all + // + // If we get here we know that there IS enough space to show just the help + let show_logo = width_of_help_with_logo <= view.area.width; + + // Each "help" line is effectively "chained" with a line of the logo (if present). + for (lines_drawn, (line, align)) in help_lines.iter().enumerate() { + // Where to start drawing `AlignLine::Left` rows + let x_start_left_help = + start_drawing_left_align_at_x + if show_logo { *HELP_X_LOGO_OFFSET } else { 0 }; + + // Where to start drawing `AlignLine::Center` rows + let x_start_center_help = x_view_center - line.width() as u16 / 2 + + if show_logo { *HELP_X_LOGO_OFFSET } else { 0 }; + + // Where to start drawing rows for the "help" section + // Includes tips about commands. Excludes the logo. + let x_start_help = match align { + Left => x_start_left_help, + Center => x_start_center_help, + }; + + let y = start_drawing_at_y + lines_drawn as u16; + + // Draw a single line of the help text + surface.set_spans(x_start_help, y, line, line.width() as u16); + + if show_logo { + // Draw a single line of the logo + surface.set_spans( + x_start_left_help - LOGO_LEFT_PADDING - *LOGO_WIDTH, + y, + &logo[lines_drawn], + *LOGO_WIDTH, + ); + } + } + } + pub fn render_view( &self, editor: &Editor, @@ -161,6 +420,15 @@ impl EditorView { Self::render_rulers(editor, doc, view, inner, surface, theme); + if config.welcome_screen && doc.version() == 0 && doc.is_welcome { + Self::render_welcome( + theme, + view, + surface, + config.true_color || crate::true_color(), + ); + } + let primary_cursor = doc .selection(view.id) .primary() diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index 1317a0095..ef98f12f1 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -249,6 +249,12 @@ impl<'a> From> for Spans<'a> { } } +impl<'a> FromIterator> for Spans<'a> { + fn from_iter>>(iter: T) -> Self { + Spans(iter.into_iter().collect()) + } +} + impl<'a> From>> for Spans<'a> { fn from(spans: Vec>) -> Spans<'a> { Spans(spans) diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 04b7703c5..4abcf5a43 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -210,6 +210,8 @@ pub struct Document { // large refactor that would make `&mut Editor` available on the `DocumentDidChange` event. pub color_swatch_controller: TaskController, + /// Whether to render the welcome screen when opening the document + pub is_welcome: bool, // NOTE: this field should eventually go away - we should use the Editor's syn_loader instead // of storing a copy on every doc. Then we can remove the surrounding `Arc` and use the // `ArcSwap` directly. @@ -727,6 +729,7 @@ impl Document { jump_labels: HashMap::new(), color_swatches: None, color_swatch_controller: TaskController::new(), + is_welcome: false, syn_loader, } } @@ -740,6 +743,11 @@ impl Document { Self::from(text, None, config, syn_loader) } + pub fn with_welcome(mut self) -> Self { + self.is_welcome = true; + self + } + // TODO: async fn? /// Create a new document from `path`. Encoding is auto-detected, but it can be manually /// overwritten with the `encoding` parameter. diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 57e130881..1c120ce05 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -249,6 +249,8 @@ where #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { + /// Whether to enable the welcome screen + pub welcome_screen: bool, /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. pub scrolloff: usize, /// Number of lines to scroll at once. Defaults to 3 @@ -996,6 +998,7 @@ impl Default for WordCompletion { impl Default for Config { fn default() -> Self { Self { + welcome_screen: true, scrolloff: 5, scroll_lines: 3, mouse: true, @@ -1784,6 +1787,14 @@ impl Editor { ) } + /// Use when Helix is opened with no arguments passed + pub fn new_file_welcome(&mut self) -> DocumentId { + self.new_file_from_document( + Action::VerticalSplit, + Document::default(self.config.clone(), self.syn_loader.clone()).with_welcome(), + ) + } + pub fn new_file_from_stdin(&mut self, action: Action) -> Result { let (stdin, encoding, has_bom) = crate::document::read_to_string(&mut stdin(), None)?; let doc = Document::from(