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.
- 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 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.
- 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.
@@ -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`).
- `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).
- 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.
- 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::{
buttons::IconButton,
primitives::IconName,
shell::{ControlsStyle, TitleBar, WindowControls, drag_region},
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
shell::{TitleBar, WindowControls, drag_region},
theme::{ActiveTheme, ControlSize, Elevation, Platform, Radius, TextSize},
typography::Caption,
};
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))
}
fn demo_bar(id: &'static str, style: ControlsStyle) -> TitleBar {
fn demo_bar(id: &'static str, style: Platform) -> TitleBar {
TitleBar::new(id)
.style(style)
.platform(style)
.title("Ely — Projects")
.on_close(|_, _| log::info!("gallery: demo close refused"))
.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<> {
let styles = [
("tb-mac", ControlsStyle::Mac, "macOS"),
("tb-windows", ControlsStyle::Windows, "Windows"),
("tb-linux", ControlsStyle::Linux, "Linux"),
("tb-mac", Platform::Mac, "macOS"),
("tb-windows", Platform::Windows, "Windows"),
("tb-linux", Platform::Linux, "Linux"),
];
section(
"TitleBar",
@@ -88,7 +88,7 @@ pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
"Mac",
plate().child(
WindowControls::new("wc-mac")
.style(ControlsStyle::Mac)
.platform(Platform::Mac)
.on_close(|_, _| log::info!("gallery: demo close refused")),
),
cx,
@@ -97,7 +97,7 @@ pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
"Windows",
plate().px_0().child(
WindowControls::new("wc-windows")
.style(ControlsStyle::Windows)
.platform(Platform::Windows)
.on_close(|_, _| log::info!("gallery: demo close refused")),
),
cx,
@@ -106,7 +106,7 @@ pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
"Linux",
plate().child(
WindowControls::new("wc-linux")
.style(ControlsStyle::Linux)
.platform(Platform::Linux)
.on_close(|_, _| log::info!("gallery: demo close refused")),
),
cx,
+9 -1
View File
@@ -11,8 +11,10 @@ use gpui::{
mod editing;
mod importing;
mod platform;
use super::Page;
use crate::script::Step;
use crate::ui::{section, specimen, specimens};
pub const PAGE: Page = Page {
@@ -21,7 +23,12 @@ pub const PAGE: Page = Page {
title: "Theme",
summary: "Tokens every component reads. Light and dark share one grammar.",
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 {
@@ -149,6 +156,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
)
.child(editing::editor(window, cx))
.child(importing::importing(window, cx))
.child(platform::platform(window, cx))
.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(
Icon::new(file_icon(&self.change.path))
Icon::new(file_icon(&self.change.path, cx))
.size(IconSize::Sm)
.color(colors.fg_muted),
)
+16 -12
View File
@@ -1,13 +1,13 @@
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,
};
use crate::{
motion::Spinner,
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
typography::keys::{Platform, keystroke_labels},
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Platform, Radius, TextSize},
typography::keys::{keystroke, keystroke_labels},
};
pub(crate) type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
@@ -112,11 +112,15 @@ pub(crate) enum Slot {
Last,
}
/// A keystroke as the platform spells it: `⌘S`, or `Ctrl+S`.
pub(crate) fn shortcut_text(stroke: &Keystroke) -> String {
let platform = Platform::current();
/// Fails on a keystroke gpui cannot read, when a builder takes it.
pub(crate) fn keystroke_of(source: &str) {
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 { "+" };
keystroke_labels(stroke, platform).join(glue)
keystroke_labels(&keystroke(source, platform), platform).join(glue)
}
#[derive(IntoElement)]
@@ -125,7 +129,7 @@ pub struct Button {
label: SharedString,
icon: Option<IconName>,
trailing_icon: Option<IconName>,
shortcut: Option<Keystroke>,
shortcut: Option<SharedString>,
variant: ButtonVariant,
size: ControlSize,
disabled: bool,
@@ -181,9 +185,8 @@ impl Button {
/// Shows the keystroke that also runs it, e.g. `"cmd-s"`. Panics on a bad keystroke.
pub fn shortcut(mut self, keystroke: &str) -> Self {
let parsed = Keystroke::parse(keystroke)
.unwrap_or_else(|error| panic!("button shortcut {keystroke:?}: {error}"));
self.shortcut = Some(parsed);
keystroke_of(keystroke);
self.shortcut = Some(SharedString::from(keystroke.to_string()));
self
}
@@ -226,7 +229,8 @@ impl RenderOnce for Button {
let link = self.variant == ButtonVariant::Link;
let icon = |name| Icon::new(name).size(icon_size).color(tone.fg);
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 parts = div()
.flex()
+7 -8
View File
@@ -1,11 +1,11 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, Keystroke, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::button::shortcut_text;
use super::button::{keystroke_of, shortcut_text};
use crate::{
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
@@ -19,7 +19,7 @@ pub struct QuickAction {
icon: IconName,
label: SharedString,
note: Option<SharedString>,
shortcut: Option<Keystroke>,
shortcut: Option<SharedString>,
run: Run,
}
@@ -45,9 +45,8 @@ impl QuickAction {
/// Panics on a bad keystroke.
pub fn shortcut(mut self, keystroke: &str) -> Self {
let parsed = Keystroke::parse(keystroke)
.unwrap_or_else(|error| panic!("quick action shortcut {keystroke:?}: {error}"));
self.shortcut = Some(parsed);
keystroke_of(keystroke);
self.shortcut = Some(SharedString::from(keystroke.to_string()));
self
}
}
@@ -171,7 +170,7 @@ impl RenderOnce for QuickActions {
div()
.text_size(theme.text_size(TextSize::Xs))
.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))
.bg(colors.hover)
.child(
Icon::new(file_icon(&item.name))
Icon::new(file_icon(&item.name, cx))
.size(IconSize::Md)
.color(colors.fg_muted),
),
+1 -1
View File
@@ -255,7 +255,7 @@ impl RenderOnce for FileMessage {
.rounded(theme.radius(Radius::Sm))
.bg(colors.hover)
.child(
Icon::new(file_icon(&self.name))
Icon::new(file_icon(&self.name, cx))
.size(IconSize::Md)
.color(colors.fg_muted),
),
+1 -1
View File
@@ -211,7 +211,7 @@ impl RenderOnce for ProjectKnowledgePanel {
.py_1()
.text_size(theme.text_size(TextSize::Sm))
.child(
Icon::new(file_icon(&name))
Icon::new(file_icon(&name, cx))
.size(IconSize::Sm)
.color(colors.fg_muted),
)
+4 -5
View File
@@ -1,9 +1,8 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, Keystroke,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*,
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::OnIndex;
@@ -68,14 +67,14 @@ impl RenderOnce for DebugToolbar {
let theme = cx.theme();
let colors = theme.colors.clone();
let paused = self.state == DebugState::Paused;
let platform = theme.platform;
let button =
|command: DebugCommand, icon: IconName, words: &str, key: &str, enabled: bool| {
let run = self.on_command.clone();
let key = Keystroke::parse(key).expect("a debug key parses");
IconButton::new((self.id.clone(), format!("{command:?}")), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(format!("{words} {}", shortcut_text(&key)))
.tooltip(format!("{words} {}", shortcut_text(key, platform)))
.disabled(!enabled)
.when_some(run, |button, run| {
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 float_id: ElementId = (self.id.clone(), "float").into();
let platform = cx.theme().platform;
let floating = FloatingToolbar::new(float_id.clone(), &self.field).children(
INLINE.iter().map(|tool_of| {
let on = pressed.contains(&tool_of.0);
@@ -107,6 +108,7 @@ impl RenderOnce for RichTextEditor {
*tool_of,
on,
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 crate::{
@@ -6,7 +6,7 @@ use crate::{
forms::{Run, TextInput},
primitives::IconName,
shell::{Toolbar, ToolbarGroup, ToolbarSeparator},
theme::ControlSize,
theme::{ActiveTheme, ControlSize, Platform},
};
/// 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,
on: bool,
on_link: Option<Run>,
platform: Platform,
) -> ToggleButton {
let tip = match key {
Some(key) => {
let stroke = Keystroke::parse(key).expect("a tool's key parses");
format!("{name} {}", shortcut_text(&stroke))
}
Some(key) => format!("{name} {}", shortcut_text(key, platform)),
None => name.to_string(),
};
let field = field.clone();
@@ -105,10 +103,18 @@ impl RenderOnce for FixedFormatToolbar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let input = self.field.read(cx);
let pressed = active(input.text(), input.selection());
let platform = cx.theme().platform;
let group = |tools: &[Tool]| {
ToolbarGroup::new().children(tools.iter().map(|tool_of| {
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()
+10 -5
View File
@@ -8,7 +8,7 @@ use gpui::{
use super::FileIcon;
use crate::{
lists::{Folder, Tree, TreeNode},
primitives::{IconName, file_icon},
primitives::{IconName, IconTheme, icon_theme},
theme::{ActiveTheme, IconSize, TextSize},
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.
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 held = 0;
let mut folders: Vec<_> = folder.folders.iter().collect();
folders.sort_by_key(|(name, _)| name.to_lowercase());
for (name, inner) in folders {
let path = format!("{prefix}{name}");
let (children, bytes) = nodes(inner, &format!("{path}/"), entries);
let (children, bytes) = nodes(inner, &format!("{path}/"), entries, icons);
held += bytes;
let node = TreeNode::new(path, name.clone())
.icon(IconName::Folder)
@@ -63,7 +68,7 @@ fn nodes(folder: &Folder, prefix: &str, entries: &[ArchiveEntry]) -> (Vec<TreeNo
);
out.push(
TreeNode::new(path, name.clone())
.icon(file_icon(name))
.icon(icons.file(name))
.note(note),
);
}
@@ -115,7 +120,7 @@ impl RenderOnce for ArchiveViewer {
let theme = cx.theme();
let colors = &theme.colors;
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 top: Vec<SharedString> = 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::{
lists::DirEntry,
@@ -6,28 +6,34 @@ use crate::{
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)]
pub struct FileIcon {
icon: IconName,
shown: Shown,
size: IconSize,
}
impl FileIcon {
pub fn file(name: impl AsRef<str>) -> Self {
Self {
icon: file_icon(name.as_ref()),
shown: Shown::File(name.as_ref().to_string().into()),
size: IconSize::Md,
}
}
pub fn folder(open: bool) -> Self {
Self {
icon: if open {
shown: Shown::Icon(if open {
IconName::FolderOpen
} else {
IconName::Folder
},
}),
size: IconSize::Md,
}
}
@@ -48,7 +54,11 @@ impl FileIcon {
impl RenderOnce for FileIcon {
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)
.color(cx.theme().colors.fg_muted)
}
+1 -1
View File
@@ -296,7 +296,7 @@ impl RenderOnce for DirectoryListing {
Icon::new(if entry.is_folder() {
IconName::Folder
} else {
file_icon(&entry.name)
file_icon(&entry.name, cx)
})
.size(IconSize::Md)
.color(colors.fg_muted),
+10 -7
View File
@@ -9,7 +9,7 @@ use super::{Tree, TreeNode};
use crate::{
data_display::Tone,
forms::{SearchInput, TextInput},
primitives::{IconName, file_icon},
primitives::{IconName, IconTheme, icon_theme},
theme::ControlSize,
};
@@ -90,6 +90,7 @@ fn nodes(
paths: &[SharedString],
status: &[(SharedString, GitStatus)],
query: &str,
icons: &IconTheme,
) -> (Vec<TreeNode>, Vec<SharedString>) {
let root = Folder::of(paths.iter().map(|path| path.as_ref()));
let state = |path: &str| {
@@ -104,6 +105,7 @@ fn nodes(
query: &str,
state: &dyn Fn(&str) -> Option<GitStatus>,
open: &mut Vec<SharedString>,
icons: &IconTheme,
) -> (Vec<TreeNode>, bool) {
let mut out = Vec::new();
let mut changed = false;
@@ -111,7 +113,8 @@ fn nodes(
folders.sort_by_key(|(name, _)| name.to_lowercase());
for (name, inner) in folders {
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) {
continue;
}
@@ -136,7 +139,7 @@ fn nodes(
files.sort_by_key(|name| name.to_lowercase());
for name in files {
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) {
Some(state) => {
changed = true;
@@ -148,7 +151,7 @@ fn nodes(
(out, changed)
}
let mut open = Vec::new();
let (nodes, _) = walk(&root, "", query, &state, &mut open);
let (nodes, _) = walk(&root, "", query, &state, &mut open, icons);
(nodes, open)
}
@@ -208,7 +211,7 @@ impl RenderOnce for FileTree {
Vec::<SharedString>::new()
});
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
.iter()
.filter(|node| node.opens())
@@ -245,7 +248,7 @@ impl RenderOnce for FileTree {
mod tests {
use gpui::SharedString;
use super::{GitStatus, nodes};
use super::{GitStatus, IconTheme, nodes};
use crate::lists::tree::{Shown, rows};
fn paths() -> Vec<SharedString> {
@@ -267,7 +270,7 @@ mod tests {
("src/ui/Button.rs".into(), GitStatus::Modified),
("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"]
.map(SharedString::from)
.into_iter()
+87 -12
View File
@@ -1,9 +1,68 @@
use std::{collections::HashMap, sync::LazyLock};
use gpui::{App, Global};
use super::IconName;
/// The icon a file's name suggests, by its extension.
pub(crate) fn file_icon(name: &str) -> IconName {
let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase());
match extension.as_deref() {
/// 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.
#[derive(Clone, Default)]
pub struct IconTheme {
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(
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "swift" | "c" | "h" | "cpp" | "java"
| "kt" | "rb" | "php" | "css" | "html",
@@ -26,16 +85,32 @@ pub(crate) fn file_icon(name: &str) -> IconName {
#[cfg(test)]
mod tests {
use super::{IconName, file_icon};
use super::{IconName, IconTheme};
#[test]
fn a_name_picks_its_icon_by_its_last_extension() {
assert_eq!(file_icon("main.RS"), IconName::FileCode);
assert_eq!(file_icon("lift.PNG"), IconName::FileImage);
assert_eq!(file_icon("notes.md"), IconName::FileText);
assert_eq!(file_icon("backup.tar.gz"), IconName::FileArchive);
assert_eq!(file_icon(".env"), IconName::FileCog);
assert_eq!(file_icon("take.mov"), IconName::FileVideoCamera);
assert_eq!(file_icon("Makefile"), IconName::File);
assert_eq!(IconTheme::default().file("main.RS"), IconName::FileCode);
assert_eq!(IconTheme::default().file("lift.PNG"), IconName::FileImage);
assert_eq!(IconTheme::default().file("notes.md"), IconName::FileText);
assert_eq!(
IconTheme::default().file("backup.tar.gz"),
IconName::FileArchive
);
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 disclosure::Disclosure;
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(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus};
pub use ghost::DragGhost;
+28 -45
View File
@@ -9,30 +9,9 @@ use smallvec::SmallVec;
use crate::{
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.
pub fn drag_region(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
let id = id.into();
@@ -91,7 +70,7 @@ fn run(control: Control, on_close: Option<&OnClose>, window: &mut Window, cx: &m
#[derive(IntoElement)]
pub struct WindowControls {
id: ElementId,
style: ControlsStyle,
platform: Option<Platform>,
on_close: Option<OnClose>,
}
@@ -99,13 +78,14 @@ impl WindowControls {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: ControlsStyle::current(),
platform: None,
on_close: None,
}
}
pub fn style(mut self, style: ControlsStyle) -> Self {
self.style = style;
/// Draws this platform's buttons instead of the theme's.
pub fn platform(mut self, platform: Platform) -> Self {
self.platform = Some(platform);
self
}
@@ -118,10 +98,10 @@ impl WindowControls {
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self.style {
ControlsStyle::Mac => traffic_lights(self.id, self.on_close, window, cx),
ControlsStyle::Windows => caption_buttons(self.id, self.on_close, window, cx),
ControlsStyle::Linux => round_buttons(self.id, self.on_close, window, cx),
match self.platform.unwrap_or(cx.theme().platform) {
Platform::Mac => traffic_lights(self.id, self.on_close, window, cx),
Platform::Windows => caption_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 {
id: ElementId,
title: Option<SharedString>,
style: Option<ControlsStyle>,
platform: Option<Platform>,
on_close: Option<OnClose>,
leading: SmallVec<[AnyElement; 2]>,
actions: SmallVec<[AnyElement; 2]>,
@@ -316,7 +296,7 @@ impl TitleBar {
Self {
id: id.into(),
title: None,
style: None,
platform: None,
on_close: None,
leading: SmallVec::new(),
actions: SmallVec::new(),
@@ -328,13 +308,13 @@ impl TitleBar {
self
}
/// Draws this style's controls instead of the system's.
pub fn style(mut self, style: ControlsStyle) -> Self {
self.style = Some(style);
/// Draws this platform's controls instead of the system's.
pub fn platform(mut self, platform: Platform) -> Self {
self.platform = Some(platform);
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 {
self.on_close = Some(Rc::new(handler));
self
@@ -355,19 +335,22 @@ impl ParentElement for TitleBar {
impl RenderOnce for TitleBar {
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);
}
let bar = drag_region(self.id, window, cx);
let theme = cx.theme();
let system = self.style.is_none() && cfg!(target_os = "macos");
let style = self.style.unwrap_or_else(ControlsStyle::current);
let system = self.platform.is_none() && cfg!(target_os = "macos");
let style = match system {
true => Platform::Mac,
false => self.platform.unwrap_or(theme.platform),
};
let mut trailing = (!system).then_some(WindowControls {
id: "window-controls".into(),
style,
platform: Some(style),
on_close: self.on_close,
});
let leading = if style == ControlsStyle::Mac {
let leading = if style == Platform::Mac {
trailing.take()
} else {
None
@@ -379,7 +362,7 @@ impl RenderOnce for TitleBar {
.text_color(theme.colors.fg_muted)
.child(title)
});
let centered = style != ControlsStyle::Windows;
let centered = style != Platform::Windows;
let inset = theme.traffic_light_inset();
let fullscreen = window.is_fullscreen();
let bar = bar
@@ -396,9 +379,9 @@ impl RenderOnce for TitleBar {
let bar = match (system, style) {
(true, _) if fullscreen => bar.pl_3().pr_2(),
(true, _) => bar.pl(inset).pr_2(),
(false, ControlsStyle::Mac) => bar.pl_3().pr_2().children(leading),
(false, ControlsStyle::Windows) => bar.pl_3(),
(false, ControlsStyle::Linux) => bar.pl_3().pr_2(),
(false, Platform::Mac) => bar.pl_3().pr_2().children(leading),
(false, Platform::Windows) => bar.pl_3(),
(false, Platform::Linux) => bar.pl_3().pr_2(),
};
let bar = bar.children(self.leading);
let bar = match (centered, title) {
+3 -1
View File
@@ -11,11 +11,12 @@ mod tabs;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod update;
mod vibrancy;
mod windows;
pub use activity::{ActivityBar, ActivityItem};
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 dialogs::{AboutDialog, SplashScreen};
pub use frame::ResizeBorder;
@@ -25,4 +26,5 @@ pub use status::{Connectivity, OfflineIndicator, ZoomControl};
pub use switcher::WindowSwitcher;
pub use tabs::{TabBar, WindowTab};
pub use update::{UpdateBanner, UpdateDialog, UpdateState};
pub use vibrancy::Vibrancy;
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_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 onboarding;
mod palette;
mod platform;
mod project;
mod syntax;
mod tokens;
@@ -32,6 +33,7 @@ pub use media::MediaSizes;
pub use messaging::MessagingSizes;
pub use onboarding::OnboardingSizes;
pub use palette::{HUE_NAMES, Mix, Palette, Syntax};
pub use platform::Platform;
pub use project::ProjectSizes;
pub use syntax::{SyntaxTheme, syntax_themes};
pub use tokens::{
@@ -69,6 +71,7 @@ pub struct Theme {
pub font_family: SharedString,
pub mono_family: SharedString,
pub reduced_motion: bool,
pub platform: Platform,
}
impl Global for Theme {}
@@ -97,6 +100,7 @@ impl Theme {
font_family: "Inter".into(),
mono_family: "JetBrains Mono".into(),
reduced_motion: false,
platform: Platform::current(),
});
}
+5
View File
@@ -116,6 +116,7 @@ mixable!(
backdrop,
media_backdrop,
shimmer,
glass,
shadow,
tooltip_bg,
tooltip_fg,
@@ -182,6 +183,7 @@ impl Palette {
backdrop: rgba(0x18161352).into(),
media_backdrop: rgba(0x0e0d0bf8).into(),
shimmer: rgba(0xffffffb3).into(),
glass: rgba(0xfcfaf7b8).into(),
shadow: c(0x181613),
tooltip_bg: c(0x181613),
tooltip_fg: c(0xfcfaf7),
@@ -215,6 +217,7 @@ impl Palette {
palette.fg_subtle = c(0x575552);
palette.border = c(0xa6a4a2);
palette.border_strong = c(0x82807d);
palette.glass = palette.bg;
}
palette
}
@@ -251,6 +254,7 @@ impl Palette {
backdrop: rgba(0x00000080).into(),
media_backdrop: rgba(0x000000f8).into(),
shimmer: rgba(0xffffff10).into(),
glass: rgba(0x131110b8).into(),
shadow: c(0x000000),
tooltip_bg: c(0x373533),
tooltip_fg: c(0xf3f1f0),
@@ -284,6 +288,7 @@ impl Palette {
palette.fg_subtle = c(0xafadab);
palette.border = c(0x6a6966);
palette.border_strong = c(0x858380);
palette.glass = palette.bg;
}
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() {
*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| {
Palette::NAMES
.iter()
@@ -50,3 +50,18 @@ fn every_color_answers_to_its_name() {
fn an_unknown_color_fails() {
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 smallvec::SmallVec;
use super::keys::{Platform, keystroke_labels};
use crate::theme::{ActiveTheme, Radius, TextSize};
use super::keys::{keystroke, keystroke_labels};
use crate::theme::{ActiveTheme, Platform, Radius, TextSize};
/// Monospace fragment on a quiet fill.
#[derive(IntoElement)]
@@ -56,28 +56,25 @@ fn cap(label: String, cx: &App) -> impl IntoElement + use<> {
.child(label)
}
fn parse(source: &str) -> Keystroke {
Keystroke::parse(source).unwrap_or_else(|error| panic!("bad keystroke {source:?}: {error}"))
}
/// One key, labeled for this platform. Takes gpui key syntax.
/// One key, labeled for the theme's platform. Takes gpui key syntax.
#[derive(IntoElement)]
pub struct Kbd {
stroke: Keystroke,
source: SharedString,
}
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 {
Self {
stroke: parse(source),
source: SharedString::from(source.to_string()),
}
}
}
impl RenderOnce for Kbd {
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()
.flex()
.gap_0p5()
@@ -88,27 +85,32 @@ impl RenderOnce for Kbd {
/// A chord sequence such as `"cmd-k cmd-s"`.
#[derive(IntoElement)]
pub struct KbdCombo {
strokes: SmallVec<[Keystroke; 2]>,
source: SharedString,
}
impl KbdCombo {
pub fn new(source: &str) -> Self {
Self {
strokes: source.split_whitespace().map(parse).collect(),
source: SharedString::from(source.to_string()),
}
}
}
impl RenderOnce for KbdCombo {
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 spelled = platform != Platform::Mac;
div()
.flex()
.items_center()
.gap_2()
.children(self.strokes.iter().map(|stroke| {
.children(strokes.iter().map(|stroke| {
let labels = keystroke_labels(stroke, platform);
let last = labels.len() - 1;
div().flex().items_center().gap_0p5().children(
+25 -16
View File
@@ -1,22 +1,20 @@
use gpui::Keystroke;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
Mac,
Windows,
Linux,
}
use crate::theme::Platform;
impl Platform {
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Linux
}
}
/// One keystroke in gpui key syntax, `secondary` read for `platform`: Command on the Mac, Control elsewhere.
pub fn keystroke(source: &str, platform: Platform) -> Keystroke {
let secondary = if platform == Platform::Mac {
"cmd"
} else {
"ctrl"
};
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.
@@ -94,6 +92,17 @@ mod tests {
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]
fn keys_keep_their_names_or_upper_case() {
assert_eq!(key_label("f12", Platform::Mac), "F12");
+4 -3
View File
@@ -266,9 +266,10 @@
## T39c Theme · Platform
- [ ] IconThemeProvider
- [ ] Platform Style
- [ ] VibrancyBackground / Mica / Acrylic — native `WindowBackgroundAppearance::Blurred`
- [x] IconThemeProvider → `primitives::IconTheme`, an owner's rules by name and extension over `file_icon`
- [x] Platform Style → `theme::Platform`, the theme's `platform`: the window buttons Ely draws, the title bar, key caps
- [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`