use crate::{
agent::Listing,
model::{update, workspace::Workspace},
view::root::{HEADER_HEIGHT, TRAFFIC_LIGHT_X, TRAFFIC_LIGHT_Y},
};
use bezel::{
gpui::{
AnyElement, App, Bounds, Context, ElementId, Entity, Render, SharedString, TitlebarOptions,
Window, WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowOptions, div, point,
prelude::*, px, size,
},
motion::{Fade, Painter},
theme::{TextStyle, Theme, Typeset, appearance},
ui::{
icons,
input::{FieldEvent, Shape, TextField},
widgets::{Content, Controls, Layout, Scaffolding},
},
};
use std::collections::{HashMap, HashSet};
mod agents;
mod developer;
mod features;
mod general;
mod mcp;
mod performance;
mod shortcuts;
mod theme;
mod typography;
const SIDEBAR_WIDTH: f32 = 200.;
pub(super) const GROUP_GAP: f32 = 20.;
pub(super) const LABEL_GAP: f32 = 8.;
const CONTENT_MAX_WIDTH: f32 = 860.;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Section {
General,
Appearance,
Shortcuts,
Features,
Agents,
Mcp,
Performance,
Developer,
}
impl Section {
const ALL: [Self; 8] = [
Self::General,
Self::Appearance,
Self::Shortcuts,
Self::Features,
Self::Agents,
Self::Mcp,
Self::Performance,
Self::Developer,
];
fn listed(self) -> bool {
!matches!(self, Self::Developer) || cfg!(debug_assertions)
}
fn title(self) -> &'static str {
match self {
Self::General => "General",
Self::Appearance => "Appearance",
Self::Shortcuts => "Shortcuts",
Self::Features => "Features",
Self::Agents => "Agents",
Self::Mcp => "MCP",
Self::Performance => "Performance",
Self::Developer => "Developer",
}
}
fn subtitle(self) -> Option<&'static str> {
match self {
Self::Shortcuts => {
Some("Press a chord to record it. ⎋ leaves it alone, ⌫ takes it away.")
}
Self::Features => Some("Parts of cydonia you can put away, and ones to ask for."),
Self::Mcp => {
Some("The tools cydonia offers the agents it runs, over a port on this machine.")
}
Self::Developer => Some("Switches for looking at what has not happened yet."),
Self::General | Self::Appearance | Self::Agents | Self::Performance => None,
}
}
fn glyph(self) -> &'static [u8] {
match self {
Self::General => icons::account::Settings,
Self::Appearance => icons::weather::Sun,
Self::Shortcuts => icons::development::Command,
Self::Features => icons::account::SlidersHorizontal,
Self::Agents => icons::layout::LayoutGrid,
Self::Mcp => icons::development::Plug,
Self::Performance => icons::devices::Cpu,
Self::Developer => icons::development::Wrench,
}
}
}
pub struct SettingsWindow {
workspace: Entity<Workspace>,
section: Section,
listings: Option<Vec<Listing>>,
busy: HashSet<String>,
output: HashMap<String, Vec<String>>,
search: Entity<TextField>,
editing: Option<Entity<TextField>>,
recording: Option<shortcuts::Recording>,
error: Option<SharedString>,
}
pub fn open(
workspace: Entity<Workspace>,
existing: Option<WindowHandle<SettingsWindow>>,
section: Section,
cx: &mut App,
) -> Option<WindowHandle<SettingsWindow>> {
if let Some(handle) = existing
&& handle
.update(cx, |this, window, cx| {
this.show(section, cx);
window.activate_window();
})
.is_ok()
{
return Some(handle);
}
let bounds = Bounds::centered(None, size(px(900.), px(620.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
appears_transparent: true,
traffic_light_position: Some(point(px(TRAFFIC_LIGHT_X), px(TRAFFIC_LIGHT_Y))),
..Default::default()
}),
window_background: WindowBackgroundAppearance::Opaque,
app_id: Some("cydonia-settings".into()),
..Default::default()
},
|window, cx| {
appearance::observe_window(window, cx).detach();
appearance::keep_background(window, cx);
cx.new(|cx| {
let search = cx.new(|cx| {
TextField::new(cx)
.with_shape(Shape::Line)
.with_frame(false)
.with_placeholder("Search agents…")
});
cx.subscribe(&search, |_, _, event: &FieldEvent, cx| {
if *event == FieldEvent::Changed {
cx.notify();
}
})
.detach();
if let Some(updater) = update::of(cx) {
cx.observe(&updater, |_, _, cx| cx.notify()).detach();
}
let mut this = SettingsWindow {
workspace,
section,
listings: None,
busy: HashSet::new(),
output: HashMap::new(),
search,
editing: None,
recording: None,
error: None,
};
this.load(cx);
cx.on_release(|this: &mut SettingsWindow, cx| {
if this.recording.is_some() {
shortcuts::restore(&this.workspace, cx);
}
})
.detach();
this
})
},
)
.ok()
}
pub(super) struct Switch {
id: ElementId,
title: SharedString,
blurb: SharedString,
on: bool,
first: bool,
glyph: Option<&'static [u8]>,
truncate: bool,
badge: Option<SharedString>,
}
impl Switch {
pub(super) fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
blurb: impl Into<SharedString>,
on: bool,
) -> Self {
Self {
id: id.into(),
title: title.into(),
blurb: blurb.into(),
on,
first: false,
glyph: None,
truncate: false,
badge: None,
}
}
pub(super) fn first(mut self, first: bool) -> Self {
self.first = first;
self
}
pub(super) fn glyph(mut self, glyph: &'static [u8]) -> Self {
self.glyph = Some(glyph);
self
}
pub(super) fn truncate(mut self) -> Self {
self.truncate = true;
self
}
pub(super) fn badge(mut self, badge: Option<impl Into<SharedString>>) -> Self {
self.badge = badge.map(Into::into);
self
}
}
impl SettingsWindow {
pub(super) fn switch_row(
&self,
switch: Switch,
cx: &Context<Self>,
flip: impl Fn(&mut Self, &mut Context<Self>) + 'static,
) -> AnyElement {
let theme = Theme::of(cx).clone();
theme
.card_row(switch.first)
.children(switch.glyph.map(|glyph| {
div()
.flex_none()
.size(px(18.))
.flex()
.items_center()
.justify_center()
.child(
icons::icon(glyph)
.size(px(16.))
.flex_none()
.text_color(theme.text_muted),
)
}))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(theme.row_title(switch.title))
.child(
div()
.mt(px(4.))
.when(switch.truncate, |el| el.truncate())
.text_style(TextStyle::Subheadline)
.text_color(theme.text_muted)
.child(switch.blurb),
),
)
.children(switch.badge.map(|label| theme.badge(label)))
.child(
div()
.id(switch.id)
.cursor_pointer()
.child(theme.toggle(switch.on))
.on_click(cx.listener(move |this, _, _, cx| {
flip(this, cx);
cx.notify();
})),
)
.into_any_element()
}
}
impl SettingsWindow {
fn show(&mut self, section: Section, cx: &mut Context<Self>) {
self.section = section;
match section {
Section::Agents => self.load(cx),
Section::General
| Section::Appearance
| Section::Shortcuts
| Section::Features
| Section::Mcp
| Section::Performance
| Section::Developer => {}
}
cx.notify();
}
fn sidebar(&self, cx: &Context<Self>) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
div()
.flex_none()
.w(px(SIDEBAR_WIDTH))
.h_full()
.bg(theme.surface)
.border_r_1()
.border_color(theme.border)
.flex()
.flex_col()
.gap(px(2.))
.px(px(8.))
.pb(px(8.))
.pt(px(HEADER_HEIGHT))
.children(
Section::ALL
.into_iter()
.filter(|section| section.listed())
.enumerate()
.map(|(ix, section)| {
theme
.nav_row(
Some(section.glyph().into()),
section.title(),
section == self.section,
Fade::new(painter, format!("section-{ix}")),
)
.id(("section", ix))
.on_click(cx.listener(move |this, _, _, cx| this.show(section, cx)))
}),
)
}
}
impl Render for SettingsWindow {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
div()
.size_full()
.relative()
.flex()
.flex_row()
.bg(theme.bg)
.font_family(theme.font_sans.clone())
.text_color(theme.text)
.text_style(TextStyle::Body)
.child(self.sidebar(cx))
.child(
div()
.id("settings-body")
.flex_1()
.min_w_0()
.h_full()
.overflow_y_scroll()
.px(px(32.))
.py(px(32.))
.flex()
.flex_col()
.items_center()
.child(
div()
.w_full()
.max_w(px(CONTENT_MAX_WIDTH))
.flex()
.flex_col()
.child(
div()
.mb(px(GROUP_GAP))
.child(theme.page_header(self.section.title(), None))
.children(
self.section
.subtitle()
.map(|copy| theme.page_subtitle(copy)),
),
)
.child(match self.section {
Section::General => self.general_body(cx),
Section::Appearance => self.appearance_body(cx),
Section::Shortcuts => self.shortcuts_body(cx),
Section::Features => self.features_body(cx),
Section::Agents => self.agents_body(cx),
Section::Mcp => self.mcp_body(cx),
Section::Performance => self.performance_body(cx),
Section::Developer => self.developer_body(cx),
}),
),
)
.children(self.cover_dialog(cx))
}
}