feat(settings): redesign theme picker with a slide-in panel (#56)

* feat(settings): redesign theme picker with a slide-in panel

Redesign the Appearance page's theme UI:

- Replace the inline theme gallery with a compact "Current theme" card
  (preview + name + light/dark) that opens a searchable theme picker in
  a right-hand panel; applying a theme keeps the panel open so several
  looks can be tried in a row.
- Add `theme_panel_open` / `theme_search` to `SettingsState`, plus
  toggle/close helpers and an `active_settings_mut` accessor.
- Share one `theme_preview` between the card and the panel cards; keep
  the existing bar-based preview shape.
- Group the custom-theme controls (duplicate / color editor / open
  folder) under the Theme section instead of stranding them at the foot
  of the page, and drop the near-black `.primary()` fill on the
  Duplicate button so it fits the soft, mostly-outline sheet.

* style: rustfmt theme panel border_color

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-07-13 10:38:45 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 0fdfcf127a
commit 78a6f00ec8
2 changed files with 284 additions and 99 deletions
+34
View File
@@ -550,6 +550,22 @@ impl Tty7App {
cx.notify();
}
/// Show/hide the theme picker panel beside the Appearance page.
pub(crate) fn toggle_theme_panel(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.theme_panel_open = !s.theme_panel_open;
cx.notify();
}
}
/// Close the theme picker panel (its `×`).
pub(crate) fn close_theme_panel(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.theme_panel_open = false;
cx.notify();
}
}
/// Open the user themes folder (`~/.config/tty7/themes`) in the system file
/// browser, creating it first so there's always somewhere to drop a theme.
pub(crate) fn open_themes_folder(&self, cx: &mut Context<Self>) {
@@ -1349,6 +1365,16 @@ impl Tty7App {
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
// Live filter for the theme picker panel; each keystroke re-renders the
// (already-cheap) list so results narrow as you type.
let theme_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search themes…"));
subs.push(
cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
self.maximized = None;
self.tabs.push(Tab {
@@ -1365,6 +1391,8 @@ impl Tty7App {
wd_path_input,
scroll_slider,
theme_editor: None,
theme_panel_open: false,
theme_search,
_subs: subs,
}),
});
@@ -1782,6 +1810,12 @@ impl Tty7App {
self.tabs.get(self.active).and_then(|t| t.settings.as_ref())
}
pub(crate) fn active_settings_mut(&mut self) -> Option<&mut SettingsState> {
self.tabs
.get_mut(self.active)
.and_then(|t| t.settings.as_mut())
}
/// Select a sidebar section in the active settings tab (no-op elsewhere).
pub(crate) fn select_settings_section(
&mut self,
+250 -99
View File
@@ -92,6 +92,11 @@ pub(crate) struct SettingsState {
/// The color editor for the active editable theme, or `None` when the active
/// theme is read-only (a built-in / import) or the system is being followed.
pub(crate) theme_editor: Option<ThemeEditor>,
/// Whether the theme picker panel is open beside the content pane
/// (Appearance section only). Toggled from the "Current theme" card.
pub(crate) theme_panel_open: bool,
/// Live filter for the theme picker panel's list.
pub(crate) theme_search: Entity<InputState>,
pub(crate) _subs: Vec<Subscription>,
}
@@ -120,10 +125,12 @@ impl Tty7App {
let theme = cx.theme();
let (background, foreground) = (theme.background, theme.foreground);
let (focus_handle, section) = match self.active_settings() {
Some(s) => (s.focus_handle.clone(), s.section),
let (focus_handle, section, theme_panel_open) = match self.active_settings() {
Some(s) => (s.focus_handle.clone(), s.section, s.theme_panel_open),
None => return div(), // not a settings tab; nothing to render
};
// The theme picker panel only makes sense beside its own page.
let show_theme_panel = theme_panel_open && section == SettingsSection::Appearance;
// `TTY7_PROFILE`: time this section's whole element build and, via the
// aggregated call rate, expose whether the panel is rebuilding once (on a
@@ -234,7 +241,8 @@ impl Tty7App {
// The Sidebar draws its own right border; no wrapper border here, or
// the two stack into one thick rule.
.child(sidebar)
.child(content_pane);
.child(content_pane)
.when(show_theme_panel, |r| r.child(self.render_theme_panel(cx)));
if let Some((start, label)) = prof {
crate::ui::perf::record(label, start.elapsed());
@@ -526,7 +534,11 @@ impl Tty7App {
"Pick a color theme. Each one sets its own light or dark look.",
cx,
))
.child(self.render_theme_picker(cx))
.child(self.render_current_theme(cx))
// Custom-theme management (duplicate / edit colors / open folder) is
// *about* themes, so it lives with the picker rather than stranded at
// the foot of the page after Cursor.
.child(self.render_custom_themes(cx))
.child(self.section_rule(cx))
.child(self.section_header("Typography", cx))
.child(self.settings_row(
@@ -579,8 +591,6 @@ impl Tty7App {
blink_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.render_custom_themes(cx))
.into_any_element()
}
@@ -608,6 +618,7 @@ impl Tty7App {
.map(|(_, label, state)| (label.clone(), state.clone()))
.collect();
return v_flex()
.mt_5()
.child(self.section_intro(
"Edit theme",
"You're editing a copy. Changes save to its file in the themes \
@@ -630,6 +641,7 @@ impl Tty7App {
// Read-only theme (built-in or import): offer to duplicate it into an
// editable copy, plus the folder affordance.
v_flex()
.mt_5()
.child(self.section_intro(
"Custom themes",
"Duplicate a theme to edit its colors here, or drop your own in the \
@@ -640,10 +652,12 @@ impl Tty7App {
h_flex()
.gap_3()
.child(
// Plain (not `.primary()`): a solid near-black fill reads
// far too heavy against this soft, mostly-outline sheet —
// it matches the "Open Themes Folder" button beside it.
Button::new("duplicate-theme")
.label("Duplicate to Edit")
.small()
.primary()
.on_click(cx.listener(|this, _, window, cx| {
this.fork_active_theme(window, cx)
})),
@@ -1026,118 +1040,255 @@ impl Tty7App {
/// a mini-terminal preview painted in its own colors. The selected card gets a
/// soft ring + a check; clicking switches the active theme live via
/// `set_preset`.
fn render_theme_picker(&self, cx: &mut Context<Self>) -> AnyElement {
/// The mini terminal preview for a theme: thin "lines of code" bars in the
/// theme's own colors over its background. Fills its container's width, so a
/// narrow "Current theme" card and the wider picker panel reuse one shape.
fn theme_preview(&self, p: &presets::Theme) -> Div {
let to_u32 = |(r, g, b): (u8, u8, u8)| (r as u32) << 16 | (g as u32) << 8 | b as u32;
let accent = rgb(p.accent);
let ansi = |i: usize| rgb(to_u32(p.ansi16[i]));
let fg = rgb(p.foreground);
// A "line of code": thin rounded bars, sized like words and tightly
// spaced so the preview reads as real terminal text, not fat pills.
let bar = |w: f32, color: gpui::Rgba| div().h(px(4.)).w(px(w)).rounded(px(1.5)).bg(color);
v_flex()
.w_full()
.bg(rgb(p.background_color()))
.rounded(px(8.))
.overflow_hidden()
.px_3()
.py_3()
.gap(px(10.))
.child(
h_flex()
.items_center()
.gap_2()
.child(div().text_size(px(11.)).text_color(accent).child(""))
.child(bar(60., fg)),
)
.child(
h_flex()
.gap_2()
.child(bar(26., ansi(2)))
.child(bar(46., ansi(4)))
.child(bar(16., ansi(3))),
)
.child(
h_flex()
.gap_2()
.child(bar(18., ansi(1)))
.child(bar(52., fg)),
)
.child(
h_flex()
.gap_2()
.child(bar(14., ansi(6)))
.child(bar(38., accent)),
)
}
/// The compact "Current theme" card on the Appearance page: a preview of the
/// active theme beside its name and light/dark mode, the whole row a click
/// target that opens the picker panel on the right.
fn render_current_theme(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let border = theme.border;
let foreground = theme.foreground;
let muted_fg = theme.muted_foreground;
let hover_bg = theme.secondary.opacity(0.5);
let surface = theme.secondary.opacity(0.28);
let active_id = cx.global::<Config>().theme_preset.clone();
let active = presets::by_id(cx, &active_id);
let name = active.name.clone();
let mode = if active.dark { "Dark" } else { "Light" };
let preview = self.theme_preview(&active);
let open = self.active_settings().is_some_and(|s| s.theme_panel_open);
// Selection chrome is monochrome — the ring and check track the theme's
// own `foreground`, exactly like the active tab in the title bar.
let sel_ring = foreground;
div()
.id("current-theme")
.mt_1()
.mb_2()
.max_w(px(520.))
.cursor_pointer()
.on_click(cx.listener(|this, _, _w, cx| this.toggle_theme_panel(cx)))
.child(
h_flex()
.items_center()
.gap_4()
.p_3()
.rounded_xl()
.border_1()
.border_color(if open {
foreground.opacity(0.35)
} else {
border
})
.bg(surface)
.hover(|h| h.bg(hover_bg))
.child(div().w(px(150.)).flex_shrink_0().child(preview))
.child(
v_flex()
.gap_0p5()
.child(
div()
.text_sm()
.font_weight(FontWeight::SEMIBOLD)
.text_color(foreground)
.child(name),
)
.child(div().text_xs().text_color(muted_fg).child(mode)),
)
.child(div().flex_1())
.child(
h_flex()
.items_center()
.gap_1()
.text_sm()
.text_color(muted_fg)
.child("Change theme")
.child(Icon::new(IconName::ChevronRight).small()),
),
)
.into_any_element()
}
let to_u32 = |(r, g, b): (u8, u8, u8)| (r as u32) << 16 | (g as u32) << 8 | b as u32;
/// The theme picker: a right-hand column of searchable preview
/// cards. Opened from the "Current theme" card; applying a theme keeps the
/// panel open (with its own `×`) so several looks can be tried in a row.
fn render_theme_panel(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let border = theme.border;
let foreground = theme.foreground;
let muted_fg = theme.muted_foreground;
// A hair off the content pane (like the settings rail) so the panel reads
// as its own surface rather than an extension of the page.
let bg = theme.sidebar;
// Cap the gallery at four cards wide but keep it flex-wrapping, so narrow
// panels fall back to three, two, or one per row.
let mut gallery = h_flex().flex_wrap().gap_5().mt_2().mb_2().max_w(px(864.));
let active_id = cx.global::<Config>().theme_preset.clone();
let (search, query) = match self.active_settings() {
Some(s) => (
s.theme_search.clone(),
s.theme_search.read(cx).value().trim().to_lowercase(),
),
None => return div().into_any_element(),
};
let header = h_flex()
.items_center()
.justify_between()
.px_4()
.pt_4()
.pb_1()
.child(
div()
.text_base()
.font_weight(FontWeight::SEMIBOLD)
.text_color(foreground)
.child("Themes"),
)
.child(
Button::new("theme-panel-close")
.icon(IconName::Close)
.ghost()
.small()
.on_click(cx.listener(|this, _, _w, cx| this.close_theme_panel(cx))),
);
let subtitle = div()
.px_4()
.pb_3()
.text_xs()
.text_color(muted_fg)
.child("Change your current theme.");
// Plain text input, the same shape the Shell section uses — our own
// field, not a bespoke pill. The Input fills its parent, but a percent
// width needs a *definite* one to resolve against, so the wrapper is sized
// explicitly (panel 300 px_4 gutters). Placeholder labels it as search;
// a leading magnifier keeps that reading at a glance.
let search_box = div().px_4().pb_3().child(
div().w(px(268.)).child(
Input::new(&search)
.small()
.prefix(Icon::new(IconName::Search).small().text_color(muted_fg)),
),
);
let mut list = v_flex().px_4().pb_4().gap_4();
for p in presets::all(cx) {
if !query.is_empty() && !p.name.to_lowercase().contains(&query) {
continue;
}
let id = p.id.clone();
let is_active = active_id == id;
let accent = rgb(p.accent);
let ansi = |i: usize| rgb(to_u32(p.ansi16[i]));
let fg = rgb(p.foreground);
// A "line of code": thin rounded bars, sized like words and tightly
// spaced so the preview reads as real terminal text, not fat pills.
let bar =
|w: f32, color: gpui::Rgba| div().h(px(4.)).w(px(w)).rounded(px(1.5)).bg(color);
let preview = v_flex()
.h(px(120.))
.bg(rgb(p.background_color()))
.rounded(px(10.))
.px_3()
.py_3()
.gap(px(10.))
.child(
h_flex()
.items_center()
.gap_2()
.child(div().text_size(px(11.)).text_color(accent).child(""))
.child(bar(60., fg)),
)
.child(
h_flex()
.gap_2()
.child(bar(26., ansi(2)))
.child(bar(46., ansi(4)))
.child(bar(16., ansi(3))),
)
.child(
h_flex()
.gap_2()
.child(bar(18., ansi(1)))
.child(bar(52., fg)),
)
.child(
h_flex()
.gap_2()
.child(bar(14., ansi(6)))
.child(bar(38., accent)),
);
let label = h_flex()
.items_center()
.gap_1p5()
.px_1()
.child(
div()
.text_sm()
.font_weight(if is_active {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
})
.text_color(if is_active { foreground } else { muted_fg })
.child(p.name.clone()),
)
.when(is_active, |s| {
s.child(Icon::new(IconName::Check).small().text_color(foreground))
});
let card = div()
.rounded_xl()
.overflow_hidden()
.border_1()
.border_color(if is_active {
sel_ring.opacity(0.35)
} else {
border
})
.when(is_active, |s| s.shadow_md())
.when(!is_active, |s| {
s.shadow_sm()
.hover(|h| h.border_color(sel_ring.opacity(0.18)))
})
.child(preview);
let preview = self.theme_preview(&p);
let click_id = id.clone();
gallery = gallery.child(
list = list.child(
v_flex()
.id(SharedString::from(format!("theme-{id}")))
.w(px(196.))
.gap_2()
.id(SharedString::from(format!("panel-theme-{id}")))
.gap_1p5()
.cursor_pointer()
.child(card)
.child(label)
.child(
div()
.rounded_lg()
.overflow_hidden()
.border_1()
.border_color(if is_active {
foreground.opacity(0.5)
} else {
border
})
.when(is_active, |s| s.shadow_md())
.when(!is_active, |s| {
s.hover(|h| h.border_color(foreground.opacity(0.25)))
})
.child(preview),
)
.child(
h_flex()
.items_center()
.gap_1p5()
.child(
div()
.text_sm()
.font_weight(if is_active {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
})
.text_color(if is_active { foreground } else { muted_fg })
.child(p.name.clone()),
)
.when(is_active, |s| {
s.child(Icon::new(IconName::Check).small().text_color(foreground))
}),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.set_preset(&click_id, window, cx)
})),
);
}
gallery.into_any_element()
v_flex()
.w(px(300.))
.h_full()
.flex_shrink_0()
.bg(bg)
.border_l_1()
.border_color(border)
.child(header)
.child(subtitle)
.child(search_box)
.child(
v_flex()
.id("theme-panel-list")
.flex_1()
.overflow_y_scroll()
.child(list),
)
.into_any_element()
}
/// Keybindings section: the effective shortcut list (defaults + overrides).