guise/overlay/
spotlight.rs1use gpui::prelude::*;
9use gpui::{
10 deferred, div, px, App, Context, FocusHandle, IntoElement, KeyDownEvent, SharedString, Window,
11};
12
13use crate::icon::{Icon, IconName};
14use crate::input::TextEdit;
15use crate::theme::{theme, Size};
16
17type CommandHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;
18
19struct Command {
20 label: SharedString,
21 hint: Option<SharedString>,
22 handler: Option<CommandHandler>,
23}
24
25pub struct Spotlight {
28 open: bool,
29 focus: FocusHandle,
30 query: TextEdit,
31 commands: Vec<Command>,
32 selected: usize,
33}
34
35impl Spotlight {
36 pub fn new(cx: &mut Context<Self>) -> Self {
37 Spotlight {
38 open: false,
39 focus: cx.focus_handle(),
40 query: TextEdit::new(""),
41 commands: Vec::new(),
42 selected: 0,
43 }
44 }
45
46 pub fn item(
48 mut self,
49 label: impl Into<SharedString>,
50 handler: impl Fn(&mut Window, &mut App) + 'static,
51 ) -> Self {
52 self.commands.push(Command {
53 label: label.into(),
54 hint: None,
55 handler: Some(Box::new(handler)),
56 });
57 self
58 }
59
60 pub fn item_hint(
62 mut self,
63 label: impl Into<SharedString>,
64 hint: impl Into<SharedString>,
65 handler: impl Fn(&mut Window, &mut App) + 'static,
66 ) -> Self {
67 self.commands.push(Command {
68 label: label.into(),
69 hint: Some(hint.into()),
70 handler: Some(Box::new(handler)),
71 });
72 self
73 }
74
75 pub fn is_open(&self) -> bool {
76 self.open
77 }
78
79 pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
80 self.open = true;
81 self.query = TextEdit::new("");
82 self.selected = 0;
83 window.focus(&self.focus);
84 cx.notify();
85 }
86
87 pub fn close(&mut self, cx: &mut Context<Self>) {
88 self.open = false;
89 cx.notify();
90 }
91
92 fn filtered(&self) -> Vec<usize> {
93 let q = self.query.text().to_lowercase();
94 self.commands
95 .iter()
96 .enumerate()
97 .filter(|(_, c)| q.is_empty() || c.label.to_lowercase().contains(&q))
98 .map(|(i, _)| i)
99 .collect()
100 }
101
102 fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
103 let ks = &event.keystroke;
104 if ks.modifiers.platform || ks.modifiers.control {
105 return;
106 }
107 match ks.key.as_str() {
108 "escape" => self.open = false,
109 "up" => self.selected = self.selected.saturating_sub(1),
110 "down" => {
111 let n = self.filtered().len();
112 if n > 0 {
113 self.selected = (self.selected + 1).min(n - 1);
114 }
115 }
116 "enter" => {
117 let filtered = self.filtered();
118 if let Some(&idx) = filtered.get(self.selected) {
119 self.open = false;
120 if let Some(handler) = &self.commands[idx].handler {
121 handler(window, cx);
122 }
123 }
124 }
125 "backspace" => {
126 self.query.backspace();
127 self.selected = 0;
128 }
129 _ => {
130 if let Some(text) = ks
131 .key_char
132 .as_deref()
133 .filter(|t| !t.is_empty() && !ks.modifiers.alt)
134 {
135 self.query.insert(text);
136 self.selected = 0;
137 }
138 }
139 }
140 cx.notify();
141 cx.stop_propagation();
142 }
143}
144
145impl Render for Spotlight {
146 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
147 let mut root = div();
148 if !self.open {
149 return root;
150 }
151
152 let t = theme(cx);
153 let radius = t.radius(Size::Md);
154 let surface = t.surface().hsla();
155 let border = t.border().hsla();
156 let text_color = t.text().hsla();
157 let dimmed = t.dimmed().hsla();
158 let caret = t.primary().hsla();
159 let selected_bg = t.primary().alpha(0.14);
160 let scrim = t.black.alpha(0.45);
161 let font = t.font_size(Size::Md);
162 let font_sm = t.font_size(Size::Sm);
163 let viewport = window.viewport_size();
164
165 let (before, after) = self.query.split();
166 let query_text = self.query.text();
167 let search = div()
168 .flex()
169 .items_center()
170 .gap(px(8.0))
171 .px(px(14.0))
172 .h(px(48.0))
173 .border_b_1()
174 .border_color(border)
175 .child(Icon::new(IconName::Search).color(crate::theme::ColorName::Gray))
176 .child(if query_text.is_empty() {
177 div()
178 .flex()
179 .items_center()
180 .text_color(dimmed)
181 .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
182 .child(SharedString::new_static("Type a command…"))
183 } else {
184 div()
185 .flex()
186 .items_center()
187 .text_color(text_color)
188 .child(SharedString::from(before))
189 .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
190 .child(SharedString::from(after))
191 });
192
193 let filtered = self.filtered();
194 let mut list = div().flex().flex_col().gap(px(2.0)).p(px(6.0));
195 if filtered.is_empty() {
196 list = list.child(
197 div()
198 .px(px(10.0))
199 .py(px(8.0))
200 .text_size(px(font_sm))
201 .text_color(dimmed)
202 .child(SharedString::new_static("No commands")),
203 );
204 }
205 for (j, idx) in filtered.iter().enumerate() {
206 let idx = *idx;
207 let is_active = j == self.selected;
208 let command = &self.commands[idx];
209 let mut row = div()
210 .id(("guise-spotlight-item", idx))
211 .flex()
212 .items_center()
213 .justify_between()
214 .px(px(10.0))
215 .py(px(8.0))
216 .rounded(px(6.0))
217 .text_size(px(font_sm))
218 .text_color(text_color)
219 .child(command.label.clone())
220 .on_click(cx.listener(move |this, _ev, window, cx| {
221 this.open = false;
222 if let Some(handler) = &this.commands[idx].handler {
223 handler(window, cx);
224 }
225 cx.notify();
226 }));
227 if let Some(hint) = command.hint.clone() {
228 row = row.child(div().text_size(px(font_sm)).text_color(dimmed).child(hint));
229 }
230 if is_active {
231 row = row.bg(selected_bg);
232 }
233 list = list.child(row);
234 }
235
236 let panel = div()
237 .id("guise-spotlight-panel")
238 .occlude()
239 .track_focus(&self.focus)
240 .on_key_down(cx.listener(Self::on_key))
241 .on_click(|_ev, _window, cx| cx.stop_propagation())
242 .mt(px(80.0))
243 .w(px(560.0))
244 .flex()
245 .flex_col()
246 .bg(surface)
247 .rounded(px(radius))
248 .border_1()
249 .border_color(border)
250 .shadow_xl()
251 .child(search)
252 .child(list);
253
254 let backdrop = div()
255 .id("guise-spotlight-backdrop")
256 .occlude()
257 .absolute()
258 .top(px(0.0))
259 .left(px(0.0))
260 .w(viewport.width)
261 .h(viewport.height)
262 .flex()
263 .justify_center()
264 .items_start()
265 .bg(scrim)
266 .on_click(cx.listener(|this, _ev, _window, cx| {
267 this.open = false;
268 cx.notify();
269 }))
270 .child(panel);
271
272 root = root.child(deferred(backdrop));
273 root
274 }
275}