Files
zed/crates/onboarding/src/basics_page.rs
T
fd20675dd7 onboarding: Fix theme picker always updating theme based on system appearance (#47245)
The settings store separate themes for Light and Dark modes. When the
user clicks a theme, the code uses the system appearance to decide which
to update, rather than the mode the user selected in the UI. This causes
the wrong theme to be saved when the system appearance differs from the
selected mode.

<img width="746" height="324" alt="image"
src="https://github.com/user-attachments/assets/b8f8d937-172d-4da8-b572-4f3db7f1de9a"
/>

Release Notes:

- Fixed onboarding page sometimes rendering the wrong theme

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: MrSubidubi <finn@zed.dev>
2026-01-21 10:12:29 +00:00

540 lines
20 KiB
Rust

use std::sync::Arc;
use client::TelemetrySettings;
use fs::Fs;
use gpui::{Action, App, IntoElement};
use project::project_settings::ProjectSettings;
use settings::{BaseKeymap, Settings, update_settings_file};
use theme::{
Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection,
ThemeSettings,
};
use ui::{
Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
prelude::*, rems_from_px,
};
use vim_mode_setting::VimModeSetting;
use crate::{
ImportCursorSettings, ImportVsCodeSettings, SettingsImportState,
theme_preview::{ThemePreviewStyle, ThemePreviewTile},
};
const LIGHT_THEMES: [&str; 3] = ["One Light", "Ayu Light", "Gruvbox Light"];
const DARK_THEMES: [&str; 3] = ["One Dark", "Ayu Dark", "Gruvbox Dark"];
const FAMILY_NAMES: [SharedString; 3] = [
SharedString::new_static("One"),
SharedString::new_static("Ayu"),
SharedString::new_static("Gruvbox"),
];
fn get_theme_family_themes(theme_name: &str) -> Option<(&'static str, &'static str)> {
for i in 0..LIGHT_THEMES.len() {
if LIGHT_THEMES[i] == theme_name || DARK_THEMES[i] == theme_name {
return Some((LIGHT_THEMES[i], DARK_THEMES[i]));
}
}
None
}
fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
let theme_selection = ThemeSettings::get_global(cx).theme.clone();
let system_appearance = theme::SystemAppearance::global(cx);
let theme_mode = theme_selection
.mode()
.unwrap_or_else(|| match *system_appearance {
Appearance::Light => ThemeAppearanceMode::Light,
Appearance::Dark => ThemeAppearanceMode::Dark,
});
return v_flex()
.gap_2()
.child(
h_flex().justify_between().child(Label::new("Theme")).child(
ToggleButtonGroup::single_row(
"theme-selector-onboarding-dark-light",
[
ThemeAppearanceMode::Light,
ThemeAppearanceMode::Dark,
ThemeAppearanceMode::System,
]
.map(|mode| {
const MODE_NAMES: [SharedString; 3] = [
SharedString::new_static("Light"),
SharedString::new_static("Dark"),
SharedString::new_static("System"),
];
ToggleButtonSimple::new(
MODE_NAMES[mode as usize].clone(),
move |_, _, cx| {
write_mode_change(mode, cx);
telemetry::event!(
"Welcome Theme mode Changed",
from = theme_mode,
to = mode
);
},
)
}),
)
.size(ToggleButtonGroupSize::Medium)
.tab_index(tab_index)
.selected_index(theme_mode as usize)
.style(ui::ToggleButtonGroupStyle::Outlined)
.width(rems_from_px(3. * 64.)),
),
)
.child(
h_flex()
.gap_4()
.justify_between()
.children(render_theme_previews(tab_index, &theme_selection, cx)),
);
fn render_theme_previews(
tab_index: &mut isize,
theme_selection: &ThemeSelection,
cx: &mut App,
) -> [impl IntoElement; 3] {
let system_appearance = SystemAppearance::global(cx);
let theme_registry = ThemeRegistry::global(cx);
let theme_seed = 0xBEEF as f32;
let theme_mode = theme_selection
.mode()
.unwrap_or_else(|| match *system_appearance {
Appearance::Light => ThemeAppearanceMode::Light,
Appearance::Dark => ThemeAppearanceMode::Dark,
});
let appearance = match theme_mode {
ThemeAppearanceMode::Light => Appearance::Light,
ThemeAppearanceMode::Dark => Appearance::Dark,
ThemeAppearanceMode::System => *system_appearance,
};
let current_theme_name: SharedString = theme_selection.name(appearance).0.into();
let theme_names = match appearance {
Appearance::Light => LIGHT_THEMES,
Appearance::Dark => DARK_THEMES,
};
let themes = theme_names.map(|theme| theme_registry.get(theme).unwrap());
[0, 1, 2].map(|index| {
let theme = &themes[index];
let is_selected = theme.name == current_theme_name;
let name = theme.name.clone();
let colors = cx.theme().colors();
v_flex()
.w_full()
.items_center()
.gap_1()
.child(
h_flex()
.id(name)
.relative()
.w_full()
.border_2()
.border_color(colors.border_transparent)
.rounded(ThemePreviewTile::ROOT_RADIUS)
.map(|this| {
if is_selected {
this.border_color(colors.border_selected)
} else {
this.opacity(0.8).hover(|s| s.border_color(colors.border))
}
})
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.focus(|mut style| {
style.border_color = Some(colors.border_focused);
style
})
.on_click({
let theme_name = theme.name.clone();
let current_theme_name = current_theme_name.clone();
move |_, _, cx| {
write_theme_change(theme_name.clone(), theme_mode, cx);
telemetry::event!(
"Welcome Theme Changed",
from = current_theme_name,
to = theme_name
);
}
})
.map(|this| {
if theme_mode == ThemeAppearanceMode::System {
let (light, dark) = (
theme_registry.get(LIGHT_THEMES[index]).unwrap(),
theme_registry.get(DARK_THEMES[index]).unwrap(),
);
this.child(
ThemePreviewTile::new(light, theme_seed)
.style(ThemePreviewStyle::SideBySide(dark)),
)
} else {
this.child(
ThemePreviewTile::new(theme.clone(), theme_seed)
.style(ThemePreviewStyle::Bordered),
)
}
}),
)
.child(
Label::new(FAMILY_NAMES[index].clone())
.color(Color::Muted)
.size(LabelSize::Small),
)
})
}
fn write_mode_change(mode: ThemeAppearanceMode, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _cx| {
theme::set_mode(settings, mode);
});
}
fn write_theme_change(
theme: impl Into<Arc<str>>,
theme_mode: ThemeAppearanceMode,
cx: &mut App,
) {
let fs = <dyn Fs>::global(cx);
let theme = theme.into();
update_settings_file(fs, cx, move |settings, cx| match theme_mode {
ThemeAppearanceMode::System => {
let (light_theme, dark_theme) =
get_theme_family_themes(&theme).unwrap_or((theme.as_ref(), theme.as_ref()));
settings.theme.theme = Some(settings::ThemeSelection::Dynamic {
mode: ThemeAppearanceMode::System,
light: ThemeName(light_theme.into()),
dark: ThemeName(dark_theme.into()),
});
}
ThemeAppearanceMode::Light => theme::set_theme(
settings,
theme,
Appearance::Light,
*SystemAppearance::global(cx),
),
ThemeAppearanceMode::Dark => theme::set_theme(
settings,
theme,
Appearance::Dark,
*SystemAppearance::global(cx),
),
});
}
}
fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement {
let fs = <dyn Fs>::global(cx);
v_flex()
.gap_4()
.child(
SwitchField::new(
"onboarding-telemetry-metrics",
None::<&str>,
Some("Help improve Zed by sending anonymous usage data".into()),
if TelemetrySettings::get_global(cx).metrics {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
{
let fs = fs.clone();
move |selection, _, cx| {
let enabled = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.telemetry.get_or_insert_default().metrics = Some(enabled);
});
// This telemetry event shouldn't fire when it's off. If it does we'll be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!(
"Welcome Page Telemetry Metrics Toggled",
options = if enabled { "on" } else { "off" }
);
}
},
)
.tab_index({
*tab_index += 1;
*tab_index
}),
)
.child(
SwitchField::new(
"onboarding-telemetry-crash-reports",
None::<&str>,
Some(
"Help fix Zed by sending crash reports so we can fix critical issues fast"
.into(),
),
if TelemetrySettings::get_global(cx).diagnostics {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
{
let fs = fs.clone();
move |selection, _, cx| {
let enabled = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.telemetry.get_or_insert_default().diagnostics = Some(enabled);
});
// This telemetry event shouldn't fire when it's off. If it does we'll be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!(
"Welcome Page Telemetry Diagnostics Toggled",
options = if enabled { "on" } else { "off" }
);
}
},
)
.tab_index({
*tab_index += 1;
*tab_index
}),
)
}
fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
let base_keymap = match BaseKeymap::get_global(cx) {
BaseKeymap::VSCode => Some(0),
BaseKeymap::JetBrains => Some(1),
BaseKeymap::SublimeText => Some(2),
BaseKeymap::Atom => Some(3),
BaseKeymap::Emacs => Some(4),
BaseKeymap::Cursor => Some(5),
BaseKeymap::TextMate | BaseKeymap::None => None,
};
return v_flex().gap_2().child(Label::new("Base Keymap")).child(
ToggleButtonGroup::two_rows(
"base_keymap_selection",
[
ToggleButtonWithIcon::new("VS Code", IconName::EditorVsCode, |_, _, cx| {
write_keymap_base(BaseKeymap::VSCode, cx);
}),
ToggleButtonWithIcon::new("JetBrains", IconName::EditorJetBrains, |_, _, cx| {
write_keymap_base(BaseKeymap::JetBrains, cx);
}),
ToggleButtonWithIcon::new("Sublime Text", IconName::EditorSublime, |_, _, cx| {
write_keymap_base(BaseKeymap::SublimeText, cx);
}),
],
[
ToggleButtonWithIcon::new("Atom", IconName::EditorAtom, |_, _, cx| {
write_keymap_base(BaseKeymap::Atom, cx);
}),
ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| {
write_keymap_base(BaseKeymap::Emacs, cx);
}),
ToggleButtonWithIcon::new("Cursor", IconName::EditorCursor, |_, _, cx| {
write_keymap_base(BaseKeymap::Cursor, cx);
}),
],
)
.when_some(base_keymap, |this, base_keymap| {
this.selected_index(base_keymap)
})
.full_width()
.tab_index(tab_index)
.size(ui::ToggleButtonGroupSize::Medium)
.style(ui::ToggleButtonGroupStyle::Outlined),
);
fn write_keymap_base(keymap_base: BaseKeymap, cx: &App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |setting, _| {
setting.base_keymap = Some(keymap_base.into());
});
telemetry::event!("Welcome Keymap Changed", keymap = keymap_base);
}
}
fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
let toggle_state = if VimModeSetting::get_global(cx).0 {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
};
SwitchField::new(
"onboarding-vim-mode",
Some("Vim Mode"),
Some("Coming from Neovim? Use our first-class implementation of Vim Mode".into()),
toggle_state,
{
let fs = <dyn Fs>::global(cx);
move |&selection, _, cx| {
let vim_mode = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.vim_mode = Some(vim_mode);
});
telemetry::event!(
"Welcome Vim Mode Toggled",
options = if vim_mode { "on" } else { "off" },
);
}
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
})
}
fn render_worktree_auto_trust_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
let toggle_state = if ProjectSettings::get_global(cx).session.trust_all_worktrees {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
};
let tooltip_description = "Zed can only allow services like language servers, project settings, and MCP servers to run after you mark a new project as trusted.";
SwitchField::new(
"onboarding-auto-trust-worktrees",
Some("Trust All Projects By Default"),
Some("Automatically mark all new projects as trusted to unlock all Zed's features".into()),
toggle_state,
{
let fs = <dyn Fs>::global(cx);
move |&selection, _, cx| {
let trust = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file(fs.clone(), cx, move |setting, _| {
setting.session.get_or_insert_default().trust_all_worktrees = Some(trust);
});
telemetry::event!(
"Welcome Page Worktree Auto Trust Toggled",
options = if trust { "on" } else { "off" }
);
}
},
)
.tab_index({
*tab_index += 1;
*tab_index - 1
})
.tooltip(Tooltip::text(tooltip_description))
}
fn render_setting_import_button(
tab_index: isize,
label: SharedString,
action: &dyn Action,
imported: bool,
) -> impl IntoElement + 'static {
let action = action.boxed_clone();
Button::new(label.clone(), label.clone())
.style(ButtonStyle::OutlinedGhost)
.size(ButtonSize::Medium)
.label_size(LabelSize::Small)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(imported)
.tab_index(tab_index)
.when(imported, |this| {
this.icon(IconName::Check)
.icon_size(IconSize::Small)
.color(Color::Success)
})
.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: &mut App) -> impl IntoElement {
let import_state = SettingsImportState::global(cx);
let imports: [(SharedString, &dyn Action, bool); 2] = [
(
"VS Code".into(),
&ImportVsCodeSettings { skip_prompt: false },
import_state.vscode,
),
(
"Cursor".into(),
&ImportCursorSettings { skip_prompt: false },
import_state.cursor,
),
];
let [vscode, cursor] = imports.map(|(label, action, imported)| {
*tab_index += 1;
render_setting_import_button(*tab_index - 1, label, action, imported)
});
h_flex()
.gap_2()
.flex_wrap()
.justify_between()
.child(
v_flex()
.gap_0p5()
.max_w_5_6()
.child(Label::new("Import Settings"))
.child(
Label::new("Automatically pull your settings from other editors")
.color(Color::Muted),
),
)
.child(h_flex().gap_1().child(vscode).child(cursor))
}
pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement {
let mut tab_index = 0;
v_flex()
.id("basics-page")
.gap_6()
.child(render_theme_section(&mut tab_index, cx))
.child(render_base_keymap_section(&mut tab_index, cx))
.child(render_import_settings_section(&mut tab_index, cx))
.child(render_vim_mode_switch(&mut tab_index, cx))
.child(render_worktree_auto_trust_switch(&mut tab_index, cx))
.child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
.child(render_telemetry_section(&mut tab_index, cx))
}