Add wrap guides (#2767)

Mikayla Maki created

fixes https://github.com/zed-industries/community/issues/48

Release notes
- Added wrap guides and two associated language settings:
`"show_wrap_guides": bool` and `"wrap_guides": [..]`. The first controls
whether wrap guides are shown when `"soft_wrap":
"preferred_line_length"` is enabled and the second allows Zed to show
additional wrap guides at whichever column index you prefer.

Here's a screenshot of Zed with wrap guides at 60 and 90, and soft wrap
active with a preferred_line_length of 80:

<img width="956" alt="Screenshot 2023-07-20 at 4 42 11 PM"
src="https://github.com/zed-industries/zed/assets/2280405/48f36be1-3bdc-48eb-bfca-e61fcfd6dbc2">

Change summary

assets/settings/default.json             | 13 ++++----
crates/editor/src/editor.rs              | 14 +++++++++
crates/editor/src/element.rs             | 38 +++++++++++++++++++++++--
crates/language/src/language_settings.rs |  9 ++++++
crates/theme/src/theme.rs                |  2 +
styles/src/style_tree/editor.ts          |  2 +
6 files changed, 68 insertions(+), 10 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -50,6 +50,13 @@
   // Whether to pop the completions menu while typing in an editor without
   // explicitly requesting it.
   "show_completions_on_input": true,
+  // Whether to show wrap guides in the editor. Setting this to true will
+  // show a guide at the 'preferred_line_length' value if softwrap is set to
+  // 'preferred_line_length', and will show any additional guides as specified
+  // by the 'wrap_guides' setting.
+  "show_wrap_guides": true,
+  // Character counts at which to show wrap guides in the editor.
+  "wrap_guides": [],
   // Whether to use additional LSP queries to format (and amend) the code after
   // every "trigger" symbol input, defined by LSP server capabilities.
   "use_on_type_format": true,
@@ -356,12 +363,6 @@
   // LSP Specific settings.
   "lsp": {
     // Specify the LSP name as a key here.
-    // As of 8/10/22, supported LSPs are:
-    // pyright
-    // gopls
-    // rust-analyzer
-    // typescript-language-server
-    // vscode-json-languageserver
     // "rust-analyzer": {
     //     //These initialization options are merged into Zed's defaults
     //     "initialization_options": {

crates/editor/src/editor.rs 🔗

@@ -7086,6 +7086,20 @@ impl Editor {
             .text()
     }
 
+    pub fn wrap_guides(&self, cx: &AppContext) -> SmallVec<[(usize, bool); 2]> {
+        let mut wrap_guides = smallvec::smallvec![];
+
+        let settings = self.buffer.read(cx).settings_at(0, cx);
+        if settings.show_wrap_guides {
+            if let SoftWrap::Column(soft_wrap) = self.soft_wrap_mode(cx) {
+                wrap_guides.push((soft_wrap as usize, true));
+            }
+            wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false)))
+        }
+
+        wrap_guides
+    }
+
     pub fn soft_wrap_mode(&self, cx: &AppContext) -> SoftWrap {
         let settings = self.buffer.read(cx).settings_at(0, cx);
         let mode = self

crates/editor/src/element.rs 🔗

@@ -541,6 +541,24 @@ impl EditorElement {
                     corner_radius: 0.,
                 });
             }
+
+            for (wrap_position, active) in layout.wrap_guides.iter() {
+                let x = text_bounds.origin_x() + wrap_position + layout.position_map.em_width / 2.;
+                let color = if *active {
+                    self.style.active_wrap_guide
+                } else {
+                    self.style.wrap_guide
+                };
+                scene.push_quad(Quad {
+                    bounds: RectF::new(
+                        vec2f(x, text_bounds.origin_y()),
+                        vec2f(1., text_bounds.height()),
+                    ),
+                    background: Some(color),
+                    border: Border::new(0., Color::transparent_black()),
+                    corner_radius: 0.,
+                });
+            }
         }
     }
 
@@ -1320,16 +1338,15 @@ impl EditorElement {
         }
     }
 
