Add hotkeys and actions for toggle light and dark theme (#49027)

Tommy Han created

Mentioned in #47258 

Release Notes:

- Added hotkey options and actions for toggling light and dark theme.
- Add default keymap as `cmd/ctrl+k cmd/ctrl+shift+t`

Change summary

assets/keymaps/default-linux.json   |  1 
assets/keymaps/default-macos.json   |  1 
assets/keymaps/default-windows.json |  1 
crates/theme/src/settings.rs        | 12 ++--
crates/workspace/src/workspace.rs   | 91 ++++++++++++++++++++++++++++++
crates/zed/src/zed.rs               |  1 
crates/zed_actions/src/lib.rs       |  6 ++
docs/src/appearance.md              |  8 +-
docs/src/themes.md                  | 29 +++++++++
9 files changed, 139 insertions(+), 11 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -624,6 +624,7 @@
       "ctrl-shift-t": "pane::ReopenClosedItem",
       "ctrl-k ctrl-s": "zed::OpenKeymap",
       "ctrl-k ctrl-t": "theme_selector::Toggle",
+      "ctrl-k ctrl-shift-t": "theme::ToggleMode",
       "ctrl-alt-super-p": "settings_profile_selector::Toggle",
       "ctrl-t": "project_symbols::Toggle",
       "ctrl-p": "file_finder::Toggle",

assets/keymaps/default-macos.json 🔗

@@ -691,6 +691,7 @@
       "cmd-shift-t": "pane::ReopenClosedItem",
       "cmd-k cmd-s": "zed::OpenKeymap",
       "cmd-k cmd-t": "theme_selector::Toggle",
+      "cmd-k cmd-shift-t": "theme::ToggleMode",
       "ctrl-alt-cmd-p": "settings_profile_selector::Toggle",
       "cmd-t": "project_symbols::Toggle",
       "cmd-p": "file_finder::Toggle",

assets/keymaps/default-windows.json 🔗

@@ -616,6 +616,7 @@
       "ctrl-shift-t": "pane::ReopenClosedItem",
       "ctrl-k ctrl-s": "zed::OpenKeymap",
       "ctrl-k ctrl-t": "theme_selector::Toggle",
+      "ctrl-k ctrl-shift-t": "theme::ToggleMode",
       "ctrl-alt-super-p": "settings_profile_selector::Toggle",
       "ctrl-t": "project_symbols::Toggle",
       "ctrl-p": "file_finder::Toggle",

crates/theme/src/settings.rs 🔗

@@ -378,14 +378,14 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) {
 
     if let Some(selection) = theme.theme.as_mut() {
         match selection {
-            settings::ThemeSelection::Static(theme) => {
+            settings::ThemeSelection::Static(_) => {
                 // If the theme was previously set to a single static theme,
-                // we don't know whether it was a light or dark theme, so we
-                // just use it for both.
+                // reset to the default dynamic light/dark pair and let users
+                // customize light/dark themes explicitly afterward.
                 *selection = settings::ThemeSelection::Dynamic {
-                    mode,
-                    light: theme.clone(),
-                    dark: theme.clone(),
+                    mode: ThemeAppearanceMode::System,
+                    light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()),
+                    dark: ThemeName(settings::DEFAULT_DARK_THEME.into()),
                 };
             }
             settings::ThemeSelection::Dynamic {

crates/workspace/src/workspace.rs 🔗

@@ -146,7 +146,7 @@ pub use workspace_settings::{
     AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings,
     WorkspaceSettings,
 };
-use zed_actions::{Spawn, feedback::FileBugReport};
+use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode};
 
 use crate::{item::ItemBufferKind, notifications::NotificationId};
 use crate::{
@@ -6499,6 +6499,7 @@ impl Workspace {
             .on_action(cx.listener(Self::move_item_to_pane_at_index))
             .on_action(cx.listener(Self::move_focused_panel_to_next_position))
             .on_action(cx.listener(Self::toggle_edit_predictions_all_files))
+            .on_action(cx.listener(Self::toggle_theme_mode))
             .on_action(cx.listener(|workspace, _: &Unfollow, window, cx| {
                 let pane = workspace.active_pane().clone();
                 workspace.unfollow_in_pane(&pane, window, cx);
@@ -7153,6 +7154,23 @@ impl Workspace {
         });
     }
 
+    fn toggle_theme_mode(&mut self, _: &ToggleMode, _window: &mut Window, cx: &mut Context<Self>) {
+        let current_mode = ThemeSettings::get_global(cx).theme.mode();
+        let next_mode = match current_mode {
+            Some(theme::ThemeAppearanceMode::Light) => theme::ThemeAppearanceMode::Dark,
+            Some(theme::ThemeAppearanceMode::Dark) => theme::ThemeAppearanceMode::Light,
+            Some(theme::ThemeAppearanceMode::System) | None => match cx.theme().appearance() {
+                theme::Appearance::Light => theme::ThemeAppearanceMode::Dark,
+                theme::Appearance::Dark => theme::ThemeAppearanceMode::Light,
+            },
+        };
+
+        let fs = self.project().read(cx).fs().clone();
+        settings::update_settings_file(fs, cx, move |settings, _cx| {
+            theme::set_mode(settings, next_mode);
+        });
+    }
+
     pub fn show_worktree_trust_security_modal(
         &mut self,
         toggle: bool,
@@ -9964,7 +9982,7 @@ pub fn with_active_or_new_workspace(
 
 #[cfg(test)]
 mod tests {
-    use std::{cell::RefCell, rc::Rc};
+    use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
 
     use super::*;
     use crate::{
@@ -9982,6 +10000,7 @@ mod tests {
     use project::{Project, ProjectEntryId};
     use serde_json::json;
     use settings::SettingsStore;
+    use util::path;
     use util::rel_path::rel_path;
 
     #[gpui::test]
@@ -13540,6 +13559,74 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_toggle_theme_mode_persists_and_updates_active_theme(cx: &mut TestAppContext) {
+        use settings::{ThemeName, ThemeSelection};
+        use theme::SystemAppearance;
+        use zed_actions::theme::ToggleMode;
+
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let settings_fs: Arc<dyn fs::Fs> = fs.clone();
+
+        fs.insert_tree(path!("/root"), json!({ "file.rs": "fn main() {}\n" }))
+            .await;
+
+        // Build a test project and workspace view so the test can invoke
+        // the workspace action handler the same way the UI would.
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        // Seed the settings file with a plain static light theme so the
+        // first toggle always starts from a known persisted state.
+        workspace.update_in(cx, |_workspace, _window, cx| {
+            *SystemAppearance::global_mut(cx) = SystemAppearance(theme::Appearance::Light);
+            settings::update_settings_file(settings_fs.clone(), cx, |settings, _cx| {
+                settings.theme.theme = Some(ThemeSelection::Static(ThemeName("One Light".into())));
+            });
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.run_until_parked();
+
+        // Confirm the initial persisted settings contain the static theme
+        // we just wrote before any toggling happens.
+        let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap();
+        assert!(settings_text.contains(r#""theme": "One Light""#));
+
+        // Toggle once. This should migrate the persisted theme settings
+        // into light/dark slots and enable system mode.
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.toggle_theme_mode(&ToggleMode, window, cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.run_until_parked();
+
+        // 1. Static -> Dynamic
+        // this assertion checks theme changed from static to dynamic.
+        let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap();
+        let parsed: serde_json::Value = settings::parse_json_with_comments(&settings_text).unwrap();
+        assert_eq!(
+            parsed["theme"],
+            serde_json::json!({
+                "mode": "system",
+                "light": "One Light",
+                "dark": "One Dark"
+            })
+        );
+
+        // 2. Toggle again, suppose it will change the mode to light
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.toggle_theme_mode(&ToggleMode, window, cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.run_until_parked();
+
+        let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap();
+        assert!(settings_text.contains(r#""mode": "light""#));
+    }
+
     fn dirty_project_item(id: u64, path: &str, cx: &mut App) -> Entity<TestProjectItem> {
         let item = TestProjectItem::new(id, path, cx);
         item.update(cx, |item, _| {

crates/zed/src/zed.rs 🔗

@@ -4878,6 +4878,7 @@ mod tests {
                 "task",
                 "terminal",
                 "terminal_panel",
+                "theme",
                 "theme_selector",
                 "toast",
                 "toolchain",

crates/zed_actions/src/lib.rs 🔗

@@ -325,6 +325,12 @@ pub mod feedback {
     );
 }
 
+pub mod theme {
+    use gpui::actions;
+
+    actions!(theme, [ToggleMode]);
+}
+
 pub mod theme_selector {
     use gpui::Action;
     use schemars::JsonSchema;

docs/src/appearance.md 🔗

@@ -15,11 +15,13 @@ Here's how to make Zed feel like home:
 
 1. **Pick a theme**: Press {#kb theme_selector::Toggle} to open the Theme Selector. Arrow through the list to preview themes in real time, and press Enter to apply.
 
-2. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes.
+2. **Toggle light/dark mode quickly**: Press {#kb theme::ToggleMode}. If you currently use a static `"theme": "..."` value, the first toggle converts it to dynamic mode settings with default themes.
 
-3. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font.
+3. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes.
 
-4. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes.
+4. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font.
+
+5. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes.
 
 That's it. You now have a personalized Zed setup.
 

docs/src/themes.md 🔗

@@ -44,6 +44,35 @@ You can set the mode to `"dark"` or `"light"` to ignore the current system mode.
 }
 ```
 
+### Toggle Theme Mode from the Keyboard
+
+Use {#kb theme::ToggleMode} to switch the current theme mode between light and dark.
+
+If your settings currently use a static theme value, like:
+
+```json [settings]
+{
+  "theme": "Any Theme"
+}
+```
+
+the first toggle converts it to dynamic theme selection with default themes:
+
+```json [settings]
+{
+  "theme": {
+    "mode": "system",
+    "light": "One Light",
+    "dark": "One Dark"
+  }
+}
+```
+
+You are required to set both `light` and `dark` themes manually after the first toggle.
+
+After that, toggling updates only `theme.mode`.
+If `light` and `dark` are the same theme, the first toggle may not produce a visible UI change until you set different values for `light` and `dark`.
+
 ## Theme Overrides
 
 To override specific attributes of a theme, use the `theme_overrides` setting.