Add a theme picker

Max Brunsfeld and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

gpui/examples/text.rs             |   2 
gpui/src/app.rs                   |  71 ++
gpui/src/assets.rs                |   7 
gpui/src/elements/uniform_list.rs |  16 
server/src/tests.rs               |   2 
zed/assets/themes/_base.toml      |   0 
zed/assets/themes/dark.toml       |   2 
zed/assets/themes/light.toml      |  21 
zed/src/assets.rs                 |   4 
zed/src/editor.rs                 |   6 
zed/src/file_finder.rs            |  32 
zed/src/lib.rs                    |  19 
zed/src/main.rs                   |  11 
zed/src/settings.rs               |  47 +
zed/src/test.rs                   |   4 
zed/src/theme_picker.rs           | 308 ++++++++++++
zed/src/workspace.rs              |  16 
zed/src/workspace/pane.rs         |   5 
zed/src/worktree.rs               |   8 
zed/src/worktree/fuzzy.rs         | 800 +++++++++++++++++++-------------
20 files changed, 963 insertions(+), 418 deletions(-)

Detailed changes

gpui/examples/text.rs 🔗

@@ -28,7 +28,7 @@ impl gpui::View for TextView {
         "View"
     }
 
-    fn render<'a>(&self, _: &gpui::AppContext) -> gpui::ElementBox {
+    fn render(&self, _: &gpui::RenderContext<Self>) -> gpui::ElementBox {
         TextElement.boxed()
     }
 }

gpui/src/app.rs 🔗

@@ -36,9 +36,9 @@ pub trait Entity: 'static + Send + Sync {
     fn release(&mut self, _: &mut MutableAppContext) {}
 }
 
