terminal_settings.rs

  1use alacritty_terminal::vte::ansi::{
  2    CursorShape as AlacCursorShape, CursorStyle as AlacCursorStyle,
  3};
  4use collections::HashMap;
  5use gpui::{AbsoluteLength, App, FontFallbacks, FontFeatures, FontWeight, Pixels, px};
  6use schemars::JsonSchema;
  7use serde_derive::{Deserialize, Serialize};
  8
  9use settings::SettingsSources;
 10use std::path::PathBuf;
 11use task::Shell;
 12use theme::FontFamilyName;
 13
 14#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 15#[serde(rename_all = "snake_case")]
 16pub enum TerminalDockPosition {
 17    Left,
 18    Bottom,
 19    Right,
 20}
 21
 22#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 23pub struct Toolbar {
 24    pub breadcrumbs: bool,
 25}
 26
 27#[derive(Clone, Debug, Deserialize)]
 28pub struct TerminalSettings {
 29    pub shell: Shell,
 30    pub working_directory: WorkingDirectory,
 31    pub font_size: Option<Pixels>,
 32    pub font_family: Option<FontFamilyName>,
 33    pub font_fallbacks: Option<FontFallbacks>,
 34    pub font_features: Option<FontFeatures>,
 35    pub font_weight: Option<FontWeight>,
 36    pub line_height: TerminalLineHeight,
 37    pub env: HashMap<String, String>,
 38    pub cursor_shape: Option<CursorShape>,
 39    pub blinking: TerminalBlink,
 40    pub alternate_scroll: AlternateScroll,
 41    pub option_as_meta: bool,
 42    pub copy_on_select: bool,
 43    pub keep_selection_on_copy: bool,
 44    pub button: bool,
 45    pub dock: TerminalDockPosition,
 46    pub default_width: Pixels,
 47    pub default_height: Pixels,
 48    pub detect_venv: VenvSettings,
 49    pub max_scroll_history_lines: Option<usize>,
 50    pub toolbar: Toolbar,
 51    pub scrollbar: ScrollbarSettings,
 52    pub minimum_contrast: f32,
 53}
 54
 55#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 56pub struct ScrollbarSettings {
 57    /// When to show the scrollbar in the terminal.
 58    ///
 59    /// Default: inherits editor scrollbar settings
 60    pub show: Option<ShowScrollbar>,
 61}
 62
 63#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 64pub struct ScrollbarSettingsContent {
 65    /// When to show the scrollbar in the terminal.
 66    ///
 67    /// Default: inherits editor scrollbar settings
 68    pub show: Option<Option<ShowScrollbar>>,
 69}
 70
 71/// When to show the scrollbar in the terminal.
 72///
 73/// Default: auto
 74#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 75#[serde(rename_all = "snake_case")]
 76pub enum ShowScrollbar {
 77    /// Show the scrollbar if there's important information or
 78    /// follow the system's configured behavior.
 79    Auto,
 80    /// Match the system's configured behavior.
 81    System,
 82    /// Always show the scrollbar.
 83    Always,
 84    /// Never show the scrollbar.
 85    Never,
 86}
 87
 88#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 89#[serde(rename_all = "snake_case")]
 90pub enum VenvSettings {
 91    #[default]
 92    Off,
 93    On {
 94        /// Default directories to search for virtual environments, relative
 95        /// to the current working directory. We recommend overriding this
 96        /// in your project's settings, rather than globally.
 97        activate_script: Option<ActivateScript>,
 98        venv_name: Option<String>,
 99        directories: Option<Vec<PathBuf>>,
100    },
101}
102
103pub struct VenvSettingsContent<'a> {
104    pub activate_script: ActivateScript,
105    pub venv_name: &'a str,
106    pub directories: &'a [PathBuf],
107}
108
109impl VenvSettings {
110    pub fn as_option(&self) -> Option<VenvSettingsContent<'_>> {
111        match self {
112            VenvSettings::Off => None,
113            VenvSettings::On {
114                activate_script,
115                venv_name,
116                directories,
117            } => Some(VenvSettingsContent {
118                activate_script: activate_script.unwrap_or(ActivateScript::Default),
119                venv_name: venv_name.as_deref().unwrap_or(""),
120                directories: directories.as_deref().unwrap_or(&[]),
121            }),
122        }
123    }
124}
125
126#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
127#[serde(rename_all = "snake_case")]
128pub enum ActivateScript {
129    #[default]
130    Default,
131    Csh,
132    Fish,
133    Nushell,
134    PowerShell,
135    Pyenv,
136}
137
138impl ActivateScript {
139    pub fn by_shell(shell: &str) -> Option<Self> {
140        Some(match shell {
141            "fish" => ActivateScript::Fish,
142            "tcsh" => ActivateScript::Csh,
143            "nu" => ActivateScript::Nushell,
144            "powershell" | "pwsh" => ActivateScript::PowerShell,
145            "sh" => ActivateScript::Default,
146            _ => return None,
147        })
148    }
149
150    pub fn by_env() -> Option<Self> {
151        let shell = std::env::var("SHELL").ok()?;
152        let shell = std::path::Path::new(&shell)
153            .file_name()
154            .and_then(|name| name.to_str())?;
155        Self::by_shell(shell)
156    }
157}
158
159#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
160pub struct TerminalSettingsContent {
161    /// What shell to use when opening a terminal.
162    ///
163    /// Default: system
164    pub shell: Option<Shell>,
165    /// What working directory to use when launching the terminal
166    ///
167    /// Default: current_project_directory
168    pub working_directory: Option<WorkingDirectory>,
169    /// Sets the terminal's font size.
170    ///
171    /// If this option is not included,
172    /// the terminal will default to matching the buffer's font size.
173    pub font_size: Option<f32>,
174    /// Sets the terminal's font family.
175    ///
176    /// If this option is not included,
177    /// the terminal will default to matching the buffer's font family.
178    pub font_family: Option<FontFamilyName>,
179
180    /// Sets the terminal's font fallbacks.
181    ///
182    /// If this option is not included,
183    /// the terminal will default to matching the buffer's font fallbacks.
184    #[schemars(extend("uniqueItems" = true))]
185    pub font_fallbacks: Option<Vec<FontFamilyName>>,
186
187    /// Sets the terminal's line height.
188    ///
189    /// Default: comfortable
190    pub line_height: Option<TerminalLineHeight>,
191    pub font_features: Option<FontFeatures>,
192    /// Sets the terminal's font weight in CSS weight units 0-900.
193    pub font_weight: Option<f32>,
194    /// Any key-value pairs added to this list will be added to the terminal's
195    /// environment. Use `:` to separate multiple values.
196    ///
197    /// Default: {}
198    pub env: Option<HashMap<String, String>>,
199    /// Default cursor shape for the terminal.
200    /// Can be "bar", "block", "underline", or "hollow".
201    ///
202    /// Default: None
203    pub cursor_shape: Option<CursorShape>,
204    /// Sets the cursor blinking behavior in the terminal.
205    ///
206    /// Default: terminal_controlled
207    pub blinking: Option<TerminalBlink>,
208    /// Sets whether Alternate Scroll mode (code: ?1007) is active by default.
209    /// Alternate Scroll mode converts mouse scroll events into up / down key
210    /// presses when in the alternate screen (e.g. when running applications
211    /// like vim or  less). The terminal can still set and unset this mode.
212    ///
213    /// Default: on
214    pub alternate_scroll: Option<AlternateScroll>,
215    /// Sets whether the option key behaves as the meta key.
216    ///
217    /// Default: false
218    pub option_as_meta: Option<bool>,
219    /// Whether or not selecting text in the terminal will automatically
220    /// copy to the system clipboard.
221    ///
222    /// Default: false
223    pub copy_on_select: Option<bool>,
224    /// Whether to keep the text selection after copying it to the clipboard.
225    ///
226    /// Default: false
227    pub keep_selection_on_copy: Option<bool>,
228    /// Whether to show the terminal button in the status bar.
229    ///
230    /// Default: true
231    pub button: Option<bool>,
232    pub dock: Option<TerminalDockPosition>,
233    /// Default width when the terminal is docked to the left or right.
234    ///
235    /// Default: 640
236    pub default_width: Option<f32>,
237    /// Default height when the terminal is docked to the bottom.
238    ///
239    /// Default: 320
240    pub default_height: Option<f32>,
241    /// Activates the python virtual environment, if one is found, in the
242    /// terminal's working directory (as resolved by the working_directory
243    /// setting). Set this to "off" to disable this behavior.
244    ///
245    /// Default: on
246    pub detect_venv: Option<VenvSettings>,
247    /// The maximum number of lines to keep in the scrollback history.
248    /// Maximum allowed value is 100_000, all values above that will be treated as 100_000.
249    /// 0 disables the scrolling.
250    /// Existing terminals will not pick up this change until they are recreated.
251    /// See <a href="https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213">Alacritty documentation</a> for more information.
252    ///
253    /// Default: 10_000
254    pub max_scroll_history_lines: Option<usize>,
255    /// Toolbar related settings
256    pub toolbar: Option<ToolbarContent>,
257    /// Scrollbar-related settings
258    pub scrollbar: Option<ScrollbarSettingsContent>,
259    /// The minimum APCA perceptual contrast between foreground and background colors.
260    ///
261    /// APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x,
262    /// especially for dark mode. Values range from 0 to 106.
263    ///
264    /// Based on APCA Readability Criterion (ARC) Bronze Simple Mode:
265    /// https://readtech.org/ARC/tests/bronze-simple-mode/
266    /// - 0: No contrast adjustment
267    /// - 45: Minimum for large fluent text (36px+)
268    /// - 60: Minimum for other content text
269    /// - 75: Minimum for body text
270    /// - 90: Preferred for body text
271    ///
272    /// Default: 45
273    pub minimum_contrast: Option<f32>,
274}
275
276impl settings::Settings for TerminalSettings {
277    const KEY: Option<&'static str> = Some("terminal");
278
279    type FileContent = TerminalSettingsContent;
280
281    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
282        let settings: Self = sources.json_merge()?;
283
284        // Validate minimum_contrast for APCA
285        if settings.minimum_contrast < 0.0 || settings.minimum_contrast > 106.0 {
286            anyhow::bail!(
287                "terminal.minimum_contrast must be between 0 and 106, but got {}. \
288                APCA values: 0 = no adjustment, 75 = recommended for body text, 106 = maximum contrast.",
289                settings.minimum_contrast
290            );
291        }
292
293        Ok(settings)
294    }
295
296    fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
297        let name = |s| format!("terminal.integrated.{s}");
298
299        vscode.f32_setting(&name("fontSize"), &mut current.font_size);
300        if let Some(font_family) = vscode.read_string(&name("fontFamily")) {
301            current.font_family = Some(FontFamilyName(font_family.into()));
302        }
303        vscode.bool_setting(&name("copyOnSelection"), &mut current.copy_on_select);
304        vscode.bool_setting("macOptionIsMeta", &mut current.option_as_meta);
305        vscode.usize_setting("scrollback", &mut current.max_scroll_history_lines);
306        match vscode.read_bool(&name("cursorBlinking")) {
307            Some(true) => current.blinking = Some(TerminalBlink::On),
308            Some(false) => current.blinking = Some(TerminalBlink::Off),
309            None => {}
310        }
311        vscode.enum_setting(
312            &name("cursorStyle"),
313            &mut current.cursor_shape,
314            |s| match s {
315                "block" => Some(CursorShape::Block),
316                "line" => Some(CursorShape::Bar),
317                "underline" => Some(CursorShape::Underline),
318                _ => None,
319            },
320        );
321        // they also have "none" and "outline" as options but just for the "Inactive" variant
322        if let Some(height) = vscode
323            .read_value(&name("lineHeight"))
324            .and_then(|v| v.as_f64())
325        {
326            current.line_height = Some(TerminalLineHeight::Custom(height as f32))
327        }
328
329        #[cfg(target_os = "windows")]
330        let platform = "windows";
331        #[cfg(target_os = "linux")]
332        let platform = "linux";
333        #[cfg(target_os = "macos")]
334        let platform = "osx";
335        #[cfg(target_os = "freebsd")]
336        let platform = "freebsd";
337
338        // TODO: handle arguments
339        let shell_name = format!("{platform}Exec");
340        if let Some(s) = vscode.read_string(&name(&shell_name)) {
341            current.shell = Some(Shell::Program(s.to_owned()))
342        }
343
344        if let Some(env) = vscode
345            .read_value(&name(&format!("env.{platform}")))
346            .and_then(|v| v.as_object())
347        {
348            for (k, v) in env {
349                if v.is_null() {
350                    if let Some(zed_env) = current.env.as_mut() {
351                        zed_env.remove(k);
352                    }
353                }
354                let Some(v) = v.as_str() else { continue };
355                if let Some(zed_env) = current.env.as_mut() {
356                    zed_env.insert(k.clone(), v.to_owned());
357                } else {
358                    current.env = Some([(k.clone(), v.to_owned())].into_iter().collect())
359                }
360            }
361        }
362    }
363}
364
365#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)]
366#[serde(rename_all = "snake_case")]
367pub enum TerminalLineHeight {
368    /// Use a line height that's comfortable for reading, 1.618
369    #[default]
370    Comfortable,
371    /// Use a standard line height, 1.3. This option is useful for TUIs,
372    /// particularly if they use box characters
373    Standard,
374    /// Use a custom line height.
375    Custom(f32),
376}
377
378impl TerminalLineHeight {
379    pub fn value(&self) -> AbsoluteLength {
380        let value = match self {
381            TerminalLineHeight::Comfortable => 1.618,
382            TerminalLineHeight::Standard => 1.3,
383            TerminalLineHeight::Custom(line_height) => f32::max(*line_height, 1.),
384        };
385        px(value).into()
386    }
387}
388
389#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
390#[serde(rename_all = "snake_case")]
391pub enum TerminalBlink {
392    /// Never blink the cursor, ignoring the terminal mode.
393    Off,
394    /// Default the cursor blink to off, but allow the terminal to
395    /// set blinking.
396    TerminalControlled,
397    /// Always blink the cursor, ignoring the terminal mode.
398    On,
399}
400
401#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
402#[serde(rename_all = "snake_case")]
403pub enum AlternateScroll {
404    On,
405    Off,
406}
407
408#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
409#[serde(rename_all = "snake_case")]
410pub enum WorkingDirectory {
411    /// Use the current file's project directory.  Will Fallback to the
412    /// first project directory strategy if unsuccessful.
413    CurrentProjectDirectory,
414    /// Use the first project in this workspace's directory.
415    FirstProjectDirectory,
416    /// Always use this platform's home directory (if it can be found).
417    AlwaysHome,
418    /// Always use a specific directory. This value will be shell expanded.
419    /// If this path is not a valid directory the terminal will default to
420    /// this platform's home directory  (if it can be found).
421    Always { directory: String },
422}
423
424// Toolbar related settings
425#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
426pub struct ToolbarContent {
427    /// Whether to display the terminal title in breadcrumbs inside the terminal pane.
428    /// Only shown if the terminal title is not empty.
429    ///
430    /// The shell running in the terminal needs to be configured to emit the title.
431    /// Example: `echo -e "\e]2;New Title\007";`
432    ///
433    /// Default: true
434    pub breadcrumbs: Option<bool>,
435}
436
437#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
438#[serde(rename_all = "snake_case")]
439pub enum CursorShape {
440    /// Cursor is a block like `█`.
441    #[default]
442    Block,
443    /// Cursor is an underscore like `_`.
444    Underline,
445    /// Cursor is a vertical bar like `⎸`.
446    Bar,
447    /// Cursor is a hollow box like `▯`.
448    Hollow,
449}
450
451impl From<CursorShape> for AlacCursorShape {
452    fn from(value: CursorShape) -> Self {
453        match value {
454            CursorShape::Block => AlacCursorShape::Block,
455            CursorShape::Underline => AlacCursorShape::Underline,
456            CursorShape::Bar => AlacCursorShape::Beam,
457            CursorShape::Hollow => AlacCursorShape::HollowBlock,
458        }
459    }
460}
461
462impl From<CursorShape> for AlacCursorStyle {
463    fn from(value: CursorShape) -> Self {
464        AlacCursorStyle {
465            shape: value.into(),
466            blinking: false,
467        }
468    }
469}