-    fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &ViewContext<Editor>) -> f32 {
-        let digit_count = (snapshot.max_buffer_row() as f32 + 1.).log10().floor() as usize + 1;
+    fn column_pixels(&self, column: usize, cx: &ViewContext<Editor>) -> f32 {
         let style = &self.style;
 
         cx.text_layout_cache()
             .layout_str(
-                "1".repeat(digit_count).as_str(),
+                " ".repeat(column).as_str(),
                 style.text.font_size,
                 &[(
-                    digit_count,
+                    column,
                     RunStyle {
                         font_id: style.text.font_id,
                         color: Color::black(),
@@ -1340,6 +1357,11 @@ impl EditorElement {
             .width()
     }
 
+    fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &ViewContext<Editor>) -> f32 {
+        let digit_count = (snapshot.max_buffer_row() as f32 + 1.).log10().floor() as usize + 1;
+        self.column_pixels(digit_count, cx)
+    }
+
     //Folds contained in a hunk are ignored apart from shrinking visual size
     //If a fold contains any hunks then that fold line is marked as modified
     fn layout_git_gutters(
@@ -2025,6 +2047,12 @@ impl Element<Editor> for EditorElement {
             }
         };
 
+        let wrap_guides = editor
+            .wrap_guides(cx)
+            .iter()
+            .map(|(guide, active)| (self.column_pixels(*guide, cx), *active))
+            .collect();
+
         let scroll_height = (snapshot.max_point().row() + 1) as f32 * line_height;
         if let EditorMode::AutoHeight { max_lines } = snapshot.mode {
             size.set_y(
@@ -2385,6 +2413,7 @@ impl Element<Editor> for EditorElement {
                     snapshot,
                 }),
                 visible_display_row_range: start_row..end_row,
+                wrap_guides,
                 gutter_size,
                 gutter_padding,
                 text_size,
@@ -2535,6 +2564,7 @@ pub struct LayoutState {
     gutter_margin: f32,
     text_size: Vector2F,
     mode: EditorMode,
+    wrap_guides: SmallVec<[(f32, bool); 2]>,
     visible_display_row_range: Range<u32>,
     active_rows: BTreeMap<u32, bool>,
     highlighted_rows: Option<Range<u32>>,

crates/language/src/language_settings.rs 🔗

@@ -44,6 +44,8 @@ pub struct LanguageSettings {
     pub hard_tabs: bool,
     pub soft_wrap: SoftWrap,
     pub preferred_line_length: u32,
+    pub show_wrap_guides: bool,
+    pub wrap_guides: Vec<usize>,
     pub format_on_save: FormatOnSave,
     pub remove_trailing_whitespace_on_save: bool,
     pub ensure_final_newline_on_save: bool,
@@ -84,6 +86,10 @@ pub struct LanguageSettingsContent {
     #[serde(default)]
     pub preferred_line_length: Option<u32>,
     #[serde(default)]
+    pub show_wrap_guides: Option<bool>,
+    #[serde(default)]
+    pub wrap_guides: Option<Vec<usize>>,
+    #[serde(default)]
     pub format_on_save: Option<FormatOnSave>,
     #[serde(default)]
     pub remove_trailing_whitespace_on_save: Option<bool>,
@@ -378,6 +384,9 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
     merge(&mut settings.tab_size, src.tab_size);
     merge(&mut settings.hard_tabs, src.hard_tabs);
     merge(&mut settings.soft_wrap, src.soft_wrap);
+    merge(&mut settings.show_wrap_guides, src.show_wrap_guides);
+    merge(&mut settings.wrap_guides, src.wrap_guides.clone());
+
     merge(
         &mut settings.preferred_line_length,
         src.preferred_line_length,

crates/theme/src/theme.rs 🔗

@@ -691,6 +691,8 @@ pub struct Editor {
     pub document_highlight_read_background: Color,
     pub document_highlight_write_background: Color,
     pub diff: DiffStyle,
+    pub wrap_guide: Color,
+    pub active_wrap_guide: Color,
     pub line_number: Color,
     pub line_number_active: Color,
     pub guest_selections: Vec<SelectionStyle>,

styles/src/style_tree/editor.ts 🔗

@@ -170,6 +170,8 @@ export default function editor(): any {
         line_number: with_opacity(foreground(layer), 0.35),
         line_number_active: foreground(layer),
         rename_fade: 0.6,
+        wrap_guide: with_opacity(foreground(layer), 0.1),
+        active_wrap_guide: with_opacity(foreground(layer), 0.2),
         unnecessary_code_fade: 0.5,
         selection: theme.players[0],
         whitespace: theme.ramps.neutral(0.5).hex(),