feat: add onboarding dialog

This commit is contained in:
2026-11-12 13:48:51 +07:00
parent 85f2bb9885
commit 8acbbaef5f
6 changed files with 643 additions and 43 deletions
+477
View File
@@ -0,0 +1,477 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, AppContext, Context, Div, Entity, Hsla, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
div, px,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{Confirm, DialogButtonProps, DialogFooter};
use gpui_kit::component::input::{InputEvent, InputState, NumberInput};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::theme::{ThemeColor, try_parse_color};
use gpui_kit::component::{
ActiveTheme, Theme, ThemeConfig, ThemeRegistry, WindowExt, h_flex, v_flex,
};
use gpui_kit::prelude::FluentBuilder;
use rand::seq::IndexedRandom;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use ui::{settings_block, settings_row};
use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE};
/// Open the first-run onboarding dialog
pub fn open(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| Onboarding::new(window, cx));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(640.))
.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Welcome to Coop")
.child(view.clone())
.button_props(
DialogButtonProps::default()
.ok_text("Get started")
.show_cancel(false)
.on_ok(|_, _, cx| {
AppSettings::update_onboarded(true, cx);
// Returning true closes the dialog
true
}),
)
.footer(
DialogFooter::new().child(
Button::new("get-started")
.label("Get started")
.primary()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
}),
),
)
});
}
pub struct Onboarding {
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
light_themes: Vec<Rc<ThemeConfig>>,
dark_themes: Vec<Rc<ThemeConfig>>,
_subscriptions: SmallVec<[Subscription; 3]>,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let theme = AppSettings::get_theme(cx);
let (light_themes, dark_themes) = Self::sample_themes(cx);
let font_size_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(theme.font_size.to_string())
.step(1.)
.min(FONT_SIZE_RANGE.0 as f64)
.max(FONT_SIZE_RANGE.1 as f64)
});
let radius_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(
theme
.radius
.map(|value| value.to_string())
.unwrap_or_default(),
)
.placeholder("System")
.step(1.)
.min(RADIUS_RANGE.0 as f64)
.max(RADIUS_RANGE.1 as f64)
});
let mut subscriptions = smallvec![];
subscriptions.push({
let state = font_size_input.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
if let Some(font_size) = Self::number_at(&state, FONT_SIZE_RANGE, cx) {
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
AppSettings::apply_theme_style(cx);
}
})
});
// One radius control tunes both radii so the whole UI stays coherent
subscriptions.push({
let state = radius_input.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
let radius = Self::number_at(&state, RADIUS_RANGE, cx);
AppSettings::update_theme(
move |theme| {
theme.radius = radius;
theme.radius_lg = radius;
},
cx,
);
AppSettings::apply_theme_style(cx);
})
});
Self {
font_size_input,
radius_input,
light_themes,
dark_themes,
_subscriptions: subscriptions,
}
}
/// The number in a theme input, clamped to the range, empty is None
fn number_at(state: &Entity<InputState>, range: (f32, f32), cx: &App) -> Option<f32> {
let (min, max) = range;
state
.read(cx)
.value()
.trim()
.parse::<f32>()
.ok()
.map(|value| value.clamp(min, max))
}
/// Three light and three dark bundled themes, sampled at random.
fn sample_themes(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
let active = Theme::global(cx).theme_name().clone();
let mut rng = rand::rng();
let (lights, darks): (Vec<_>, Vec<_>) = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.partition(|theme| !theme.mode.is_dark());
let mut sample = |group: &[Rc<ThemeConfig>]| {
let mut picks: Vec<_> = group.sample(&mut rng, 3).cloned().collect();
let active_config = group.iter().find(|theme| theme.name == active);
let already_sampled = picks.iter().any(|theme| theme.name == active);
if let (Some(config), Some(slot)) = (active_config, picks.first_mut())
&& !already_sampled
{
*slot = config.clone();
}
picks
};
(sample(&lights), sample(&darks))
}
/// The theme picker: a demo block per sampled bundled theme
fn themes_view(&self, cx: &App) -> AnyElement {
let active = Theme::global(cx).theme_name().clone();
settings_block(
"Theme",
"Customize UI colors. More in Settings.",
v_flex()
.gap_3()
.child(Self::theme_group("Light", &self.light_themes, &active, cx))
.child(Self::theme_group("Dark", &self.dark_themes, &active, cx)),
cx,
)
.into_any_element()
}
fn theme_group(
label: &'static str,
themes: &[Rc<ThemeConfig>],
active: &SharedString,
cx: &App,
) -> Div {
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(
div().flex().flex_wrap().gap_2().children(
themes
.iter()
.map(|config| Self::theme_card(config, active, cx)),
),
)
}
/// A card painting a mini app mockup in the theme's own colors
fn theme_card(config: &Rc<ThemeConfig>, active: &SharedString, cx: &App) -> impl IntoElement {
let selected = &config.name == active;
let name = config.name.clone();
let preview = Self::theme_preview(config);
let border = if selected {
cx.theme().list_active_border
} else {
preview.border
};
// Square block; the bigger inset keeps the mock window small inside it
let block = div()
.flex()
.w_full()
.aspect_square()
.p_4()
.rounded(cx.theme().radius_lg)
.overflow_hidden()
.bg(preview.background)
.border_1()
.border_color(border)
.hover(|this| this.border_color(cx.theme().border));
let block = if selected { block.border_2() } else { block };
v_flex()
.id(SharedString::from(format!("theme-{name}")))
.flex_1()
.cursor_pointer()
.child(
block.child(
h_flex()
.size_full()
.rounded(cx.theme().radius)
.when(cx.theme().shadow, |this| this.shadow_sm())
.overflow_hidden()
.border_1()
.border_color(preview.border)
.bg(preview.window)
.child(
v_flex()
.w_1_3()
.h_full()
.p_1()
.justify_between()
.bg(preview.sidebar)
.child(
v_flex()
.gap_1()
.child(h_flex().gap_0p5().children((0..3).map(|_| {
div().size_1().rounded_full().bg(preview.accent)
})))
.child(Self::demo_bar(preview.bar))
.child(Self::demo_bar(preview.bar)),
)
.child(
h_flex()
.gap_0p5()
.items_center()
.child(
div()
.size_2()
.rounded_full()
.bg(preview.bar.opacity(0.55)),
)
.child(
div()
.flex_1()
.h_1()
.rounded_full()
.bg(preview.bar.opacity(0.55)),
),
),
)
.child(
v_flex()
.flex_1()
.h_full()
.p_1()
.gap_1()
.child(Self::demo_bar(preview.bar))
.child(
h_flex()
.gap_1()
.child(div().flex_1().h_4().rounded_sm().bg(preview.block))
.child(div().flex_1().h_4().rounded_sm().bg(preview.block)),
),
),
),
)
.child(
div()
.mt_1()
.text_xs()
.truncate()
.text_color(if selected {
cx.theme().foreground
} else {
cx.theme().muted_foreground
})
.child(name),
)
.on_click({
let name = config.name.clone();
move |_, _window, cx| AppSettings::select_theme(&name, cx)
})
}
/// A skeleton text bar for the demo block
fn demo_bar(color: Hsla) -> Div {
div().w_full().h_1().rounded_full().bg(color.opacity(0.55))
}
/// The demo block colors resolved from a theme config.
fn theme_preview(config: &ThemeConfig) -> ThemePreview {
let defaults = if config.mode.is_dark() {
ThemeColor::dark()
} else {
ThemeColor::light()
};
let colors = &config.colors;
let resolve = |value: Option<&SharedString>, fallback: Hsla| {
value
.and_then(|value| try_parse_color(value).ok())
.unwrap_or(fallback)
};
ThemePreview {
background: resolve(colors.background.as_ref(), defaults.background),
border: resolve(colors.border.as_ref(), defaults.border),
window: resolve(colors.popover.as_ref(), defaults.popover),
sidebar: resolve(colors.secondary.as_ref(), defaults.secondary),
bar: resolve(colors.muted_foreground.as_ref(), defaults.muted_foreground),
block: resolve(colors.secondary.as_ref(), defaults.secondary),
accent: resolve(colors.primary.as_ref(), defaults.primary),
}
}
}
/// The colors of a theme's demo block
struct ThemePreview {
background: Hsla,
border: Hsla,
window: Hsla,
sidebar: Hsla,
bar: Hsla,
block: Hsla,
accent: Hsla,
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const INTRO: &str =
"Pick how Coop works and looks. You can change all of this later in Preferences.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
#[cfg(not(target_arch = "wasm32"))]
const WALLET: &str = "Show the wallet and allow ecash payments.";
const FONT_SIZE: &str = "The base font size for the interface.";
const RADIUS: &str =
"Corner radius for UI elements, including dialogs. Empty follows the theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const SHADOW: &str = "Draw soft shadows on buttons, inputs and dialogs.";
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let nip4e = AppSettings::get_nip4e(cx);
let theme = AppSettings::get_theme(cx);
let features = v_flex()
.gap_3()
.child(settings_row(
"Web of Trust",
WOT,
Switch::new("onboarding-wot")
.checked(wot_auto_trust)
.on_click(move |checked, _window, cx| {
AppSettings::update_wot_auto_trust(*checked, cx);
}),
cx,
))
.child(settings_row(
"Decoupling Encryption Key",
NIP4E,
Switch::new("onboarding-nip4e").checked(nip4e).on_click(
move |checked, _window, cx| {
AppSettings::update_nip4e(*checked, cx);
},
),
cx,
));
#[cfg(not(target_arch = "wasm32"))]
let features = features.child(settings_row(
"Wallet",
WALLET,
Switch::new("onboarding-wallet")
.checked(AppSettings::get_wallet(cx))
.on_click(move |checked, _window, cx| {
AppSettings::update_wallet(*checked, cx);
}),
cx,
));
v_flex()
.gap_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(INTRO),
)
.child(features)
.child(Separator::horizontal())
.child(self.themes_view(cx))
.child(Separator::horizontal())
.child(
v_flex()
.gap_3()
.child(settings_row(
"Font Size",
FONT_SIZE,
NumberInput::new(&self.font_size_input).w_24(),
cx,
))
.child(settings_row(
"Corner Radius",
RADIUS,
NumberInput::new(&self.radius_input).w_24(),
cx,
))
.child(settings_row(
"Focus Ring",
FOCUS_RING,
Switch::new("onboarding-focus-ring")
.checked(theme.focus_ring)
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.focus_ring = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
))
.child(settings_row(
"Shadows",
SHADOW,
Switch::new("onboarding-shadow")
.checked(theme.shadow)
.on_click(move |checked, _window, cx| {
AppSettings::update_theme(|theme| theme.shadow = *checked, cx);
AppSettings::apply_theme_style(cx);
}),
cx,
)),
)
}
}