Theme · Platform: one platform for buttons and caps, icon themes, frosted panes

theme::Platform moves out of typography and shell's ControlsStyle folds
into it; the theme's platform starts as the system's. Window buttons Ely
draws, the title bar's layout and every key cap follow it, secondary
included, through typography::keys::keystroke; a title bar whose buttons
the OS draws follows the OS. primitives::IconTheme lays an owner's rules
by file name and extension over Ely's map, for every file icon.
shell::Vibrancy fills a pane with the palette's new glass, translucent
so a window opened Blurred shows its backdrop, solid under high contrast.
gpui 0.2.2 has no Mica; that line stays blocked.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 17:41:15 -04:00
1 parent 7f2205c811
commit d3cfc71ead
30 files changed
+593 -162

No files matched your search

+3 -1
View File
@@ -127,7 +127,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A file on its way is a `files::Transfer` drawn by one row in `files::transfer`: TransferQueue and DownloadManager share it, and its actions follow its state. - A file on its way is a `files::Transfer` drawn by one row in `files::transfer`: TransferQueue and DownloadManager share it, and its actions follow its state.
- Paths with `/` between folders become a tree through `lists::Folder::of`, for `FileTree` and `files::ArchiveViewer` alike. - Paths with `/` between folders become a tree through `lists::Folder::of`, for `FileTree` and `files::ArchiveViewer` alike.
- A folder's path over its entries is `lists::path_bar`, and its first order is `lists::listed`, folders first by name: DirectoryListing and FileExplorer share both, so every view shows one path and one order. - A folder's path over its entries is `lists::path_bar`, and its first order is `lists::listed`, folders first by name: DirectoryListing and FileExplorer share both, so every view shows one path and one order.
- A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it. - A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter, under the owner's `primitives::IconTheme`, whose rules by name and extension come first; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it.
- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. A hue worn in a row, a label's or an event's, is `data_display::color_mark`, and `Palette::hue` gives the chart color by index. Avatars take square pictures. A tag is never wider than its container; a long label ends in an ellipsis beside its remove mark. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. A hue worn in a row, a label's or an event's, is `data_display::color_mark`, and `Palette::hue` gives the chart color by index. Avatars take square pictures. A tag is never wider than its container; a long label ends in an ellipsis beside its remove mark.
- A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model). - A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model).
- `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. - `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
@@ -185,6 +185,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A choice that names a person leads with their initials through `forms::Choice::avatar`, in the list and in a Select's trigger alike (`project::AssigneePicker`). - A choice that names a person leads with their initials through `forms::Choice::avatar`, in the list and in a Select's trigger alike (`project::AssigneePicker`).
- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title). - `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title).
- Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns). - Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns).
- The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS.
- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui 0.2.2 has no Mica.
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike. - The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`). - Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
+10 -10
View File
@@ -1,8 +1,8 @@
use ely_gpui_component::{ use ely_gpui_component::{
buttons::IconButton, buttons::IconButton,
primitives::IconName, primitives::IconName,
shell::{ControlsStyle, TitleBar, WindowControls, drag_region}, shell::{TitleBar, WindowControls, drag_region},
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}, theme::{ActiveTheme, ControlSize, Elevation, Platform, Radius, TextSize},
typography::Caption, typography::Caption,
}; };
use gpui::{App, Div, IntoElement, ParentElement, Styled, Window, div, px}; use gpui::{App, Div, IntoElement, ParentElement, Styled, Window, div, px};
@@ -25,9 +25,9 @@ pub fn window_frame(width: f32, height: f32, cx: &App) -> Div {
.shadow(theme.elevation(Elevation::Raised)) .shadow(theme.elevation(Elevation::Raised))
} }
fn demo_bar(id: &'static str, style: ControlsStyle) -> TitleBar { fn demo_bar(id: &'static str, style: Platform) -> TitleBar {
TitleBar::new(id) TitleBar::new(id)
.style(style) .platform(style)
.title("Ely — Projects") .title("Ely — Projects")
.on_close(|_, _| log::info!("gallery: demo close refused")) .on_close(|_, _| log::info!("gallery: demo close refused"))
.child(IconButton::new((id, 1usize), IconName::PanelLeft).size(ControlSize::Sm)) .child(IconButton::new((id, 1usize), IconName::PanelLeft).size(ControlSize::Sm))
@@ -36,9 +36,9 @@ fn demo_bar(id: &'static str, style: ControlsStyle) -> TitleBar {
pub fn title_bar(cx: &App) -> impl IntoElement + use<> { pub fn title_bar(cx: &App) -> impl IntoElement + use<> {
let styles = [ let styles = [
("tb-mac", ControlsStyle::Mac, "macOS"), ("tb-mac", Platform::Mac, "macOS"),
("tb-windows", ControlsStyle::Windows, "Windows"), ("tb-windows", Platform::Windows, "Windows"),
("tb-linux", ControlsStyle::Linux, "Linux"), ("tb-linux", Platform::Linux, "Linux"),
]; ];
section( section(
"TitleBar", "TitleBar",
@@ -88,7 +88,7 @@ pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
"Mac", "Mac",
plate().child( plate().child(
WindowControls::new("wc-mac") WindowControls::new("wc-mac")
.style(ControlsStyle::Mac) .platform(Platform::Mac)
.on_close(|_, _| log::info!("gallery: demo close refused")), .on_close(|_, _| log::info!("gallery: demo close refused")),
), ),
cx, cx,
@@ -97,7 +97,7 @@ pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
"Windows", "Windows",
plate().px_0().child( plate().px_0().child(
WindowControls::new("wc-windows") WindowControls::new("wc-windows")
.style(ControlsStyle::Windows) .platform(Platform::Windows)
.on_close(|_, _| log::info!("gallery: demo close refused")), .on_close(|_, _| log::info!("gallery: demo close refused")),
), ),
cx, cx,
@@ -106,7 +106,7 @@ pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
"Linux", "Linux",
plate().child( plate().child(
WindowControls::new("wc-linux") WindowControls::new("wc-linux")
.style(ControlsStyle::Linux) .platform(Platform::Linux)
.on_close(|_, _| log::info!("gallery: demo close refused")), .on_close(|_, _| log::info!("gallery: demo close refused")),
), ),
cx, cx,
+9 -1
View File
@@ -11,8 +11,10 @@ use gpui::{
mod editing; mod editing;
mod importing; mod importing;
mod platform;
use super::Page; use super::Page;
use crate::script::Step;
use crate::ui::{section, specimen, specimens}; use crate::ui::{section, specimen, specimens};
pub const PAGE: Page = Page { pub const PAGE: Page = Page {
@@ -21,7 +23,12 @@ pub const PAGE: Page = Page {
title: "Theme", title: "Theme",
summary: "Tokens every component reads. Light and dark share one grammar.", summary: "Tokens every component reads. Light and dark share one grammar.",
render, render,
script: &[], script: &[
Step::Click("frosted-open"),
Step::Wait(700),
Step::ShotWindow("frosted", "frosted-window"),
Step::CloseWindow("frosted"),
],
}; };
fn render(window: &mut Window, cx: &mut App) -> AnyElement { fn render(window: &mut Window, cx: &mut App) -> AnyElement {
@@ -149,6 +156,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
) )
.child(editing::editor(window, cx)) .child(editing::editor(window, cx))
.child(importing::importing(window, cx)) .child(importing::importing(window, cx))
.child(platform::platform(window, cx))
.into_any_element() .into_any_element()
} }
+192
View File
@@ -0,0 +1,192 @@
use ely_gpui_component::{
buttons::{Button, SegmentedControl},
files::FileIcon,
forms::Switch,
primitives::{IconName, IconTheme},
shell::{Vibrancy, WindowControls},
theme::{ActiveTheme, Platform, Theme},
typography::{Kbd, KbdCombo},
};
use gpui::{
App, AppContext, Bounds, Context, IntoElement, ParentElement, Render, Styled, TitlebarOptions,
Window, WindowBackgroundAppearance, WindowBounds, WindowOptions, div, point, px, size,
};
use crate::{
probe::{Opened, probe},
ui::{change, keep, section, specimen, specimens},
};
const PLATFORMS: [(Platform, &str); 3] = [
(Platform::Mac, "macOS"),
(Platform::Windows, "Windows"),
(Platform::Linux, "Linux"),
];
/// Whether the demo's own icon rules are on.
struct Owned(bool);
pub fn platform(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
section(
"Platform Style · IconThemeProvider · VibrancyBackground",
"The theme's platform decides window buttons and key caps, and starts as the system's. An icon theme lays an owner's rules over Ely's file icons. A frosted pane lets a blurred window's backdrop through; gpui 0.2.2 blurs as vibrancy on macOS and acrylic on Windows, and has no Mica.",
cx,
)
.child(
specimens()
.child(specimen("Platform", platforms(cx), cx))
.child(specimen("IconTheme", icons(window, cx), cx))
.child(specimen(
"Vibrancy",
probe(
"frosted-open",
Button::new("frosted-open", "Open a frosted window")
.on_click(|_, _, cx| frosted(cx)),
),
cx,
)),
)
}
fn platforms(cx: &App) -> impl IntoElement + use<> {
let shown = cx.theme().platform;
let label = PLATFORMS
.iter()
.find(|(platform, _)| *platform == shown)
.map(|(_, label)| *label)
.expect("a listed platform");
let control = PLATFORMS.iter().fold(
SegmentedControl::new("platform", label),
|control, (_, label)| control.segment(*label, *label, None),
);
div()
.w(px(320.))
.flex()
.flex_col()
.gap_4()
.child(control.on_change(|picked, _, cx| {
let (platform, _) = PLATFORMS
.iter()
.find(|(_, label)| *label == picked.as_ref())
.expect("a listed platform");
Theme::update(cx, |theme| theme.platform = *platform);
}))
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_3()
.child(WindowControls::new("platform-controls"))
.child(
div()
.flex()
.items_center()
.gap_3()
.child(Kbd::new("secondary-s"))
.child(KbdCombo::new("secondary-k secondary-s")),
),
)
}
fn icons(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let owned = keep("icon-theme", || Owned(false), window, cx);
let on = owned.read(cx).0;
let muted = cx.theme().colors.fg_muted;
let rows = ["Cargo.toml", "README.md", "main.rs", "logo.svg"].map(|name| {
div()
.flex()
.items_center()
.gap_2()
.child(FileIcon::file(name))
.child(div().text_color(muted).child(name))
});
div()
.w(px(240.))
.flex()
.flex_col()
.gap_2()
.child(
Switch::new("icon-theme", on)
.label("The owner's icons")
.on_change(move |on, _, cx| {
let theme = if on {
IconTheme::default()
.name("Cargo.toml", IconName::Package)
.name("README.md", IconName::BookOpen)
.extension("rs", IconName::Braces)
} else {
IconTheme::default()
};
theme.apply(cx);
change(&owned, cx, |owned| owned.0 = on);
}),
)
.children(rows)
}
fn frosted(cx: &mut App) {
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(560.), px(340.)),
cx,
))),
titlebar: Some(TitlebarOptions {
title: Some("Frosted".into()),
appears_transparent: true,
traffic_light_position: Some(point(px(16.0), px(18.0))),
}),
window_background: WindowBackgroundAppearance::Blurred,
..Default::default()
};
let handle = cx
.open_window(options, |_, cx| cx.new(|_| Frosted))
.expect("the frosted window opens");
Opened::insert("frosted", handle.into(), cx);
}
/// A window whose sidebar lets the desktop through.
struct Frosted;
impl Render for Frosted {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
div()
.size_full()
.flex()
.font_family(theme.font_family.clone())
.text_color(colors.fg)
.child(
Vibrancy::new()
.w(px(180.))
.h_full()
.pt_12()
.px_3()
.flex()
.flex_col()
.gap_1()
.children(
["Library", "Recents", "Shared"]
.map(|name| div().px_2().py_1().child(name)),
),
)
.child(
div()
.flex_1()
.h_full()
.p_6()
.pt_12()
.bg(colors.bg)
.border_l_1()
.border_color(colors.border)
.min_w_0()
.text_color(colors.fg_muted)
.child(
div().child("The sidebar lets the desktop through; this pane stays solid."),
),
)
}
}
+1 -1
View File
@@ -150,7 +150,7 @@ impl RenderOnce for FileChangeCard {
}) })
.child(Disclosure::new((self.id.clone(), "chevron"), open).size(IconSize::Sm)) .child(Disclosure::new((self.id.clone(), "chevron"), open).size(IconSize::Sm))
.child( .child(
Icon::new(file_icon(&self.change.path)) Icon::new(file_icon(&self.change.path, cx))
.size(IconSize::Sm) .size(IconSize::Sm)
.color(colors.fg_muted), .color(colors.fg_muted),
) )
+16 -12
View File
@@ -1,13 +1,13 @@
use gpui::{ use gpui::{
App, ClickEvent, ElementId, FocusHandle, FontWeight, Hsla, IntoElement, Keystroke, MouseButton, App, ClickEvent, ElementId, FocusHandle, FontWeight, Hsla, IntoElement, MouseButton,
RenderOnce, SharedString, Window, div, prelude::*, transparent_black, RenderOnce, SharedString, Window, div, prelude::*, transparent_black,
}; };
use crate::{ use crate::{
motion::Spinner, motion::Spinner,
primitives::{FocusRing, Icon, IconName}, primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize}, theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Platform, Radius, TextSize},
typography::keys::{Platform, keystroke_labels}, typography::keys::{keystroke, keystroke_labels},
}; };
pub(crate) type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>; pub(crate) type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
@@ -112,11 +112,15 @@ pub(crate) enum Slot {
Last, Last,
} }
/// A keystroke as the platform spells it: `⌘S`, or `Ctrl+S`. /// Fails on a keystroke gpui cannot read, when a builder takes it.
pub(crate) fn shortcut_text(stroke: &Keystroke) -> String { pub(crate) fn keystroke_of(source: &str) {
let platform = Platform::current(); keystroke(source, Platform::current());
}
/// A keystroke in gpui key syntax as `platform` spells it: `⌘S`, or `Ctrl+S`.
pub(crate) fn shortcut_text(source: &str, platform: Platform) -> String {
let glue = if platform == Platform::Mac { "" } else { "+" }; let glue = if platform == Platform::Mac { "" } else { "+" };
keystroke_labels(stroke, platform).join(glue) keystroke_labels(&keystroke(source, platform), platform).join(glue)
} }
#[derive(IntoElement)] #[derive(IntoElement)]
@@ -125,7 +129,7 @@ pub struct Button {
label: SharedString, label: SharedString,
icon: Option<IconName>, icon: Option<IconName>,
trailing_icon: Option<IconName>, trailing_icon: Option<IconName>,
shortcut: Option<Keystroke>, shortcut: Option<SharedString>,
variant: ButtonVariant, variant: ButtonVariant,
size: ControlSize, size: ControlSize,
disabled: bool, disabled: bool,
@@ -181,9 +185,8 @@ impl Button {
/// Shows the keystroke that also runs it, e.g. `"cmd-s"`. Panics on a bad keystroke. /// Shows the keystroke that also runs it, e.g. `"cmd-s"`. Panics on a bad keystroke.
pub fn shortcut(mut self, keystroke: &str) -> Self { pub fn shortcut(mut self, keystroke: &str) -> Self {
let parsed = Keystroke::parse(keystroke) keystroke_of(keystroke);
.unwrap_or_else(|error| panic!("button shortcut {keystroke:?}: {error}")); self.shortcut = Some(SharedString::from(keystroke.to_string()));
self.shortcut = Some(parsed);
self self
} }
@@ -226,7 +229,8 @@ impl RenderOnce for Button {
let link = self.variant == ButtonVariant::Link; let link = self.variant == ButtonVariant::Link;
let icon = |name| Icon::new(name).size(icon_size).color(tone.fg); let icon = |name| Icon::new(name).size(icon_size).color(tone.fg);
let radius = theme.radius(Radius::Md); let radius = theme.radius(Radius::Md);
let hint = self.shortcut.as_ref().map(shortcut_text); let platform = cx.theme().platform;
let hint = self.shortcut.map(|source| shortcut_text(&source, platform));
let spinner = (self.id.clone(), "spinner"); let spinner = (self.id.clone(), "spinner");
let parts = div() let parts = div()
.flex() .flex()
+7 -8
View File
@@ -1,11 +1,11 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, Keystroke, ParentElement, App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
}; };
use super::button::shortcut_text; use super::button::{keystroke_of, shortcut_text};
use crate::{ use crate::{
primitives::{FocusRing, Icon, IconName}, primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
@@ -19,7 +19,7 @@ pub struct QuickAction {
icon: IconName, icon: IconName,
label: SharedString, label: SharedString,
note: Option<SharedString>, note: Option<SharedString>,
shortcut: Option<Keystroke>, shortcut: Option<SharedString>,
run: Run, run: Run,
} }
@@ -45,9 +45,8 @@ impl QuickAction {
/// Panics on a bad keystroke. /// Panics on a bad keystroke.
pub fn shortcut(mut self, keystroke: &str) -> Self { pub fn shortcut(mut self, keystroke: &str) -> Self {
let parsed = Keystroke::parse(keystroke) keystroke_of(keystroke);
.unwrap_or_else(|error| panic!("quick action shortcut {keystroke:?}: {error}")); self.shortcut = Some(SharedString::from(keystroke.to_string()));
self.shortcut = Some(parsed);
self self
} }
} }
@@ -171,7 +170,7 @@ impl RenderOnce for QuickActions {
div() div()
.text_size(theme.text_size(TextSize::Xs)) .text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle) .text_color(colors.fg_subtle)
.child(shortcut_text(stroke)), .child(shortcut_text(stroke, theme.platform)),
) )
}) })
}) })
+1 -1
View File
@@ -80,7 +80,7 @@ impl RenderOnce for AttachmentChip {
.rounded(theme.radius(Radius::Sm)) .rounded(theme.radius(Radius::Sm))
.bg(colors.hover) .bg(colors.hover)
.child( .child(
Icon::new(file_icon(&item.name)) Icon::new(file_icon(&item.name, cx))
.size(IconSize::Md) .size(IconSize::Md)
.color(colors.fg_muted), .color(colors.fg_muted),
), ),
+1 -1
View File
@@ -255,7 +255,7 @@ impl RenderOnce for FileMessage {
.rounded(theme.radius(Radius::Sm)) .rounded(theme.radius(Radius::Sm))
.bg(colors.hover) .bg(colors.hover)
.child( .child(
Icon::new(file_icon(&self.name)) Icon::new(file_icon(&self.name, cx))
.size(IconSize::Md) .size(IconSize::Md)
.color(colors.fg_muted), .color(colors.fg_muted),
), ),
+1 -1
View File
@@ -211,7 +211,7 @@ impl RenderOnce for ProjectKnowledgePanel {
.py_1() .py_1()
.text_size(theme.text_size(TextSize::Sm)) .text_size(theme.text_size(TextSize::Sm))
.child( .child(
Icon::new(file_icon(&name)) Icon::new(file_icon(&name, cx))
.size(IconSize::Sm) .size(IconSize::Sm)
.color(colors.fg_muted), .color(colors.fg_muted),
) )
+4 -5
View File
@@ -1,9 +1,8 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, Keystroke, AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
prelude::*,
}; };
use super::OnIndex; use super::OnIndex;
@@ -68,14 +67,14 @@ impl RenderOnce for DebugToolbar {
let theme = cx.theme(); let theme = cx.theme();
let colors = theme.colors.clone(); let colors = theme.colors.clone();
let paused = self.state == DebugState::Paused; let paused = self.state == DebugState::Paused;
let platform = theme.platform;
let button = let button =
|command: DebugCommand, icon: IconName, words: &str, key: &str, enabled: bool| { |command: DebugCommand, icon: IconName, words: &str, key: &str, enabled: bool| {
let run = self.on_command.clone(); let run = self.on_command.clone();
let key = Keystroke::parse(key).expect("a debug key parses");
IconButton::new((self.id.clone(), format!("{command:?}")), icon) IconButton::new((self.id.clone(), format!("{command:?}")), icon)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.size(ControlSize::Sm) .size(ControlSize::Sm)
.tooltip(format!("{words} {}", shortcut_text(&key))) .tooltip(format!("{words} {}", shortcut_text(key, platform)))
.disabled(!enabled) .disabled(!enabled)
.when_some(run, |button, run| { .when_some(run, |button, run| {
button.on_click(move |_, window, cx| { button.on_click(move |_, window, cx| {
+2
View File
@@ -98,6 +98,7 @@ impl RenderOnce for RichTextEditor {
}; };
let (open_link, close_link) = (set_linking(true), set_linking(false)); let (open_link, close_link) = (set_linking(true), set_linking(false));
let float_id: ElementId = (self.id.clone(), "float").into(); let float_id: ElementId = (self.id.clone(), "float").into();
let platform = cx.theme().platform;
let floating = FloatingToolbar::new(float_id.clone(), &self.field).children( let floating = FloatingToolbar::new(float_id.clone(), &self.field).children(
INLINE.iter().map(|tool_of| { INLINE.iter().map(|tool_of| {
let on = pressed.contains(&tool_of.0); let on = pressed.contains(&tool_of.0);
@@ -107,6 +108,7 @@ impl RenderOnce for RichTextEditor {
*tool_of, *tool_of,
on, on,
Some(open_link.clone()), Some(open_link.clone()),
platform,
) )
}), }),
); );
+13 -7
View File
@@ -1,4 +1,4 @@
use gpui::{App, ElementId, Entity, IntoElement, Keystroke, ParentElement, RenderOnce, Window}; use gpui::{App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, Window};
use super::format::{Format, active, format}; use super::format::{Format, active, format};
use crate::{ use crate::{
@@ -6,7 +6,7 @@ use crate::{
forms::{Run, TextInput}, forms::{Run, TextInput},
primitives::IconName, primitives::IconName,
shell::{Toolbar, ToolbarGroup, ToolbarSeparator}, shell::{Toolbar, ToolbarGroup, ToolbarSeparator},
theme::ControlSize, theme::{ActiveTheme, ControlSize, Platform},
}; };
/// A tool: the format it applies, its icon, its name and its key. /// A tool: the format it applies, its icon, its name and its key.
@@ -56,12 +56,10 @@ pub(crate) fn tool(
(format_of, icon, name, key): Tool, (format_of, icon, name, key): Tool,
on: bool, on: bool,
on_link: Option<Run>, on_link: Option<Run>,
platform: Platform,
) -> ToggleButton { ) -> ToggleButton {
let tip = match key { let tip = match key {
Some(key) => { Some(key) => format!("{name} {}", shortcut_text(key, platform)),
let stroke = Keystroke::parse(key).expect("a tool's key parses");
format!("{name} {}", shortcut_text(&stroke))
}
None => name.to_string(), None => name.to_string(),
}; };
let field = field.clone(); let field = field.clone();
@@ -105,10 +103,18 @@ impl RenderOnce for FixedFormatToolbar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let input = self.field.read(cx); let input = self.field.read(cx);
let pressed = active(input.text(), input.selection()); let pressed = active(input.text(), input.selection());
let platform = cx.theme().platform;
let group = |tools: &[Tool]| { let group = |tools: &[Tool]| {
ToolbarGroup::new().children(tools.iter().map(|tool_of| { ToolbarGroup::new().children(tools.iter().map(|tool_of| {
let on = pressed.contains(&tool_of.0); let on = pressed.contains(&tool_of.0);
tool(&self.id, &self.field, *tool_of, on, self.on_link.clone()) tool(
&self.id,
&self.field,
*tool_of,
on,
self.on_link.clone(),
platform,
)
})) }))
}; };
Toolbar::new() Toolbar::new()
+10 -5
View File
@@ -8,7 +8,7 @@ use gpui::{
use super::FileIcon; use super::FileIcon;
use crate::{ use crate::{
lists::{Folder, Tree, TreeNode}, lists::{Folder, Tree, TreeNode},
primitives::{IconName, file_icon}, primitives::{IconName, IconTheme, icon_theme},
theme::{ActiveTheme, IconSize, TextSize}, theme::{ActiveTheme, IconSize, TextSize},
typography::{MiddleEllipsis, format}, typography::{MiddleEllipsis, format},
}; };
@@ -32,14 +32,19 @@ pub(crate) fn saving(size: u64, packed: u64) -> u64 {
} }
/// Nodes for `folder` under `prefix`, folders first, then files, each by name; and the bytes it holds. /// Nodes for `folder` under `prefix`, folders first, then files, each by name; and the bytes it holds.
fn nodes(folder: &Folder, prefix: &str, entries: &[ArchiveEntry]) -> (Vec<TreeNode>, u64) { fn nodes(
folder: &Folder,
prefix: &str,
entries: &[ArchiveEntry],
icons: &IconTheme,
) -> (Vec<TreeNode>, u64) {
let mut out = Vec::new(); let mut out = Vec::new();
let mut held = 0; let mut held = 0;
let mut folders: Vec<_> = folder.folders.iter().collect(); let mut folders: Vec<_> = folder.folders.iter().collect();
folders.sort_by_key(|(name, _)| name.to_lowercase()); folders.sort_by_key(|(name, _)| name.to_lowercase());
for (name, inner) in folders { for (name, inner) in folders {
let path = format!("{prefix}{name}"); let path = format!("{prefix}{name}");
let (children, bytes) = nodes(inner, &format!("{path}/"), entries); let (children, bytes) = nodes(inner, &format!("{path}/"), entries, icons);
held += bytes; held += bytes;
let node = TreeNode::new(path, name.clone()) let node = TreeNode::new(path, name.clone())
.icon(IconName::Folder) .icon(IconName::Folder)
@@ -63,7 +68,7 @@ fn nodes(folder: &Folder, prefix: &str, entries: &[ArchiveEntry]) -> (Vec<TreeNo
); );
out.push( out.push(
TreeNode::new(path, name.clone()) TreeNode::new(path, name.clone())
.icon(file_icon(name)) .icon(icons.file(name))
.note(note), .note(note),
); );
} }
@@ -115,7 +120,7 @@ impl RenderOnce for ArchiveViewer {
let theme = cx.theme(); let theme = cx.theme();
let colors = &theme.colors; let colors = &theme.colors;
let folders = Folder::of(self.entries.iter().map(|entry| entry.path.as_ref())); let folders = Folder::of(self.entries.iter().map(|entry| entry.path.as_ref()));
let (nodes, size) = nodes(&folders, "", &self.entries); let (nodes, size) = nodes(&folders, "", &self.entries, icon_theme(cx));
let packed: u64 = self.entries.iter().map(|entry| entry.packed).sum(); let packed: u64 = self.entries.iter().map(|entry| entry.packed).sum();
let top: Vec<SharedString> = folders let top: Vec<SharedString> = folders
.folders .folders
+17 -7
View File
@@ -1,4 +1,4 @@
use gpui::{App, IntoElement, RenderOnce, Window}; use gpui::{App, IntoElement, RenderOnce, SharedString, Window};
use crate::{ use crate::{
lists::DirEntry, lists::DirEntry,
@@ -6,28 +6,34 @@ use crate::{
theme::{ActiveTheme, IconSize}, theme::{ActiveTheme, IconSize},
}; };
/// A file's icon by its name's extension, or a folder's, open or shut. /// A file's name, its icon found under the icon theme at render, or an icon itself.
enum Shown {
File(SharedString),
Icon(IconName),
}
/// A file's icon by its name under the owner's icon theme, or a folder's, open or shut.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct FileIcon { pub struct FileIcon {
icon: IconName, shown: Shown,
size: IconSize, size: IconSize,
} }
impl FileIcon { impl FileIcon {
pub fn file(name: impl AsRef<str>) -> Self { pub fn file(name: impl AsRef<str>) -> Self {
Self { Self {
icon: file_icon(name.as_ref()), shown: Shown::File(name.as_ref().to_string().into()),
size: IconSize::Md, size: IconSize::Md,
} }
} }
pub fn folder(open: bool) -> Self { pub fn folder(open: bool) -> Self {
Self { Self {
icon: if open { shown: Shown::Icon(if open {
IconName::FolderOpen IconName::FolderOpen
} else { } else {
IconName::Folder IconName::Folder
}, }),
size: IconSize::Md, size: IconSize::Md,
} }
} }
@@ -48,7 +54,11 @@ impl FileIcon {
impl RenderOnce for FileIcon { impl RenderOnce for FileIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
Icon::new(self.icon) let icon = match self.shown {
Shown::File(name) => file_icon(&name, cx),
Shown::Icon(icon) => icon,
};
Icon::new(icon)
.size(self.size) .size(self.size)
.color(cx.theme().colors.fg_muted) .color(cx.theme().colors.fg_muted)
} }
+1 -1
View File
@@ -296,7 +296,7 @@ impl RenderOnce for DirectoryListing {
Icon::new(if entry.is_folder() { Icon::new(if entry.is_folder() {
IconName::Folder IconName::Folder
} else { } else {
file_icon(&entry.name) file_icon(&entry.name, cx)
}) })
.size(IconSize::Md) .size(IconSize::Md)
.color(colors.fg_muted), .color(colors.fg_muted),
+10 -7
View File
@@ -9,7 +9,7 @@ use super::{Tree, TreeNode};
use crate::{ use crate::{
data_display::Tone, data_display::Tone,
forms::{SearchInput, TextInput}, forms::{SearchInput, TextInput},
primitives::{IconName, file_icon}, primitives::{IconName, IconTheme, icon_theme},
theme::ControlSize, theme::ControlSize,
}; };
@@ -90,6 +90,7 @@ fn nodes(
paths: &[SharedString], paths: &[SharedString],
status: &[(SharedString, GitStatus)], status: &[(SharedString, GitStatus)],
query: &str, query: &str,
icons: &IconTheme,
) -> (Vec<TreeNode>, Vec<SharedString>) { ) -> (Vec<TreeNode>, Vec<SharedString>) {
let root = Folder::of(paths.iter().map(|path| path.as_ref())); let root = Folder::of(paths.iter().map(|path| path.as_ref()));
let state = |path: &str| { let state = |path: &str| {
@@ -104,6 +105,7 @@ fn nodes(
query: &str, query: &str,
state: &dyn Fn(&str) -> Option<GitStatus>, state: &dyn Fn(&str) -> Option<GitStatus>,
open: &mut Vec<SharedString>, open: &mut Vec<SharedString>,
icons: &IconTheme,
) -> (Vec<TreeNode>, bool) { ) -> (Vec<TreeNode>, bool) {
let mut out = Vec::new(); let mut out = Vec::new();
let mut changed = false; let mut changed = false;
@@ -111,7 +113,8 @@ fn nodes(
folders.sort_by_key(|(name, _)| name.to_lowercase()); folders.sort_by_key(|(name, _)| name.to_lowercase());
for (name, inner) in folders { for (name, inner) in folders {
let path = format!("{prefix}{name}"); let path = format!("{prefix}{name}");
let (children, inner_changed) = walk(inner, &format!("{path}/"), query, state, open); let (children, inner_changed) =
walk(inner, &format!("{path}/"), query, state, open, icons);
if children.is_empty() && !matches(name, query) { if children.is_empty() && !matches(name, query) {
continue; continue;
} }
@@ -136,7 +139,7 @@ fn nodes(
files.sort_by_key(|name| name.to_lowercase()); files.sort_by_key(|name| name.to_lowercase());
for name in files { for name in files {
let path = format!("{prefix}{name}"); let path = format!("{prefix}{name}");
let node = TreeNode::new(path.clone(), name.clone()).icon(file_icon(name)); let node = TreeNode::new(path.clone(), name.clone()).icon(icons.file(name));
out.push(match state(&path) { out.push(match state(&path) {
Some(state) => { Some(state) => {
changed = true; changed = true;
@@ -148,7 +151,7 @@ fn nodes(
(out, changed) (out, changed)
} }
let mut open = Vec::new(); let mut open = Vec::new();
let (nodes, _) = walk(&root, "", query, &state, &mut open); let (nodes, _) = walk(&root, "", query, &state, &mut open, icons);
(nodes, open) (nodes, open)
} }
@@ -208,7 +211,7 @@ impl RenderOnce for FileTree {
Vec::<SharedString>::new() Vec::<SharedString>::new()
}); });
let query = filter.read(cx).text().trim().to_string(); let query = filter.read(cx).text().trim().to_string();
let (nodes, open) = nodes(&self.paths, &self.status, &query); let (nodes, open) = nodes(&self.paths, &self.status, &query, icon_theme(cx));
let top: Vec<SharedString> = nodes let top: Vec<SharedString> = nodes
.iter() .iter()
.filter(|node| node.opens()) .filter(|node| node.opens())
@@ -245,7 +248,7 @@ impl RenderOnce for FileTree {
mod tests { mod tests {
use gpui::SharedString; use gpui::SharedString;
use super::{GitStatus, nodes}; use super::{GitStatus, IconTheme, nodes};
use crate::lists::tree::{Shown, rows}; use crate::lists::tree::{Shown, rows};
fn paths() -> Vec<SharedString> { fn paths() -> Vec<SharedString> {
@@ -267,7 +270,7 @@ mod tests {
("src/ui/Button.rs".into(), GitStatus::Modified), ("src/ui/Button.rs".into(), GitStatus::Modified),
("README.md".into(), GitStatus::Added), ("README.md".into(), GitStatus::Added),
]; ];
let (nodes, open) = nodes(&paths(), &status, query); let (nodes, open) = nodes(&paths(), &status, query, &IconTheme::default());
let every = ["assets", "src", "src/ui"] let every = ["assets", "src", "src/ui"]
.map(SharedString::from) .map(SharedString::from)
.into_iter() .into_iter()
+87 -12
View File
@@ -1,9 +1,68 @@
use std::{collections::HashMap, sync::LazyLock};
use gpui::{App, Global};
use super::IconName; use super::IconName;
/// The icon a file's name suggests, by its extension. /// Which icon a file shows: an owner's rules by name and by extension, laid over Ely's map. Without one, Ely's map alone.
pub(crate) fn file_icon(name: &str) -> IconName { #[derive(Clone, Default)]
let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase()); pub struct IconTheme {
match extension.as_deref() { names: HashMap<String, IconName>,
extensions: HashMap<String, IconName>,
}
impl Global for IconTheme {}
impl IconTheme {
/// A file of this exact name, in any case: `Cargo.toml`, `Dockerfile`.
pub fn name(mut self, name: &str, icon: IconName) -> Self {
self.names.insert(name.to_lowercase(), icon);
self
}
/// Files ending in this extension, given without its dot.
pub fn extension(mut self, extension: &str, icon: IconName) -> Self {
self.extensions.insert(extension.to_lowercase(), icon);
self
}
/// Serves every window from now on.
pub fn apply(self, cx: &mut App) {
log::info!(
"icon theme: {} names, {} extensions",
self.names.len(),
self.extensions.len()
);
cx.set_global(self);
cx.refresh_windows();
}
pub(crate) fn file(&self, name: &str) -> IconName {
let name = name.to_lowercase();
let extension = name.rsplit_once('.').map(|(_, extension)| extension);
self.names
.get(&name)
.or_else(|| extension.and_then(|extension| self.extensions.get(extension)))
.copied()
.unwrap_or_else(|| ely_icon(extension))
}
}
static ELY: LazyLock<IconTheme> = LazyLock::new(IconTheme::default);
/// The owner's icon theme, or Ely's map alone.
pub(crate) fn icon_theme(cx: &App) -> &IconTheme {
cx.try_global::<IconTheme>().unwrap_or(&ELY)
}
/// The icon a file's name suggests, under the owner's icon theme.
pub(crate) fn file_icon(name: &str, cx: &App) -> IconName {
icon_theme(cx).file(name)
}
/// Ely's icon for a file by its extension, in lower case.
fn ely_icon(extension: Option<&str>) -> IconName {
match extension {
Some( Some(
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "swift" | "c" | "h" | "cpp" | "java" "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "swift" | "c" | "h" | "cpp" | "java"
| "kt" | "rb" | "php" | "css" | "html", | "kt" | "rb" | "php" | "css" | "html",
@@ -26,16 +85,32 @@ pub(crate) fn file_icon(name: &str) -> IconName {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{IconName, file_icon}; use super::{IconName, IconTheme};
#[test] #[test]
fn a_name_picks_its_icon_by_its_last_extension() { fn a_name_picks_its_icon_by_its_last_extension() {
assert_eq!(file_icon("main.RS"), IconName::FileCode); assert_eq!(IconTheme::default().file("main.RS"), IconName::FileCode);
assert_eq!(file_icon("lift.PNG"), IconName::FileImage); assert_eq!(IconTheme::default().file("lift.PNG"), IconName::FileImage);
assert_eq!(file_icon("notes.md"), IconName::FileText); assert_eq!(IconTheme::default().file("notes.md"), IconName::FileText);
assert_eq!(file_icon("backup.tar.gz"), IconName::FileArchive); assert_eq!(
assert_eq!(file_icon(".env"), IconName::FileCog); IconTheme::default().file("backup.tar.gz"),
assert_eq!(file_icon("take.mov"), IconName::FileVideoCamera); IconName::FileArchive
assert_eq!(file_icon("Makefile"), IconName::File); );
assert_eq!(IconTheme::default().file(".env"), IconName::FileCog);
assert_eq!(
IconTheme::default().file("take.mov"),
IconName::FileVideoCamera
);
assert_eq!(IconTheme::default().file("Makefile"), IconName::File);
}
#[test]
fn an_owners_rules_come_before_elys_map() {
let theme = IconTheme::default()
.name("Cargo.toml", IconName::Package)
.extension("RS", IconName::Braces);
assert_eq!(theme.file("cargo.TOML"), IconName::Package);
assert_eq!(theme.file("main.rs"), IconName::Braces);
assert_eq!(theme.file("deploy.toml"), IconName::FileCog);
} }
} }
+2 -1
View File
@@ -18,7 +18,8 @@ mod tests;
pub use backdrop::{Backdrop, Place}; pub use backdrop::{Backdrop, Place};
pub use disclosure::Disclosure; pub use disclosure::Disclosure;
pub use divider::Divider; pub use divider::Divider;
pub(crate) use files::file_icon; pub use files::IconTheme;
pub(crate) use files::{file_icon, icon_theme};
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus}; pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus};
pub use ghost::DragGhost; pub use ghost::DragGhost;
+28 -45
View File
@@ -9,30 +9,9 @@ use smallvec::SmallVec;
use crate::{ use crate::{
primitives::{Icon, IconName}, primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize}, theme::{ActiveTheme, ControlSize, IconSize, Platform, TextSize},
}; };
/// Which system's window buttons to draw.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlsStyle {
Mac,
Windows,
Linux,
}
impl ControlsStyle {
/// The style of the system this build targets.
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::Mac
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Linux
}
}
}
/// Drags the window, and zooms it on double-click. /// Drags the window, and zooms it on double-click.
pub fn drag_region(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> Stateful<Div> { pub fn drag_region(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
let id = id.into(); let id = id.into();
@@ -91,7 +70,7 @@ fn run(control: Control, on_close: Option<&OnClose>, window: &mut Window, cx: &m
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct WindowControls { pub struct WindowControls {
id: ElementId, id: ElementId,
style: ControlsStyle, platform: Option<Platform>,
on_close: Option<OnClose>, on_close: Option<OnClose>,
} }
@@ -99,13 +78,14 @@ impl WindowControls {
pub fn new(id: impl Into<ElementId>) -> Self { pub fn new(id: impl Into<ElementId>) -> Self {
Self { Self {
id: id.into(), id: id.into(),
style: ControlsStyle::current(), platform: None,
on_close: None, on_close: None,
} }
} }
pub fn style(mut self, style: ControlsStyle) -> Self { /// Draws this platform's buttons instead of the theme's.
self.style = style; pub fn platform(mut self, platform: Platform) -> Self {
self.platform = Some(platform);
self self
} }
@@ -118,10 +98,10 @@ impl WindowControls {
impl RenderOnce for WindowControls { impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self.style { match self.platform.unwrap_or(cx.theme().platform) {
ControlsStyle::Mac => traffic_lights(self.id, self.on_close, window, cx), Platform::Mac => traffic_lights(self.id, self.on_close, window, cx),
ControlsStyle::Windows => caption_buttons(self.id, self.on_close, window, cx), Platform::Windows => caption_buttons(self.id, self.on_close, window, cx),
ControlsStyle::Linux => round_buttons(self.id, self.on_close, window, cx), Platform::Linux => round_buttons(self.id, self.on_close, window, cx),
} }
} }
} }
@@ -304,7 +284,7 @@ fn route_close(id: &ElementId, handler: Option<OnClose>, window: &mut Window, cx
pub struct TitleBar { pub struct TitleBar {
id: ElementId, id: ElementId,
title: Option<SharedString>, title: Option<SharedString>,
style: Option<ControlsStyle>, platform: Option<Platform>,
on_close: Option<OnClose>, on_close: Option<OnClose>,
leading: SmallVec<[AnyElement; 2]>, leading: SmallVec<[AnyElement; 2]>,
actions: SmallVec<[AnyElement; 2]>, actions: SmallVec<[AnyElement; 2]>,
@@ -316,7 +296,7 @@ impl TitleBar {
Self { Self {
id: id.into(), id: id.into(),
title: None, title: None,
style: None, platform: None,
on_close: None, on_close: None,
leading: SmallVec::new(), leading: SmallVec::new(),
actions: SmallVec::new(), actions: SmallVec::new(),
@@ -328,13 +308,13 @@ impl TitleBar {
self self
} }
/// Draws this style's controls instead of the system's. /// Draws this platform's controls instead of the system's.
pub fn style(mut self, style: ControlsStyle) -> Self { pub fn platform(mut self, platform: Platform) -> Self {
self.style = Some(style); self.platform = Some(platform);
self self
} }
/// Runs instead of closing; without a forced style, system closes run it too. /// Runs instead of closing; without a forced platform, system closes run it too.
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler)); self.on_close = Some(Rc::new(handler));
self self
@@ -355,19 +335,22 @@ impl ParentElement for TitleBar {
impl RenderOnce for TitleBar { impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.style.is_none() { if self.platform.is_none() {
route_close(&self.id, self.on_close.clone(), window, cx); route_close(&self.id, self.on_close.clone(), window, cx);
} }
let bar = drag_region(self.id, window, cx); let bar = drag_region(self.id, window, cx);
let theme = cx.theme(); let theme = cx.theme();
let system = self.style.is_none() && cfg!(target_os = "macos"); let system = self.platform.is_none() && cfg!(target_os = "macos");
let style = self.style.unwrap_or_else(ControlsStyle::current); let style = match system {
true => Platform::Mac,
false => self.platform.unwrap_or(theme.platform),
};
let mut trailing = (!system).then_some(WindowControls { let mut trailing = (!system).then_some(WindowControls {
id: "window-controls".into(), id: "window-controls".into(),
style, platform: Some(style),
on_close: self.on_close, on_close: self.on_close,
}); });
let leading = if style == ControlsStyle::Mac { let leading = if style == Platform::Mac {
trailing.take() trailing.take()
} else { } else {
None None
@@ -379,7 +362,7 @@ impl RenderOnce for TitleBar {
.text_color(theme.colors.fg_muted) .text_color(theme.colors.fg_muted)
.child(title) .child(title)
}); });
let centered = style != ControlsStyle::Windows; let centered = style != Platform::Windows;
let inset = theme.traffic_light_inset(); let inset = theme.traffic_light_inset();
let fullscreen = window.is_fullscreen(); let fullscreen = window.is_fullscreen();
let bar = bar let bar = bar
@@ -396,9 +379,9 @@ impl RenderOnce for TitleBar {
let bar = match (system, style) { let bar = match (system, style) {
(true, _) if fullscreen => bar.pl_3().pr_2(), (true, _) if fullscreen => bar.pl_3().pr_2(),
(true, _) => bar.pl(inset).pr_2(), (true, _) => bar.pl(inset).pr_2(),
(false, ControlsStyle::Mac) => bar.pl_3().pr_2().children(leading), (false, Platform::Mac) => bar.pl_3().pr_2().children(leading),
(false, ControlsStyle::Windows) => bar.pl_3(), (false, Platform::Windows) => bar.pl_3(),
(false, ControlsStyle::Linux) => bar.pl_3().pr_2(), (false, Platform::Linux) => bar.pl_3().pr_2(),
}; };
let bar = bar.children(self.leading); let bar = bar.children(self.leading);
let bar = match (centered, title) { let bar = match (centered, title) {
+3 -1
View File
@@ -11,11 +11,12 @@ mod tabs;
#[cfg(all(test, feature = "test-support"))] #[cfg(all(test, feature = "test-support"))]
mod tests; mod tests;
mod update; mod update;
mod vibrancy;
mod windows; mod windows;
pub use activity::{ActivityBar, ActivityItem}; pub use activity::{ActivityBar, ActivityItem};
pub use bars::{StatusBar, StatusBarItem, Toolbar, ToolbarGroup, ToolbarSeparator}; pub use bars::{StatusBar, StatusBarItem, Toolbar, ToolbarGroup, ToolbarSeparator};
pub use chrome::{ControlsStyle, TitleBar, WindowControls, drag_region}; pub use chrome::{TitleBar, WindowControls, drag_region};
pub use crash::CrashReporter; pub use crash::CrashReporter;
pub use dialogs::{AboutDialog, SplashScreen}; pub use dialogs::{AboutDialog, SplashScreen};
pub use frame::ResizeBorder; pub use frame::ResizeBorder;
@@ -25,4 +26,5 @@ pub use status::{Connectivity, OfflineIndicator, ZoomControl};
pub use switcher::WindowSwitcher; pub use switcher::WindowSwitcher;
pub use tabs::{TabBar, WindowTab}; pub use tabs::{TabBar, WindowTab};
pub use update::{UpdateBanner, UpdateDialog, UpdateState}; pub use update::{UpdateBanner, UpdateDialog, UpdateState};
pub use vibrancy::Vibrancy;
pub use windows::{Corner, Hosted, MiniWindow, WindowManager, open_hosted}; pub use windows::{Corner, Hosted, MiniWindow, WindowManager, open_hosted};
+42
View File
@@ -88,3 +88,45 @@ fn the_switcher_switches_on_release_so_its_opener_stays_shut(cx: &mut TestAppCon
assert!(!desk.read_with(cx, |desk, _| desk.open)); assert!(!desk.read_with(cx, |desk, _| desk.open));
assert_eq!(desk.read_with(cx, |desk, _| desk.enters_above), above); assert_eq!(desk.read_with(cx, |desk, _| desk.enters_above), above);
} }
struct Platformed;
impl Render for Platformed {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.child(
div()
.debug_selector(|| "controls".into())
.child(super::WindowControls::new("controls")),
)
.child(
div()
.debug_selector(|| "caps".into())
.child(crate::typography::Kbd::new("secondary-s")),
)
}
}
/// Window buttons and key caps follow the theme's platform: Windows draws three wide caption buttons and spells Ctrl.
#[gpui::test]
fn the_themes_platform_draws_the_buttons_and_the_caps(cx: &mut TestAppContext) {
cx.update(Theme::init);
cx.update(|cx| Theme::update(cx, |theme| theme.platform = crate::theme::Platform::Mac));
let (_, cx) = cx.add_window_view(|_, _| Platformed);
settle(cx);
let width = |name: &'static str, cx: &mut VisualTestContext| {
cx.debug_bounds(name).expect("a measured part").size.width
};
let (lights, command) = (width("controls", cx), width("caps", cx));
cx.update(|_, cx| Theme::update(cx, |theme| theme.platform = crate::theme::Platform::Windows));
settle(cx);
assert!(
width("controls", cx) > lights * 2.0,
"caption buttons wider than lights"
);
assert!(
width("caps", cx) > command,
"Ctrl wider than the command mark"
);
}
+42
View File
@@ -0,0 +1,42 @@
use gpui::{
AnyElement, App, Div, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window,
div,
};
use crate::theme::ActiveTheme;
/// A pane the window's blur shows through: vibrancy on macOS, acrylic on Windows. Its window opens with `WindowBackgroundAppearance::Blurred` and nothing opaque beneath the pane; high contrast fills it solid.
#[derive(IntoElement)]
pub struct Vibrancy {
base: Div,
}
impl Vibrancy {
pub fn new() -> Self {
Self { base: div() }
}
}
impl Default for Vibrancy {
fn default() -> Self {
Self::new()
}
}
impl Styled for Vibrancy {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Vibrancy {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.base.extend(elements);
}
}
impl RenderOnce for Vibrancy {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.base.bg(cx.theme().colors.glass)
}
}
+4
View File
@@ -9,6 +9,7 @@ mod media;
mod messaging; mod messaging;
mod onboarding; mod onboarding;
mod palette; mod palette;
mod platform;
mod project; mod project;
mod syntax; mod syntax;
mod tokens; mod tokens;
@@ -32,6 +33,7 @@ pub use media::MediaSizes;
pub use messaging::MessagingSizes; pub use messaging::MessagingSizes;
pub use onboarding::OnboardingSizes; pub use onboarding::OnboardingSizes;
pub use palette::{HUE_NAMES, Mix, Palette, Syntax}; pub use palette::{HUE_NAMES, Mix, Palette, Syntax};
pub use platform::Platform;
pub use project::ProjectSizes; pub use project::ProjectSizes;
pub use syntax::{SyntaxTheme, syntax_themes}; pub use syntax::{SyntaxTheme, syntax_themes};
pub use tokens::{ pub use tokens::{
@@ -69,6 +71,7 @@ pub struct Theme {
pub font_family: SharedString, pub font_family: SharedString,
pub mono_family: SharedString, pub mono_family: SharedString,
pub reduced_motion: bool, pub reduced_motion: bool,
pub platform: Platform,
} }
impl Global for Theme {} impl Global for Theme {}
@@ -97,6 +100,7 @@ impl Theme {
font_family: "Inter".into(), font_family: "Inter".into(),
mono_family: "JetBrains Mono".into(), mono_family: "JetBrains Mono".into(),
reduced_motion: false, reduced_motion: false,
platform: Platform::current(),
}); });
} }
+5
View File
@@ -116,6 +116,7 @@ mixable!(
backdrop, backdrop,
media_backdrop, media_backdrop,
shimmer, shimmer,
glass,
shadow, shadow,
tooltip_bg, tooltip_bg,
tooltip_fg, tooltip_fg,
@@ -182,6 +183,7 @@ impl Palette {
backdrop: rgba(0x18161352).into(), backdrop: rgba(0x18161352).into(),
media_backdrop: rgba(0x0e0d0bf8).into(), media_backdrop: rgba(0x0e0d0bf8).into(),
shimmer: rgba(0xffffffb3).into(), shimmer: rgba(0xffffffb3).into(),
glass: rgba(0xfcfaf7b8).into(),
shadow: c(0x181613), shadow: c(0x181613),
tooltip_bg: c(0x181613), tooltip_bg: c(0x181613),
tooltip_fg: c(0xfcfaf7), tooltip_fg: c(0xfcfaf7),
@@ -215,6 +217,7 @@ impl Palette {
palette.fg_subtle = c(0x575552); palette.fg_subtle = c(0x575552);
palette.border = c(0xa6a4a2); palette.border = c(0xa6a4a2);
palette.border_strong = c(0x82807d); palette.border_strong = c(0x82807d);
palette.glass = palette.bg;
} }
palette palette
} }
@@ -251,6 +254,7 @@ impl Palette {
backdrop: rgba(0x00000080).into(), backdrop: rgba(0x00000080).into(),
media_backdrop: rgba(0x000000f8).into(), media_backdrop: rgba(0x000000f8).into(),
shimmer: rgba(0xffffff10).into(), shimmer: rgba(0xffffff10).into(),
glass: rgba(0x131110b8).into(),
shadow: c(0x000000), shadow: c(0x000000),
tooltip_bg: c(0x373533), tooltip_bg: c(0x373533),
tooltip_fg: c(0xf3f1f0), tooltip_fg: c(0xf3f1f0),
@@ -284,6 +288,7 @@ impl Palette {
palette.fg_subtle = c(0xafadab); palette.fg_subtle = c(0xafadab);
palette.border = c(0x6a6966); palette.border = c(0x6a6966);
palette.border_strong = c(0x858380); palette.border_strong = c(0x858380);
palette.glass = palette.bg;
} }
palette palette
} }
+20
View File
@@ -0,0 +1,20 @@
/// Whose conventions the components follow: window buttons, the title bar's layout and key caps.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
Mac,
Windows,
Linux,
}
impl Platform {
/// The system this build targets, where the theme starts.
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Linux
}
}
}
+16 -1
View File
@@ -26,7 +26,7 @@ fn every_color_answers_to_its_name() {
for (ix, name) in Palette::NAMES.iter().enumerate() { for (ix, name) in Palette::NAMES.iter().enumerate() {
*palette.token_mut(name) = hsla(ix as f32 / 64.0, 0.5, 0.5, 1.0); *palette.token_mut(name) = hsla(ix as f32 / 64.0, 0.5, 0.5, 1.0);
} }
assert_eq!(Palette::NAMES.len(), 35, "every color of the palette"); assert_eq!(Palette::NAMES.len(), 36, "every color of the palette");
let at = |name: &str| { let at = |name: &str| {
Palette::NAMES Palette::NAMES
.iter() .iter()
@@ -50,3 +50,18 @@ fn every_color_answers_to_its_name() {
fn an_unknown_color_fails() { fn an_unknown_color_fails() {
Palette::light(false).token_mut("glow"); Palette::light(false).token_mut("glow");
} }
#[test]
fn glass_lets_the_blur_through_until_high_contrast() {
for (plain, strong) in [
(Palette::light(false), Palette::light(true)),
(Palette::dark(false), Palette::dark(true)),
] {
assert!(
plain.glass.a > 0.0 && plain.glass.a < 1.0,
"{:?}",
plain.glass
);
assert_eq!(strong.glass, strong.bg);
}
}
+17 -15
View File
@@ -7,8 +7,8 @@ use gpui::{
use regex::RegexBuilder; use regex::RegexBuilder;
use smallvec::SmallVec; use smallvec::SmallVec;
use super::keys::{Platform, keystroke_labels}; use super::keys::{keystroke, keystroke_labels};
use crate::theme::{ActiveTheme, Radius, TextSize}; use crate::theme::{ActiveTheme, Platform, Radius, TextSize};
/// Monospace fragment on a quiet fill. /// Monospace fragment on a quiet fill.
#[derive(IntoElement)] #[derive(IntoElement)]
@@ -56,28 +56,25 @@ fn cap(label: String, cx: &App) -> impl IntoElement + use<> {
.child(label) .child(label)
} }
fn parse(source: &str) -> Keystroke { /// One key, labeled for the theme's platform. Takes gpui key syntax.
Keystroke::parse(source).unwrap_or_else(|error| panic!("bad keystroke {source:?}: {error}"))
}
/// One key, labeled for this platform. Takes gpui key syntax.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Kbd { pub struct Kbd {
stroke: Keystroke, source: SharedString,
} }
impl Kbd { impl Kbd {
/// `"secondary-s"` reads ⌘S on macOS and Ctrl S elsewhere. /// `"secondary-s"` reads ⌘S for the Mac and Ctrl S for the others.
pub fn new(source: &str) -> Self { pub fn new(source: &str) -> Self {
Self { Self {
stroke: parse(source), source: SharedString::from(source.to_string()),
} }
} }
} }
impl RenderOnce for Kbd { impl RenderOnce for Kbd {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let labels = keystroke_labels(&self.stroke, Platform::current()); let platform = cx.theme().platform;
let labels = keystroke_labels(&keystroke(&self.source, platform), platform);
div() div()
.flex() .flex()
.gap_0p5() .gap_0p5()
@@ -88,27 +85,32 @@ impl RenderOnce for Kbd {
/// A chord sequence such as `"cmd-k cmd-s"`. /// A chord sequence such as `"cmd-k cmd-s"`.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct KbdCombo { pub struct KbdCombo {
strokes: SmallVec<[Keystroke; 2]>, source: SharedString,
} }
impl KbdCombo { impl KbdCombo {
pub fn new(source: &str) -> Self { pub fn new(source: &str) -> Self {
Self { Self {
strokes: source.split_whitespace().map(parse).collect(), source: SharedString::from(source.to_string()),
} }
} }
} }
impl RenderOnce for KbdCombo { impl RenderOnce for KbdCombo {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let platform = Platform::current(); let platform = cx.theme().platform;
let strokes: Vec<Keystroke> = self
.source
.split_whitespace()
.map(|source| keystroke(source, platform))
.collect();
let subtle = cx.theme().colors.fg_subtle; let subtle = cx.theme().colors.fg_subtle;
let spelled = platform != Platform::Mac; let spelled = platform != Platform::Mac;
div() div()
.flex() .flex()
.items_center() .items_center()
.gap_2() .gap_2()
.children(self.strokes.iter().map(|stroke| { .children(strokes.iter().map(|stroke| {
let labels = keystroke_labels(stroke, platform); let labels = keystroke_labels(stroke, platform);
let last = labels.len() - 1; let last = labels.len() - 1;
div().flex().items_center().gap_0p5().children( div().flex().items_center().gap_0p5().children(
+25 -16
View File
@@ -1,22 +1,20 @@
use gpui::Keystroke; use gpui::Keystroke;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] use crate::theme::Platform;
pub enum Platform {
Mac,
Windows,
Linux,
}
impl Platform { /// One keystroke in gpui key syntax, `secondary` read for `platform`: Command on the Mac, Control elsewhere.
pub fn current() -> Self { pub fn keystroke(source: &str, platform: Platform) -> Keystroke {
if cfg!(target_os = "macos") { let secondary = if platform == Platform::Mac {
Platform::Mac "cmd"
} else if cfg!(target_os = "windows") { } else {
Platform::Windows "ctrl"
} else { };
Platform::Linux let spelled: Vec<&str> = source
} .split('-')
} .map(|part| if part == "secondary" { secondary } else { part })
.collect();
Keystroke::parse(&spelled.join("-"))
.unwrap_or_else(|error| panic!("bad keystroke {source:?}: {error}"))
} }
/// Caps for one keystroke, modifiers first, in platform order. /// Caps for one keystroke, modifiers first, in platform order.
@@ -94,6 +92,17 @@ mod tests {
assert_eq!(labels, ["Super", "Esc"]); assert_eq!(labels, ["Super", "Esc"]);
} }
#[test]
fn secondary_reads_for_the_platform() {
let mac = keystroke_labels(&keystroke("secondary-s", Platform::Mac), Platform::Mac);
assert_eq!(mac, ["⌘", "S"]);
let windows = Platform::Windows;
assert_eq!(
keystroke_labels(&keystroke("secondary-s", windows), windows),
["Ctrl", "S"]
);
}
#[test] #[test]
fn keys_keep_their_names_or_upper_case() { fn keys_keep_their_names_or_upper_case() {
assert_eq!(key_label("f12", Platform::Mac), "F12"); assert_eq!(key_label("f12", Platform::Mac), "F12");
+4 -3
View File
@@ -266,9 +266,10 @@
## T39c Theme · Platform ## T39c Theme · Platform
- [ ] IconThemeProvider - [x] IconThemeProvider → `primitives::IconTheme`, an owner's rules by name and extension over `file_icon`
- [ ] Platform Style - [x] Platform Style → `theme::Platform`, the theme's `platform`: the window buttons Ely draws, the title bar, key caps
- [ ] VibrancyBackground / Mica / Acrylic — native `WindowBackgroundAppearance::Blurred` - [x] VibrancyBackground / Acrylic → `shell::Vibrancy` in a window opened `WindowBackgroundAppearance::Blurred`
- [ ] Mica — blocked: gpui 0.2.2's one blur, `Blurred`, is acrylic on Windows
## T40 i18n & a11y — `src/i18n` ## T40 i18n & a11y — `src/i18n`