guise-ui 1.5.2

A component library for gpui, Zed's GPU-accelerated UI framework: a themed palette, sizing tokens, 130+ composable components, a reactive state layer, and an in-app Safari-style inspector.
Documentation
//! `Spotlight` — a command palette (gpui entity).
//!
//! A centered overlay with a search field and a keyboard-navigable command list.
//! Type to filter, ↑/↓ to move the highlight, Enter to run, Esc to dismiss.
//! Built on the same deferred-backdrop pattern as [`Modal`](super::Modal) plus
//! the [`TextEdit`](crate::TextEdit) query model.

use gpui::prelude::*;
use gpui::{
  deferred, div, px, App, Context, FocusHandle, IntoElement, KeyDownEvent, SharedString, Window,
};

use crate::devtools::ProbedAny;
use crate::icon::{Icon, IconName};
use crate::input::TextEdit;
use crate::theme::{theme, Size};

type CommandHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;

struct Command {
  label: SharedString,
  hint: Option<SharedString>,
  handler: Option<CommandHandler>,
}

/// A command palette. Create with `cx.new(|cx| Spotlight::new(cx))`, register
/// commands with [`Spotlight::item`], and open it from an action.
pub struct Spotlight {
  open: bool,
  focus: FocusHandle,
  query: TextEdit,
  commands: Vec<Command>,
  selected: usize,
}

impl Spotlight {
  pub fn new(cx: &mut Context<Self>) -> Self {
    Spotlight {
      open: false,
      focus: cx.focus_handle(),
      query: TextEdit::new(""),
      commands: Vec::new(),
      selected: 0,
    }
  }

  /// Register a command with the action to run when it is chosen.
  pub fn item(
    mut self,
    label: impl Into<SharedString>,
    handler: impl Fn(&mut Window, &mut App) + 'static,
  ) -> Self {
    self.commands.push(Command {
      label: label.into(),
      hint: None,
      handler: Some(Box::new(handler)),
    });
    self
  }

  /// Register a command with a trailing hint (e.g. a shortcut).
  pub fn item_hint(
    mut self,
    label: impl Into<SharedString>,
    hint: impl Into<SharedString>,
    handler: impl Fn(&mut Window, &mut App) + 'static,
  ) -> Self {
    self.commands.push(Command {
      label: label.into(),
      hint: Some(hint.into()),
      handler: Some(Box::new(handler)),
    });
    self
  }

  pub fn is_open(&self) -> bool {
    self.open
  }

  pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
    self.open = true;
    self.query = TextEdit::new("");
    self.selected = 0;
    window.focus(&self.focus);
    cx.notify();
  }

  pub fn close(&mut self, cx: &mut Context<Self>) {
    self.open = false;
    cx.notify();
  }

  fn filtered(&self) -> Vec<usize> {
    let q = self.query.text().to_lowercase();
    self
      .commands
      .iter()
      .enumerate()
      .filter(|(_, c)| q.is_empty() || c.label.to_lowercase().contains(&q))
      .map(|(i, _)| i)
      .collect()
  }

  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
    let ks = &event.keystroke;
    if ks.modifiers.platform || ks.modifiers.control {
      return;
    }
    match ks.key.as_str() {
      "escape" => self.open = false,
      "up" => self.selected = self.selected.saturating_sub(1),
      "down" => {
        let n = self.filtered().len();
        if n > 0 {
          self.selected = (self.selected + 1).min(n - 1);
        }
      }
      "enter" => {
        let filtered = self.filtered();
        if let Some(&idx) = filtered.get(self.selected) {
          self.open = false;
          if let Some(handler) = &self.commands[idx].handler {
            handler(window, cx);
          }
        }
      }
      "backspace" => {
        self.query.backspace();
        self.selected = 0;
      }
      _ => {
        if let Some(text) = ks
          .key_char
          .as_deref()
          .filter(|t| !t.is_empty() && !ks.modifiers.alt)
        {
          self.query.insert(text);
          self.selected = 0;
        }
      }
    }
    cx.notify();
    cx.stop_propagation();
  }
}

impl Render for Spotlight {
  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    let mut root = div();
    if !self.open {
      return root.into_any_element();
    }

    let t = theme(cx);
    let radius = t.radius(Size::Md);
    let surface = t.surface().hsla();
    let border = t.border().hsla();
    let text_color = t.text().hsla();
    let dimmed = t.dimmed().hsla();
    let caret = t.primary().hsla();
    let selected_bg = t.primary().alpha(0.14);
    let scrim = t.black.alpha(0.45);
    let font = t.font_size(Size::Md);
    let font_sm = t.font_size(Size::Sm);
    let viewport = window.viewport_size();

    let (before, after) = self.query.split();
    let query_text = self.query.text();
    let search = div()
      .flex()
      .items_center()
      .gap(px(8.0))
      .px(px(14.0))
      .h(px(48.0))
      .border_b_1()
      .border_color(border)
      .child(Icon::new(IconName::Search).color(crate::theme::ColorName::Gray))
      .child(if query_text.is_empty() {
        div()
          .flex()
          .items_center()
          .text_color(dimmed)
          .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
          .child(SharedString::new_static("Type a command…"))
      } else {
        div()
          .flex()
          .items_center()
          .text_color(text_color)
          .child(SharedString::from(before))
          .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
          .child(SharedString::from(after))
      });

    let filtered = self.filtered();
    let mut list = div().flex().flex_col().gap(px(2.0)).p(px(6.0));
    if filtered.is_empty() {
      list = list.child(
        div()
          .px(px(10.0))
          .py(px(8.0))
          .text_size(px(font_sm))
          .text_color(dimmed)
          .child(SharedString::new_static("No commands")),
      );
    }
    for (j, idx) in filtered.iter().enumerate() {
      let idx = *idx;
      let is_active = j == self.selected;
      let command = &self.commands[idx];
      let mut row = div()
        .id(("guise-spotlight-item", idx))
        .flex()
        .items_center()
        .justify_between()
        .px(px(10.0))
        .py(px(8.0))
        .rounded(px(6.0))
        .text_size(px(font_sm))
        .text_color(text_color)
        .child(command.label.clone())
        .on_click(cx.listener(move |this, _ev, window, cx| {
          this.open = false;
          if let Some(handler) = &this.commands[idx].handler {
            handler(window, cx);
          }
          cx.notify();
        }));
      if let Some(hint) = command.hint.clone() {
        row = row.child(div().text_size(px(font_sm)).text_color(dimmed).child(hint));
      }
      if is_active {
        row = row.bg(selected_bg);
      }
      list = list.child(row);
    }

    let panel = div()
      .id("guise-spotlight-panel")
      .occlude()
      .track_focus(&self.focus)
      .on_key_down(cx.listener(Self::on_key))
      .on_click(|_ev, _window, cx| cx.stop_propagation())
      .mt(px(80.0))
      .w(px(560.0))
      .flex()
      .flex_col()
      .bg(surface)
      .rounded(px(radius))
      .border_1()
      .border_color(border)
      .shadow_xl()
      .child(search)
      .child(list);

    let backdrop = div()
      .id("guise-spotlight-backdrop")
      .occlude()
      .absolute()
      .top(px(0.0))
      .left(px(0.0))
      .w(viewport.width)
      .h(viewport.height)
      .flex()
      .justify_center()
      .items_start()
      .bg(scrim)
      .on_click(cx.listener(|this, _ev, _window, cx| {
        this.open = false;
        cx.notify();
      }))
      .child(panel);

    root = root.child(deferred(backdrop));
    root.probe_any("Spotlight").into_any_element()
  }
}