ZIm/crates/onboarding/src/editing_page.rs
Peter Tripp ac9fdaa1da
onboarding: Improve Windows/Linux keyboard shortcuts; example ligature (#36712)
Small fixes to onboarding.
Correct ligature example.
Replace`ctrl-escape` and `alt-tab` since they are reserved on windows
(and often on linux) and so are caught by the OS.

Release Notes:

- N/A
2025-08-22 11:51:01 -04:00

765 lines
27 KiB
Rust

use std::sync::Arc;
use editor::{EditorSettings, ShowMinimap};
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Action, AnyElement, App, Context, FontFeatures, IntoElement, Pixels, SharedString, Task, Window,
};
use language::language_settings::{AllLanguageSettings, FormatOnSave};
use picker::{Picker, PickerDelegate};
use project::project_settings::ProjectSettings;
use settings::{Settings as _, update_settings_file};
use theme::{FontFamilyCache, FontFamilyName, ThemeSettings};
use ui::{
ButtonLike, ListItem, ListItemSpacing, NumericStepper, PopoverMenu, SwitchField,
ToggleButtonGroup, ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip,
prelude::*,
};
use crate::{ImportCursorSettings, ImportVsCodeSettings, SettingsImportState};
fn read_show_mini_map(cx: &App) -> ShowMinimap {
editor::EditorSettings::get_global(cx).minimap.show
}
fn write_show_mini_map(show: ShowMinimap, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
// This is used to speed up the UI
// the UI reads the current values to get what toggle state to show on buttons
// there's a slight delay if we just call update_settings_file so we manually set
// the value here then call update_settings file to get around the delay
let mut curr_settings = EditorSettings::get_global(cx).clone();
curr_settings.minimap.show = show;
EditorSettings::override_global(curr_settings, cx);
update_settings_file::<EditorSettings>(fs, cx, move |editor_settings, _| {
telemetry::event!(
"Welcome Minimap Clicked",
from = editor_settings.minimap.unwrap_or_default(),
to = show
);
editor_settings.minimap.get_or_insert_default().show = Some(show);
});
}
fn read_inlay_hints(cx: &App) -> bool {
AllLanguageSettings::get_global(cx)
.defaults
.inlay_hints
.enabled
}
fn write_inlay_hints(enabled: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
let mut curr_settings = AllLanguageSettings::get_global(cx).clone();
curr_settings.defaults.inlay_hints.enabled = enabled;
AllLanguageSettings::override_global(curr_settings, cx);
update_settings_file::<AllLanguageSettings>(fs, cx, move |all_language_settings, cx| {
all_language_settings
.defaults
.inlay_hints
.get_or_insert_with(|| {
AllLanguageSettings::get_global(cx)
.clone()
.defaults
.inlay_hints
})
.enabled = enabled;
});
}
fn read_git_blame(cx: &App) -> bool {
ProjectSettings::get_global(cx).git.inline_blame_enabled()
}
fn write_git_blame(enabled: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
let mut curr_settings = ProjectSettings::get_global(cx).clone();
curr_settings
.git
.inline_blame
.get_or_insert_default()
.enabled = enabled;
ProjectSettings::override_global(curr_settings, cx);
update_settings_file::<ProjectSettings>(fs, cx, move |project_settings, _| {
project_settings
.git
.inline_blame
.get_or_insert_default()
.enabled = enabled;
});
}
fn write_ui_font_family(font: SharedString, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| {
telemetry::event!(
"Welcome Font Changed",
type = "ui font",
old = theme_settings.ui_font_family,
new = font
);
theme_settings.ui_font_family = Some(FontFamilyName(font.into()));
});
}
fn write_ui_font_size(size: Pixels, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| {
theme_settings.ui_font_size = Some(size.into());
});
}
fn write_buffer_font_size(size: Pixels, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| {
theme_settings.buffer_font_size = Some(size.into());
});
}
fn write_buffer_font_family(font_family: SharedString, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| {
telemetry::event!(
"Welcome Font Changed",
type = "editor font",
old = theme_settings.buffer_font_family,
new = font_family
);
theme_settings.buffer_font_family = Some(FontFamilyName(font_family.into()));
});
}
fn read_font_ligatures(cx: &App) -> bool {
ThemeSettings::get_global(cx)
.buffer_font
.features
.is_calt_enabled()
.unwrap_or(true)
}
fn write_font_ligatures(enabled: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
let bit = if enabled { 1 } else { 0 };
update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| {
let mut features = theme_settings
.buffer_font_features
.as_mut()
.map(|features| features.tag_value_list().to_vec())
.unwrap_or_default();
if let Some(calt_index) = features.iter().position(|(tag, _)| tag == "calt") {
features[calt_index].1 = bit;
} else {
features.push(("calt".into(), bit));
}
theme_settings.buffer_font_features = Some(FontFeatures(Arc::new(features)));
});
}
fn read_format_on_save(cx: &App) -> bool {
match AllLanguageSettings::get_global(cx).defaults.format_on_save {
FormatOnSave::On | FormatOnSave::List(_) => true,
FormatOnSave::Off => false,
}
}
fn write_format_on_save(format_on_save: bool, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file::<AllLanguageSettings>(fs, cx, move |language_settings, _| {
language_settings.defaults.format_on_save = Some(match format_on_save {
true => FormatOnSave::On,
false => FormatOnSave::Off,
});
});
}
fn render_setting_import_button(
tab_index: isize,
label: SharedString,
icon_name: IconName,
action: &dyn Action,
imported: bool,
) -> impl IntoElement {
let action = action.boxed_clone();
h_flex().w_full().child(
ButtonLike::new(label.clone())
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Large)
.tab_index(tab_index)
.child(
h_flex()
.w_full()
.justify_between()
.child(
h_flex()
.gap_1p5()
.px_1()
.child(
Icon::new(icon_name)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(Label::new(label.clone())),
)
.when(imported, |this| {
this.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Check)
.color(Color::Success)
.size(IconSize::XSmall),
)
.child(Label::new("Imported").size(LabelSize::Small)),
)
}),
)
.on_click(move |_, window, cx| {
telemetry::event!("Welcome Import Settings", import_source = label,);
window.dispatch_action(action.boxed_clone(), cx);
}),
)
}
fn render_import_settings_section(tab_index: &mut isize, cx: &App) -> impl IntoElement {
let import_state = SettingsImportState::global(cx);
let imports: [(SharedString, IconName, &dyn Action, bool); 2] = [
(
"VS Code".into(),
IconName::EditorVsCode,
&ImportVsCodeSettings { skip_prompt: false },
import_state.vscode,
),
(
"Cursor".into(),
IconName::EditorCursor,
&ImportCursorSettings { skip_prompt: false },
import_state.cursor,
),
];
let [vscode, cursor] = imports.map(|(label, icon_name, action, imported)| {
*tab_index += 1;
render_setting_import_button(*tab_index - 1, label, icon_name, action, imported)
});
v_flex()
.gap_4()
.child(
v_flex()
.child(Label::new("Import Settings").size(LabelSize::Large))
.child(
Label::new("Automatically pull your settings from other editors.")
.color(Color::Muted),
),
)
.child(h_flex().w_full().gap_4().child(vscode).child(cursor))
}
fn render_font_customization_section(
tab_index: &mut isize,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let theme_settings = ThemeSettings::get_global(cx);
let ui_font_size = theme_settings.ui_font_size(cx);
let ui_font_family = theme_settings.ui_font.family.clone();
let buffer_font_family = theme_settings.buffer_font.family.clone();
let buffer_font_size = theme_settings.buffer_font_size(cx);
let ui_font_picker =
cx.new(|cx| font_picker(ui_font_family.clone(), write_ui_font_family, window, cx));
let buffer_font_picker = cx.new(|cx| {
font_picker(
buffer_font_family.clone(),
write_buffer_font_family,
window,
cx,
)
});
let ui_font_handle = ui::PopoverMenuHandle::default();
let buffer_font_handle = ui::PopoverMenuHandle::default();
h_flex()
.w_full()
.gap_4()
.child(
v_flex()
.w_full()
.gap_1()
.child(Label::new("UI Font"))
.child(
h_flex()
.w_full()
.justify_between()
.gap_2()
.child(
PopoverMenu::new("ui-font-picker")
.menu({
let ui_font_picker = ui_font_picker;
move |_window, _cx| Some(ui_font_picker.clone())
})
.trigger(
ButtonLike::new("ui-font-family-button")
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.full_width()
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.child(
h_flex()
.w_full()
.justify_between()
.child(Label::new(ui_font_family))
.child(
Icon::new(IconName::ChevronUpDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
)
.full_width(true)
.anchor(gpui::Corner::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(4.0),
})
.with_handle(ui_font_handle),
)
.child(
NumericStepper::new(
"ui-font-size",
ui_font_size.to_string(),
move |_, _, cx| {
write_ui_font_size(ui_font_size - px(1.), cx);
},
move |_, _, cx| {
write_ui_font_size(ui_font_size + px(1.), cx);
},
)
.style(ui::NumericStepperStyle::Outlined)
.tab_index({
*tab_index += 2;
*tab_index - 2
}),
),
),
)
.child(
v_flex()
.w_full()
.gap_1()
.child(Label::new("Editor Font"))
.child(
h_flex()
.w_full()
.justify_between()
.gap_2()
.child(
PopoverMenu::new("buffer-font-picker")
.menu({
let buffer_font_picker = buffer_font_picker;
move |_window, _cx| Some(buffer_font_picker.clone())
})
.trigger(
ButtonLike::new("buffer-font-family-button")
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.full_width()
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.child(
h_flex()
.w_full()
.justify_between()
.child(Label::new(buffer_font_family))
.child(
Icon::new(IconName::ChevronUpDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
)
.full_width(true)
.anchor(gpui::Corner::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(4.0),
})
.with_handle(buffer_font_handle),
)
.child(
NumericStepper::new(
"buffer-font-size",
buffer_font_size.to_string(),
move |_, _, cx| {
write_buffer_font_size(buffer_font_size - px(1.), cx);
},
move |_, _, cx| {
write_buffer_font_size(buffer_font_size + px(1.), cx);
},
)
.style(ui::NumericStepperStyle::Outlined)
.tab_index({
*tab_index += 2;
*tab_index - 2
}),
),
),
)
}
type FontPicker = Picker<FontPickerDelegate>;
pub struct FontPickerDelegate {
fonts: Vec<SharedString>,
filtered_fonts: Vec<StringMatch>,
selected_index: usize,
current_font: SharedString,
on_font_changed: Arc<dyn Fn(SharedString, &mut App) + 'static>,
}
impl FontPickerDelegate {
fn new(
current_font: SharedString,
on_font_changed: impl Fn(SharedString, &mut App) + 'static,
cx: &mut Context<FontPicker>,
) -> Self {
let font_family_cache = FontFamilyCache::global(cx);
let fonts: Vec<SharedString> = font_family_cache
.list_font_families(cx)
.into_iter()
.collect();
let selected_index = fonts
.iter()
.position(|font| *font == current_font)
.unwrap_or(0);
Self {
fonts: fonts.clone(),
filtered_fonts: fonts
.iter()
.enumerate()
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect(),
selected_index,
current_font,
on_font_changed: Arc::new(on_font_changed),
}
}
}
impl PickerDelegate for FontPickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_fonts.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<FontPicker>) {
self.selected_index = ix.min(self.filtered_fonts.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search fonts…".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
cx: &mut Context<FontPicker>,
) -> Task<()> {
let fonts = self.fonts.clone();
let current_font = self.current_font.clone();
let matches: Vec<StringMatch> = if query.is_empty() {
fonts
.iter()
.enumerate()
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
let _candidates: Vec<StringMatchCandidate> = fonts
.iter()
.enumerate()
.map(|(id, font)| StringMatchCandidate::new(id, font.as_ref()))
.collect();
fonts
.iter()
.enumerate()
.filter(|(_, font)| font.to_lowercase().contains(&query.to_lowercase()))
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect()
};
let selected_index = if query.is_empty() {
fonts
.iter()
.position(|font| *font == current_font)
.unwrap_or(0)
} else {
matches
.iter()
.position(|m| fonts[m.candidate_id] == current_font)
.unwrap_or(0)
};
self.filtered_fonts = matches;
self.selected_index = selected_index;
cx.notify();
Task::ready(())
}
fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<FontPicker>) {
if let Some(font_match) = self.filtered_fonts.get(self.selected_index) {
let font = font_match.string.clone();
(self.on_font_changed)(font.into(), cx);
}
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<FontPicker>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<FontPicker>,
) -> Option<Self::ListItem> {
let font_match = self.filtered_fonts.get(ix)?;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(font_match.string.clone()))
.into_any_element(),
)
}
}
fn font_picker(
current_font: SharedString,
on_font_changed: impl Fn(SharedString, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<FontPicker>,
) -> FontPicker {
let delegate = FontPickerDelegate::new(current_font, on_font_changed, cx);
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.width(rems_from_px(210.))
.max_height(Some(rems(20.).into()))
}
fn render_popular_settings_section(
tab_index: &mut isize,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
const LIGATURE_TOOLTIP: &str =
"Font ligatures combine two characters into one. For example, turning != into ≠.";
v_flex()
.pt_6()
.gap_4()
.border_t_1()
.border_color(cx.theme().colors().border_variant.opacity(0.5))
.child(Label::new("Popular Settings").size(LabelSize::Large))
.child(render_font_customization_section(tab_index, window, cx))
.child(
SwitchField::new(
"onboarding-font-ligatures",
"Font Ligatures",
Some("Combine text characters into their associated symbols.".into()),
if read_font_ligatures(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Font Ligature",
options = if enabled { "on" } else { "off" },
);
write_font_ligatures(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.tooltip(Tooltip::text(LIGATURE_TOOLTIP)),
)
.child(
SwitchField::new(
"onboarding-format-on-save",
"Format on Save",
Some("Format code automatically when saving.".into()),
if read_format_on_save(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Format On Save Changed",
options = if enabled { "on" } else { "off" },
);
write_format_on_save(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
.child(
SwitchField::new(
"onboarding-enable-inlay-hints",
"Inlay Hints",
Some("See parameter names for function and method calls inline.".into()),
if read_inlay_hints(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Inlay Hints Changed",
options = if enabled { "on" } else { "off" },
);
write_inlay_hints(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
.child(
SwitchField::new(
"onboarding-git-blame-switch",
"Inline Git Blame",
Some("See who committed each line on a given file.".into()),
if read_git_blame(cx) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
|toggle_state, _, cx| {
let enabled = toggle_state == &ToggleState::Selected;
telemetry::event!(
"Welcome Git Blame Changed",
options = if enabled { "on" } else { "off" },
);
write_git_blame(enabled, cx);
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
}),
)
.child(
h_flex()
.items_start()
.justify_between()
.child(
v_flex().child(Label::new("Minimap")).child(
Label::new("See a high-level overview of your source code.")
.color(Color::Muted),
),
)
.child(
ToggleButtonGroup::single_row(
"onboarding-show-mini-map",
[
ToggleButtonSimple::new("Auto", |_, _, cx| {
write_show_mini_map(ShowMinimap::Auto, cx);
})
.tooltip(Tooltip::text(
"Show the minimap if the editor's scrollbar is visible.",
)),
ToggleButtonSimple::new("Always", |_, _, cx| {
write_show_mini_map(ShowMinimap::Always, cx);
}),
ToggleButtonSimple::new("Never", |_, _, cx| {
write_show_mini_map(ShowMinimap::Never, cx);
}),
],
)
.selected_index(match read_show_mini_map(cx) {
ShowMinimap::Auto => 0,
ShowMinimap::Always => 1,
ShowMinimap::Never => 2,
})
.tab_index(tab_index)
.style(ToggleButtonGroupStyle::Outlined)
.width(ui::rems_from_px(3. * 64.)),
),
)
}
pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut tab_index = 0;
v_flex()
.gap_6()
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_popular_settings_section(&mut tab_index, window, cx))
}