guise-ui 1.5.3

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
//! `SettingsView` — the settings window shape: a list of pages down the left,
//! the selected page's content on the right, an optional search field, and an
//! optional footer.
//!
//! The view owns navigation and nothing else. It does not know what a setting
//! is, cannot read one, and will not write one — the content closure is handed
//! the active page and the current query and returns whatever should be on
//! screen. That is deliberate: every app types its settings against its own
//! config struct, and a component generic enough to hold those would make the
//! caller's code worse than the twenty lines it replaced.
//!
//! Search works the same way. The view has nothing to search, so it reports the
//! query through [`SettingsViewEvent::Search`] and passes it to the content
//! closure; matching is the host's, because only the host knows what the
//! settings are.

use gpui::prelude::*;
use gpui::{
  div, px, AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
  IntoElement, SharedString, Window,
};

use crate::devtools::Probed;
use crate::icon::IconName;
use crate::input::{TextInput, TextInputEvent};
use crate::theme::{theme, Size};
use crate::Icon;

/// What the view builds its page content with: the active page id, the current
/// search query, and the usual pair.
type ContentBuilder = Box<dyn Fn(&str, &str, &mut Window, &mut App) -> AnyElement + 'static>;
/// What the view builds its footer with.
type FooterBuilder = Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>;

/// One entry in the page list.
pub struct SettingsPage {
  pub id: SharedString,
  pub title: SharedString,
  pub icon: Option<IconName>,
}

/// Emitted as the user navigates.
#[derive(Debug, Clone)]
pub enum SettingsViewEvent {
  /// A different page was selected. Carries its id.
  PageChanged(SharedString),
  /// The search field changed. Carries the full query, empty when cleared.
  Search(SharedString),
}

/// The settings shell. Create with `cx.new(|cx| SettingsView::new(cx))`.
pub struct SettingsView {
  focus: FocusHandle,
  pages: Vec<SettingsPage>,
  active: usize,
  search: Entity<TextInput>,
  searchable: bool,
  query: SharedString,
  content: Option<ContentBuilder>,
  footer: Option<FooterBuilder>,
  sidebar_width: f32,
  sidebar_matches_body: bool,
}

impl EventEmitter<SettingsViewEvent> for SettingsView {}

impl Focusable for SettingsView {
  fn focus_handle(&self, _cx: &App) -> FocusHandle {
    self.focus.clone()
  }
}

impl SettingsView {
  pub fn new(cx: &mut Context<Self>) -> Self {
    let search = cx.new(|cx| {
      TextInput::new(cx)
        .placeholder("Search settings")
        .size(Size::Xs)
    });

    cx.subscribe(&search, |this: &mut SettingsView, _search, event, cx| {
      if let TextInputEvent::Change(query) = event {
        this.query = SharedString::from(query.clone());
        cx.emit(SettingsViewEvent::Search(this.query.clone()));
        cx.notify();
      }
    })
    .detach();

    SettingsView {
      focus: cx.focus_handle(),
      pages: Vec::new(),
      active: 0,
      search,
      searchable: false,
      query: SharedString::default(),
      content: None,
      footer: None,
      sidebar_width: 190.0,
      sidebar_matches_body: false,
    }
  }

  /// Add a page to the list.
  pub fn page(mut self, id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
    self.pages.push(SettingsPage {
      id: id.into(),
      title: title.into(),
      icon: None,
    });
    self
  }

  /// Add a page with an icon beside its title.
  pub fn page_icon(
    mut self,
    id: impl Into<SharedString>,
    title: impl Into<SharedString>,
    icon: IconName,
  ) -> Self {
    self.pages.push(SettingsPage {
      id: id.into(),
      title: title.into(),
      icon: Some(icon),
    });
    self
  }

  /// Show the search field above the page list.
  pub fn searchable(mut self, searchable: bool) -> Self {
    self.searchable = searchable;
    self
  }

  /// What to render for the active page. Re-invoked every frame — with the
  /// active page's id and the current query — so a page shows live values
  /// rather than a snapshot taken when the view was built.
  pub fn content<E: IntoElement>(
    mut self,
    content: impl Fn(&str, &str, &mut Window, &mut App) -> E + 'static,
  ) -> Self {
    self.content = Some(Box::new(move |page, query, window, cx| {
      content(page, query, window, cx).into_any_element()
    }));
    self
  }

  /// A row pinned under the content — where the file lives, a Done button.
  pub fn footer<E: IntoElement>(
    mut self,
    footer: impl Fn(&mut Window, &mut App) -> E + 'static,
  ) -> Self {
    self.footer = Some(Box::new(move |window, cx| {
      footer(window, cx).into_any_element()
    }));
    self
  }

  /// Width of the page list (default 190px).
  pub fn sidebar_width(mut self, width: f32) -> Self {
    self.sidebar_width = width;
    self
  }

  /// Use the content background for the page list instead of the raised surface.
  pub fn sidebar_matches_body(mut self, matches: bool) -> Self {
    self.sidebar_matches_body = matches;
    self
  }