-pub trait View: Entity {
+pub trait View: Entity + Sized {
     fn ui_name() -> &'static str;
-    fn render<'a>(&self, cx: &AppContext) -> ElementBox;
+    fn render(&self, cx: &RenderContext<'_, Self>) -> ElementBox;
     fn on_focus(&mut self, _: &mut ViewContext<Self>) {}
     fn on_blur(&mut self, _: &mut ViewContext<Self>) {}
     fn keymap_context(&self, _: &AppContext) -> keymap::Context {
@@ -1503,7 +1503,7 @@ impl AppContext {
     pub fn render_view(&self, window_id: usize, view_id: usize) -> Result<ElementBox> {
         self.views
             .get(&(window_id, view_id))
-            .map(|v| v.render(self))
+            .map(|v| v.render(window_id, view_id, self))
             .ok_or(anyhow!("view not found"))
     }
 
@@ -1512,7 +1512,7 @@ impl AppContext {
             .iter()
             .filter_map(|((win_id, view_id), view)| {
                 if *win_id == window_id {
-                    Some((*view_id, view.render(self)))
+                    Some((*view_id, view.render(*win_id, *view_id, self)))
                 } else {
                     None
                 }
@@ -1650,7 +1650,7 @@ pub trait AnyView: Send + Sync {
     fn as_any_mut(&mut self) -> &mut dyn Any;
     fn release(&mut self, cx: &mut MutableAppContext);
     fn ui_name(&self) -> &'static str;
-    fn render<'a>(&self, cx: &AppContext) -> ElementBox;
+    fn render<'a>(&self, window_id: usize, view_id: usize, cx: &AppContext) -> ElementBox;
     fn on_focus(&mut self, cx: &mut MutableAppContext, window_id: usize, view_id: usize);
     fn on_blur(&mut self, cx: &mut MutableAppContext, window_id: usize, view_id: usize);
     fn keymap_context(&self, cx: &AppContext) -> keymap::Context;
@@ -1676,8 +1676,16 @@ where
         T::ui_name()
     }
 
-    fn render<'a>(&self, cx: &AppContext) -> ElementBox {
-        View::render(self, cx)
+    fn render<'a>(&self, window_id: usize, view_id: usize, cx: &AppContext) -> ElementBox {
+        View::render(
+            self,
+            &RenderContext {
+                window_id,
+                view_id,
+                app: cx,
+                view_type: PhantomData::<T>,
+            },
+        )
     }
 
     fn on_focus(&mut self, cx: &mut MutableAppContext, window_id: usize, view_id: usize) {
@@ -2094,12 +2102,33 @@ impl<'a, T: View> ViewContext<'a, T> {
     }
 }
 
+pub struct RenderContext<'a, T: View> {
+    pub app: &'a AppContext,
+    window_id: usize,
+    view_id: usize,
+    view_type: PhantomData<T>,
+}
+
+impl<'a, T: View> RenderContext<'a, T> {
+    pub fn handle(&self) -> WeakViewHandle<T> {
+        WeakViewHandle::new(self.window_id, self.view_id)
+    }
+}
+
 impl AsRef<AppContext> for &AppContext {
     fn as_ref(&self) -> &AppContext {
         self
     }
 }
 
+impl<V: View> Deref for RenderContext<'_, V> {
+    type Target = AppContext;
+
+    fn deref(&self) -> &Self::Target {
+        &self.app
+    }
+}
+
 impl<M> AsRef<AppContext> for ViewContext<'_, M> {
     fn as_ref(&self) -> &AppContext {
         &self.app.cx
@@ -3004,7 +3033,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3067,7 +3096,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 let mouse_down_count = self.mouse_down_count.clone();
                 EventHandler::new(Empty::new().boxed())
                     .on_mouse_down(move |_| {
@@ -3129,7 +3158,7 @@ mod tests {
                 "View"
             }
 
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
         }
@@ -3169,7 +3198,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3222,7 +3251,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3272,7 +3301,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3315,7 +3344,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3362,7 +3391,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3420,7 +3449,7 @@ mod tests {
         }
 
         impl View for ViewA {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3438,7 +3467,7 @@ mod tests {
         }
 
         impl View for ViewB {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3541,7 +3570,7 @@ mod tests {
         }
 
         impl super::View for View {
-            fn render<'a>(&self, _: &AppContext) -> ElementBox {
+            fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
 
@@ -3674,7 +3703,7 @@ mod tests {
                 "test view"
             }
 
-            fn render(&self, _: &AppContext) -> ElementBox {
+            fn render(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
         }
@@ -3719,7 +3748,7 @@ mod tests {
                 "test view"
             }
 
-            fn render(&self, _: &AppContext) -> ElementBox {
+            fn render(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
         }
@@ -3742,7 +3771,7 @@ mod tests {
                 "test view"
             }
 
-            fn render(&self, _: &AppContext) -> ElementBox {
+            fn render(&self, _: &RenderContext<Self>) -> ElementBox {
                 Empty::new().boxed()
             }
         }

gpui/src/assets.rs 🔗

@@ -1,8 +1,9 @@
 use anyhow::{anyhow, Result};
 use std::{borrow::Cow, cell::RefCell, collections::HashMap};
 
-pub trait AssetSource: 'static {
+pub trait AssetSource: 'static + Send + Sync {
     fn load(&self, path: &str) -> Result<Cow<[u8]>>;
+    fn list(&self, path: &str) -> Vec<Cow<'static, str>>;
 }
 
 impl AssetSource for () {
@@ -12,6 +13,10 @@ impl AssetSource for () {
             path
         ))
     }
+
+    fn list(&self, _: &str) -> Vec<Cow<'static, str>> {
+        vec![]
+    }
 }
 
 pub struct AssetCache {

gpui/src/elements/uniform_list.rs 🔗

@@ -13,17 +13,10 @@ use json::ToJson;
 use parking_lot::Mutex;
 use std::{cmp, ops::Range, sync::Arc};
 
-#[derive(Clone)]
+#[derive(Clone, Default)]
 pub struct UniformListState(Arc<Mutex<StateInner>>);
 
 impl UniformListState {
-    pub fn new() -> Self {
-        Self(Arc::new(Mutex::new(StateInner {
-            scroll_top: 0.0,
-            scroll_to: None,
-        })))
-    }
-
     pub fn scroll_to(&self, item_ix: usize) {
         self.0.lock().scroll_to = Some(item_ix);
     }
@@ -33,6 +26,7 @@ impl UniformListState {
     }
 }
 
+#[derive(Default)]
 struct StateInner {
     scroll_top: f32,
     scroll_to: Option<usize>,
@@ -57,11 +51,11 @@ impl<F> UniformList<F>
 where
     F: Fn(Range<usize>, &mut Vec<ElementBox>, &AppContext),
 {
-    pub fn new(state: UniformListState, item_count: usize, build_items: F) -> Self {
+    pub fn new(state: UniformListState, item_count: usize, append_items: F) -> Self {
         Self {
             state,
             item_count,
-            append_items: build_items,
+            append_items,
         }
     }
 
@@ -79,7 +73,7 @@ where
 
         let mut state = self.state.0.lock();
         state.scroll_top = (state.scroll_top - delta.y()).max(0.0).min(scroll_max);
-        cx.dispatch_action("uniform_list:scroll", state.scroll_top);
+        cx.notify();
 
         true
     }

server/src/tests.rs 🔗

@@ -607,7 +607,7 @@ impl gpui::View for EmptyView {
         "empty view"
     }
 
-    fn render<'a>(&self, _: &gpui::AppContext) -> gpui::ElementBox {
+    fn render<'a>(&self, _: &gpui::RenderContext<Self>) -> gpui::ElementBox {
         gpui::Element::boxed(gpui::elements::Empty)
     }
 }

zed/assets/themes/light.toml 🔗

@@ -0,0 +1,21 @@
+extends = "_base"
+
+[variables]
+elevation_1 = 0xffffff
+elevation_2 = 0xf3f3f3
+elevation_3 = 0xececec
+elevation_4 = 0x3a3b3c
+text_dull = 0xacacac
+text_bright = 0x111111
+text_normal = 0x333333
+
+[syntax]
+keyword = 0x0000fa
+function = 0x795e26
+string = 0xa82121
+type = 0x267f29
+number = 0xb5cea8
+comment = 0x6a9955
+property = 0x4e94ce
+variant = 0x4fc1ff
+constant = 0x9cdcfe

zed/src/assets.rs 🔗

@@ -10,4 +10,8 @@ impl AssetSource for Assets {
     fn load(&self, path: &str) -> Result<std::borrow::Cow<[u8]>> {
         Self::get(path).ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
     }
+
+    fn list(&self, path: &str) -> Vec<std::borrow::Cow<'static, str>> {
+        Self::iter().filter(|p| p.starts_with(path)).collect()
+    }
 }

zed/src/editor.rs 🔗

@@ -18,8 +18,8 @@ pub use element::*;
 use gpui::{
     color::ColorU, font_cache::FamilyId, fonts::Properties as FontProperties,
     geometry::vector::Vector2F, keymap::Binding, text_layout, AppContext, ClipboardItem, Element,
-    ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, Task, TextLayoutCache, View,
-    ViewContext, WeakViewHandle,
+    ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, RenderContext, Task,
+    TextLayoutCache, View, ViewContext, WeakViewHandle,
 };
 use postage::watch;
 use serde::{Deserialize, Serialize};
@@ -2533,7 +2533,7 @@ impl Entity for Editor {
 }
 
 impl View for Editor {
-    fn render<'a>(&self, _: &AppContext) -> ElementBox {
+    fn render<'a>(&self, _: &RenderContext<Self>) -> ElementBox {
         EditorElement::new(self.handle.clone()).boxed()
     }
 

zed/src/file_finder.rs 🔗

@@ -11,8 +11,8 @@ use gpui::{
     fonts::{Properties, Weight},
     geometry::vector::vec2f,
     keymap::{self, Binding},
-    AppContext, Axis, Border, Entity, MutableAppContext, Task, View, ViewContext, ViewHandle,
-    WeakViewHandle,
+    AppContext, Axis, Border, Entity, MutableAppContext, RenderContext, Task, View, ViewContext,
+    ViewHandle, WeakViewHandle,
 };
 use postage::watch;
 use std::{
@@ -45,7 +45,6 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action("file_finder:select", FileFinder::select);
     cx.add_action("menu:select_prev", FileFinder::select_prev);
     cx.add_action("menu:select_next", FileFinder::select_next);
-    cx.add_action("uniform_list:scroll", FileFinder::scroll);
 
     cx.add_bindings(vec![
         Binding::new("cmd-p", "file_finder:toggle", None),
@@ -68,7 +67,7 @@ impl View for FileFinder {
         "FileFinder"
     }
 
-    fn render(&self, _: &AppContext) -> ElementBox {
+    fn render(&self, _: &RenderContext<Self>) -> ElementBox {
         let settings = self.settings.borrow();
 
         Align::new(
@@ -267,31 +266,30 @@ impl FileFinder {
         })
     }
 
-    fn toggle(workspace_view: &mut Workspace, _: &(), cx: &mut ViewContext<Workspace>) {
-        workspace_view.toggle_modal(cx, |cx, workspace_view| {
-            let workspace = cx.handle();
-            let finder =
-                cx.add_view(|cx| Self::new(workspace_view.settings.clone(), workspace, cx));
+    fn toggle(workspace: &mut Workspace, _: &(), cx: &mut ViewContext<Workspace>) {
+        workspace.toggle_modal(cx, |cx, workspace| {
+            let handle = cx.handle();
+            let finder = cx.add_view(|cx| Self::new(workspace.settings.clone(), handle, cx));
             cx.subscribe_to_view(&finder, Self::on_event);
             finder
         });
     }
 
     fn on_event(
-        workspace_view: &mut Workspace,
+        workspace: &mut Workspace,
         _: ViewHandle<FileFinder>,
         event: &Event,
         cx: &mut ViewContext<Workspace>,
     ) {
         match event {
             Event::Selected(tree_id, path) => {
-                workspace_view
+                workspace
                     .open_entry((*tree_id, path.clone()), cx)
                     .map(|d| d.detach());
-                workspace_view.dismiss_modal(cx);
+                workspace.dismiss_modal(cx);
             }
             Event::Dismissed => {
-                workspace_view.dismiss_modal(cx);
+                workspace.dismiss_modal(cx);
             }
         }
     }
@@ -318,7 +316,7 @@ impl FileFinder {
             matches: Vec::new(),
             selected: None,
             cancel_flag: Arc::new(AtomicBool::new(false)),
-            list_state: UniformListState::new(),
+            list_state: Default::default(),
         }
     }
 
@@ -388,10 +386,6 @@ impl FileFinder {
         cx.notify();
     }
 
-    fn scroll(&mut self, _: &f32, cx: &mut ViewContext<Self>) {
-        cx.notify();
-    }
-
     fn confirm(&mut self, _: &(), cx: &mut ViewContext<Self>) {
         if let Some(m) = self.matches.get(self.selected_index()) {
             cx.emit(Event::Selected(m.tree_id, m.path.clone()));
@@ -426,7 +420,7 @@ impl FileFinder {
                 false,
                 false,
                 100,
-                cancel_flag.clone(),
+                cancel_flag.as_ref(),
                 background,
             )
             .await;

zed/src/lib.rs 🔗

@@ -1,5 +1,3 @@
-use zrpc::ForegroundRouter;
-
 pub mod assets;
 pub mod editor;
 pub mod file_finder;
@@ -12,6 +10,7 @@ pub mod settings;
 mod sum_tree;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
+pub mod theme_picker;
 mod time;
 mod util;
 pub mod workspace;
@@ -19,13 +18,19 @@ pub mod worktree;
 
 pub use settings::Settings;
 
+use futures::lock::Mutex;
+use postage::watch;
+use std::sync::Arc;
+use zrpc::ForegroundRouter;
+
 pub struct AppState {
-    pub settings: postage::watch::Receiver<Settings>,
-    pub languages: std::sync::Arc<language::LanguageRegistry>,
-    pub themes: std::sync::Arc<settings::ThemeRegistry>,
-    pub rpc_router: std::sync::Arc<ForegroundRouter>,
+    pub settings_tx: Arc<Mutex<watch::Sender<Settings>>>,
+    pub settings: watch::Receiver<Settings>,
+    pub languages: Arc<language::LanguageRegistry>,
+    pub themes: Arc<settings::ThemeRegistry>,
+    pub rpc_router: Arc<ForegroundRouter>,
     pub rpc: rpc::Client,
-    pub fs: std::sync::Arc<dyn fs::Fs>,
+    pub fs: Arc<dyn fs::Fs>,
 }
 
 pub fn init(cx: &mut gpui::MutableAppContext) {

zed/src/main.rs 🔗

@@ -2,13 +2,14 @@
 #![allow(non_snake_case)]
 
 use fs::OpenOptions;
+use futures::lock::Mutex;
 use log::LevelFilter;
 use simplelog::SimpleLogger;
 use std::{fs, path::PathBuf, sync::Arc};
 use zed::{
     self, assets, editor, file_finder,
     fs::RealFs,
-    language, menus, rpc, settings,
+    language, menus, rpc, settings, theme_picker,
     workspace::{self, OpenParams},
     worktree::{self},
     AppState,
@@ -21,12 +22,14 @@ fn main() {
     let app = gpui::App::new(assets::Assets).unwrap();
 
     let themes = settings::ThemeRegistry::new(assets::Assets);
-    let (_, settings) = settings::channel_with_themes(&app.font_cache(), &themes).unwrap();
+    let (settings_tx, settings) =
+        settings::channel_with_themes(&app.font_cache(), &themes).unwrap();
     let languages = Arc::new(language::LanguageRegistry::new());
     languages.set_theme(&settings.borrow().theme);
 
     let mut app_state = AppState {
         languages: languages.clone(),
+        settings_tx: Arc::new(Mutex::new(settings_tx)),
         settings,
         themes,
         rpc_router: Arc::new(ForegroundRouter::new()),
@@ -40,12 +43,14 @@ fn main() {
             &app_state.rpc,
             Arc::get_mut(&mut app_state.rpc_router).unwrap(),
         );
+        let app_state = Arc::new(app_state);
+
         zed::init(cx);
         workspace::init(cx);
         editor::init(cx);
         file_finder::init(cx);
+        theme_picker::init(cx, &app_state);
 
-        let app_state = Arc::new(app_state);
         cx.set_menus(menus::menus(&app_state.clone()));
 
         if stdout_is_a_pty() {

zed/src/settings.rs 🔗

@@ -133,6 +133,18 @@ impl ThemeRegistry {
         })
     }
 
+    pub fn list(&self) -> impl Iterator<Item = String> {
+        self.assets.list("themes/").into_iter().filter_map(|path| {
+            let filename = path.strip_prefix("themes/")?;
+            let theme_name = filename.strip_suffix(".toml")?;
+            if theme_name.starts_with('_') {
+                None
+            } else {
+                Some(theme_name.to_string())
+            }
+        })
+    }
+
     pub fn get(&self, name: &str) -> Result<Arc<Theme>> {
         if let Some(theme) = self.themes.lock().get(name) {
             return Ok(theme.clone());
@@ -497,8 +509,10 @@ mod tests {
     fn test_parse_extended_theme() {
         let assets = TestAssets(&[
             (
-                "themes/base.toml",
+                "themes/_base.toml",
                 r#"
+                abstract = true
+
                 [ui]
                 tab_background = 0x111111
                 tab_text = "$variable_1"
@@ -511,7 +525,7 @@ mod tests {
             (
                 "themes/light.toml",
                 r#"
-                extends = "base"
+                extends = "_base"
 
                 [variables]
                 variable_1 = 0x333333
@@ -524,6 +538,16 @@ mod tests {
                 background = 0x666666
                 "#,
             ),
+            (
+                "themes/dark.toml",
+                r#"
+                extends = "_base"
+
+                [variables]
+                variable_1 = 0x555555
+                variable_2 = 0x666666
+                "#,
+            ),
         ]);
 
         let registry = ThemeRegistry::new(assets);
@@ -533,6 +557,11 @@ mod tests {
         assert_eq!(theme.ui.tab_text, ColorU::from_u32(0x333333ff));
         assert_eq!(theme.editor.background, ColorU::from_u32(0x666666ff));
         assert_eq!(theme.editor.default_text, ColorU::from_u32(0x444444ff));
+
+        assert_eq!(
+            registry.list().collect::<Vec<_>>(),
+            &["light".to_string(), "dark".to_string()]
+        );
     }
 
     #[test]
@@ -585,5 +614,19 @@ mod tests {
                 Err(anyhow!("no such path {}", path))
             }
         }
+
+        fn list(&self, prefix: &str) -> Vec<std::borrow::Cow<'static, str>> {
+            self.0
+                .iter()
+                .copied()
+                .filter_map(|(path, _)| {
+                    if path.starts_with(prefix) {
+                        Some(path.into())
+                    } else {
+                        None
+                    }
+                })
+                .collect()
+        }
     }
 }

zed/src/test.rs 🔗

@@ -6,6 +6,7 @@ use crate::{
     time::ReplicaId,
     AppState,
 };
+use futures::lock::Mutex;
 use gpui::{AppContext, Entity, ModelHandle};
 use smol::channel;
 use std::{
@@ -154,10 +155,11 @@ fn write_tree(path: &Path, tree: serde_json::Value) {
 }
 
 pub fn build_app_state(cx: &AppContext) -> Arc<AppState> {
-    let settings = settings::channel(&cx.font_cache()).unwrap().1;
+    let (settings_tx, settings) = settings::channel(&cx.font_cache()).unwrap();
     let languages = Arc::new(LanguageRegistry::new());
     let themes = ThemeRegistry::new(());
     Arc::new(AppState {
+        settings_tx: Arc::new(Mutex::new(settings_tx)),
         settings,
         themes,
         languages: languages.clone(),

zed/src/theme_picker.rs 🔗

@@ -0,0 +1,308 @@
+use std::{cmp, sync::Arc};
+
+use crate::{
+    editor::{self, Editor},
+    settings::ThemeRegistry,
+    workspace::Workspace,
+    worktree::fuzzy::{match_strings, StringMatch, StringMatchCandidate},
+    AppState, Settings,
+};
+use futures::lock::Mutex;
+use gpui::{
+    color::ColorF,
+    elements::{
+        Align, ChildView, ConstrainedBox, Container, Expanded, Flex, Label, ParentElement,
+        UniformList, UniformListState,
+    },
+    fonts::{Properties, Weight},
+    geometry::vector::vec2f,
+    keymap::{self, Binding},
+    AppContext, Axis, Border, Element, ElementBox, Entity, MutableAppContext, RenderContext, View,
+    ViewContext, ViewHandle,
+};
+use postage::watch;
+
+pub struct ThemePicker {
+    settings_tx: Arc<Mutex<watch::Sender<Settings>>>,
+    settings: watch::Receiver<Settings>,
+    registry: Arc<ThemeRegistry>,
+    matches: Vec<StringMatch>,
+    query_buffer: ViewHandle<Editor>,
+    list_state: UniformListState,
+    selected_index: usize,
+}
+
+pub fn init(cx: &mut MutableAppContext, app_state: &Arc<AppState>) {
+    cx.add_action("theme_picker:confirm", ThemePicker::confirm);
+    // cx.add_action("file_finder:select", ThemePicker::select);
+    cx.add_action("menu:select_prev", ThemePicker::select_prev);
+    cx.add_action("menu:select_next", ThemePicker::select_next);
+    cx.add_action("theme_picker:toggle", ThemePicker::toggle);
+
+    cx.add_bindings(vec![
+        Binding::new("cmd-k cmd-t", "theme_picker:toggle", None).with_arg(app_state.clone()),
+        Binding::new("escape", "theme_picker:toggle", Some("ThemePicker"))
+            .with_arg(app_state.clone()),
+        Binding::new("enter", "theme_picker:confirm", Some("ThemePicker")),
+    ]);
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+impl ThemePicker {
+    fn new(
+        settings_tx: Arc<Mutex<watch::Sender<Settings>>>,
+        settings: watch::Receiver<Settings>,
+        registry: Arc<ThemeRegistry>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let query_buffer = cx.add_view(|cx| Editor::single_line(settings.clone(), cx));
+        cx.subscribe_to_view(&query_buffer, Self::on_query_editor_event);
+
+        let mut this = Self {
+            settings,
+            settings_tx,
+            registry,
+            query_buffer,
+            matches: Vec::new(),
+            list_state: Default::default(),
+            selected_index: 0,
+        };
+        this.update_matches(cx);
+        this
+    }
+
+    fn toggle(
+        workspace: &mut Workspace,
+        app_state: &Arc<AppState>,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        workspace.toggle_modal(cx, |cx, _| {
+            let picker = cx.add_view(|cx| {
+                Self::new(
+                    app_state.settings_tx.clone(),
+                    app_state.settings.clone(),
+                    app_state.themes.clone(),
+                    cx,
+                )
+            });
+            cx.subscribe_to_view(&picker, Self::on_event);
+            picker
+        });
+    }
+
+    fn confirm(&mut self, _: &(), cx: &mut ViewContext<Self>) {
+        if let Some(mat) = self.matches.get(self.selected_index) {
+            let settings_tx = self.settings_tx.clone();
+            if let Ok(theme) = self.registry.get(&mat.string) {
+                cx.foreground()
+                    .spawn(async move {
+                        settings_tx.lock().await.borrow_mut().theme = theme;
+                    })
+                    .detach();
+            }
+        }
+        cx.emit(Event::Dismissed);
+    }
+
+    fn select_prev(&mut self, _: &(), cx: &mut ViewContext<Self>) {
+        if self.selected_index > 0 {
+            self.selected_index -= 1;
+        }
+        self.list_state.scroll_to(self.selected_index);
+        cx.notify();
+    }
+
+    fn select_next(&mut self, _: &(), cx: &mut ViewContext<Self>) {
+        if self.selected_index + 1 < self.matches.len() {
+            self.selected_index += 1;
+        }
+        self.list_state.scroll_to(self.selected_index);
+        cx.notify();
+    }
+
+    // fn select(&mut self, selected_index: &usize, cx: &mut ViewContext<Self>) {
+    //     self.selected_index = *selected_index;
+    //     self.confirm(&(), cx);
+    // }
+
+    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
+        let background = cx.background().clone();
+        let candidates = self
+            .registry
+            .list()
+            .map(|name| StringMatchCandidate {
+                char_bag: name.as_str().into(),
+                string: name,
+            })
+            .collect::<Vec<_>>();
+        let query = self.query_buffer.update(cx, |buffer, cx| buffer.text(cx));
+
+        self.matches = if query.is_empty() {
+            candidates
+                .into_iter()
+                .map(|candidate| StringMatch {
+                    string: candidate.string,
+                    positions: Vec::new(),
+                    score: 0.0,
+                })
+                .collect()
+        } else {
+            smol::block_on(match_strings(
+                &candidates,
+                &query,
+                false,
+                100,
+                &Default::default(),
+                background,
+            ))
+        };
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<ThemePicker>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => {
+                workspace.dismiss_modal(cx);
+            }
+        }
+    }
+
+    fn on_query_editor_event(
+        &mut self,
+        _: ViewHandle<Editor>,
+        event: &editor::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            editor::Event::Edited => self.update_matches(cx),
+            editor::Event::Blurred => cx.emit(Event::Dismissed),
+            _ => {}
+        }
+    }
+
+    fn render_matches(&self, cx: &RenderContext<Self>) -> ElementBox {
+        if self.matches.is_empty() {
+            let settings = self.settings.borrow();
+            return Container::new(
+                Label::new(
+                    "No matches".into(),
+                    settings.ui_font_family,
+                    settings.ui_font_size,
+                )
+                .with_default_color(settings.theme.editor.default_text.0)
+                .boxed(),
+            )
+            .with_margin_top(6.0)
+            .named("empty matches");
+        }
+
+        let handle = cx.handle();
+        let list = UniformList::new(
+            self.list_state.clone(),
+            self.matches.len(),
+            move |mut range, items, cx| {
+                let cx = cx.as_ref();
+                let picker = handle.upgrade(cx).unwrap();
+                let picker = picker.read(cx);
+                let start = range.start;
+                range.end = cmp::min(range.end, picker.matches.len());
+                items.extend(
+                    picker.matches[range]
+                        .iter()
+                        .enumerate()
+                        .map(move |(i, path_match)| picker.render_match(path_match, start + i)),
+                );
+            },
+        );
+
+        Container::new(list.boxed())
+            .with_margin_top(6.0)
+            .named("matches")
+    }
+
+    fn render_match(&self, theme_match: &StringMatch, index: usize) -> ElementBox {
+        let settings = self.settings.borrow();
+        let theme = &settings.theme.ui;
+        let bold = *Properties::new().weight(Weight::BOLD);
+
+        let mut container = Container::new(
+            Label::new(
+                theme_match.string.clone(),
+                settings.ui_font_family,
+                settings.ui_font_size,
+            )
+            .with_default_color(theme.modal_match_text.0)
+            .with_highlights(
+                theme.modal_match_text_highlight.0,
+                bold,
+                theme_match.positions.clone(),
+            )
+            .boxed(),
+        )
+        .with_uniform_padding(6.0)
+        .with_background_color(if index == self.selected_index {
+            theme.modal_match_background_active.0
+        } else {
+            theme.modal_match_background.0
+        });
+
+        if index == self.selected_index || index < self.matches.len() - 1 {
+            container = container.with_border(Border::bottom(1.0, theme.modal_match_border));
+        }
+
+        container.boxed()
+    }
+}
+
+impl Entity for ThemePicker {
+    type Event = Event;
+}
+
+impl View for ThemePicker {
+    fn ui_name() -> &'static str {
+        "ThemePicker"
+    }
+
+    fn render(&self, cx: &RenderContext<Self>) -> ElementBox {
+        let settings = self.settings.borrow();
+
+        Align::new(
+            ConstrainedBox::new(
+                Container::new(
+                    Flex::new(Axis::Vertical)
+                        .with_child(ChildView::new(self.query_buffer.id()).boxed())
+                        .with_child(Expanded::new(1.0, self.render_matches(cx)).boxed())
+                        .boxed(),
+                )
+                .with_margin_top(12.0)
+                .with_uniform_padding(6.0)
+                .with_corner_radius(6.0)
+                .with_background_color(settings.theme.ui.modal_background)
+                .with_shadow(vec2f(0., 4.), 12., ColorF::new(0.0, 0.0, 0.0, 0.5).to_u8())
+                .boxed(),
+            )
+            .with_max_width(600.0)
+            .with_max_height(400.0)
+            .boxed(),
+        )
+        .top()
+        .named("theme picker")
+    }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.query_buffer);
+    }
+
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+}

zed/src/workspace.rs 🔗

@@ -13,8 +13,8 @@ use crate::{
 use anyhow::{anyhow, Result};
 use gpui::{
     elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext, ClipboardItem,
-    Entity, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, Task, View,
-    ViewContext, ViewHandle, WeakModelHandle,
+    Entity, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task,
+    View, ViewContext, ViewHandle, WeakModelHandle,
 };
 use log::error;
 pub use pane::*;
@@ -879,7 +879,7 @@ impl View for Workspace {
         "Workspace"
     }
 
-    fn render(&self, _: &AppContext) -> ElementBox {
+    fn render(&self, _: &RenderContext<Self>) -> ElementBox {
         let settings = self.settings.borrow();
         Container::new(
             Stack::new()
@@ -974,8 +974,8 @@ mod tests {
         })
         .await;
         assert_eq!(cx.window_ids().len(), 1);
-        let workspace_view_1 = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
-        workspace_view_1.read_with(&cx, |workspace, _| {
+        let workspace_1 = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
+        workspace_1.read_with(&cx, |workspace, _| {
             assert_eq!(workspace.worktrees().len(), 2)
         });
 
@@ -1380,9 +1380,9 @@ mod tests {
             assert_eq!(pane2_item.entry_id(cx.as_ref()), Some(file1.clone()));
 
             cx.dispatch_action(window_id, vec![pane_2.id()], "pane:close_active_item", ());
-            let workspace_view = workspace.read(cx);
-            assert_eq!(workspace_view.panes.len(), 1);
-            assert_eq!(workspace_view.active_pane(), &pane_1);
+            let workspace = workspace.read(cx);
+            assert_eq!(workspace.panes.len(), 1);
+            assert_eq!(workspace.active_pane(), &pane_1);
         });
     }
 }

zed/src/workspace/pane.rs 🔗

@@ -5,7 +5,8 @@ use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
     keymap::Binding,
-    AppContext, Border, Entity, MutableAppContext, Quad, View, ViewContext, ViewHandle,
+    AppContext, Border, Entity, MutableAppContext, Quad, RenderContext, View, ViewContext,
+    ViewHandle,
 };
 use postage::watch;
 use std::{cmp, path::Path, sync::Arc};
@@ -371,7 +372,7 @@ impl View for Pane {
         "Pane"
     }
 
-    fn render<'a>(&self, cx: &AppContext) -> ElementBox {
+    fn render<'a>(&self, cx: &RenderContext<Self>) -> ElementBox {
         if let Some(active_item) = self.active_item() {
             Flex::column()
                 .with_child(self.render_tabs(cx))

zed/src/worktree.rs 🔗

@@ -1,5 +1,5 @@
 mod char_bag;
-mod fuzzy;
+pub(crate) mod fuzzy;
 mod ignore;
 
 use self::{char_bag::CharBag, ignore::IgnoreStack};
@@ -2615,6 +2615,7 @@ mod tests {
 
             tree.snapshot()
         });
+        let cancel_flag = Default::default();
         let results = cx
             .read(|cx| {
                 match_paths(
@@ -2624,7 +2625,7 @@ mod tests {
                     false,
                     false,
                     10,
-                    Default::default(),
+                    &cancel_flag,
                     cx.background().clone(),
                 )
             })
@@ -2667,6 +2668,7 @@ mod tests {
             assert_eq!(tree.file_count(), 0);
             tree.snapshot()
         });
+        let cancel_flag = Default::default();
         let results = cx
             .read(|cx| {
                 match_paths(
@@ -2676,7 +2678,7 @@ mod tests {
                     false,
                     false,
                     10,
-                    Default::default(),
+                    &cancel_flag,
                     cx.background().clone(),
                 )
             })

zed/src/worktree/fuzzy.rs 🔗

@@ -2,6 +2,7 @@ use super::{char_bag::CharBag, EntryKind, Snapshot};
 use crate::util;
 use gpui::executor;
 use std::{
+    borrow::Cow,
     cmp::{max, min, Ordering},
     path::Path,
     sync::atomic::{self, AtomicBool},
@@ -12,8 +13,31 @@ const BASE_DISTANCE_PENALTY: f64 = 0.6;
 const ADDITIONAL_DISTANCE_PENALTY: f64 = 0.05;
 const MIN_DISTANCE_PENALTY: f64 = 0.2;
 
+struct Matcher<'a> {
+    query: &'a [char],
+    lowercase_query: &'a [char],
+    query_char_bag: CharBag,
+    smart_case: bool,
+    max_results: usize,
+    min_score: f64,
+    match_positions: Vec<usize>,
+    last_positions: Vec<usize>,
+    score_matrix: Vec<Option<f64>>,
+    best_position_matrix: Vec<usize>,
+}
+
+trait Match: Ord {
+    fn score(&self) -> f64;
+    fn set_positions(&mut self, positions: Vec<usize>);
+}
+
+trait MatchCandidate {
+    fn has_chars(&self, bag: CharBag) -> bool;
+    fn to_string<'a>(&'a self) -> Cow<'a, str>;
+}
+
 #[derive(Clone, Debug)]
-pub struct MatchCandidate<'a> {
+pub struct PathMatchCandidate<'a> {
     pub path: &'a Arc<Path>,
     pub char_bag: CharBag,
 }
@@ -27,6 +51,82 @@ pub struct PathMatch {
     pub include_root_name: bool,
 }
 
+#[derive(Clone, Debug)]
+pub struct StringMatchCandidate {
+    pub string: String,
+    pub char_bag: CharBag,
+}
+
+impl Match for PathMatch {
+    fn score(&self) -> f64 {
+        self.score
+    }
+
+    fn set_positions(&mut self, positions: Vec<usize>) {
+        self.positions = positions;
+    }
+}
+
+impl Match for StringMatch {
+    fn score(&self) -> f64 {
+        self.score
+    }
+
+    fn set_positions(&mut self, positions: Vec<usize>) {
+        self.positions = positions;
+    }
+}
+
+impl<'a> MatchCandidate for PathMatchCandidate<'a> {
+    fn has_chars(&self, bag: CharBag) -> bool {
+        self.char_bag.is_superset(bag)
+    }
+
+    fn to_string(&self) -> Cow<'a, str> {
+        self.path.to_string_lossy()
+    }
+}
+
+impl<'a> MatchCandidate for &'a StringMatchCandidate {
+    fn has_chars(&self, bag: CharBag) -> bool {
+        self.char_bag.is_superset(bag)
+    }
+
+    fn to_string(&self) -> Cow<'a, str> {
+        self.string.as_str().into()
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct StringMatch {
+    pub score: f64,
+    pub positions: Vec<usize>,
+    pub string: String,
+}
+
+impl PartialEq for StringMatch {
+    fn eq(&self, other: &Self) -> bool {
+        self.score.eq(&other.score)
+    }
+}
+
+impl Eq for StringMatch {}
+
+impl PartialOrd for StringMatch {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for StringMatch {
+    fn cmp(&self, other: &Self) -> Ordering {
+        self.score
+            .partial_cmp(&other.score)
+            .unwrap_or(Ordering::Equal)
+            .then_with(|| self.string.cmp(&other.string))
+    }
+}
+
 impl PartialEq for PathMatch {
     fn eq(&self, other: &Self) -> bool {
         self.score.eq(&other.score)
@@ -51,6 +151,62 @@ impl Ord for PathMatch {
     }
 }
 
+pub async fn match_strings(
+    candidates: &[StringMatchCandidate],
+    query: &str,
+    smart_case: bool,
+    max_results: usize,
+    cancel_flag: &AtomicBool,
+    background: Arc<executor::Background>,
+) -> Vec<StringMatch> {
+    let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
+    let query = query.chars().collect::<Vec<_>>();
+
+    let lowercase_query = &lowercase_query;
+    let query = &query;
+    let query_char_bag = CharBag::from(&lowercase_query[..]);
+
+    let num_cpus = background.num_cpus().min(candidates.len());
+    let segment_size = (candidates.len() + num_cpus - 1) / num_cpus;
+    let mut segment_results = (0..num_cpus)
+        .map(|_| Vec::with_capacity(max_results))
+        .collect::<Vec<_>>();
+
+    background
+        .scoped(|scope| {
+            for (segment_idx, results) in segment_results.iter_mut().enumerate() {
+                let cancel_flag = &cancel_flag;
+                scope.spawn(async move {
+                    let segment_start = segment_idx * segment_size;
+                    let segment_end = segment_start + segment_size;
+                    let mut matcher = Matcher::new(
+                        query,
+                        lowercase_query,
+                        query_char_bag,
+                        smart_case,
+                        max_results,
+                    );
+                    matcher.match_strings(
+                        &candidates[segment_start..segment_end],
+                        results,
+                        cancel_flag,
+                    );
+                });
+            }
+        })
+        .await;
+
+    let mut results = Vec::new();
+    for segment_result in segment_results {
+        if results.is_empty() {
+            results = segment_result;
+        } else {
+            util::extend_sorted(&mut results, segment_result, max_results, |a, b| b.cmp(&a));
+        }
+    }
+    results
+}
+
 pub async fn match_paths<'a, T>(
     snapshots: T,
     query: &str,
@@ -58,7 +214,7 @@ pub async fn match_paths<'a, T>(
     include_ignored: bool,
     smart_case: bool,
     max_results: usize,
-    cancel_flag: Arc<AtomicBool>,
+    cancel_flag: &AtomicBool,
     background: Arc<executor::Background>,
 ) -> Vec<PathMatch>
 where
@@ -78,7 +234,7 @@ where
 
     let lowercase_query = &lowercase_query;
     let query = &query;
-    let query_chars = CharBag::from(&lowercase_query[..]);
+    let query_char_bag = CharBag::from(&lowercase_query[..]);
 
     let num_cpus = background.num_cpus().min(path_count);
     let segment_size = (path_count + num_cpus - 1) / num_cpus;
@@ -90,18 +246,16 @@ where
         .scoped(|scope| {
             for (segment_idx, results) in segment_results.iter_mut().enumerate() {
                 let snapshots = snapshots.clone();
-                let cancel_flag = &cancel_flag;
                 scope.spawn(async move {
                     let segment_start = segment_idx * segment_size;
                     let segment_end = segment_start + segment_size;
-
-                    let mut min_score = 0.0;
-                    let mut last_positions = Vec::new();
-                    last_positions.resize(query.len(), 0);
-                    let mut match_positions = Vec::new();
-                    match_positions.resize(query.len(), 0);
-                    let mut score_matrix = Vec::new();
-                    let mut best_position_matrix = Vec::new();
+                    let mut matcher = Matcher::new(
+                        query,
+                        lowercase_query,
+                        query_char_bag,
+                        smart_case,
+                        max_results,
+                    );
 
                     let mut tree_start = 0;
                     for snapshot in snapshots {
@@ -123,7 +277,7 @@ where
                             };
                             let paths = entries.map(|entry| {
                                 if let EntryKind::File(char_bag) = entry.kind {
-                                    MatchCandidate {
+                                    PathMatchCandidate {
                                         path: &entry.path,
                                         char_bag,
                                     }
@@ -132,21 +286,11 @@ where
                                 }
                             });
 
-                            match_single_tree_paths(
+                            matcher.match_paths(
                                 snapshot,
                                 include_root_name,
                                 paths,
-                                query,
-                                lowercase_query,
-                                query_chars,
-                                smart_case,
                                 results,
-                                max_results,
-                                &mut min_score,
-                                &mut match_positions,
-                                &mut last_positions,
-                                &mut score_matrix,
-                                &mut best_position_matrix,
                                 &cancel_flag,
                             );
                         }
@@ -171,322 +315,335 @@ where
     results
 }
 
-fn match_single_tree_paths<'a>(
-    snapshot: &Snapshot,
-    include_root_name: bool,
-    path_entries: impl Iterator<Item = MatchCandidate<'a>>,
-    query: &[char],
-    lowercase_query: &[char],
-    query_chars: CharBag,
-    smart_case: bool,
-    results: &mut Vec<PathMatch>,
-    max_results: usize,
-    min_score: &mut f64,
-    match_positions: &mut Vec<usize>,
-    last_positions: &mut Vec<usize>,
-    score_matrix: &mut Vec<Option<f64>>,
-    best_position_matrix: &mut Vec<usize>,
-    cancel_flag: &AtomicBool,
-) {
-    let mut path_chars = Vec::new();
-    let mut lowercase_path_chars = Vec::new();
+impl<'a> Matcher<'a> {
+    fn new(
+        query: &'a [char],
+        lowercase_query: &'a [char],
+        query_char_bag: CharBag,
+        smart_case: bool,
+        max_results: usize,
+    ) -> Self {
+        Self {
+            query,
+            lowercase_query,
+            query_char_bag,
+            min_score: 0.0,
+            last_positions: vec![0; query.len()],
+            match_positions: vec![0; query.len()],
+            score_matrix: Vec::new(),
+            best_position_matrix: Vec::new(),
+            smart_case,
+            max_results,
+        }
+    }
 
-    let prefix = if include_root_name {
-        snapshot.root_name()
-    } else {
-        ""
+    fn match_strings(
+        &mut self,
+        candidates: &[StringMatchCandidate],
+        results: &mut Vec<StringMatch>,
+        cancel_flag: &AtomicBool,
+    ) {
+        self.match_internal(
+            &[],
+            &[],
+            candidates.iter(),
+            results,
+            cancel_flag,
+            |candidate, score| StringMatch {
+                score,
+                positions: Vec::new(),
+                string: candidate.string.to_string(),
+            },
+        )
     }
-    .chars()
-    .collect::<Vec<_>>();
-    let lowercase_prefix = prefix
-        .iter()
-        .map(|c| c.to_ascii_lowercase())
-        .collect::<Vec<_>>();
 
-    for candidate in path_entries {
-        if !candidate.char_bag.is_superset(query_chars) {
-            continue;
+    fn match_paths(
+        &mut self,
+        snapshot: &Snapshot,
+        include_root_name: bool,
+        path_entries: impl Iterator<Item = PathMatchCandidate<'a>>,
+        results: &mut Vec<PathMatch>,
+        cancel_flag: &AtomicBool,
+    ) {
+        let tree_id = snapshot.id;
+        let prefix = if include_root_name {
+            snapshot.root_name()
+        } else {
+            ""
         }
+        .chars()
+        .collect::<Vec<_>>();
+        let lowercase_prefix = prefix
+            .iter()
+            .map(|c| c.to_ascii_lowercase())
+            .collect::<Vec<_>>();
+        self.match_internal(
+            &prefix,
+            &lowercase_prefix,
+            path_entries,
+            results,
+            cancel_flag,
+            |candidate, score| PathMatch {
+                score,
+                tree_id,
+                positions: Vec::new(),
+                path: candidate.path.clone(),
+                include_root_name,
+            },
+        )
+    }
 
-        if cancel_flag.load(atomic::Ordering::Relaxed) {
-            break;
-        }
+    fn match_internal<C: MatchCandidate, R, F>(
+        &mut self,
+        prefix: &[char],
+        lowercase_prefix: &[char],
+        candidates: impl Iterator<Item = C>,
+        results: &mut Vec<R>,
+        cancel_flag: &AtomicBool,
+        build_match: F,
+    ) where
+        R: Match,
+        F: Fn(&C, f64) -> R,
+    {
+        let mut candidate_chars = Vec::new();
+        let mut lowercase_candidate_chars = Vec::new();
+
+        for candidate in candidates {
+            if !candidate.has_chars(self.query_char_bag) {
+                continue;
+            }
 
-        path_chars.clear();
-        lowercase_path_chars.clear();
-        for c in candidate.path.to_string_lossy().chars() {
-            path_chars.push(c);
-            lowercase_path_chars.push(c.to_ascii_lowercase());
-        }
+            if cancel_flag.load(atomic::Ordering::Relaxed) {
+                break;
+            }
 
-        if !find_last_positions(
-            last_positions,
-            &lowercase_prefix,
-            &lowercase_path_chars,
-            &lowercase_query[..],
-        ) {
-            continue;
-        }
+            candidate_chars.clear();
+            lowercase_candidate_chars.clear();
+            for c in candidate.to_string().chars() {
+                candidate_chars.push(c);
+                lowercase_candidate_chars.push(c.to_ascii_lowercase());
+            }
 
-        let matrix_len = query.len() * (path_chars.len() + prefix.len());
-        score_matrix.clear();
-        score_matrix.resize(matrix_len, None);
-        best_position_matrix.clear();
-        best_position_matrix.resize(matrix_len, 0);
-
-        let score = score_match(
-            &query[..],
-            &lowercase_query[..],
-            &path_chars,
-            &lowercase_path_chars,
-            &prefix,
-            &lowercase_prefix,
-            smart_case,
-            &last_positions,
-            score_matrix,
-            best_position_matrix,
-            match_positions,
-            *min_score,
-        );
+            if !self.find_last_positions(&lowercase_prefix, &lowercase_candidate_chars) {
+                continue;
+            }
 
-        if score > 0.0 {
-            let mat = PathMatch {
-                tree_id: snapshot.id,
-                path: candidate.path.clone(),
-                score,
-                positions: match_positions.clone(),
-                include_root_name,
-            };
-            if let Err(i) = results.binary_search_by(|m| mat.cmp(&m)) {
-                if results.len() < max_results {
-                    results.insert(i, mat);
-                } else if i < results.len() {
-                    results.pop();
-                    results.insert(i, mat);
-                }
-                if results.len() == max_results {
-                    *min_score = results.last().unwrap().score;
+            let matrix_len = self.query.len() * (prefix.len() + candidate_chars.len());
+            self.score_matrix.clear();
+            self.score_matrix.resize(matrix_len, None);
+            self.best_position_matrix.clear();
+            self.best_position_matrix.resize(matrix_len, 0);
+
+            let score = self.score_match(
+                &candidate_chars,
+                &lowercase_candidate_chars,
+                &prefix,
+                &lowercase_prefix,
+            );
+
+            if score > 0.0 {
+                let mut mat = build_match(&candidate, score);
+                if let Err(i) = results.binary_search_by(|m| mat.cmp(&m)) {
+                    if results.len() < self.max_results {
+                        mat.set_positions(self.match_positions.clone());
+                        results.insert(i, mat);
+                    } else if i < results.len() {
+                        results.pop();
+                        mat.set_positions(self.match_positions.clone());
+                        results.insert(i, mat);
+                    }
+                    if results.len() == self.max_results {
+                        self.min_score = results.last().unwrap().score();
+                    }
                 }
             }
         }
     }
-}
 
-fn find_last_positions(
-    last_positions: &mut Vec<usize>,
-    prefix: &[char],
-    path: &[char],
-    query: &[char],
-) -> bool {
-    let mut path = path.iter();
-    let mut prefix_iter = prefix.iter();
-    for (i, char) in query.iter().enumerate().rev() {
-        if let Some(j) = path.rposition(|c| c == char) {
-            last_positions[i] = j + prefix.len();
-        } else if let Some(j) = prefix_iter.rposition(|c| c == char) {
-            last_positions[i] = j;
-        } else {
-            return false;
+    fn find_last_positions(&mut self, prefix: &[char], path: &[char]) -> bool {
+        let mut path = path.iter();
+        let mut prefix_iter = prefix.iter();
+        for (i, char) in self.query.iter().enumerate().rev() {
+            if let Some(j) = path.rposition(|c| c == char) {
+                self.last_positions[i] = j + prefix.len();
+            } else if let Some(j) = prefix_iter.rposition(|c| c == char) {
+                self.last_positions[i] = j;
+            } else {
+                return false;
+            }
         }
-    }
-    true
-}
-
-fn score_match(
-    query: &[char],
-    query_cased: &[char],
-    path: &[char],
-    path_cased: &[char],
-    prefix: &[char],
-    lowercase_prefix: &[char],
-    smart_case: bool,
-    last_positions: &[usize],
-    score_matrix: &mut [Option<f64>],
-    best_position_matrix: &mut [usize],
-    match_positions: &mut [usize],
-    min_score: f64,
-) -> f64 {
-    let score = recursive_score_match(
-        query,
-        query_cased,
-        path,
-        path_cased,
-        prefix,
-        lowercase_prefix,
-        smart_case,
-        last_positions,
-        score_matrix,
-        best_position_matrix,
-        min_score,
-        0,
-        0,
-        query.len() as f64,
-    ) * query.len() as f64;
-
-    if score <= 0.0 {
-        return 0.0;
+        true
     }
 
-    let path_len = prefix.len() + path.len();
-    let mut cur_start = 0;
-    let mut byte_ix = 0;
-    let mut char_ix = 0;
-    for i in 0..query.len() {
-        let match_char_ix = best_position_matrix[i * path_len + cur_start];
-        while char_ix < match_char_ix {
-            let ch = prefix
-                .get(char_ix)
-                .or_else(|| path.get(char_ix - prefix.len()))
-                .unwrap();
-            byte_ix += ch.len_utf8();
-            char_ix += 1;
+    fn score_match(
+        &mut self,
+        path: &[char],
+        path_cased: &[char],
+        prefix: &[char],
+        lowercase_prefix: &[char],
+    ) -> f64 {
+        let score = self.recursive_score_match(
+            path,
+            path_cased,
+            prefix,
+            lowercase_prefix,
+            0,
+            0,
+            self.query.len() as f64,
+        ) * self.query.len() as f64;
+
+        if score <= 0.0 {
+            return 0.0;
         }
-        cur_start = match_char_ix + 1;
-        match_positions[i] = byte_ix;
-    }
 
-    score
-}
+        let path_len = prefix.len() + path.len();
+        let mut cur_start = 0;
+        let mut byte_ix = 0;
+        let mut char_ix = 0;
+        for i in 0..self.query.len() {
+            let match_char_ix = self.best_position_matrix[i * path_len + cur_start];
+            while char_ix < match_char_ix {
+                let ch = prefix
+                    .get(char_ix)
+                    .or_else(|| path.get(char_ix - prefix.len()))
+                    .unwrap();
+                byte_ix += ch.len_utf8();
+                char_ix += 1;
+            }
+            cur_start = match_char_ix + 1;
+            self.match_positions[i] = byte_ix;
+        }
 
-fn recursive_score_match(
-    query: &[char],
-    query_cased: &[char],
-    path: &[char],
-    path_cased: &[char],
-    prefix: &[char],
-    lowercase_prefix: &[char],
-    smart_case: bool,
-    last_positions: &[usize],
-    score_matrix: &mut [Option<f64>],
-    best_position_matrix: &mut [usize],
-    min_score: f64,
-    query_idx: usize,
-    path_idx: usize,
-    cur_score: f64,
-) -> f64 {
-    if query_idx == query.len() {
-        return 1.0;
+        score
     }
 
-    let path_len = prefix.len() + path.len();
-
-    if let Some(memoized) = score_matrix[query_idx * path_len + path_idx] {
-        return memoized;
-    }
+    fn recursive_score_match(
+        &mut self,
+        path: &[char],
+        path_cased: &[char],
+        prefix: &[char],
+        lowercase_prefix: &[char],
+        query_idx: usize,
+        path_idx: usize,
+        cur_score: f64,
+    ) -> f64 {
+        if query_idx == self.query.len() {
+            return 1.0;
+        }
 
-    let mut score = 0.0;
-    let mut best_position = 0;
+        let path_len = prefix.len() + path.len();
 
-    let query_char = query_cased[query_idx];
-    let limit = last_positions[query_idx];
+        if let Some(memoized) = self.score_matrix[query_idx * path_len + path_idx] {
+            return memoized;
+        }
 
-    let mut last_slash = 0;
-    for j in path_idx..=limit {
-        let path_char = if j < prefix.len() {
-            lowercase_prefix[j]
-        } else {
-            path_cased[j - prefix.len()]
-        };
-        let is_path_sep = path_char == '/' || path_char == '\\';
+        let mut score = 0.0;
+        let mut best_position = 0;
 
-        if query_idx == 0 && is_path_sep {
-            last_slash = j;
-        }
+        let query_char = self.lowercase_query[query_idx];
+        let limit = self.last_positions[query_idx];
 
-        if query_char == path_char || (is_path_sep && query_char == '_' || query_char == '\\') {
-            let curr = if j < prefix.len() {
-                prefix[j]
+        let mut last_slash = 0;
+        for j in path_idx..=limit {
+            let path_char = if j < prefix.len() {
+                lowercase_prefix[j]
             } else {
-                path[j - prefix.len()]
+                path_cased[j - prefix.len()]
             };
+            let is_path_sep = path_char == '/' || path_char == '\\';
+
+            if query_idx == 0 && is_path_sep {
+                last_slash = j;
+            }
 
-            let mut char_score = 1.0;
-            if j > path_idx {
-                let last = if j - 1 < prefix.len() {
-                    prefix[j - 1]
+            if query_char == path_char || (is_path_sep && query_char == '_' || query_char == '\\') {
+                let curr = if j < prefix.len() {
+                    prefix[j]
                 } else {
-                    path[j - 1 - prefix.len()]
+                    path[j - prefix.len()]
                 };
 
-                if last == '/' {
-                    char_score = 0.9;
-                } else if last == '-' || last == '_' || last == ' ' || last.is_numeric() {
-                    char_score = 0.8;
-                } else if last.is_lowercase() && curr.is_uppercase() {
-                    char_score = 0.8;
-                } else if last == '.' {
-                    char_score = 0.7;
-                } else if query_idx == 0 {
-                    char_score = BASE_DISTANCE_PENALTY;
-                } else {
-                    char_score = MIN_DISTANCE_PENALTY.max(
-                        BASE_DISTANCE_PENALTY
-                            - (j - path_idx - 1) as f64 * ADDITIONAL_DISTANCE_PENALTY,
-                    );
+                let mut char_score = 1.0;
+                if j > path_idx {
+                    let last = if j - 1 < prefix.len() {
+                        prefix[j - 1]
+                    } else {
+                        path[j - 1 - prefix.len()]
+                    };
+
+                    if last == '/' {
+                        char_score = 0.9;
+                    } else if last == '-' || last == '_' || last == ' ' || last.is_numeric() {
+                        char_score = 0.8;
+                    } else if last.is_lowercase() && curr.is_uppercase() {
+                        char_score = 0.8;
+                    } else if last == '.' {
+                        char_score = 0.7;
+                    } else if query_idx == 0 {
+                        char_score = BASE_DISTANCE_PENALTY;
+                    } else {
+                        char_score = MIN_DISTANCE_PENALTY.max(
+                            BASE_DISTANCE_PENALTY
+                                - (j - path_idx - 1) as f64 * ADDITIONAL_DISTANCE_PENALTY,
+                        );
+                    }
                 }
-            }
 
-            // Apply a severe penalty if the case doesn't match.
-            // This will make the exact matches have higher score than the case-insensitive and the
-            // path insensitive matches.
-            if (smart_case || curr == '/') && query[query_idx] != curr {
-                char_score *= 0.001;
-            }
+                // Apply a severe penalty if the case doesn't match.
+                // This will make the exact matches have higher score than the case-insensitive and the
+                // path insensitive matches.
+                if (self.smart_case || curr == '/') && self.query[query_idx] != curr {
+                    char_score *= 0.001;
+                }
 
-            let mut multiplier = char_score;
+                let mut multiplier = char_score;
 
-            // Scale the score based on how deep within the path we found the match.
-            if query_idx == 0 {
-                multiplier /= ((prefix.len() + path.len()) - last_slash) as f64;
-            }
+                // Scale the score based on how deep within the path we found the match.
+                if query_idx == 0 {
+                    multiplier /= ((prefix.len() + path.len()) - last_slash) as f64;
+                }
 
-            let mut next_score = 1.0;
-            if min_score > 0.0 {
-                next_score = cur_score * multiplier;
-                // Scores only decrease. If we can't pass the previous best, bail
-                if next_score < min_score {
-                    // Ensure that score is non-zero so we use it in the memo table.
-                    if score == 0.0 {
-                        score = 1e-18;
+                let mut next_score = 1.0;
+                if self.min_score > 0.0 {
+                    next_score = cur_score * multiplier;
+                    // Scores only decrease. If we can't pass the previous best, bail
+                    if next_score < self.min_score {
+                        // Ensure that score is non-zero so we use it in the memo table.
+                        if score == 0.0 {
+                            score = 1e-18;
+                        }
+                        continue;
                     }
-                    continue;
                 }
-            }
 
-            let new_score = recursive_score_match(
-                query,
-                query_cased,
-                path,
-                path_cased,
-                prefix,
-                lowercase_prefix,
-                smart_case,
-                last_positions,
-                score_matrix,
-                best_position_matrix,
-                min_score,
-                query_idx + 1,
-                j + 1,
-                next_score,
-            ) * multiplier;
-
-            if new_score > score {
-                score = new_score;
-                best_position = j;
-                // Optimization: can't score better than 1.
-                if new_score == 1.0 {
-                    break;
+                let new_score = self.recursive_score_match(
+                    path,
+                    path_cased,
+                    prefix,
+                    lowercase_prefix,
+                    query_idx + 1,
+                    j + 1,
+                    next_score,
+                ) * multiplier;
+
+                if new_score > score {
+                    score = new_score;
+                    best_position = j;
+                    // Optimization: can't score better than 1.
+                    if new_score == 1.0 {
+                        break;
+                    }
                 }
             }
         }
-    }
 
-    if best_position != 0 {
-        best_position_matrix[query_idx * path_len + path_idx] = best_position;
-    }
+        if best_position != 0 {
+            self.best_position_matrix[query_idx * path_len + path_idx] = best_position;
+        }
 
-    score_matrix[query_idx * path_len + path_idx] = Some(score);
-    score
+        self.score_matrix[query_idx * path_len + path_idx] = Some(score);
+        score
+    }
 }
 
 #[cfg(test)]
@@ -496,34 +653,22 @@ mod tests {
 
     #[test]
     fn test_get_last_positions() {
-        let mut last_positions = vec![0; 2];
-        let result = find_last_positions(
-            &mut last_positions,
-            &['a', 'b', 'c'],
-            &['b', 'd', 'e', 'f'],
-            &['d', 'c'],
-        );
+        let mut query: &[char] = &['d', 'c'];
+        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
+        let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
         assert_eq!(result, false);
 
-        last_positions.resize(2, 0);
-        let result = find_last_positions(
-            &mut last_positions,
-            &['a', 'b', 'c'],
-            &['b', 'd', 'e', 'f'],
-            &['c', 'd'],
-        );
+        query = &['c', 'd'];
+        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
+        let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
         assert_eq!(result, true);
-        assert_eq!(last_positions, vec![2, 4]);
-
-        last_positions.resize(4, 0);
-        let result = find_last_positions(
-            &mut last_positions,
-            &['z', 'e', 'd', '/'],
-            &['z', 'e', 'd', '/', 'f'],
-            &['z', '/', 'z', 'f'],
-        );
+        assert_eq!(matcher.last_positions, vec![2, 4]);
+
+        query = &['z', '/', 'z', 'f'];
+        let mut matcher = Matcher::new(query, query, query.into(), false, 10);
+        let result = matcher.find_last_positions(&['z', 'e', 'd', '/'], &['z', 'e', 'd', '/', 'f']);
         assert_eq!(result, true);
-        assert_eq!(last_positions, vec![0, 3, 4, 8]);
+        assert_eq!(matcher.last_positions, vec![0, 3, 4, 8]);
     }
 
     #[test]
@@ -604,20 +749,17 @@ mod tests {
         for (i, path) in paths.iter().enumerate() {
             let lowercase_path = path.to_lowercase().chars().collect::<Vec<_>>();
             let char_bag = CharBag::from(lowercase_path.as_slice());
-            path_entries.push(MatchCandidate {
+            path_entries.push(PathMatchCandidate {
                 char_bag,
                 path: path_arcs.get(i).unwrap(),
             });
         }
 
-        let mut match_positions = Vec::new();
-        let mut last_positions = Vec::new();
-        match_positions.resize(query.len(), 0);
-        last_positions.resize(query.len(), 0);
+        let mut matcher = Matcher::new(&query, &lowercase_query, query_chars, smart_case, 100);
 
         let cancel_flag = AtomicBool::new(false);
         let mut results = Vec::new();
-        match_single_tree_paths(
+        matcher.match_paths(
             &Snapshot {
                 id: 0,
                 scan_id: 0,
@@ -632,17 +774,7 @@ mod tests {
             },
             false,
             path_entries.into_iter(),
-            &query[..],
-            &lowercase_query[..],
-            query_chars,
-            smart_case,
             &mut results,
-            100,
-            &mut 0.0,
-            &mut match_positions,
-            &mut last_positions,
-            &mut Vec::new(),
-            &mut Vec::new(),
             &cancel_flag,
         );