diff --git a/AGENTS.md b/AGENTS.md index e904ffe..9f59f60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/examples/gallery/pages/shell/chrome.rs b/examples/gallery/pages/shell/chrome.rs index c7386bb..0a65fa0 100644 --- a/examples/gallery/pages/shell/chrome.rs +++ b/examples/gallery/pages/shell/chrome.rs @@ -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, diff --git a/examples/gallery/pages/theme/mod.rs b/examples/gallery/pages/theme/mod.rs index 2cccafa..fd8dee0 100644 --- a/examples/gallery/pages/theme/mod.rs +++ b/examples/gallery/pages/theme/mod.rs @@ -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() } diff --git a/examples/gallery/pages/theme/platform.rs b/examples/gallery/pages/theme/platform.rs new file mode 100644 index 0000000..3652918 --- /dev/null +++ b/examples/gallery/pages/theme/platform.rs @@ -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) -> 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."), + ), + ) + } +} diff --git a/src/agent/changes.rs b/src/agent/changes.rs index ced7ea0..0946f8a 100644 --- a/src/agent/changes.rs +++ b/src/agent/changes.rs @@ -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), ) diff --git a/src/buttons/button.rs b/src/buttons/button.rs index a840ad5..7c90224 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -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; @@ -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, trailing_icon: Option, - shortcut: Option, + shortcut: Option, 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() diff --git a/src/buttons/quick.rs b/src/buttons/quick.rs index 49f2361..f365138 100644 --- a/src/buttons/quick.rs +++ b/src/buttons/quick.rs @@ -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, - shortcut: Option, + shortcut: Option, 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)), ) }) }) diff --git a/src/chat/attach.rs b/src/chat/attach.rs index 43cbe75..d4b9aa2 100644 --- a/src/chat/attach.rs +++ b/src/chat/attach.rs @@ -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), ), diff --git a/src/chat/media.rs b/src/chat/media.rs index 31fe0a5..66b1111 100644 --- a/src/chat/media.rs +++ b/src/chat/media.rs @@ -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), ), diff --git a/src/chat/projects.rs b/src/chat/projects.rs index 53b2958..dd0be47 100644 --- a/src/chat/projects.rs +++ b/src/chat/projects.rs @@ -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), ) diff --git a/src/debug/session.rs b/src/debug/session.rs index d56016b..85e5208 100644 --- a/src/debug/session.rs +++ b/src/debug/session.rs @@ -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| { diff --git a/src/documents/rich.rs b/src/documents/rich.rs index dce1e25..1c82c67 100644 --- a/src/documents/rich.rs +++ b/src/documents/rich.rs @@ -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, ) }), ); diff --git a/src/documents/toolbar.rs b/src/documents/toolbar.rs index 2e9e98b..28e768c 100644 --- a/src/documents/toolbar.rs +++ b/src/documents/toolbar.rs @@ -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, + 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() diff --git a/src/files/archive.rs b/src/files/archive.rs index 623340c..a3674a2 100644 --- a/src/files/archive.rs +++ b/src/files/archive.rs @@ -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, u64) { +fn nodes( + folder: &Folder, + prefix: &str, + entries: &[ArchiveEntry], + icons: &IconTheme, +) -> (Vec, 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 = folders .folders diff --git a/src/files/icon.rs b/src/files/icon.rs index 0126f03..7cd187b 100644 --- a/src/files/icon.rs +++ b/src/files/icon.rs @@ -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) -> 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) } diff --git a/src/lists/directory.rs b/src/lists/directory.rs index 959acb9..a06fff6 100644 --- a/src/lists/directory.rs +++ b/src/lists/directory.rs @@ -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), diff --git a/src/lists/files.rs b/src/lists/files.rs index f5656d1..39652a0 100644 --- a/src/lists/files.rs +++ b/src/lists/files.rs @@ -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, Vec) { 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, open: &mut Vec, + icons: &IconTheme, ) -> (Vec, 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::::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 = 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 { @@ -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() diff --git a/src/primitives/files.rs b/src/primitives/files.rs index 6e20faf..54b2fad 100644 --- a/src/primitives/files.rs +++ b/src/primitives/files.rs @@ -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, + extensions: HashMap, +} + +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 = 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::().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); } } diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 22192ca..efba801 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -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; diff --git a/src/shell/chrome.rs b/src/shell/chrome.rs index 6f2e22f..9bfcd78 100644 --- a/src/shell/chrome.rs +++ b/src/shell/chrome.rs @@ -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, window: &mut Window, cx: &mut App) -> Stateful
{ 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, on_close: Option, } @@ -99,13 +78,14 @@ impl WindowControls { pub fn new(id: impl Into) -> 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, window: &mut Window, cx pub struct TitleBar { id: ElementId, title: Option, - style: Option, + platform: Option, on_close: Option, 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) { diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 3d61167..3c30209 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -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}; diff --git a/src/shell/tests.rs b/src/shell/tests.rs index 215d0d2..efe02c5 100644 --- a/src/shell/tests.rs +++ b/src/shell/tests.rs @@ -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) -> 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" + ); +} diff --git a/src/shell/vibrancy.rs b/src/shell/vibrancy.rs new file mode 100644 index 0000000..e98c9f4 --- /dev/null +++ b/src/shell/vibrancy.rs @@ -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) { + 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) + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index f708322..77fb61b 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -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(), }); } diff --git a/src/theme/palette.rs b/src/theme/palette.rs index 226dafd..9759409 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -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 } diff --git a/src/theme/platform.rs b/src/theme/platform.rs new file mode 100644 index 0000000..be1f1f3 --- /dev/null +++ b/src/theme/platform.rs @@ -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 + } + } +} diff --git a/src/theme/tests.rs b/src/theme/tests.rs index a60b05b..f2b1d73 100644 --- a/src/theme/tests.rs +++ b/src/theme/tests.rs @@ -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); + } +} diff --git a/src/typography/inline.rs b/src/typography/inline.rs index 4a7e331..76ddfb0 100644 --- a/src/typography/inline.rs +++ b/src/typography/inline.rs @@ -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 = 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( diff --git a/src/typography/keys.rs b/src/typography/keys.rs index c6b7851..3107c6e 100644 --- a/src/typography/keys.rs +++ b/src/typography/keys.rs @@ -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"); diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 3fc4b24..7b17531 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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`