  /// Open on a particular page. Unknown ids are ignored rather than
  /// panicking — a stale id from a restored session is not a crash.
  pub fn active(mut self, id: impl Into<SharedString>) -> Self {
    let id = id.into();
    if let Some(index) = self.pages.iter().position(|page| page.id == id) {
      self.active = index;
    }
    self
  }

  /// The active page's id, or `None` when no pages were added.
  pub fn active_page(&self) -> Option<&SharedString> {
    self.pages.get(self.active).map(|page| &page.id)
  }

  /// Select a page by id, emitting [`SettingsViewEvent::PageChanged`].
  pub fn set_page(&mut self, id: &str, cx: &mut Context<Self>) {
    if let Some(index) = self.pages.iter().position(|page| page.id.as_ref() == id) {
      self.select(index, cx);
    }
  }

  fn select(&mut self, index: usize, cx: &mut Context<Self>) {
    if index >= self.pages.len() || index == self.active {
      return;
    }
    self.active = index;
    cx.emit(SettingsViewEvent::PageChanged(self.pages[index].id.clone()));
    cx.notify();
  }

  /// The current search query.
  pub fn query(&self) -> &SharedString {
    &self.query
  }

  /// Clear the search field.
  pub fn clear_search(&mut self, cx: &mut Context<Self>) {
    self.query = SharedString::default();
    self.search.update(cx, |search, cx| search.set_text("", cx));
    cx.emit(SettingsViewEvent::Search(self.query.clone()));
    cx.notify();
  }

  fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
    let t = theme(cx);
    let surface = if self.sidebar_matches_body {
      t.body().hsla()
    } else {
      t.surface_hover().hsla()
    };
    let border = t.border().hsla();
    let text = t.text().hsla();
    let dimmed = t.dimmed().hsla();
    let selected_bg = t.primary().hsla();
    let selected_fg = t.white.hsla();
    let hover = t.surface_hover().hsla();
    let font_sm = t.font_size(Size::Sm);
    let radius = t.radius(Size::Sm);
    let gap = t.spacing(Size::Xs);

    let mut bar = div()
      .flex()
      .flex_col()
      .flex_none()
      .w(px(self.sidebar_width))
      .h_full()
      .p(px(8.0))
      .gap(px(2.0))
      .bg(surface)
      .border_r_1()
      .border_color(border);

    if self.searchable {
      bar = bar.child(div().pb(px(gap)).child(self.search.clone()));
    }

    for (index, page) in self.pages.iter().enumerate() {
      let selected = index == self.active;
      let fg = if selected { selected_fg } else { dimmed };
      let focus = if selected { selected_fg } else { selected_bg };
      bar = bar.child(
        div()
          .id(ElementId::NamedInteger(
            SharedString::new_static("guise-settings-page"),
            index as u64,
          ))
          .flex()
          .items_center()
          .gap(px(6.0))
          .h(px(30.0))
          .px(px(8.0))
          .rounded(px(radius))
          .cursor_pointer()
          .tab_index(0)
          .focus(move |el| el.border_2().border_color(focus))
          .text_size(px(font_sm))
          .text_color(if selected { selected_fg } else { text })
          .when(selected, |el| el.bg(selected_bg))
          .when(!selected, |el| el.hover(move |st| st.bg(hover)))
          .children(page.icon.map(|icon| Icon::new(icon).size(Size::Xs)))
          .child(page.title.clone())
          .on_click(cx.listener(move |this, _event, _window, cx| {
            this.select(index, cx);
          }))
          .text_color(if selected { selected_fg } else { fg }),
      );
    }

    bar.child(div().flex_1())
  }
}

impl Render for SettingsView {
  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    let t = theme(cx);
    let body = t.body().hsla();
    let border = t.border().hsla();
    let text = t.text().hsla();
    let dimmed = t.dimmed().hsla();
    let font_sm = t.font_size(Size::Sm);

    let page = self
      .active_page()
      .cloned()
      .unwrap_or_else(|| SharedString::new_static(""));

    let content = match &self.content {
      Some(build) => build(page.as_ref(), self.query.as_ref(), window, cx),
      None => div()
        .flex()
        .flex_1()
        .items_center()
        .justify_center()
        .text_size(px(font_sm))
        .text_color(dimmed)
        .child(SharedString::new_static("No content builder set"))
        .into_any_element(),
    };

    let footer = self.footer.as_ref().map(|build| {
      div()
        .flex()
        .flex_none()
        .items_center()
        .w_full()
        .px(px(24.0))
        .py(px(10.0))
        .border_t_1()
        .border_color(border)
        .child(build(window, cx))
    });

    div()
      .track_focus(&self.focus)
      .key_context("SettingsView")
      .flex()
      .size_full()
      .min_h(px(0.0))
      .bg(body)
      .text_color(text)
      .child(self.sidebar(cx))
      .child(
        div()
          .flex()
          .flex_col()
          .flex_1()
          .min_w(px(0.0))
          .h_full()
          .child(
            div()
              .id("guise-settings-content")
              .flex()
              .flex_col()
              .flex_1()
              .min_h(px(0.0))
              .w_full()
              .px(px(24.0))
              .pb(px(24.0))
              .overflow_y_scroll()
              .child(content),
          )
          .children(footer),
      )
      .probe("SettingsView")
      .attr("page", page)
  }
}