Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ce99f4e6 | ||
|
|
4de0029b03 | ||
|
|
37d95d3c75 | ||
|
|
2e7711ee2e | ||
|
|
122f9b12ae | ||
|
|
73f7dc31d4 | ||
|
|
f022f08947 |
@@ -1,6 +1,5 @@
|
||||
# Coding guidelines
|
||||
|
||||
- Prefer existing components (e.g. build a close affordance out of `Button`) over custom elements; build a custom element only when no existing component fits.
|
||||
- Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
- Do not write organizational or comments that summarize the code. Comments should only be written to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
- Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||
@@ -64,7 +63,7 @@ GPUI is a UI framework which also provides primitives for state and concurrency
|
||||
## Rendering and state
|
||||
|
||||
- `Render` builds an element tree from entity state; `RenderOnce` (with `#[derive(IntoElement)]`) is for value-like components whose `render` takes `self` and `&mut App`.
|
||||
- Keep `render` declarative: build elements as one chained expression, using `.when(...)` / `.when_some(...)` / `.map(...)` for conditional refinements instead of `let mut` element variables reassigned under `if` / `if let`.
|
||||
- Keep `render` declarative; use `.when(...)` / `.when_some(...)` for small conditional refinements.
|
||||
- Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth.
|
||||
- Call `cx.notify()` after a mutation that changes rendering; notify the narrowest owning entity. Never notify from `render`.
|
||||
- `render` is side-effect free: no `cx.notify()`, no `entity.update(...)`, no writing derived text or state back into other entities during render. A write there forces another frame, and a write that emits an event re-enters the event handler — an unbounded redraw loop. Reconcile state in event handlers instead, with silent setters (`set_value_silent`-style) so a control's own write does not re-enter the events it mirrors.
|
||||
|
||||
Generated
+862
-1068
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -24,14 +24,14 @@ gpui-native-icons = { path = "crates/icons" }
|
||||
gpui-native-theme = { path = "crates/theme" }
|
||||
|
||||
[workspace.dependencies]
|
||||
gpui = { package = "gpui-pre", version = "=0.3.10" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.10", features = ["font-kit"] }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "=0.3.10" }
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
gpui-native-core = { path = "crates/core", default-features = false }
|
||||
gpui-native-data = { path = "crates/data", default-features = false }
|
||||
gpui-native-editor = { path = "crates/editor", default-features = false }
|
||||
gpui-native-layout = { path = "crates/layout", default-features = false }
|
||||
gpui-native-layout = { path = "crates/layout" }
|
||||
gpui-native-icons = { path = "crates/icons" }
|
||||
gpui-native-theme = { path = "crates/theme" }
|
||||
gpui-native-utils = { path = "crates/utils" }
|
||||
|
||||
@@ -9,12 +9,7 @@ gpui = { workspace = true }
|
||||
gpui-native-icons = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
gpui-native-utils = { workspace = true }
|
||||
log = "0.4"
|
||||
unicode-segmentation = "1.12"
|
||||
flume = { version = "0.12", optional = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
cpal = { version = "0.18", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
@@ -53,7 +48,6 @@ full = [
|
||||
"empty_state",
|
||||
"alert",
|
||||
"toast",
|
||||
"speech",
|
||||
]
|
||||
button = ["spinner"]
|
||||
checkbox = []
|
||||
@@ -82,8 +76,7 @@ tooltip = []
|
||||
menu = ["scroll_area"]
|
||||
dropdown_menu = ["menu", "button"]
|
||||
select = ["menu"]
|
||||
dialog = ["button"]
|
||||
dialog = []
|
||||
empty_state = []
|
||||
alert = []
|
||||
toast = []
|
||||
speech = ["spinner", "tooltip", "dep:flume", "dep:cpal"]
|
||||
|
||||
+25
-33
@@ -3,16 +3,13 @@ use std::rc::Rc;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, deferred, div, ease_out_quint,
|
||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, deferred, div, ease_out_quint, px,
|
||||
};
|
||||
use gpui_native_icons::{Icon, IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, ENTER_SLIDE_DISTANCE, Size};
|
||||
use gpui_native_utils::ResultLogErr as _;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
use crate::common::DismissHandler;
|
||||
use crate::common::Sizable as _;
|
||||
use crate::common::v_flex;
|
||||
|
||||
/// How alarming an [`Alert`] is; picks the icon and the accent color.
|
||||
@@ -31,13 +28,12 @@ pub enum AlertSeverity {
|
||||
|
||||
/// A static callout banner.
|
||||
#[derive(Clone, IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Alert {
|
||||
id: ElementId,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
severity: AlertSeverity,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
show_icon: bool,
|
||||
on_dismiss: Option<DismissHandler>,
|
||||
}
|
||||
@@ -82,12 +78,9 @@ impl Alert {
|
||||
self
|
||||
}
|
||||
|
||||
/// Overrides the severity-derived icon.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
/// Overrides the severity-derived icon with this glyph.
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -123,16 +116,15 @@ impl RenderOnce for Alert {
|
||||
AlertSeverity::Danger => theme.ramps.destructive,
|
||||
};
|
||||
|
||||
let icon_glyph = self.icon.unwrap_or_else(|| {
|
||||
icon(match self.severity {
|
||||
let icon_role = self.icon.unwrap_or(match self.severity {
|
||||
AlertSeverity::Info => IconRole::Info,
|
||||
AlertSeverity::Success => IconRole::Check,
|
||||
AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning,
|
||||
})
|
||||
});
|
||||
|
||||
let foreground = theme.colors.foreground;
|
||||
let muted_foreground = theme.colors.muted_foreground;
|
||||
let muted = theme.colors.muted;
|
||||
let surface = theme.colors.surface;
|
||||
|
||||
let border = if theme.borders.overlays {
|
||||
@@ -148,8 +140,6 @@ impl RenderOnce for Alert {
|
||||
let caption_size = metrics.caption_size;
|
||||
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.role(Role::Alert)
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_start()
|
||||
@@ -168,7 +158,7 @@ impl RenderOnce for Alert {
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(window.line_height())
|
||||
.child(icon_glyph.size(icon_size).color(ramp.base)),
|
||||
.child(icon(icon_role).size(icon_size).color(ramp.base)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
@@ -202,11 +192,18 @@ impl RenderOnce for Alert {
|
||||
)
|
||||
.when_some(self.on_dismiss, |alert, on_dismiss| {
|
||||
alert.child(
|
||||
Button::new((self.id.clone(), "alert-close"))
|
||||
.icon(IconRole::Close)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.small()
|
||||
.accessibility_label("Dismiss")
|
||||
div()
|
||||
.id((self.id, "alert-close"))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.p_1()
|
||||
.rounded(theme.radius.sm)
|
||||
.hover(|style| style.bg(muted))
|
||||
.child(
|
||||
icon(IconRole::Close)
|
||||
.size(theme.sizes.small.dense_icon_size)
|
||||
.color(theme.colors.muted_foreground),
|
||||
)
|
||||
.on_click(move |_, _window, cx| on_dismiss(cx)),
|
||||
)
|
||||
})
|
||||
@@ -238,9 +235,7 @@ impl Alerts {
|
||||
let stack = cx.entity().downgrade();
|
||||
|
||||
alert.on_dismiss = Some(Rc::new(move |cx: &mut App| {
|
||||
stack
|
||||
.update(cx, |alerts, cx| alerts.dismiss(id, cx))
|
||||
.log_err();
|
||||
stack.update(cx, |alerts, cx| alerts.dismiss(id, cx)).ok();
|
||||
}));
|
||||
|
||||
self.entries.push(AlertEntry {
|
||||
@@ -294,7 +289,6 @@ impl Alerts {
|
||||
|
||||
/// The floating stack of pushed alerts, topmost right.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct AlertsView {
|
||||
alerts: Entity<Alerts>,
|
||||
id: ElementId,
|
||||
@@ -343,9 +337,7 @@ impl RenderOnce for AlertsView {
|
||||
.with_animation(
|
||||
("alert-enter", id),
|
||||
Animation::new(enter_duration),
|
||||
move |layer, delta| {
|
||||
layer.left(ENTER_SLIDE_DISTANCE * (1. - ease_slide(delta)))
|
||||
},
|
||||
move |layer, delta| layer.left(px(24. * (1. - ease_slide(delta)))),
|
||||
)
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ const MIN_FILLED: usize = 5;
|
||||
|
||||
/// An avatar for a user or other entity.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Avatar {
|
||||
id: SharedString,
|
||||
name: Option<SharedString>,
|
||||
@@ -211,9 +210,9 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
break;
|
||||
}
|
||||
|
||||
let flat_index = (start + offset) % total;
|
||||
let row = MARGIN + flat_index / art_columns;
|
||||
let col = MARGIN + flat_index % art_columns;
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * GRID_SIZE + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyView, App, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
|
||||
Window, transparent_black,
|
||||
App, Hsla, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement,
|
||||
Styled as _, Window,
|
||||
};
|
||||
use gpui_native_icons::Icon;
|
||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||
|
||||
use crate::common::{Sizable, StyledExt, h_flex};
|
||||
|
||||
/// The tooltip view builder gpui's `.tooltip(...)` expects.
|
||||
type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
||||
|
||||
/// How loud a badge is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum BadgeVariant {
|
||||
@@ -28,42 +20,18 @@ pub enum BadgeVariant {
|
||||
Warning,
|
||||
/// The destructive ramp.
|
||||
Danger,
|
||||
/// No fill, just a border; the quietest badge.
|
||||
Outline,
|
||||
}
|
||||
|
||||
impl BadgeVariant {
|
||||
fn tokens(self, theme: &Theme) -> (Hsla, Hsla, Hsla) {
|
||||
fn tokens(self, theme: &Theme) -> (Hsla, Hsla) {
|
||||
match self {
|
||||
Self::Neutral => (
|
||||
theme.colors.muted,
|
||||
theme.colors.muted_foreground,
|
||||
transparent_black(),
|
||||
),
|
||||
Self::Accent => (
|
||||
theme.ramps.primary.base,
|
||||
theme.colors.primary_foreground,
|
||||
transparent_black(),
|
||||
),
|
||||
Self::Success => (
|
||||
theme.ramps.success.base,
|
||||
theme.colors.success_foreground,
|
||||
transparent_black(),
|
||||
),
|
||||
Self::Warning => (
|
||||
theme.ramps.warning.base,
|
||||
theme.colors.warning_foreground,
|
||||
transparent_black(),
|
||||
),
|
||||
Self::Neutral => (theme.colors.muted, theme.colors.muted_foreground),
|
||||
Self::Accent => (theme.ramps.primary.base, theme.colors.primary_foreground),
|
||||
Self::Success => (theme.ramps.success.base, theme.colors.success_foreground),
|
||||
Self::Warning => (theme.ramps.warning.base, theme.colors.warning_foreground),
|
||||
Self::Danger => (
|
||||
theme.ramps.destructive.base,
|
||||
theme.colors.destructive_foreground,
|
||||
transparent_black(),
|
||||
),
|
||||
Self::Outline => (
|
||||
transparent_black(),
|
||||
theme.colors.foreground,
|
||||
theme.colors.border,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -71,31 +39,23 @@ impl BadgeVariant {
|
||||
|
||||
/// A small pill for a count or a state.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Badge {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
variant: BadgeVariant,
|
||||
size: Size,
|
||||
tooltip: Option<TooltipBuilder>,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Badge {
|
||||
/// Creates a badge showing `label` — a count or state word the caller formats.
|
||||
pub fn new<I, L>(id: I, label: L) -> Self
|
||||
pub fn new<L>(label: L) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
variant: BadgeVariant::default(),
|
||||
size: Size::default(),
|
||||
tooltip: None,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
@@ -105,24 +65,6 @@ impl Badge {
|
||||
self.variant = variant;
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows an icon before the label.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows a tooltip view next to the badge after it is hovered.
|
||||
pub fn tooltip<F>(mut self, tooltip: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
self.tooltip = Some(Rc::new(tooltip));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Badge {
|
||||
@@ -142,28 +84,18 @@ impl RenderOnce for Badge {
|
||||
let theme = cx.theme();
|
||||
let metrics = theme.sizes.resolve(self.size);
|
||||
let label_size = metrics.caption_size;
|
||||
let (background, foreground, border) = self.variant.tokens(theme);
|
||||
let (background, foreground) = self.variant.tokens(theme);
|
||||
|
||||
h_flex()
|
||||
.id(self.id.clone())
|
||||
.flex_none()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.rounded(theme.radius.full)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.bg(background)
|
||||
.text_color(foreground)
|
||||
.text_size(label_size)
|
||||
.px(metrics.dense_padding_x)
|
||||
.py_0p5()
|
||||
.when_some(self.icon, |badge, icon| {
|
||||
badge.child(icon.size(metrics.dense_icon_size).color(foreground))
|
||||
})
|
||||
.child(self.label)
|
||||
.refine_style(&self.style)
|
||||
.when_some(self.tooltip, |badge, tooltip| {
|
||||
badge.tooltip(move |window, cx| tooltip(window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use gpui::{
|
||||
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
|
||||
relative, transparent_black,
|
||||
};
|
||||
use gpui_native_icons::Icon;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_utils::{focus_ring, record_anchor_bounds};
|
||||
use std::rc::Rc;
|
||||
@@ -35,11 +35,10 @@ pub enum ButtonVariant {
|
||||
|
||||
/// A push button: pointer, Enter, and Space all fire the single `on_click`.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Button {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
variant: ButtonVariant,
|
||||
size: Size,
|
||||
disabled: bool,
|
||||
@@ -83,11 +82,8 @@ impl Button {
|
||||
}
|
||||
|
||||
/// Shows an icon beside the label.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -240,8 +236,8 @@ impl RenderOnce for Button {
|
||||
)
|
||||
})
|
||||
.when(!self.loading, |button| {
|
||||
button.when_some(self.icon, |button, icon| {
|
||||
button.child(icon.size(metrics.icon_size).color(foreground))
|
||||
button.when_some(self.icon, |button, role| {
|
||||
button.child(icon(role).size(metrics.icon_size).color(foreground))
|
||||
})
|
||||
})
|
||||
.when_some(self.label, |button, label| button.child(label))
|
||||
|
||||
@@ -16,7 +16,6 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A controlled checkbox component.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Checkbox {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
|
||||
@@ -10,7 +10,6 @@ use gpui::{
|
||||
};
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::Size;
|
||||
use gpui_native_utils::ResultLogErr as _;
|
||||
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
use crate::common::Sizable;
|
||||
@@ -26,7 +25,6 @@ type CopiedHandler = Box<dyn Fn(&mut App)>;
|
||||
|
||||
/// A quiet icon button that copies a value to the system clipboard.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct ClipboardButton {
|
||||
id: ElementId,
|
||||
value: SharedString,
|
||||
@@ -155,9 +153,9 @@ impl RenderOnce for ClipboardButton {
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.log_err();
|
||||
.ok();
|
||||
})
|
||||
.log_err();
|
||||
.ok();
|
||||
});
|
||||
|
||||
copied.update(cx, |state, _| {
|
||||
|
||||
+34
-53
@@ -16,9 +16,7 @@ use gpui::{
|
||||
Window, actions, anchored, canvas, deferred, div, point, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_theme::{BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
|
||||
use gpui_native_utils::ResultLogErr as _;
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
|
||||
|
||||
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
|
||||
use crate::input::{Input, InputState, InputStateEvent};
|
||||
@@ -69,7 +67,6 @@ pub type ComboboxCreateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>
|
||||
pub type ComboboxFilter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
|
||||
|
||||
/// Builder for a combobox. Pass it to [`ComboboxState::new`].
|
||||
#[must_use]
|
||||
pub struct Combobox<T>
|
||||
where
|
||||
T: Clone + PartialEq + 'static,
|
||||
@@ -112,10 +109,7 @@ where
|
||||
}
|
||||
|
||||
/// Sets the initially selected value; its label seeds the field.
|
||||
pub fn selected<S>(mut self, selected: S) -> Self
|
||||
where
|
||||
S: Into<Option<T>>,
|
||||
{
|
||||
pub fn selected(mut self, selected: impl Into<Option<T>>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
@@ -343,8 +337,7 @@ where
|
||||
if let Some(on_change) = self.on_change.clone() {
|
||||
let value = value.clone();
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.update(|window, cx| on_change(value, window, cx))
|
||||
.log_err();
|
||||
cx.update(|window, cx| on_change(value, window, cx)).ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
@@ -411,8 +404,7 @@ where
|
||||
{
|
||||
let text = SharedString::from(text);
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.update(|window, cx| on_create(text, window, cx))
|
||||
.log_err();
|
||||
cx.update(|window, cx| on_create(text, window, cx)).ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
@@ -426,20 +418,17 @@ where
|
||||
if self.visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let count = self.visible.len();
|
||||
let current = self.highlight.filter(|row| *row < count);
|
||||
let current = self.highlight.filter(|row| *row < self.visible.len());
|
||||
let next = match current {
|
||||
Some(row) => match row.checked_add_signed(isize::try_from(delta).unwrap_or(0)) {
|
||||
Some(next) if next < count => next,
|
||||
Some(_) => 0,
|
||||
None => count - 1,
|
||||
},
|
||||
Some(row) => {
|
||||
let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32);
|
||||
row as usize
|
||||
}
|
||||
None => {
|
||||
if delta > 0 {
|
||||
0
|
||||
} else {
|
||||
count - 1
|
||||
self.visible.len() - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -529,7 +518,7 @@ where
|
||||
|
||||
let wrapper_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
div()
|
||||
let mut wrapper = div()
|
||||
.id(id.clone())
|
||||
.key_context(COMBOBOX_CONTEXT)
|
||||
.relative()
|
||||
@@ -586,30 +575,26 @@ where
|
||||
.right(metrics.dense_padding_x)
|
||||
.top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.)
|
||||
.child(chevron),
|
||||
)
|
||||
.when(open, |wrapper| {
|
||||
);
|
||||
|
||||
if open {
|
||||
let rows: Vec<gpui::AnyElement> = self
|
||||
.visible
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row, option_index)| {
|
||||
let Some((value, label)) = self.options.get(*option_index) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
.map(|(row, option_ix)| {
|
||||
let (value, label) = &self.options[*option_ix];
|
||||
let highlighted = self.highlight == Some(row);
|
||||
let checked = self.selected.as_ref() == Some(value);
|
||||
let state = state.clone();
|
||||
let row_index = row;
|
||||
let row_ix = row;
|
||||
|
||||
div()
|
||||
.id(("option", *option_index))
|
||||
.id(("option", row))
|
||||
.debug_selector({
|
||||
let id = id.clone();
|
||||
move || format!("{id}-option-{row}")
|
||||
})
|
||||
.role(Role::ListBoxOption)
|
||||
.aria_label(label.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -621,9 +606,9 @@ where
|
||||
.on_mouse_move({
|
||||
let state = state.clone();
|
||||
move |_, _window, cx| {
|
||||
if state.read(cx).highlight != Some(row_index) {
|
||||
if state.read(cx).highlight != Some(row_ix) {
|
||||
state.update(cx, |state, cx| {
|
||||
state.highlight = Some(row_index);
|
||||
state.highlight = Some(row_ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -632,9 +617,7 @@ where
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.choose_row(row_index, window, cx)
|
||||
});
|
||||
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
||||
}
|
||||
})
|
||||
.child(
|
||||
@@ -666,9 +649,8 @@ where
|
||||
.child("No matches")
|
||||
.into_any_element()
|
||||
} else {
|
||||
let scroll = window.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| {
|
||||
ScrollHandle::new()
|
||||
});
|
||||
let scroll = window
|
||||
.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new());
|
||||
|
||||
ScrollArea::new((id.clone(), "rows"))
|
||||
.handle(scroll.read(cx).clone())
|
||||
@@ -679,7 +661,6 @@ where
|
||||
};
|
||||
|
||||
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
let popup_width = wrapper_bounds
|
||||
.borrow()
|
||||
.map(|bounds| bounds.size.width)
|
||||
@@ -720,14 +701,12 @@ where
|
||||
if phase != gpui::DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
let on_panel =
|
||||
panel_bounds.borrow().is_some_and(|bounds| {
|
||||
bounds.contains(&event.position)
|
||||
});
|
||||
let on_wrapper =
|
||||
wrapper_bounds.borrow().is_some_and(|bounds| {
|
||||
bounds.contains(&event.position)
|
||||
});
|
||||
let on_panel = panel_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
let on_wrapper = wrapper_bounds
|
||||
.borrow()
|
||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
||||
if on_panel || on_wrapper {
|
||||
return;
|
||||
}
|
||||
@@ -744,9 +723,11 @@ where
|
||||
.inset_0(),
|
||||
);
|
||||
|
||||
wrapper.child(deferred(
|
||||
wrapper = wrapper.child(deferred(
|
||||
anchored().offset(point(px(0.), spacing_unit)).child(panel),
|
||||
))
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, deferred,
|
||||
div, relative,
|
||||
};
|
||||
use gpui_native_icons::Icon;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT, Size};
|
||||
|
||||
use crate::common::{h_flex, v_flex};
|
||||
@@ -39,9 +39,8 @@ pub fn bind_command_palette_keys(cx: &mut App) {
|
||||
]);
|
||||
}
|
||||
|
||||
/// A command the palette can run: a stable id and the label the matcher sees.
|
||||
/// One action the palette can run: a stable id and the label the matcher sees.
|
||||
#[derive(Clone)]
|
||||
#[must_use]
|
||||
pub struct Command {
|
||||
/// The stable domain id handed to `on_execute`.
|
||||
pub id: SharedString,
|
||||
@@ -50,16 +49,12 @@ pub struct Command {
|
||||
/// Gray text on the row's right edge, e.g. the keybinding.
|
||||
pub hint: Option<SharedString>,
|
||||
/// Leading glyph shown before the label.
|
||||
pub icon: Option<Icon>,
|
||||
pub icon: Option<IconRole>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Creates a command without a hint or icon.
|
||||
pub fn new<I, L>(id: I, label: L) -> Self
|
||||
where
|
||||
I: Into<SharedString>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
@@ -69,19 +64,13 @@ impl Command {
|
||||
}
|
||||
|
||||
/// Shows an icon before the label.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the row's right-edge hint.
|
||||
pub fn hint<H>(mut self, hint: H) -> Self
|
||||
where
|
||||
H: Into<SharedString>,
|
||||
{
|
||||
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
@@ -93,7 +82,6 @@ pub type CommandMatcher = Rc<dyn Fn(&str, &[Command]) -> Vec<Command>>;
|
||||
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
|
||||
|
||||
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
|
||||
#[must_use]
|
||||
pub struct CommandPalette {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
@@ -270,15 +258,10 @@ impl CommandPaletteState {
|
||||
if self.matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let count = self.matches.len();
|
||||
let next = match self.highlight {
|
||||
Some(row) => match row.checked_add_signed(isize::try_from(delta).unwrap_or(0)) {
|
||||
Some(next) if next < count => next,
|
||||
Some(_) => 0,
|
||||
None => count - 1,
|
||||
},
|
||||
Some(row) => (row as i32 + delta).rem_euclid(self.matches.len() as i32) as usize,
|
||||
None if delta > 0 => 0,
|
||||
None => count - 1,
|
||||
None => self.matches.len() - 1,
|
||||
};
|
||||
self.highlight = Some(next);
|
||||
cx.notify();
|
||||
@@ -347,7 +330,7 @@ impl Render for CommandPaletteState {
|
||||
.map(|(row, command)| {
|
||||
let highlighted = self.highlight == Some(row);
|
||||
let state = state.clone();
|
||||
let row_index = row;
|
||||
let row_ix = row;
|
||||
|
||||
h_flex()
|
||||
.id((ElementId::from(command.id.clone()), "command"))
|
||||
@@ -355,8 +338,6 @@ impl Render for CommandPaletteState {
|
||||
let id = id.clone();
|
||||
move || format!("{id}-command-{row}")
|
||||
})
|
||||
.role(Role::ListBoxOption)
|
||||
.aria_label(command.label.clone())
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
@@ -366,9 +347,9 @@ impl Render for CommandPaletteState {
|
||||
.on_mouse_move({
|
||||
let state = state.clone();
|
||||
move |_, _window, cx| {
|
||||
if state.read(cx).highlight != Some(row_index) {
|
||||
if state.read(cx).highlight != Some(row_ix) {
|
||||
state.update(cx, |state, cx| {
|
||||
state.highlight = Some(row_index);
|
||||
state.highlight = Some(row_ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -377,11 +358,11 @@ impl Render for CommandPaletteState {
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| state.choose_row(row_index, window, cx));
|
||||
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
||||
}
|
||||
})
|
||||
.when_some(command.icon.clone(), |row, icon| {
|
||||
row.child(icon.size(metrics.icon_size).color(colors.foreground))
|
||||
.when_some(command.icon, |row, role| {
|
||||
row.child(icon(role).size(metrics.icon_size).color(colors.foreground))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -12,10 +12,7 @@ type TargetFn = Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyElement>;
|
||||
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
|
||||
|
||||
/// A menu that opens at the pointer when its target is right-clicked.
|
||||
///
|
||||
/// Open state is owned internally by design, unlike the controlled [`Menu`].
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct ContextMenu {
|
||||
id: ElementId,
|
||||
target: Option<TargetFn>,
|
||||
|
||||
@@ -6,11 +6,9 @@ use gpui::{
|
||||
IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
|
||||
};
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
use crate::common::{Sizable, v_flex};
|
||||
use crate::common::v_flex;
|
||||
|
||||
/// Requests the dialog's close: `false` closes.
|
||||
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
@@ -19,7 +17,6 @@ type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
|
||||
|
||||
/// A centred panel over a dim scrim: the platform's modal.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Dialog {
|
||||
id: ElementId,
|
||||
open: bool,
|
||||
@@ -61,7 +58,7 @@ impl Dialog {
|
||||
self
|
||||
}
|
||||
|
||||
/// Requests close through `on_change` on escape, scrim clicks, and the close button.
|
||||
/// Requests close through `on_change` on escape and scrim clicks.
|
||||
pub fn on_change<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(bool, &mut Window, &mut App) + 'static,
|
||||
@@ -158,7 +155,6 @@ impl RenderOnce for Dialog {
|
||||
|
||||
let on_change = self.on_change.clone();
|
||||
let alert = self.alert;
|
||||
let show_close = !alert && on_change.is_some();
|
||||
|
||||
// Keyboard focus rides the panel across frames.
|
||||
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
||||
@@ -195,42 +191,14 @@ impl RenderOnce for Dialog {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.when(self.title.is_some() || show_close, |panel| {
|
||||
.when_some(self.title, |panel, title| {
|
||||
panel.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.when_some(self.title.clone(), |row, title| {
|
||||
row.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_size(title_size)
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(title),
|
||||
)
|
||||
})
|
||||
.when(show_close, |row| {
|
||||
row.child(
|
||||
Button::new("dialog-close")
|
||||
.icon(IconRole::Close)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.small()
|
||||
.debug_selector(|| "dialog-close".to_string())
|
||||
.accessibility_label("Close")
|
||||
.on_click({
|
||||
let on_change = on_change.clone();
|
||||
move |_, window, cx| {
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(false, window, cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(content, |panel, content| {
|
||||
panel.child(div().text_color(muted_foreground).child(content))
|
||||
})
|
||||
|
||||
@@ -5,19 +5,17 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, ClickEvent, DefiniteLength, DispatchPhase, ElementId, InteractiveElement as _,
|
||||
IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role,
|
||||
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
|
||||
App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
|
||||
MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
|
||||
};
|
||||
use gpui_native_icons::{Icon, IconRole, icon};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_utils::{focus_handle, focus_ring};
|
||||
|
||||
use crate::button::ButtonVariant;
|
||||
use crate::common::{Sizable, h_flex};
|
||||
use crate::menu::{
|
||||
ChangeHandler, MenuChrome, MenuItem, MenuPanelSpec, TriggerBoundsCell, anchored_menu_panel,
|
||||
};
|
||||
use crate::menu::{ChangeHandler, MenuChrome, MenuItem, TriggerBoundsCell, anchored_menu_panel};
|
||||
use crate::spinner::Spinner;
|
||||
|
||||
/// The main zone's activation handler, shared by pointer, Enter, and Space.
|
||||
@@ -26,11 +24,10 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
|
||||
|
||||
/// A push button with an attached dropdown menu.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct DropdownMenu {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
variant: ButtonVariant,
|
||||
size: Size,
|
||||
disabled: bool,
|
||||
@@ -41,7 +38,6 @@ pub struct DropdownMenu {
|
||||
on_click: Option<ClickHandler>,
|
||||
on_open_change: Option<ChangeHandler>,
|
||||
items: Option<ItemsFn>,
|
||||
min_width: Option<DefiniteLength>,
|
||||
}
|
||||
|
||||
impl DropdownMenu {
|
||||
@@ -64,7 +60,6 @@ impl DropdownMenu {
|
||||
on_click: None,
|
||||
on_open_change: None,
|
||||
items: None,
|
||||
min_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,11 +73,8 @@ impl DropdownMenu {
|
||||
}
|
||||
|
||||
/// Shows an icon beside the label.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -151,15 +143,6 @@ impl DropdownMenu {
|
||||
self.items = Some(Box::new(items));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the panel's minimum width, defaults to the standard menu width.
|
||||
pub fn min_width<W>(mut self, width: W) -> Self
|
||||
where
|
||||
W: Into<DefiniteLength>,
|
||||
{
|
||||
self.min_width = Some(width.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for DropdownMenu {
|
||||
@@ -284,8 +267,8 @@ impl RenderOnce for DropdownMenu {
|
||||
)
|
||||
})
|
||||
.when(!self.loading, |zone| {
|
||||
zone.when_some(self.icon, |zone, icon| {
|
||||
zone.child(icon.size(metrics.icon_size).color(foreground))
|
||||
zone.when_some(self.icon, |zone, role| {
|
||||
zone.child(icon(role).size(metrics.icon_size).color(foreground))
|
||||
})
|
||||
})
|
||||
.when_some(self.label, |zone, label| zone.child(label));
|
||||
@@ -380,30 +363,7 @@ impl RenderOnce for DropdownMenu {
|
||||
trigger
|
||||
};
|
||||
|
||||
let items = if self.open {
|
||||
self.items
|
||||
.map(|build| Rc::from(build(window, cx)))
|
||||
.unwrap_or_else(|| Rc::from([]))
|
||||
} else {
|
||||
Rc::from([])
|
||||
};
|
||||
|
||||
let on_close = Some(on_open.clone());
|
||||
|
||||
let spec = MenuPanelSpec {
|
||||
open: self.open,
|
||||
items,
|
||||
initial_selection: None,
|
||||
preselect: *keyboard_open.read(cx),
|
||||
min_width: self.min_width,
|
||||
label,
|
||||
position: None,
|
||||
trigger_bounds: trigger_bounds.clone(),
|
||||
on_dismiss: on_close.clone(),
|
||||
on_activate: on_close,
|
||||
};
|
||||
|
||||
div()
|
||||
let mut wrapper = div()
|
||||
.relative()
|
||||
.child(
|
||||
canvas(
|
||||
@@ -458,10 +418,34 @@ impl RenderOnce for DropdownMenu {
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.child(trigger)
|
||||
.when_some(
|
||||
anchored_menu_panel(window, cx, &self.id, spec, &panel_chrome, enter_duration),
|
||||
|wrapper, panel| wrapper.child(panel),
|
||||
)
|
||||
.child(trigger);
|
||||
|
||||
let items = if self.open {
|
||||
self.items
|
||||
.map(|build| Rc::from(build(window, cx)))
|
||||
.unwrap_or_else(|| Rc::from([]))
|
||||
} else {
|
||||
Rc::from([])
|
||||
};
|
||||
|
||||
if let Some(panel) = anchored_menu_panel(
|
||||
window,
|
||||
cx,
|
||||
&self.id,
|
||||
self.open,
|
||||
items,
|
||||
None,
|
||||
*keyboard_open.read(cx),
|
||||
Some(on_open),
|
||||
label,
|
||||
None,
|
||||
&trigger_bounds,
|
||||
&panel_chrome,
|
||||
enter_duration,
|
||||
) {
|
||||
wrapper = wrapper.child(panel);
|
||||
}
|
||||
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,15 @@ use gpui::{
|
||||
App, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled as _,
|
||||
Window, div, relative,
|
||||
};
|
||||
use gpui_native_icons::Icon;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::common::{StyledExt, v_flex};
|
||||
|
||||
/// The centered "nothing here yet" panel for lists, tables, and searches.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct EmptyState {
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
headline: SharedString,
|
||||
hint: Option<SharedString>,
|
||||
action: Option<gpui::AnyElement>,
|
||||
@@ -35,11 +34,8 @@ impl EmptyState {
|
||||
}
|
||||
|
||||
/// Sets a large muted icon above the headline.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -82,11 +78,11 @@ impl RenderOnce for EmptyState {
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.p_4()
|
||||
.when_some(self.icon, |state, icon| {
|
||||
.when_some(self.icon, |state, role| {
|
||||
state.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.child(icon.size(illustration).color(muted_foreground)),
|
||||
.child(icon(role).size(illustration).color(muted_foreground)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
|
||||
@@ -21,7 +21,6 @@ pub enum FieldVariant {
|
||||
|
||||
/// A labeled control: the label, description, and error around any element.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Field {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
|
||||
@@ -570,10 +570,7 @@ impl InputState {
|
||||
}
|
||||
|
||||
/// Creates the state entity with initial text, caret at the end.
|
||||
pub fn new_with_text<T>(text: T, cx: &mut App) -> Entity<Self>
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
||||
let text = sanitize(text.into(), false);
|
||||
cx.new(|cx| {
|
||||
let mut state = Self::new_inner(cx);
|
||||
@@ -1009,7 +1006,7 @@ impl InputState {
|
||||
start = end + '\n'.len_utf8();
|
||||
}
|
||||
}
|
||||
Err(err) => log::warn!("shaping input text failed: {err}"),
|
||||
Err(err) => eprintln!("shaping input text failed: {err}"),
|
||||
}
|
||||
|
||||
ShapedText { lines, mask_map }
|
||||
@@ -1189,7 +1186,7 @@ impl InputState {
|
||||
window,
|
||||
cx,
|
||||
) {
|
||||
log::warn!("painting input text failed: {err}");
|
||||
eprintln!("painting input text failed: {err}");
|
||||
}
|
||||
line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32;
|
||||
}
|
||||
@@ -1841,7 +1838,6 @@ impl EntityInputHandler for InputState {
|
||||
|
||||
/// A text field bound to an application-owned [`InputState`].
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Input {
|
||||
id: ElementId,
|
||||
state: Entity<InputState>,
|
||||
@@ -2474,10 +2470,7 @@ fn real_index(mask_map: &Option<Vec<(usize, usize)>>, display: usize) -> usize {
|
||||
|
||||
/// Newlines have no meaning in a single-line field; multiline fields keep
|
||||
/// them and drop carriage returns.
|
||||
fn sanitize<T>(text: T, multiline: bool) -> String
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
fn sanitize(text: impl Into<String>, multiline: bool) -> String {
|
||||
let text = text.into();
|
||||
if multiline {
|
||||
text.replace('\r', "")
|
||||
|
||||
@@ -81,9 +81,6 @@ pub mod separator;
|
||||
#[cfg(feature = "spinner")]
|
||||
pub mod spinner;
|
||||
|
||||
#[cfg(feature = "speech")]
|
||||
pub mod speech;
|
||||
|
||||
#[cfg(feature = "field")]
|
||||
pub mod field;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
/// Link component.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Link {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
|
||||
+177
-592
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
|
||||
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point,
|
||||
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point, px,
|
||||
};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
@@ -21,7 +21,6 @@ type BoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
|
||||
|
||||
/// A floating panel anchored below its trigger.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Popover {
|
||||
id: ElementId,
|
||||
open: bool,
|
||||
@@ -115,7 +114,7 @@ impl RenderOnce for Popover {
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||
|
||||
div()
|
||||
let mut wrapper = div()
|
||||
.relative()
|
||||
.child(
|
||||
canvas(
|
||||
@@ -162,8 +161,9 @@ impl RenderOnce for Popover {
|
||||
)
|
||||
.when_some(self.trigger, |wrapper, trigger| {
|
||||
wrapper.child(trigger(window, cx))
|
||||
})
|
||||
.when(self.open, |wrapper| {
|
||||
});
|
||||
|
||||
if self.open {
|
||||
let on_change = self.on_change;
|
||||
let label = fallback_label.clone();
|
||||
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
||||
@@ -224,15 +224,17 @@ impl RenderOnce for Popover {
|
||||
.inset_0(),
|
||||
);
|
||||
|
||||
wrapper.child(deferred(
|
||||
wrapper = wrapper.child(deferred(
|
||||
anchored()
|
||||
.offset(point(Pixels::ZERO, anchor_gap))
|
||||
.offset(point(px(0.), anchor_gap))
|
||||
.child(panel.with_animation(
|
||||
"popover-enter",
|
||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
||||
|panel, delta| panel.opacity(delta),
|
||||
)),
|
||||
))
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
//! The progress bar: determinate fill or an indeterminate sweep.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, div, relative,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, PROGRESS_ANIMATION_PERIOD, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::common::Sizable;
|
||||
|
||||
/// A progress bar. With a `value` in `0..=1` it paints a determinate fill.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Progress {
|
||||
id: ElementId,
|
||||
value: f32,
|
||||
@@ -96,7 +97,7 @@ impl RenderOnce for Progress {
|
||||
.bg(ramps.primary.base)
|
||||
.with_animation(
|
||||
"indeterminate",
|
||||
Animation::new(PROGRESS_ANIMATION_PERIOD)
|
||||
Animation::new(Duration::from_millis(1500))
|
||||
.repeat()
|
||||
.with_max_fps(30.),
|
||||
|pill, delta| pill.ml(relative(delta * 0.7)),
|
||||
|
||||
@@ -12,10 +12,6 @@ use crate::common::{Sizable, h_flex, v_flex};
|
||||
type ChangeHandler<V> = std::rc::Rc<dyn Fn(V, &mut Window, &mut App)>;
|
||||
|
||||
/// Radio button.
|
||||
///
|
||||
/// Each radio is its own tab stop; the full ARIA radiogroup pattern (single
|
||||
/// stop plus arrow-key roving focus) needs a group owner that does not exist yet.
|
||||
#[must_use]
|
||||
pub struct Radio<V> {
|
||||
id: ElementId,
|
||||
value: V,
|
||||
@@ -69,7 +65,6 @@ where
|
||||
|
||||
/// Group of radio buttons.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct RadioGroup<V>
|
||||
where
|
||||
V: Clone + PartialEq + 'static,
|
||||
|
||||
@@ -23,7 +23,6 @@ pub enum ScrollAxes {
|
||||
|
||||
/// A scrollable container with an overlay scrollbar.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct ScrollArea {
|
||||
id: ElementId,
|
||||
handle: Option<ScrollHandle>,
|
||||
@@ -96,7 +95,7 @@ impl RenderOnce for ScrollArea {
|
||||
let mut pane_style = self.style.clone();
|
||||
pane_style.padding = Default::default();
|
||||
|
||||
let pane = div()
|
||||
let mut pane = div()
|
||||
.id(self.id.clone())
|
||||
.overflow_hidden()
|
||||
.when(self.axes != ScrollAxes::Horizontal, |area| {
|
||||
@@ -113,31 +112,34 @@ impl RenderOnce for ScrollArea {
|
||||
// Keep wheel events from scrolling an outer scroll area too.
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.refine_style(&pane_style)
|
||||
.children(self.children);
|
||||
.refine_style(&pane_style);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.refine_style(&self.style)
|
||||
.child(pane.into_any_element())
|
||||
.when_some(self.handle, |wrapper, handle| {
|
||||
// Extend with children
|
||||
pane.extend(self.children);
|
||||
|
||||
let mut wrapper = div().relative().refine_style(&self.style);
|
||||
wrapper.extend(std::iter::once(pane.into_any_element()));
|
||||
|
||||
if let Some(handle) = self.handle {
|
||||
let id = self.id.clone();
|
||||
|
||||
wrapper
|
||||
.when(self.axes != ScrollAxes::Horizontal, |wrapper| {
|
||||
wrapper.child(
|
||||
if self.axes != ScrollAxes::Horizontal {
|
||||
wrapper.extend(std::iter::once(
|
||||
Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
|
||||
.visibility(self.visibility)
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
.when(self.axes != ScrollAxes::Vertical, |wrapper| {
|
||||
wrapper.child(
|
||||
));
|
||||
}
|
||||
|
||||
if self.axes != ScrollAxes::Vertical {
|
||||
wrapper.extend(std::iter::once(
|
||||
Scrollbar::horizontal((id, "h-scrollbar"), handle)
|
||||
.visibility(self.visibility)
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
wrapper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ use gpui::{
|
||||
ScrollHandle, SharedString, Styled as _, Task, UniformListScrollHandle, Window, canvas, fill,
|
||||
point,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, SCROLLBAR_AUTOHIDE_DELAY, SCROLLBAR_FADE_DURATION};
|
||||
use gpui_native_utils::ResultLogErr as _;
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
/// When an overlay scrollbar shows.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -23,8 +22,12 @@ pub enum ScrollbarVisibility {
|
||||
Autohide,
|
||||
}
|
||||
|
||||
/// How often the autohide fade repaints while it runs.
|
||||
const FADE_STEP: Duration = Duration::from_millis(50);
|
||||
/// How long an autohide thumb stays fully visible before its fade begins.
|
||||
const AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
|
||||
/// How long the autohide fade runs.
|
||||
const AUTOHIDE_FADE: Duration = Duration::from_millis(250);
|
||||
/// How often the fade repaints while it runs.
|
||||
const AUTOHIDE_FADE_STEP: Duration = Duration::from_millis(50);
|
||||
|
||||
/// The shared autohide timeline for an overlay scrollbar.
|
||||
#[derive(Clone)]
|
||||
@@ -56,23 +59,21 @@ impl Autohide {
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
if reduce_motion {
|
||||
cx.background_executor()
|
||||
.timer(SCROLLBAR_AUTOHIDE_DELAY + SCROLLBAR_FADE_DURATION)
|
||||
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
|
||||
.await;
|
||||
cx.update(|_, cx| cx.notify(view)).log_err();
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let steps = (SCROLLBAR_FADE_DURATION.as_millis() / FADE_STEP.as_millis()).max(1);
|
||||
cx.background_executor()
|
||||
.timer(SCROLLBAR_AUTOHIDE_DELAY)
|
||||
.await;
|
||||
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
|
||||
cx.background_executor().timer(AUTOHIDE_DELAY).await;
|
||||
|
||||
for _ in 0..steps {
|
||||
cx.update(|_, cx| cx.notify(view)).log_err();
|
||||
cx.background_executor().timer(FADE_STEP).await;
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
cx.background_executor().timer(AUTOHIDE_FADE_STEP).await;
|
||||
}
|
||||
|
||||
cx.update(|_, cx| cx.notify(view)).log_err();
|
||||
cx.update(|_, cx| cx.notify(view)).ok();
|
||||
});
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
@@ -92,19 +93,50 @@ impl Autohide {
|
||||
};
|
||||
|
||||
let idle = activity.elapsed();
|
||||
if idle <= SCROLLBAR_AUTOHIDE_DELAY {
|
||||
if idle <= AUTOHIDE_DELAY {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let fade = idle.saturating_sub(SCROLLBAR_AUTOHIDE_DELAY);
|
||||
if fade >= SCROLLBAR_FADE_DURATION {
|
||||
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
|
||||
if fade >= AUTOHIDE_FADE {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
1.0 - fade.as_secs_f32() / SCROLLBAR_FADE_DURATION.as_secs_f32()
|
||||
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
fn scrollbar_state_key(name: &'static str, id: &ElementId) -> ElementId {
|
||||
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
|
||||
}
|
||||
|
||||
/// Pure thumb geometry for an overlay scrollbar.
|
||||
fn thumb_geometry(
|
||||
viewport: Pixels,
|
||||
content: Pixels,
|
||||
offset: Pixels,
|
||||
track: Pixels,
|
||||
min_thumb: Pixels,
|
||||
) -> (Pixels, Pixels) {
|
||||
if content <= viewport || content <= Pixels::ZERO {
|
||||
return (track, Pixels::ZERO);
|
||||
}
|
||||
|
||||
let length = (viewport / content * track).max(min_thumb).min(track);
|
||||
let overflow = content - viewport;
|
||||
let progress = (-offset / overflow).clamp(0., 1.);
|
||||
let travel = track - length;
|
||||
(length, travel * progress)
|
||||
}
|
||||
|
||||
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
|
||||
fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
|
||||
(alpha > 0.0).then_some(gpui::Hsla {
|
||||
a: color.a * alpha,
|
||||
..color
|
||||
})
|
||||
}
|
||||
|
||||
/// The scroll state a scrollbar drives, implemented for gpui's scroll handles.
|
||||
pub trait ScrollbarHandle: 'static {
|
||||
/// How far the content can scroll, positive along each axis.
|
||||
@@ -213,9 +245,8 @@ impl Axis {
|
||||
}
|
||||
}
|
||||
|
||||
/// An overlay scrollbar, rendered over the scrolled surface.
|
||||
/// An overlay scrollbar, the caller renders it over the scrolled surface.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Scrollbar {
|
||||
axis: Axis,
|
||||
handle: Rc<dyn ScrollbarHandle>,
|
||||
@@ -257,37 +288,6 @@ impl Scrollbar {
|
||||
self.visibility = visibility;
|
||||
self
|
||||
}
|
||||
|
||||
fn state_key(name: &'static str, id: &ElementId) -> ElementId {
|
||||
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
|
||||
}
|
||||
|
||||
/// Pure thumb geometry for an overlay scrollbar.
|
||||
fn thumb_geometry(
|
||||
viewport: Pixels,
|
||||
content: Pixels,
|
||||
offset: Pixels,
|
||||
track: Pixels,
|
||||
min_thumb: Pixels,
|
||||
) -> (Pixels, Pixels) {
|
||||
if content <= viewport || content <= Pixels::ZERO {
|
||||
return (track, Pixels::ZERO);
|
||||
}
|
||||
|
||||
let length = (viewport / content * track).max(min_thumb).min(track);
|
||||
let overflow = content - viewport;
|
||||
let progress = (-offset / overflow).clamp(0., 1.);
|
||||
let travel = track - length;
|
||||
(length, travel * progress)
|
||||
}
|
||||
|
||||
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
|
||||
fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
|
||||
(alpha > 0.0).then_some(gpui::Hsla {
|
||||
a: color.a * alpha,
|
||||
..color
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Scrollbar {
|
||||
@@ -297,7 +297,7 @@ impl RenderOnce for Scrollbar {
|
||||
let axis = self.axis;
|
||||
let visibility = self.visibility;
|
||||
let id = self.id;
|
||||
move |_, _, window, cx| Self::paint(&handle, axis, visibility, &id, window, cx)
|
||||
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &id, window, cx)
|
||||
})
|
||||
.absolute()
|
||||
.inset_0()
|
||||
@@ -307,15 +307,14 @@ impl RenderOnce for Scrollbar {
|
||||
/// The drag state shared between the scrollbar's mouse handlers.
|
||||
type Drag = Option<(Pixels, Pixels)>;
|
||||
|
||||
impl Scrollbar {
|
||||
fn paint(
|
||||
fn paint_scrollbar(
|
||||
handle: &Rc<dyn ScrollbarHandle>,
|
||||
axis: Axis,
|
||||
visibility: ScrollbarVisibility,
|
||||
id: &ElementId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
) {
|
||||
let view = window.current_view();
|
||||
let theme = cx.theme();
|
||||
|
||||
@@ -337,12 +336,11 @@ impl Scrollbar {
|
||||
ScrollbarVisibility::Always => false,
|
||||
ScrollbarVisibility::Autohide => true,
|
||||
};
|
||||
|
||||
let autohide_state =
|
||||
autohide.then(|| Autohide::new(Self::state_key("autohide", id), window, cx));
|
||||
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", id), window, cx));
|
||||
|
||||
let track_length = viewport_along - inset * 2.0;
|
||||
let (thumb_length, thumb_travel) = Self::thumb_geometry(
|
||||
let (thumb_length, thumb_travel) = thumb_geometry(
|
||||
viewport_along,
|
||||
viewport_along + max_offset,
|
||||
axis.along(handle.offset()),
|
||||
@@ -386,7 +384,7 @@ impl Scrollbar {
|
||||
}
|
||||
};
|
||||
|
||||
let drag = window.use_keyed_state(Self::state_key("thumb-drag", id), cx, |_, _| Drag::None);
|
||||
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", id), cx, |_, _| Drag::None);
|
||||
let dragging = drag.read(cx).is_some();
|
||||
|
||||
let alpha = match &autohide_state {
|
||||
@@ -394,7 +392,7 @@ impl Scrollbar {
|
||||
None => 1.0,
|
||||
};
|
||||
|
||||
if let Some(color) = Self::thumb_style(color, alpha) {
|
||||
if let Some(color) = thumb_style(color, alpha) {
|
||||
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
|
||||
}
|
||||
|
||||
@@ -429,8 +427,7 @@ impl Scrollbar {
|
||||
let start_offset = if in_thumb {
|
||||
axis.along(handle.offset())
|
||||
} else if track_length > thumb_length {
|
||||
let thumb_start = ((axis.along(event.position)
|
||||
- axis.along(track_bounds.origin))
|
||||
let thumb_start = ((axis.along(event.position) - axis.along(track_bounds.origin))
|
||||
- thumb_length / 2.0)
|
||||
.clamp(Pixels::ZERO, track_length - thumb_length);
|
||||
let offset = -(thumb_start / (track_length - thumb_length)) * max_offset;
|
||||
@@ -516,5 +513,4 @@ impl Scrollbar {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||
KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation,
|
||||
StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, canvas, div,
|
||||
SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window,
|
||||
canvas, div,
|
||||
};
|
||||
use gpui_native_icons::Icon;
|
||||
use gpui_native_theme::{ActiveTheme as _, CONTROL_SPRING, Size};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_utils::{focus_handle, focus_ring};
|
||||
|
||||
use crate::common::{Sizable, StyledExt, h_flex};
|
||||
@@ -17,11 +18,10 @@ type ChangeHandler<T> = Rc<dyn Fn(T, &mut Window, &mut App)>;
|
||||
|
||||
/// Segment option for a segmented control.
|
||||
#[derive(Clone)]
|
||||
#[must_use]
|
||||
pub struct SegmentedOption<T> {
|
||||
value: T,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
}
|
||||
|
||||
impl<T> SegmentedOption<T> {
|
||||
@@ -38,11 +38,8 @@ impl<T> SegmentedOption<T> {
|
||||
}
|
||||
|
||||
/// Sets a leading icon for the segment.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -62,7 +59,6 @@ where
|
||||
|
||||
/// A pill strip of mutually exclusive choices.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Segmented<T>
|
||||
where
|
||||
T: Clone + PartialEq + 'static,
|
||||
@@ -188,6 +184,7 @@ where
|
||||
window.use_keyed_state("segmented-track-origin", cx, |_, _| None);
|
||||
|
||||
let focus = focus_handle(&self.id, window, cx);
|
||||
let anim_config = SpringConfig::new(500., 35., 1.);
|
||||
let track_point = *track_origin.read(cx);
|
||||
|
||||
let plate_target = geometry
|
||||
@@ -214,7 +211,7 @@ where
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id((ElementId::from("segment"), segment.label.clone()))
|
||||
.id(("segment", index))
|
||||
.gap_1p5()
|
||||
.justify_center()
|
||||
.cursor_pointer()
|
||||
@@ -229,8 +226,8 @@ where
|
||||
.role(Role::RadioButton)
|
||||
.aria_label(segment.label.clone())
|
||||
.aria_toggled(Toggled::from(is_selected))
|
||||
.when_some(segment.icon.clone(), |segment, icon| {
|
||||
segment.child(icon.size(icon_size).color(content_color))
|
||||
.when_some(segment.icon, |segment, role| {
|
||||
segment.child(icon(role).size(icon_size).color(content_color))
|
||||
})
|
||||
.child(segment.label.clone())
|
||||
.child(
|
||||
@@ -309,12 +306,12 @@ where
|
||||
.shadow(plate_shadow.clone())
|
||||
.with_spring(
|
||||
"segmented-plate-x",
|
||||
SpringAnimation::new(CONTROL_SPRING).to(x),
|
||||
SpringAnimation::new(anim_config).to(x),
|
||||
|plate, x| plate.left(x),
|
||||
)
|
||||
.with_spring(
|
||||
"segmented-plate-w",
|
||||
SpringAnimation::new(CONTROL_SPRING).to(width),
|
||||
SpringAnimation::new(anim_config).to(width),
|
||||
|spring_plate, width| {
|
||||
spring_plate.map_element(move |plate| plate.w(width))
|
||||
},
|
||||
|
||||
+16
-82
@@ -2,13 +2,12 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _,
|
||||
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
|
||||
Window, canvas,
|
||||
App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
|
||||
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
|
||||
};
|
||||
use gpui_native_icons::{Icon, IconRole, icon};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_utils::{focus_handle, focus_ring};
|
||||
use gpui_native_utils::focus_ring;
|
||||
|
||||
use crate::common::{Sizable, StyledExt, h_flex};
|
||||
use crate::menu::{Menu, MenuItem};
|
||||
@@ -18,11 +17,10 @@ pub type SelectHandler<T> = Rc<dyn Fn(Option<T>, &mut Window, &mut App)>;
|
||||
|
||||
/// Select option for a [`Select`] control.
|
||||
#[derive(Clone)]
|
||||
#[must_use]
|
||||
pub struct SelectOption<T> {
|
||||
value: T,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
}
|
||||
|
||||
impl<T> SelectOption<T> {
|
||||
@@ -39,11 +37,8 @@ impl<T> SelectOption<T> {
|
||||
}
|
||||
|
||||
/// Sets a leading icon for the option's row.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -63,7 +58,6 @@ where
|
||||
|
||||
/// A control that shows the chosen value and opens a menu of the choices.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Select<T>
|
||||
where
|
||||
T: Clone + PartialEq + 'static,
|
||||
@@ -110,10 +104,7 @@ where
|
||||
}
|
||||
|
||||
/// Sets the current value. `None` shows the placeholder.
|
||||
pub fn selected<S>(mut self, selected: S) -> Self
|
||||
where
|
||||
S: Into<Option<T>>,
|
||||
{
|
||||
pub fn selected(mut self, selected: impl Into<Option<T>>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
@@ -193,16 +184,6 @@ where
|
||||
.clone()
|
||||
});
|
||||
|
||||
let focus = focus_handle(&self.id, window, cx);
|
||||
|
||||
// Set when the popup closes without an activation; the trigger claims
|
||||
// focus at paint, leaving keyboard activations to drop focus in place.
|
||||
let refocus_pending = window.with_element_namespace(self.id.clone(), |window| {
|
||||
window
|
||||
.use_keyed_state("select-refocus", cx, |_, _| false)
|
||||
.clone()
|
||||
});
|
||||
|
||||
let is_open = *open.read(cx);
|
||||
let open_for_items = open.clone();
|
||||
|
||||
@@ -236,31 +217,12 @@ where
|
||||
let trigger_label = label.clone();
|
||||
let trigger_style = self.style;
|
||||
|
||||
let trigger = {
|
||||
let open = open.clone();
|
||||
let focus = focus.clone();
|
||||
let refocus_for_trigger = refocus_pending.clone();
|
||||
move |_window: &mut Window, _cx: &mut App| {
|
||||
let trigger = move |_window: &mut Window, _cx: &mut App| {
|
||||
h_flex()
|
||||
.id((trigger_id.clone(), "select-trigger"))
|
||||
.debug_selector(|| "select-trigger".to_string())
|
||||
.role(Role::ComboBox)
|
||||
.aria_label(trigger_label.clone())
|
||||
.aria_expanded(is_open)
|
||||
.track_focus(&focus)
|
||||
.tab_stop(true)
|
||||
.focus_visible(move |style| style.shadow(focus_ring(ring)))
|
||||
.on_key_down({
|
||||
let open = open.clone();
|
||||
move |event: &KeyDownEvent, _window, cx| {
|
||||
if !matches!(event.keystroke.key.as_str(), "enter" | "space") {
|
||||
return;
|
||||
}
|
||||
|
||||
open.update(cx, |open, _| *open = !*open);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.cursor_pointer()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
@@ -287,69 +249,41 @@ where
|
||||
.size(icon_size)
|
||||
.color(muted_foreground),
|
||||
)
|
||||
.child(
|
||||
canvas(|_, _, _| {}, {
|
||||
let focus = focus.clone();
|
||||
let refocus_pending = refocus_for_trigger.clone();
|
||||
move |_, _, window, cx| {
|
||||
if *refocus_pending.read(cx) {
|
||||
refocus_pending.update(cx, |pending, _| *pending = false);
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.refine_style(&trigger_style)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
|
||||
let select_id = self.id.clone();
|
||||
let refocus_for_items = refocus_pending.clone();
|
||||
let items = move |_window: &mut Window, _cx: &mut App| {
|
||||
self.options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, option)| {
|
||||
.map(|option| {
|
||||
let is_selected = self.selected.as_ref() == Some(&option.value);
|
||||
|
||||
let handler = {
|
||||
let open = open_for_items.clone();
|
||||
let refocus_pending = refocus_for_items.clone();
|
||||
let value = option.value.clone();
|
||||
let on_change = self.on_change.clone();
|
||||
move |window: &mut Window, cx: &mut App| {
|
||||
open.update(cx, |open, _| *open = false);
|
||||
// Activations own the close, so no focus returns to the trigger.
|
||||
refocus_pending.update(cx, |pending, _| *pending = false);
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(Some(value.clone()), window, cx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
MenuItem::new(option.label.clone())
|
||||
.id((select_id.clone(), format!("option-{index}")))
|
||||
.when_some(option.icon.clone(), |row, icon| row.icon(icon))
|
||||
.checked(is_selected)
|
||||
.handler(handler)
|
||||
let mut row = MenuItem::new(option.label.clone());
|
||||
if let Some(role) = option.icon {
|
||||
row = row.icon(role);
|
||||
}
|
||||
row.checked(is_selected).handler(handler)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let menu = Menu::new(self.id)
|
||||
.open(is_open)
|
||||
.on_change({
|
||||
let open = open.clone();
|
||||
let refocus_pending = refocus_pending.clone();
|
||||
move |next, _window, cx| {
|
||||
if !next {
|
||||
refocus_pending.update(cx, |pending, _| *pending = true);
|
||||
}
|
||||
.on_change(move |next, _window, cx| {
|
||||
open.update(cx, |open, _| *open = next);
|
||||
}
|
||||
})
|
||||
.trigger(trigger)
|
||||
.items(items);
|
||||
|
||||
@@ -20,7 +20,6 @@ type DebugSelector = Rc<dyn Fn() -> String>;
|
||||
|
||||
/// A hairline rule that divides sibling content.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Separator {
|
||||
orientation: SeparatorOrientation,
|
||||
debug_selector: Option<DebugSelector>,
|
||||
|
||||
@@ -18,24 +18,18 @@ type ChangeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
||||
/// The pointer→value setter shared by the click and drag listeners.
|
||||
type ValueSetter = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
|
||||
|
||||
/// The arrow-key stride as a share of the range when the slider is continuous.
|
||||
const FALLBACK_STEP_FRACTION: f32 = 0.05;
|
||||
|
||||
impl Slider {
|
||||
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
|
||||
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
|
||||
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
|
||||
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
|
||||
let clamped = value.clamp(min, max);
|
||||
if step > 0.0 {
|
||||
(min + ((clamped - min) / step).round() * step).clamp(min, max)
|
||||
} else {
|
||||
clamped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A controlled slider over `[min, max]`.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Slider {
|
||||
id: ElementId,
|
||||
min: f32,
|
||||
@@ -152,7 +146,7 @@ impl RenderOnce for Slider {
|
||||
{
|
||||
let fraction = ((x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
|
||||
on_change(
|
||||
Self::constrain(min, max, step, min + fraction * (max - min)),
|
||||
constrain(min, max, step, min + fraction * (max - min)),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -190,29 +184,20 @@ impl RenderOnce for Slider {
|
||||
if !focus_handle.contains_focused(window, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let single_step = if self.step > 0.0 {
|
||||
self.step
|
||||
} else {
|
||||
(self.max - self.min) * FALLBACK_STEP_FRACTION
|
||||
(self.max - self.min) / 20.0
|
||||
};
|
||||
|
||||
let next = match event.keystroke.key.as_str() {
|
||||
"left" | "down" => Some(value - single_step),
|
||||
"right" | "up" => Some(value + single_step),
|
||||
"home" => Some(self.min),
|
||||
"end" => Some(self.max),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(next) = next
|
||||
&& let Some(on_change) = self.on_change.as_ref()
|
||||
{
|
||||
on_change(
|
||||
Self::constrain(self.min, self.max, self.step, next),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
on_change(constrain(self.min, self.max, self.step, next), window, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
use std::cell::Cell;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
@@ -12,10 +13,12 @@ use gpui::{
|
||||
Window, canvas, div, percentage, point, px,
|
||||
};
|
||||
use gpui_native_icons::{Icon, IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, SPINNER_PERIOD, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::common::Sizable;
|
||||
|
||||
/// One revolution takes this long, the system loaders' cadence.
|
||||
const SPIN_PERIOD: Duration = Duration::from_millis(800);
|
||||
/// The share of the circle the spinning arc paints.
|
||||
const SPIN_SWEEP: f32 = 0.75;
|
||||
/// The ring stroke as a share of the side.
|
||||
@@ -33,7 +36,6 @@ pub enum SpinnerVariant {
|
||||
|
||||
/// A loading ring.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Spinner {
|
||||
id: ElementId,
|
||||
value: Option<f32>,
|
||||
@@ -176,7 +178,7 @@ fn spinning_ring(side: Pixels, stroke: Pixels, accent: Hsla) -> AnimationElement
|
||||
.size(side)
|
||||
.with_animation(
|
||||
"spin",
|
||||
Animation::new(SPINNER_PERIOD).repeat().with_max_fps(60.),
|
||||
Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.),
|
||||
move |ring, delta| {
|
||||
angle.set(delta * TAU);
|
||||
ring
|
||||
@@ -190,7 +192,7 @@ fn spinning_icon(side: Pixels, accent: Hsla) -> AnimationElement<Icon> {
|
||||
.color(accent)
|
||||
.with_animation(
|
||||
"spin",
|
||||
Animation::new(SPINNER_PERIOD).repeat().with_max_fps(60.),
|
||||
Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.),
|
||||
|glyph, delta| glyph.with_transformation(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, Role, SharedString,
|
||||
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||
@@ -34,7 +34,6 @@ impl StatusDotVariant {
|
||||
|
||||
/// A small circular bead marking a row's state.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct StatusDot {
|
||||
id: ElementId,
|
||||
variant: StatusDotVariant,
|
||||
@@ -100,9 +99,7 @@ impl RenderOnce for StatusDot {
|
||||
.size(side)
|
||||
.rounded(theme.radius.full)
|
||||
.bg(color)
|
||||
.when_some(self.label, |dot, label| {
|
||||
dot.role(Role::Status).aria_label(label)
|
||||
})
|
||||
.when_some(self.label, |dot, label| dot.aria_label(label))
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
RenderOnce, Role, SharedString, SpringAnimation, StatefulInteractiveElement as _, Styled as _,
|
||||
Toggled, Window, div, px,
|
||||
RenderOnce, Role, SharedString, SpringAnimation, SpringConfig, StatefulInteractiveElement as _,
|
||||
Styled as _, Toggled, Window, div, px,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, CONTROL_SPRING, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_utils::focus_ring;
|
||||
|
||||
use crate::common::{Sizable, h_flex};
|
||||
@@ -16,7 +16,6 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Switch component.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Switch {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
@@ -109,8 +108,8 @@ impl RenderOnce for Switch {
|
||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
||||
let id = self.id.clone();
|
||||
|
||||
let anim =
|
||||
SpringAnimation::new(CONTROL_SPRING).to(if self.checked { travel } else { px(0.) });
|
||||
let anim_config = SpringConfig::new(500., 35., 1.);
|
||||
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
|
||||
+37
-42
@@ -6,12 +6,11 @@ use std::time::Duration;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Task, Window, deferred, div, ease_out_quint, px,
|
||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Task, Window, deferred, div, ease_out_quint, px,
|
||||
};
|
||||
use gpui_native_icons::{Icon, IconRole};
|
||||
use gpui_native_theme::{ActiveTheme as _, TOAST_MAX_WIDTH};
|
||||
use gpui_native_utils::ResultLogErr as _;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
use crate::button::{Button, ButtonVariant};
|
||||
use crate::common::{DismissHandler, Sizable as _};
|
||||
@@ -42,10 +41,9 @@ impl ToastAction {
|
||||
|
||||
/// A toast notification.
|
||||
#[derive(Clone)]
|
||||
#[must_use]
|
||||
pub struct Toast {
|
||||
message: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
action: Option<ToastAction>,
|
||||
duration: Option<Duration>,
|
||||
on_dismiss: Option<DismissHandler>,
|
||||
@@ -70,11 +68,8 @@ impl Toast {
|
||||
}
|
||||
|
||||
/// Shows this glyph before the content.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -135,8 +130,7 @@ impl Toasts {
|
||||
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
this.update(cx, |toasts, cx| toasts.dismiss(id, cx))
|
||||
.log_err();
|
||||
this.update(cx, |toasts, cx| toasts.dismiss(id, cx)).ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
@@ -150,7 +144,8 @@ impl Toasts {
|
||||
self.finish_exit(cx);
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = self.current.take_if(|entry| entry.id == id) {
|
||||
if self.current.as_ref().is_some_and(|entry| entry.id == id) {
|
||||
let entry = self.current.take().expect("current checked above");
|
||||
self.start_exit(entry, cx);
|
||||
}
|
||||
}
|
||||
@@ -179,7 +174,7 @@ impl Toasts {
|
||||
if let Some(previous) = self.exiting.take()
|
||||
&& let Some(on_dismiss) = &previous.toast.on_dismiss
|
||||
{
|
||||
on_dismiss(cx);
|
||||
(on_dismiss.clone())(cx);
|
||||
}
|
||||
|
||||
self.exiting = Some(entry);
|
||||
@@ -188,8 +183,7 @@ impl Toasts {
|
||||
let fade = cx.theme().motion.overlay;
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(fade).await;
|
||||
this.update(cx, |toasts, cx| toasts.finish_exit(cx))
|
||||
.log_err();
|
||||
this.update(cx, |toasts, cx| toasts.finish_exit(cx)).ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
@@ -198,15 +192,13 @@ impl Toasts {
|
||||
if let Some(entry) = self.exiting.take() {
|
||||
cx.notify();
|
||||
if let Some(on_dismiss) = &entry.toast.on_dismiss {
|
||||
on_dismiss(cx);
|
||||
(on_dismiss.clone())(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating stack of toasts, bottom center.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct ToastsView {
|
||||
toasts: Entity<Toasts>,
|
||||
id: ElementId,
|
||||
@@ -244,7 +236,7 @@ impl RenderOnce for ToastsView {
|
||||
.items_center()
|
||||
.when_some(exiting, |column, entry| {
|
||||
let id = entry.id;
|
||||
column.child(entry.pill(&self.toasts, cx).with_animation(
|
||||
column.child(pill(&entry, &self.toasts, cx).with_animation(
|
||||
("toast-exit", id),
|
||||
Animation::new(fade),
|
||||
|pill, delta| pill.opacity(1. - delta),
|
||||
@@ -252,7 +244,7 @@ impl RenderOnce for ToastsView {
|
||||
})
|
||||
.when_some(current, |column, entry| {
|
||||
let id = entry.id;
|
||||
column.child(entry.pill(&self.toasts, cx).with_animation(
|
||||
column.child(pill(&entry, &self.toasts, cx).with_animation(
|
||||
("toast-enter", id),
|
||||
Animation::new(fade).with_easing(ease_out_quint()),
|
||||
|pill, delta| pill.opacity(delta),
|
||||
@@ -262,9 +254,7 @@ impl RenderOnce for ToastsView {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastEntry {
|
||||
/// The rendered pill for this toast.
|
||||
fn pill(&self, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
||||
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
||||
let theme = cx.theme();
|
||||
let surface = theme.colors.surface;
|
||||
let border = if theme.borders.overlays {
|
||||
@@ -273,20 +263,20 @@ impl ToastEntry {
|
||||
surface
|
||||
};
|
||||
let foreground = theme.colors.foreground;
|
||||
let muted = theme.colors.muted;
|
||||
let radius = theme.radius.full;
|
||||
let shadows = theme.elevations.popover.shadows();
|
||||
let icon_size = theme.sizes.small.icon_size;
|
||||
let dense_icon_size = theme.sizes.small.dense_icon_size;
|
||||
let text_size = theme.sizes.small.text_size;
|
||||
let toast = &self.toast;
|
||||
let toast = &entry.toast;
|
||||
let toasts = toasts.clone();
|
||||
let id = self.id;
|
||||
let id = entry.id;
|
||||
|
||||
div()
|
||||
.id(("toast", id))
|
||||
.debug_selector(|| "toast-pill".to_string())
|
||||
.role(Role::Alert)
|
||||
.aria_label(toast.message.clone())
|
||||
.max_w(TOAST_MAX_WIDTH)
|
||||
.max_w(px(480.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.py_1p5()
|
||||
@@ -296,14 +286,14 @@ impl ToastEntry {
|
||||
.border_color(border)
|
||||
.rounded(radius)
|
||||
.shadow(shadows.clone())
|
||||
.when_some(toast.icon.clone(), |pill, icon| {
|
||||
.when_some(toast.icon, |pill, icon_role| {
|
||||
pill.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.mr_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(icon.size(icon_size).color(foreground)),
|
||||
.child(icon(icon_role).size(icon_size).color(foreground)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
@@ -323,7 +313,7 @@ impl ToastEntry {
|
||||
.variant(ButtonVariant::Primary)
|
||||
.small()
|
||||
.on_click(move |_, _window, cx| {
|
||||
(action.handler)(cx);
|
||||
(action.handler.clone())(cx);
|
||||
toasts.update(cx, |toasts, cx| {
|
||||
toasts.dismiss(id, cx);
|
||||
});
|
||||
@@ -332,18 +322,23 @@ impl ToastEntry {
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div().flex_none().ml_4().child(
|
||||
Button::new(("toast-close", id))
|
||||
.icon(IconRole::Close)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.small()
|
||||
.accessibility_label("Dismiss")
|
||||
div()
|
||||
.id(("toast-close", id))
|
||||
.flex_none()
|
||||
.ml_4()
|
||||
.cursor_pointer()
|
||||
.p_1()
|
||||
.rounded(theme.radius.sm)
|
||||
.hover(|style| style.bg(muted))
|
||||
.child(
|
||||
icon(IconRole::Close)
|
||||
.size(dense_icon_size)
|
||||
.color(theme.colors.muted_foreground),
|
||||
)
|
||||
.on_click(move |_, _window, cx| {
|
||||
toasts.update(cx, |toasts, cx| {
|
||||
toasts.dismiss(id, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A toggle component.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Toggle {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use gpui::AppContext as _;
|
||||
use gpui::{
|
||||
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Pixels, Render, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, canvas, div, point,
|
||||
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, TOOLTIP_MAX_WIDTH, TOOLTIP_OFFSET};
|
||||
use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
|
||||
@@ -28,12 +27,7 @@ impl Tooltip {
|
||||
}
|
||||
|
||||
/// Sets the anchor element ID for the tooltip.
|
||||
///
|
||||
/// The `.tooltip(...)` closure pinning the panel next to `anchor_id`.
|
||||
pub fn anchored<L, I>(
|
||||
text: L,
|
||||
anchor_id: I,
|
||||
) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static
|
||||
pub fn anchored<L, I>(text: L, anchor_id: I) -> impl Fn(&mut Window, &mut App) -> AnyView
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
I: Into<ElementId>,
|
||||
@@ -79,8 +73,6 @@ impl Render for Tooltip {
|
||||
|
||||
let build_panel = || {
|
||||
div()
|
||||
.id("tooltip-panel")
|
||||
.role(Role::Tooltip)
|
||||
.debug_selector(|| "tooltip-panel".to_string())
|
||||
.px_2()
|
||||
.py_1()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||
@@ -69,31 +70,19 @@ fn real_keystrokes_are_not_pathologically_slow() {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
// The focused caret must ride executor timers, never frame requests.
|
||||
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||
assert_eq!(
|
||||
frames, 0,
|
||||
"an idle focused combobox must not request animation frames"
|
||||
);
|
||||
|
||||
let started = Instant::now();
|
||||
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
||||
visual.simulate_keystrokes(&letter.to_string());
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
let total_renders = *renders.borrow();
|
||||
println!(
|
||||
"26 real keystrokes: {total_renders} host renders ({:.2} per keystroke)",
|
||||
"26 real keystrokes: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
|
||||
total_renders as f32 / 26.
|
||||
);
|
||||
assert!(
|
||||
total_renders >= 26,
|
||||
"every keystroke must redraw the page; got {total_renders} renders"
|
||||
);
|
||||
assert!(
|
||||
total_renders <= 26 * 3,
|
||||
"typing caused {total_renders} host renders; healthy filtering stays under 3 per keystroke"
|
||||
);
|
||||
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||
@@ -77,31 +78,19 @@ fn typing_with_the_palette_open_is_not_pathologically_slow() {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
// The open palette must sit idle on executor timers, never frame requests.
|
||||
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||
assert_eq!(
|
||||
frames, 0,
|
||||
"an idle open palette must not request animation frames"
|
||||
);
|
||||
|
||||
let started = Instant::now();
|
||||
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
||||
visual.simulate_keystrokes(&letter.to_string());
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
let total_renders = *renders.borrow();
|
||||
println!(
|
||||
"26 real keystrokes with the palette open: {total_renders} host renders ({:.2} per keystroke)",
|
||||
"26 real keystrokes with the palette open: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
|
||||
total_renders as f32 / 26.
|
||||
);
|
||||
assert!(
|
||||
total_renders >= 26,
|
||||
"every keystroke must redraw the page; got {total_renders} renders"
|
||||
);
|
||||
assert!(
|
||||
total_renders <= 26 * 3,
|
||||
"typing caused {total_renders} host renders; healthy filtering stays under 3 per keystroke"
|
||||
);
|
||||
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
|
||||
}
|
||||
|
||||
@@ -158,26 +158,3 @@ fn clicking_the_panel_does_not_dismiss() {
|
||||
click(&mut visual, point(px(960.), px(540.)));
|
||||
assert!(changes.borrow().is_empty());
|
||||
}
|
||||
|
||||
/// Clicking the close button reports close like escape and the scrim; the
|
||||
/// panel then unmounts the button with the rest of the dialog.
|
||||
#[test]
|
||||
fn the_close_button_reports_close_and_disappears() {
|
||||
let (_cx, mut visual, changes) = dialog_window(true, false);
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("dialog-close")
|
||||
.expect("the close button rendered");
|
||||
click(&mut visual, bounds.center());
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
|
||||
draw(&mut visual);
|
||||
assert!(visual.debug_bounds("dialog-close").is_none());
|
||||
}
|
||||
|
||||
/// An alert answers only through its actions, so it renders no close button.
|
||||
#[test]
|
||||
fn an_alert_hides_the_close_button() {
|
||||
let (_cx, mut visual, _changes) = dialog_window(true, true);
|
||||
assert!(visual.debug_bounds("dialog-close").is_none());
|
||||
}
|
||||
|
||||
+6
-323
@@ -1,15 +1,14 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
Context, DefiniteLength, IntoElement, Modifiers, MouseButton, ParentElement as _, Render,
|
||||
Styled as _, TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px,
|
||||
Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _,
|
||||
TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px,
|
||||
};
|
||||
use gpui_native_core::menu::{Menu, MenuItem};
|
||||
use gpui_native_theme::Theme;
|
||||
|
||||
struct Harness {
|
||||
open: bool,
|
||||
min_width: Option<DefiniteLength>,
|
||||
changes: Rc<RefCell<Vec<bool>>>,
|
||||
activated: Rc<RefCell<Vec<&'static str>>>,
|
||||
}
|
||||
@@ -19,7 +18,8 @@ impl Render for Harness {
|
||||
let changes = self.changes.clone();
|
||||
let view = cx.entity();
|
||||
let activated = self.activated.clone();
|
||||
let mut menu = Menu::new("test-menu")
|
||||
div().size_full().child(
|
||||
Menu::new("test-menu")
|
||||
.open(self.open)
|
||||
.on_change(move |next, _, cx| {
|
||||
changes.borrow_mut().push(next);
|
||||
@@ -46,11 +46,8 @@ impl Render for Harness {
|
||||
}),
|
||||
delete_item(activated.clone()),
|
||||
]
|
||||
});
|
||||
if let Some(width) = self.min_width {
|
||||
menu = menu.min_width(width);
|
||||
}
|
||||
div().size_full().child(menu)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,61 +67,6 @@ fn delete_item(activated: Rc<RefCell<Vec<&'static str>>>) -> MenuItem {
|
||||
.handler(move |_, _| record(&activated, "delete"))
|
||||
}
|
||||
|
||||
fn action_item(
|
||||
label: &'static str,
|
||||
key: &'static str,
|
||||
activated: Rc<RefCell<Vec<&'static str>>>,
|
||||
) -> MenuItem {
|
||||
MenuItem::new(label).handler(move |_, _| record(&activated, key))
|
||||
}
|
||||
|
||||
fn submenu_item(activated: Rc<RefCell<Vec<&'static str>>>) -> MenuItem {
|
||||
MenuItem::new("Export").submenu(vec![
|
||||
action_item("PNG", "png", activated.clone()),
|
||||
action_item("PDF", "pdf", activated.clone()),
|
||||
])
|
||||
}
|
||||
|
||||
/// A menu with an Export row whose flyout holds PNG and PDF actions.
|
||||
struct SubmenuHarness {
|
||||
open: bool,
|
||||
changes: Rc<RefCell<Vec<bool>>>,
|
||||
activated: Rc<RefCell<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
impl Render for SubmenuHarness {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let changes = self.changes.clone();
|
||||
let view = cx.entity();
|
||||
let activated = self.activated.clone();
|
||||
div().size_full().child(
|
||||
Menu::new("test-submenu")
|
||||
.open(self.open)
|
||||
.on_change(move |next, _, cx| {
|
||||
changes.borrow_mut().push(next);
|
||||
view.update(cx, |harness, cx| {
|
||||
harness.open = next;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.trigger(|_, _| {
|
||||
div()
|
||||
.w(px(100.))
|
||||
.h(px(32.))
|
||||
.bg(gpui::black())
|
||||
.into_any_element()
|
||||
})
|
||||
.items(move |_, _| {
|
||||
vec![
|
||||
action_item("Cut", "cut", activated.clone()),
|
||||
submenu_item(activated.clone()),
|
||||
action_item("Delete", "delete", activated.clone()),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn record(sink: &Rc<RefCell<Vec<&'static str>>>, what: &'static str) {
|
||||
sink.borrow_mut().push(what);
|
||||
}
|
||||
@@ -140,19 +82,6 @@ fn menu_window(
|
||||
WindowHandle<Harness>,
|
||||
Changes,
|
||||
Activated,
|
||||
) {
|
||||
menu_window_with(open, None)
|
||||
}
|
||||
|
||||
fn menu_window_with(
|
||||
open: bool,
|
||||
min_width: Option<DefiniteLength>,
|
||||
) -> (
|
||||
TestAppContext,
|
||||
VisualTestContext,
|
||||
WindowHandle<Harness>,
|
||||
Changes,
|
||||
Activated,
|
||||
) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
@@ -162,7 +91,6 @@ fn menu_window_with(
|
||||
let harness_activated = activated.clone();
|
||||
let window = cx.add_window(move |_, _| Harness {
|
||||
open,
|
||||
min_width,
|
||||
changes: harness_changes,
|
||||
activated: harness_activated,
|
||||
});
|
||||
@@ -173,25 +101,6 @@ fn menu_window_with(
|
||||
(cx, visual, window, changes, activated)
|
||||
}
|
||||
|
||||
fn submenu_window(open: bool) -> (TestAppContext, VisualTestContext, Changes, Activated) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let changes = Rc::new(RefCell::new(Vec::new()));
|
||||
let activated = Rc::new(RefCell::new(Vec::new()));
|
||||
let harness_changes = changes.clone();
|
||||
let harness_activated = activated.clone();
|
||||
let window = cx.add_window(move |_, _| SubmenuHarness {
|
||||
open,
|
||||
changes: harness_changes,
|
||||
activated: harness_activated,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, visual, changes, activated)
|
||||
}
|
||||
|
||||
fn draw(visual: &mut VisualTestContext) {
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
@@ -203,17 +112,6 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
||||
}
|
||||
|
||||
fn hover(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||
visual.simulate_mouse_move(position, None, Modifiers::default());
|
||||
}
|
||||
|
||||
fn center(bounds: gpui::Bounds<gpui::Pixels>) -> gpui::Point<gpui::Pixels> {
|
||||
point(
|
||||
bounds.origin.x + bounds.size.width / 2.,
|
||||
bounds.origin.y + bounds.size.height / 2.,
|
||||
)
|
||||
}
|
||||
|
||||
/// Every action row renders at the platform's stated menu row height,
|
||||
/// whatever its content — the metric is the whole row, not a floor under
|
||||
/// padding. Item 1 of the harness menu is a separator, so it is skipped.
|
||||
@@ -306,218 +204,3 @@ fn clicking_an_item_activates_and_closes() {
|
||||
assert_eq!(*activated.borrow(), vec!["cut"]);
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_arrow_opens_the_submenu_and_enter_within_activates_through() {
|
||||
let (_cx, mut visual, changes, activated) = submenu_window(true);
|
||||
|
||||
// down arms Cut, the next down reaches Export, right opens its flyout
|
||||
// with the first row armed.
|
||||
visual.simulate_keystrokes("down down right");
|
||||
draw(&mut visual);
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_some());
|
||||
assert!(changes.borrow().is_empty(), "the menu stays open");
|
||||
assert!(activated.borrow().is_empty());
|
||||
|
||||
// Enter inside the flyout fires PNG and closes the whole menu.
|
||||
visual.simulate_keystrokes("enter");
|
||||
draw(&mut visual);
|
||||
assert_eq!(*activated.borrow(), vec!["png"]);
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_submenu_row_opens_it_and_navigates_within() {
|
||||
let (_cx, mut visual, changes, activated) = submenu_window(true);
|
||||
|
||||
visual.simulate_keystrokes("down down enter");
|
||||
draw(&mut visual);
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_some());
|
||||
assert!(
|
||||
changes.borrow().is_empty(),
|
||||
"enter opens, it does not close"
|
||||
);
|
||||
|
||||
// The flyout armed PNG; down reaches PDF and enter fires it.
|
||||
visual.simulate_keystrokes("down enter");
|
||||
draw(&mut visual);
|
||||
assert_eq!(*activated.borrow(), vec!["pdf"]);
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_closes_the_submenu_and_returns_to_the_menu() {
|
||||
let (_cx, mut visual, changes, activated) = submenu_window(true);
|
||||
|
||||
visual.simulate_keystrokes("down down right");
|
||||
draw(&mut visual);
|
||||
visual.simulate_keystrokes("left");
|
||||
draw(&mut visual);
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_none());
|
||||
assert!(changes.borrow().is_empty(), "the menu stays open");
|
||||
assert!(activated.borrow().is_empty());
|
||||
|
||||
// Navigation resumes in the parent: down reaches Delete, enter fires it.
|
||||
visual.simulate_keystrokes("down enter");
|
||||
draw(&mut visual);
|
||||
assert_eq!(*activated.borrow(), vec!["delete"]);
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_the_submenu_then_the_menu() {
|
||||
let (_cx, mut visual, changes, activated) = submenu_window(true);
|
||||
|
||||
visual.simulate_keystrokes("down down right");
|
||||
draw(&mut visual);
|
||||
visual.simulate_keystrokes("escape");
|
||||
draw(&mut visual);
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_none());
|
||||
assert!(
|
||||
changes.borrow().is_empty(),
|
||||
"the first escape only closes the flyout"
|
||||
);
|
||||
|
||||
visual.simulate_keystrokes("escape");
|
||||
draw(&mut visual);
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
assert!(activated.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_submenu_row_opens_the_flyout_and_a_flyout_action_closes_the_menu() {
|
||||
let (_cx, mut visual, changes, activated) = submenu_window(true);
|
||||
|
||||
let row = visual
|
||||
.debug_bounds("menu-item-1")
|
||||
.unwrap_or_else(|| panic!("the Export row should have been laid out"));
|
||||
click(
|
||||
&mut visual,
|
||||
point(
|
||||
row.origin.x + row.size.width / 2.,
|
||||
row.origin.y + row.size.height / 2.,
|
||||
),
|
||||
);
|
||||
draw(&mut visual);
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_some());
|
||||
assert!(changes.borrow().is_empty());
|
||||
|
||||
let png = visual
|
||||
.debug_bounds("submenu-item-0")
|
||||
.unwrap_or_else(|| panic!("the PNG row should have been laid out"));
|
||||
click(
|
||||
&mut visual,
|
||||
point(
|
||||
png.origin.x + png.size.width / 2.,
|
||||
png.origin.y + png.size.height / 2.,
|
||||
),
|
||||
);
|
||||
draw(&mut visual);
|
||||
assert_eq!(*activated.borrow(), vec!["png"]);
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_inside_the_flyout_padding_does_not_dismiss_the_menu() {
|
||||
let (_cx, mut visual, changes, _activated) = submenu_window(true);
|
||||
|
||||
let row = visual.debug_bounds("menu-item-1").unwrap();
|
||||
click(
|
||||
&mut visual,
|
||||
point(
|
||||
row.origin.x + row.size.width / 2.,
|
||||
row.origin.y + row.size.height / 2.,
|
||||
),
|
||||
);
|
||||
draw(&mut visual);
|
||||
|
||||
// The flyout's own padding belongs to the flyout, not to the dismissal
|
||||
// listener of the panel behind it.
|
||||
let panel = visual.debug_bounds("submenu-panel").unwrap();
|
||||
click(
|
||||
&mut visual,
|
||||
point(
|
||||
panel.origin.x + px(4.),
|
||||
panel.size.height + panel.origin.y - px(2.),
|
||||
),
|
||||
);
|
||||
draw(&mut visual);
|
||||
assert!(changes.borrow().is_empty(), "the flyout stays open");
|
||||
assert!(visual.debug_bounds("submenu-item-0").is_some());
|
||||
|
||||
// A click outside everything still closes the whole menu.
|
||||
click(&mut visual, point(px(600.), px(600.)));
|
||||
assert_eq!(*changes.borrow(), vec![false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_a_submenu_row_opens_its_flyout_and_an_action_row_closes_it() {
|
||||
let (_cx, mut visual, changes, _activated) = submenu_window(true);
|
||||
|
||||
let export = visual.debug_bounds("menu-item-1").unwrap();
|
||||
hover(&mut visual, center(export));
|
||||
draw(&mut visual);
|
||||
assert!(
|
||||
visual.debug_bounds("submenu-item-0").is_some(),
|
||||
"hover opens the flyout"
|
||||
);
|
||||
assert!(changes.borrow().is_empty(), "the menu stays open");
|
||||
|
||||
let cut = visual.debug_bounds("menu-item-0").unwrap();
|
||||
hover(&mut visual, center(cut));
|
||||
draw(&mut visual);
|
||||
assert!(
|
||||
visual.debug_bounds("submenu-item-0").is_none(),
|
||||
"hovering an action row closes the flyout"
|
||||
);
|
||||
assert!(changes.borrow().is_empty(), "the menu stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flyout_overlaps_the_parent_from_the_rows_right_edge() {
|
||||
let (_cx, mut visual, _changes, _activated) = submenu_window(true);
|
||||
|
||||
let export = visual.debug_bounds("menu-item-1").unwrap();
|
||||
hover(&mut visual, center(export));
|
||||
draw(&mut visual);
|
||||
|
||||
let parent = visual.debug_bounds("menu-panel").unwrap();
|
||||
let flyout = visual.debug_bounds("submenu-panel").unwrap();
|
||||
let parent_right = parent.origin.x + parent.size.width;
|
||||
let row_right = export.origin.x + export.size.width;
|
||||
// macOS style: the flyout covers the parent's trailing padding, starting
|
||||
// at the row's right edge.
|
||||
assert!(
|
||||
flyout.origin.x < parent_right - px(1.),
|
||||
"the flyout starts at {:?}, inside the parent edge at {:?}",
|
||||
flyout.origin.x,
|
||||
parent_right
|
||||
);
|
||||
assert!(
|
||||
flyout.origin.x >= row_right - px(1.),
|
||||
"the flyout starts at {:?}, at or after the row's right edge {:?}",
|
||||
flyout.origin.x,
|
||||
row_right
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_min_width_overrides_the_default_panel_width() {
|
||||
let (_cx, mut visual, _window, _changes, _activated) = menu_window(true);
|
||||
let default_width = visual.debug_bounds("menu-panel").unwrap().size.width;
|
||||
|
||||
let (_cx, mut visual, _window, _changes, _activated) =
|
||||
menu_window_with(true, Some(px(320.).into()));
|
||||
let width = visual.debug_bounds("menu-panel").unwrap().size.width;
|
||||
// Painted bounds are snapped to device pixels; allow half a pixel.
|
||||
assert!(
|
||||
(width - px(320.)).abs() <= px(0.5),
|
||||
"the panel is {width:?} wide, expected the 320px minimum"
|
||||
);
|
||||
assert!(
|
||||
width > default_width,
|
||||
"the default panel is {:?} wide and should be narrower",
|
||||
default_width
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{cell::RefCell, rc::Rc};
|
||||
use gpui::{
|
||||
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _,
|
||||
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
|
||||
Window, WindowHandle, div, point, px,
|
||||
Window, WindowHandle, div, point, px, rgba,
|
||||
};
|
||||
use gpui_native_core::popover::Popover;
|
||||
use gpui_native_theme::Theme;
|
||||
@@ -26,6 +26,7 @@ impl Render for Harness {
|
||||
div()
|
||||
.id("popover-target")
|
||||
.size(px(40.))
|
||||
.bg(rgba(0x000000ff))
|
||||
.on_click(move |_, _, cx| {
|
||||
*target_clicks.borrow_mut() += 1;
|
||||
cx.stop_propagation();
|
||||
@@ -47,6 +48,7 @@ impl Render for Harness {
|
||||
.debug_selector(|| "popover-trigger".to_string())
|
||||
.w(px(100.))
|
||||
.h(px(32.))
|
||||
.bg(rgba(0x000000ff))
|
||||
.into_any_element()
|
||||
})
|
||||
.content(|_, _| {
|
||||
|
||||
@@ -3,7 +3,7 @@ use gpui::{
|
||||
VisualTestContext, Window, div, px,
|
||||
};
|
||||
use gpui_native_core::separator::{Separator, SeparatorOrientation};
|
||||
use gpui_native_theme::{BORDER_WIDTH, Theme};
|
||||
use gpui_native_theme::Theme;
|
||||
|
||||
struct Harness {
|
||||
orientation: SeparatorOrientation,
|
||||
@@ -47,7 +47,7 @@ fn the_horizontal_rule_spans_its_container_at_hairline_height() {
|
||||
.debug_bounds("test-separator")
|
||||
.expect("horizontal separator rendered");
|
||||
assert_eq!(bounds.size.width, px(200.));
|
||||
assert_eq!(bounds.size.height, BORDER_WIDTH);
|
||||
assert_eq!(bounds.size.height, px(1.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -56,6 +56,6 @@ fn the_vertical_rule_spans_its_container_at_hairline_width() {
|
||||
let bounds = visual
|
||||
.debug_bounds("test-separator")
|
||||
.expect("vertical separator rendered");
|
||||
assert_eq!(bounds.size.width, BORDER_WIDTH);
|
||||
assert_eq!(bounds.size.width, px(1.));
|
||||
assert_eq!(bounds.size.height, px(50.));
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
#![cfg(feature = "speech")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{App, AppContext as _, Entity, SharedString, Subscription, TestAppContext};
|
||||
use gpui_native_core::speech::{
|
||||
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechEvent,
|
||||
SpeechRecognizer, SpeechSink, SpeechState, SpeechStatus,
|
||||
};
|
||||
|
||||
type EventLog = Rc<RefCell<Vec<SpeechEvent>>>;
|
||||
type Harness = (
|
||||
Entity<SpeechState>,
|
||||
EventLog,
|
||||
Rc<RefCell<Option<SpeechSink>>>,
|
||||
Rc<RefCell<usize>>,
|
||||
);
|
||||
|
||||
struct FakeRecognizer {
|
||||
available: bool,
|
||||
finish_reports: bool,
|
||||
sink: Rc<RefCell<Option<SpeechSink>>>,
|
||||
received: Rc<RefCell<usize>>,
|
||||
}
|
||||
|
||||
impl SpeechRecognizer for FakeRecognizer {
|
||||
fn is_available(&self, _cx: &App) -> bool {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
sink: SpeechSink,
|
||||
cx: &mut App,
|
||||
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
|
||||
*self.sink.borrow_mut() = Some(sink.clone());
|
||||
sink.ready(cx);
|
||||
Ok(Box::new(FakeSession {
|
||||
sink,
|
||||
finish_reports: self.finish_reports,
|
||||
received: self.received.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSession {
|
||||
sink: SpeechSink,
|
||||
finish_reports: bool,
|
||||
received: Rc<RefCell<usize>>,
|
||||
}
|
||||
|
||||
impl RecognitionSession for FakeSession {
|
||||
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
|
||||
*self.received.borrow_mut() += samples.len();
|
||||
}
|
||||
|
||||
fn finish(&mut self, cx: &mut App) {
|
||||
if self.finish_reports {
|
||||
self.sink.finish(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers one silent second, 16 kHz mono, in 100 ms chunks.
|
||||
struct FakeInput;
|
||||
|
||||
impl AudioInput for FakeInput {
|
||||
fn start(
|
||||
&self,
|
||||
format: AudioFormat,
|
||||
sink: AudioSink,
|
||||
cx: &mut App,
|
||||
) -> Result<Subscription, SpeechError> {
|
||||
let chunk_len = (format.sample_rate() / 10) as usize * format.channels() as usize;
|
||||
let task = cx.spawn(async move |cx| {
|
||||
for _ in 0..10 {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(100))
|
||||
.await;
|
||||
cx.update(|cx| sink.push(vec![0; chunk_len], cx));
|
||||
}
|
||||
});
|
||||
Ok(Subscription::new(move || drop(task)))
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(cx: &mut TestAppContext, available: bool, finish_reports: bool) -> Harness {
|
||||
let sink_slot: Rc<RefCell<Option<SpeechSink>>> = Rc::new(RefCell::new(None));
|
||||
let received = Rc::new(RefCell::new(0));
|
||||
let log: EventLog = Rc::new(RefCell::new(Vec::new()));
|
||||
let record = log.clone();
|
||||
let speech = cx.update(|cx| {
|
||||
let speech = cx.new(|cx| {
|
||||
SpeechState::new(cx)
|
||||
.recognizer(FakeRecognizer {
|
||||
available,
|
||||
finish_reports,
|
||||
sink: sink_slot.clone(),
|
||||
received: received.clone(),
|
||||
})
|
||||
.input(FakeInput)
|
||||
.stop_timeout(Duration::from_secs(2))
|
||||
});
|
||||
cx.subscribe(&speech, move |_, event, _| {
|
||||
record.borrow_mut().push(event.clone())
|
||||
})
|
||||
.detach();
|
||||
speech
|
||||
});
|
||||
(speech, log, sink_slot, received)
|
||||
}
|
||||
|
||||
fn start(speech: &Entity<SpeechState>, cx: &mut TestAppContext) {
|
||||
cx.update(|cx| speech.update(cx, |state, cx| state.start(cx)));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_starts_reads_and_finalizes() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (speech, events, sink_slot, _) = harness(&mut cx, true, true);
|
||||
|
||||
start(&speech, &mut cx);
|
||||
cx.update(|cx| assert_eq!(speech.read(cx).status(), SpeechStatus::Recording));
|
||||
assert_eq!(*events.borrow(), vec![SpeechEvent::Started]);
|
||||
|
||||
let sink = sink_slot.borrow().clone().expect("session opened");
|
||||
cx.update(|cx| sink.hypothesis("hel", cx));
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| {
|
||||
sink.phrase("hello ", cx);
|
||||
sink.hypothesis("wor", cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| {
|
||||
assert_eq!(
|
||||
speech.read(cx).transcript(),
|
||||
SharedString::from("hello wor")
|
||||
);
|
||||
});
|
||||
|
||||
cx.update(|cx| speech.update(cx, |state, cx| state.stop(cx)));
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| assert_eq!(speech.read(cx).status(), SpeechStatus::Idle));
|
||||
|
||||
let events = events.borrow();
|
||||
assert!(
|
||||
matches!(events.last(), Some(SpeechEvent::Final(text)) if text.as_ref() == "hello wor")
|
||||
);
|
||||
assert!(matches!(
|
||||
events.get(events.len() - 2),
|
||||
Some(SpeechEvent::Partial(text)) if text.as_ref() == "hello wor"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_twice_runs_a_single_session() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (speech, events, _, _) = harness(&mut cx, true, true);
|
||||
|
||||
start(&speech, &mut cx);
|
||||
start(&speech, &mut cx);
|
||||
|
||||
assert_eq!(*events.borrow(), vec![SpeechEvent::Started]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_discards_the_transcript_and_ignores_late_reports() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (speech, events, sink_slot, _) = harness(&mut cx, true, true);
|
||||
|
||||
start(&speech, &mut cx);
|
||||
let sink = sink_slot.borrow().clone().expect("session opened");
|
||||
cx.update(|cx| sink.phrase("hello", cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update(|cx| speech.update(cx, |state, cx| state.cancel(cx)));
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| {
|
||||
let state = speech.read(cx);
|
||||
assert_eq!(state.status(), SpeechStatus::Idle);
|
||||
assert_eq!(state.transcript(), SharedString::from(""));
|
||||
});
|
||||
assert!(matches!(
|
||||
events.borrow().last(),
|
||||
Some(SpeechEvent::Cancelled)
|
||||
));
|
||||
|
||||
cx.update(|cx| sink.finish(cx));
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
!events
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|event| matches!(event, SpeechEvent::Final(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_without_a_recognizer_fails() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let log: EventLog = Rc::new(RefCell::new(Vec::new()));
|
||||
let record = log.clone();
|
||||
let speech = cx.update(|cx| {
|
||||
let speech = cx.new(|cx| SpeechState::new(cx).input(FakeInput));
|
||||
cx.subscribe(&speech, move |_, event, _| {
|
||||
record.borrow_mut().push(event.clone())
|
||||
})
|
||||
.detach();
|
||||
speech
|
||||
});
|
||||
|
||||
cx.update(|cx| assert!(!speech.read(cx).has_recognizer()));
|
||||
start(&speech, &mut cx);
|
||||
|
||||
cx.update(|cx| assert_eq!(speech.read(cx).status(), SpeechStatus::Idle));
|
||||
assert!(matches!(events_last(&log), Some(SpeechEvent::Error(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_an_unavailable_recognizer_fails() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (speech, events, _, _) = harness(&mut cx, false, true);
|
||||
|
||||
start(&speech, &mut cx);
|
||||
cx.update(|cx| assert_eq!(speech.read(cx).status(), SpeechStatus::Idle));
|
||||
assert!(matches!(
|
||||
events.borrow().last(),
|
||||
Some(SpeechEvent::Error(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stop_timeout_finalizes_with_the_transcript_so_far() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (speech, events, sink_slot, _) = harness(&mut cx, true, false);
|
||||
|
||||
start(&speech, &mut cx);
|
||||
let sink = sink_slot.borrow().clone().expect("session opened");
|
||||
cx.update(|cx| sink.phrase("hello", cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update(|cx| speech.update(cx, |state, cx| state.stop(cx)));
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| assert_eq!(speech.read(cx).status(), SpeechStatus::Stopping));
|
||||
|
||||
cx.executor().advance_clock(Duration::from_secs(2));
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| assert_eq!(speech.read(cx).status(), SpeechStatus::Idle));
|
||||
assert!(
|
||||
matches!(events.borrow().last(), Some(SpeechEvent::Final(text)) if text.as_ref() == "hello")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_audio_reaches_the_session_and_the_levels() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (speech, _, _, received) = harness(&mut cx, true, true);
|
||||
|
||||
start(&speech, &mut cx);
|
||||
cx.executor().advance_clock(Duration::from_secs(1));
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update(|cx| {
|
||||
let state = speech.read(cx);
|
||||
assert_eq!(*received.borrow(), 16_000);
|
||||
// One level per 80 ms bucket of the one-second capture.
|
||||
let levels = state.levels();
|
||||
assert_eq!(levels.len(), 12);
|
||||
assert!(levels.iter().all(|level| *level == 0.0));
|
||||
});
|
||||
|
||||
cx.update(|cx| speech.update(cx, |state, cx| state.stop(cx)));
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| assert!(speech.read(cx).levels().is_empty()));
|
||||
}
|
||||
|
||||
fn events_last(log: &EventLog) -> Option<SpeechEvent> {
|
||||
log.borrow().last().cloned()
|
||||
}
|
||||
@@ -155,4 +155,8 @@ fn dismissal_does_not_run_the_action() {
|
||||
*dismissed.borrow(),
|
||||
"on_dismiss still reports the dismissal"
|
||||
);
|
||||
assert!(
|
||||
*dismissed.borrow(),
|
||||
"on_dismiss still reports the dismissal"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,10 @@ fn hovering_the_target_deploys_the_tooltip_after_the_delay() {
|
||||
calls.borrow().is_empty(),
|
||||
"builder runs only after the delay"
|
||||
);
|
||||
assert!(
|
||||
calls.borrow().is_empty(),
|
||||
"builder runs only after the delay"
|
||||
);
|
||||
|
||||
cx.executor().advance_clock(SHOW_DELAY);
|
||||
visual.run_until_parked();
|
||||
|
||||
+24
-29
@@ -6,7 +6,7 @@ use gpui::{
|
||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_icons::Icon;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
||||
|
||||
@@ -15,24 +15,20 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
|
||||
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<ListItem>>;
|
||||
|
||||
/// One row of a [`List`].
|
||||
#[must_use]
|
||||
pub struct ListItem {
|
||||
key: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
detail: Option<SharedString>,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl ListItem {
|
||||
/// Creates a row shown as `label`, identified by `key`.
|
||||
pub fn new<K, L>(key: K, label: L) -> Self
|
||||
/// Creates a row with this label.
|
||||
pub fn new<L>(label: L) -> Self
|
||||
where
|
||||
K: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
detail: None,
|
||||
@@ -40,12 +36,9 @@ impl ListItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an icon leading the label, a role or any custom [`gpui_native_icons::IconNamed`].
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
/// Shows an icon leading the label.
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -67,7 +60,6 @@ impl ListItem {
|
||||
|
||||
/// A single-selection list.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct List {
|
||||
id: ElementId,
|
||||
selected: Option<usize>,
|
||||
@@ -106,7 +98,7 @@ impl List {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the rows, the closure runs during render and must not mutate entities or notify.
|
||||
/// Sets the rows, built fresh each render.
|
||||
pub fn items<F>(mut self, items: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> Vec<ListItem> + 'static,
|
||||
@@ -151,10 +143,11 @@ impl RenderOnce for List {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
item.render_row(
|
||||
render_row(
|
||||
cx,
|
||||
index,
|
||||
items.len(),
|
||||
item,
|
||||
self.selected == Some(index),
|
||||
&self.on_change,
|
||||
&focus,
|
||||
@@ -191,22 +184,21 @@ impl RenderOnce for List {
|
||||
}
|
||||
}
|
||||
|
||||
impl ListItem {
|
||||
fn render_row(
|
||||
&self,
|
||||
fn render_row(
|
||||
cx: &App,
|
||||
index: usize,
|
||||
count: usize,
|
||||
item: &ListItem,
|
||||
selected: bool,
|
||||
on_change: &Option<ChangeHandler>,
|
||||
focus: &FocusHandle,
|
||||
) -> AnyElement {
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let label_color = selectable_label_color(cx, selected, self.disabled);
|
||||
let disabled = self.disabled;
|
||||
let label_color = selectable_label_color(cx, selected, item.disabled);
|
||||
let disabled = item.disabled;
|
||||
|
||||
div()
|
||||
.id((self.key.clone(), "list-item"))
|
||||
.id(("list-item", index))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -230,16 +222,20 @@ impl ListItem {
|
||||
}
|
||||
})
|
||||
})
|
||||
.when_some(self.icon.clone(), |row, icon| {
|
||||
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
|
||||
.when_some(item.icon, |row, role| {
|
||||
row.child(
|
||||
icon(role)
|
||||
.size(theme.sizes.medium.icon_size)
|
||||
.color(label_color),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_color(label_color)
|
||||
.child(self.label.clone()),
|
||||
.child(item.label.clone()),
|
||||
)
|
||||
.when_some(self.detail.clone(), |row, detail| {
|
||||
.when_some(item.detail.clone(), |row, detail| {
|
||||
row.child(
|
||||
div()
|
||||
.flex_none()
|
||||
@@ -253,5 +249,4 @@ impl ListItem {
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
+54
-74
@@ -10,6 +10,7 @@ use gpui::{
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_core::scroll_area::ScrollArea;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
||||
|
||||
@@ -18,17 +19,15 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
|
||||
type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>;
|
||||
|
||||
/// One entry of a [`Sidebar`], a section header or a selectable row.
|
||||
#[must_use]
|
||||
#[derive(Clone)]
|
||||
pub enum SidebarEntry {
|
||||
/// A non-selectable section label.
|
||||
Header { label: SharedString },
|
||||
/// A selectable row.
|
||||
Row {
|
||||
key: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<IconRole>,
|
||||
detail: Option<SharedString>,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
disabled: bool,
|
||||
},
|
||||
}
|
||||
@@ -44,22 +43,27 @@ impl SidebarEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a selectable row shown as `label`, identified by `key`.
|
||||
pub fn row<K, L>(key: K, label: L) -> Self
|
||||
/// Creates a selectable row.
|
||||
pub fn row<L>(label: L) -> Self
|
||||
where
|
||||
K: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self::Row {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
detail: None,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an icon leading the label.
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
if let Self::Row { icon, .. } = &mut self {
|
||||
*icon = Some(role);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows muted trailing text.
|
||||
pub fn detail<D>(mut self, detail: D) -> Self
|
||||
where
|
||||
@@ -74,28 +78,6 @@ impl SidebarEntry {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows an element leading the row.
|
||||
pub fn prefix<E>(mut self, prefix: E) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
if let Self::Row { prefix: slot, .. } = &mut self {
|
||||
*slot = Some(prefix.into_any_element());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows an element trailing the row.
|
||||
pub fn suffix<E>(mut self, suffix: E) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
if let Self::Row { suffix: slot, .. } = &mut self {
|
||||
*slot = Some(suffix.into_any_element());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Renders the row dimmed; clicks and keyboard skip it.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
if let Self::Row {
|
||||
@@ -111,7 +93,6 @@ impl SidebarEntry {
|
||||
|
||||
/// A sidebar.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Sidebar {
|
||||
id: ElementId,
|
||||
selected: Option<usize>,
|
||||
@@ -163,7 +144,7 @@ impl Sidebar {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the entries, the closure runs during render and must not mutate entities or notify.
|
||||
/// Sets the entries, built fresh each render.
|
||||
pub fn entries<F>(mut self, entries: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> Vec<SidebarEntry> + 'static,
|
||||
@@ -223,23 +204,21 @@ impl RenderOnce for Sidebar {
|
||||
|
||||
let mut row_position = 0usize;
|
||||
let children: Vec<AnyElement> = entries
|
||||
.into_iter()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| {
|
||||
let is_row = matches!(entry, SidebarEntry::Row { .. });
|
||||
|
||||
if is_row {
|
||||
row_position += 1;
|
||||
}
|
||||
|
||||
let (position_in_set, size_of_set) = if is_row {
|
||||
(row_position, row_count)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
entry.render_entry(
|
||||
render_entry(
|
||||
cx,
|
||||
entry,
|
||||
index,
|
||||
position_in_set,
|
||||
size_of_set,
|
||||
@@ -289,11 +268,10 @@ impl RenderOnce for Sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
impl SidebarEntry {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_entry(
|
||||
self,
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_entry(
|
||||
cx: &App,
|
||||
entry: &SidebarEntry,
|
||||
index: usize,
|
||||
position_in_set: usize,
|
||||
size_of_set: usize,
|
||||
@@ -301,36 +279,29 @@ impl SidebarEntry {
|
||||
material: bool,
|
||||
on_change: &Option<ChangeHandler>,
|
||||
focus: &FocusHandle,
|
||||
) -> AnyElement {
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
|
||||
match self {
|
||||
SidebarEntry::Header { label } => {
|
||||
// The id is required for the role to surface in the a11y tree.
|
||||
div()
|
||||
.id(("sidebar-header".into(), label.clone()))
|
||||
match entry {
|
||||
SidebarEntry::Header { label } => div()
|
||||
.px_2()
|
||||
.pt_2()
|
||||
.pb_1()
|
||||
.text_sm()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(theme.colors.muted_foreground)
|
||||
.role(Role::Heading)
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
}
|
||||
.child(label.clone())
|
||||
.into_any_element(),
|
||||
SidebarEntry::Row {
|
||||
key,
|
||||
label,
|
||||
icon: row_icon,
|
||||
detail,
|
||||
prefix,
|
||||
suffix,
|
||||
disabled: row_disabled,
|
||||
} => {
|
||||
let label_color = selectable_label_color(cx, selected, row_disabled);
|
||||
let label_color = selectable_label_color(cx, selected, *row_disabled);
|
||||
|
||||
div()
|
||||
.id((key.clone(), "sidebar-row"))
|
||||
let mut row = div()
|
||||
.id(("sidebar-row", index))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -342,18 +313,19 @@ impl SidebarEntry {
|
||||
.aria_position_in_set(position_in_set)
|
||||
.aria_size_of_set(size_of_set)
|
||||
.map(|row| {
|
||||
if material && !row_disabled {
|
||||
if material && !*row_disabled {
|
||||
if selected {
|
||||
row.bg(theme.material.selection)
|
||||
} else {
|
||||
row.hover(|row| row.bg(theme.material.hover))
|
||||
}
|
||||
} else {
|
||||
paint_selectable_row(cx, row, selected, row_disabled)
|
||||
paint_selectable_row(cx, row, selected, *row_disabled)
|
||||
}
|
||||
})
|
||||
.when(!row_disabled, |row| {
|
||||
row.cursor_pointer().on_click({
|
||||
});
|
||||
|
||||
if !*row_disabled {
|
||||
row = row.cursor_pointer().on_click({
|
||||
let on_change = on_change.clone();
|
||||
let focus = focus.clone();
|
||||
move |_, window, cx| {
|
||||
@@ -362,12 +334,21 @@ impl SidebarEntry {
|
||||
on_change(Some(index), window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.when_some(prefix, |row, prefix| row.child(prefix))
|
||||
.child(div().flex_1().text_color(label_color).child(label))
|
||||
.when_some(detail, |row, detail| {
|
||||
});
|
||||
}
|
||||
|
||||
row = row.when_some(*row_icon, |row, role| {
|
||||
row.child(
|
||||
icon(role)
|
||||
.size(theme.sizes.medium.icon_size)
|
||||
.color(label_color),
|
||||
)
|
||||
});
|
||||
|
||||
row = row.child(div().flex_1().text_color(label_color).child(label.clone()));
|
||||
|
||||
if let Some(detail) = detail {
|
||||
row = row.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_sm()
|
||||
@@ -376,12 +357,11 @@ impl SidebarEntry {
|
||||
} else {
|
||||
theme.colors.muted_foreground
|
||||
})
|
||||
.child(detail),
|
||||
)
|
||||
})
|
||||
.when_some(suffix, |row, suffix| row.child(suffix))
|
||||
.into_any_element()
|
||||
.child(detail.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
row.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+89
-62
@@ -1,6 +1,6 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
|
||||
ParentElement as _, RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _,
|
||||
@@ -10,7 +10,7 @@ use gpui_native_core::button::{Button, ButtonVariant};
|
||||
use gpui_native_core::common::Sizable as _;
|
||||
use gpui_native_core::spinner::Spinner;
|
||||
use gpui_native_core::tooltip::Tooltip;
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_utils::adjacent_selectable;
|
||||
|
||||
@@ -31,11 +31,11 @@ pub enum TabsVariant {
|
||||
}
|
||||
|
||||
/// One tab of a [`Tabs`] strip.
|
||||
#[must_use]
|
||||
pub struct Tab {
|
||||
/// The stable identity for element state; must survive reordering.
|
||||
key: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<IconRole>,
|
||||
lead: Option<AnyElement>,
|
||||
busy: bool,
|
||||
disabled: bool,
|
||||
@@ -53,6 +53,7 @@ impl Tab {
|
||||
Self {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
lead: None,
|
||||
busy: false,
|
||||
disabled: false,
|
||||
@@ -61,7 +62,13 @@ impl Tab {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the leading element, e.g. an icon or an avatar.
|
||||
/// Shows an icon leading the label.
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the leading element.
|
||||
pub fn lead<E>(mut self, lead: E) -> Self
|
||||
where
|
||||
E: IntoElement + 'static,
|
||||
@@ -98,12 +105,8 @@ impl Tab {
|
||||
}
|
||||
}
|
||||
|
||||
// Tab is a data builder, not an element, adopt the combinators explicitly.
|
||||
impl FluentBuilder for Tab {}
|
||||
|
||||
/// A tab strip.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Tabs {
|
||||
id: ElementId,
|
||||
selected: usize,
|
||||
@@ -131,7 +134,7 @@ impl Tabs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current selection, as an index into the `tabs` vec — not a tab key.
|
||||
/// Sets the current selection.
|
||||
pub fn selected(mut self, selected: usize) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
@@ -143,7 +146,7 @@ impl Tabs {
|
||||
self
|
||||
}
|
||||
|
||||
/// Reports the next selected tab, as an index into the `tabs` vec — not a tab key; indices shift on reorder.
|
||||
/// Reports the next selected tab, from clicks and keys alike.
|
||||
pub fn on_change<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(usize, &mut Window, &mut App) + 'static,
|
||||
@@ -152,7 +155,7 @@ impl Tabs {
|
||||
self
|
||||
}
|
||||
|
||||
/// Reports a closed tab, as an index into the `tabs` vec — not a tab key; indices shift on reorder.
|
||||
/// Reports a closed tab by index.
|
||||
pub fn on_close<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(usize, &mut Window, &mut App) + 'static,
|
||||
@@ -161,7 +164,7 @@ impl Tabs {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the tabs; the closure runs during render and must not mutate entities or notify.
|
||||
/// Sets the tabs, built fresh each render.
|
||||
pub fn tabs<F>(mut self, tabs: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> Vec<Tab> + 'static,
|
||||
@@ -206,9 +209,10 @@ impl RenderOnce for Tabs {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, tab)| {
|
||||
tab.render_tab(
|
||||
render_tab(
|
||||
&self.id,
|
||||
cx,
|
||||
tab,
|
||||
index,
|
||||
self.selected,
|
||||
self.variant,
|
||||
@@ -219,7 +223,7 @@ impl RenderOnce for Tabs {
|
||||
})
|
||||
.collect();
|
||||
|
||||
div()
|
||||
let mut strip_root = div()
|
||||
.id(self.id.clone())
|
||||
.flex()
|
||||
.text_size(theme.sizes.medium.text_size)
|
||||
@@ -247,30 +251,33 @@ impl RenderOnce for Tabs {
|
||||
}
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.map(|root| match self.variant {
|
||||
TabsVariant::Underline => {
|
||||
root.gap_1().border_b_1().border_color(theme.colors.border)
|
||||
}
|
||||
TabsVariant::Pill => root.items_center().gap_1(),
|
||||
})
|
||||
.children(strip)
|
||||
});
|
||||
|
||||
strip_root = match self.variant {
|
||||
TabsVariant::Underline => strip_root
|
||||
.gap_1()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border),
|
||||
TabsVariant::Pill => strip_root.items_center().gap_1(),
|
||||
};
|
||||
|
||||
strip_root.children(strip)
|
||||
}
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
mut self,
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
tabs_id: &ElementId,
|
||||
cx: &App,
|
||||
tab: Tab,
|
||||
index: usize,
|
||||
selected_index: usize,
|
||||
variant: TabsVariant,
|
||||
on_change: &Option<ChangeHandler>,
|
||||
on_close: &Option<CloseHandler>,
|
||||
focus: &gpui::FocusHandle,
|
||||
) -> AnyElement {
|
||||
) -> AnyElement {
|
||||
let mut tab = tab;
|
||||
let selected = index == selected_index;
|
||||
|
||||
let theme = cx.theme();
|
||||
@@ -279,7 +286,7 @@ impl Tab {
|
||||
|
||||
let label_color = match variant {
|
||||
TabsVariant::Underline => {
|
||||
if self.disabled {
|
||||
if tab.disabled {
|
||||
theme.colors.muted_foreground
|
||||
} else if selected {
|
||||
theme.ramps.primary.base
|
||||
@@ -288,7 +295,7 @@ impl Tab {
|
||||
}
|
||||
}
|
||||
TabsVariant::Pill => {
|
||||
if self.disabled {
|
||||
if tab.disabled {
|
||||
theme.colors.muted_foreground
|
||||
} else {
|
||||
theme.colors.foreground
|
||||
@@ -296,14 +303,11 @@ impl Tab {
|
||||
}
|
||||
};
|
||||
|
||||
let close = (self.closeable && on_close.is_some() && !self.disabled)
|
||||
.then(|| self.render_tab_close(tabs_id, index, on_close));
|
||||
|
||||
let strip_tab = div()
|
||||
.id((self.key.clone(), "tab"))
|
||||
let mut strip_tab = div()
|
||||
.id((tab.key.clone(), "tab"))
|
||||
.debug_selector({
|
||||
let tabs_id = tabs_id.clone();
|
||||
let label = self.label.clone();
|
||||
let label = tab.label.clone();
|
||||
move || format!("{tabs_id}-tab-{}", label)
|
||||
})
|
||||
.flex()
|
||||
@@ -312,10 +316,11 @@ impl Tab {
|
||||
.role(Role::Tab)
|
||||
.aria_selected(selected)
|
||||
.text_color(label_color)
|
||||
.when(!self.disabled && !selected, |strip_tab| {
|
||||
.when(!tab.disabled && !selected, |strip_tab| {
|
||||
strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted))
|
||||
})
|
||||
.map(|strip_tab| match variant {
|
||||
});
|
||||
|
||||
strip_tab = match variant {
|
||||
TabsVariant::Underline => strip_tab
|
||||
.px_2()
|
||||
.py_1()
|
||||
@@ -336,22 +341,45 @@ impl Tab {
|
||||
.bg(theme.colors.muted)
|
||||
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
|
||||
}),
|
||||
})
|
||||
.when_some(self.lead.take(), |strip_tab, lead| strip_tab.child(lead))
|
||||
.child(self.label.clone())
|
||||
.when(variant == TabsVariant::Pill, |strip_tab| {
|
||||
strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5))
|
||||
})
|
||||
.when(self.busy && !self.disabled, |strip_tab| {
|
||||
strip_tab.child(
|
||||
Spinner::new((self.key.clone(), "tab-busy"))
|
||||
};
|
||||
|
||||
let lead = tab.lead.take();
|
||||
|
||||
strip_tab = match lead {
|
||||
Some(lead) => strip_tab.child(lead),
|
||||
None => match variant {
|
||||
TabsVariant::Underline => strip_tab.child(div().flex_none().pb_0p5().when_some(
|
||||
tab.icon,
|
||||
|slot, role| {
|
||||
slot.child(icon(role).size(metrics.dense_icon_size).color(label_color))
|
||||
},
|
||||
)),
|
||||
TabsVariant::Pill => strip_tab.when_some(tab.icon, |strip_tab, role| {
|
||||
strip_tab.child(icon(role).size(metrics.dense_icon_size).color(label_color))
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
strip_tab = strip_tab.child(tab.label.clone());
|
||||
|
||||
if variant == TabsVariant::Pill {
|
||||
strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5));
|
||||
}
|
||||
|
||||
if tab.busy && !tab.disabled {
|
||||
strip_tab = strip_tab.child(
|
||||
Spinner::new((tab.key.clone(), "tab-busy"))
|
||||
.small()
|
||||
.accessibility_label("Loading"),
|
||||
)
|
||||
})
|
||||
.when_some(close, |strip_tab, close| strip_tab.child(close))
|
||||
.when(!self.disabled, |strip_tab| {
|
||||
strip_tab.cursor_pointer().on_click({
|
||||
);
|
||||
}
|
||||
|
||||
if tab.closeable && on_close.is_some() && !tab.disabled {
|
||||
strip_tab = strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close));
|
||||
}
|
||||
|
||||
if !tab.disabled {
|
||||
strip_tab = strip_tab.cursor_pointer().on_click({
|
||||
let on_change = on_change.clone();
|
||||
let focus = focus.clone();
|
||||
move |_, window, cx| {
|
||||
@@ -362,33 +390,33 @@ impl Tab {
|
||||
on_change(index, window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
match self.wrap {
|
||||
match tab.wrap {
|
||||
Some(wrap) => wrap(strip_tab),
|
||||
None => strip_tab.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tab_close(
|
||||
&self,
|
||||
fn render_tab_close(
|
||||
tabs_id: &ElementId,
|
||||
tab: &Tab,
|
||||
index: usize,
|
||||
on_close: &Option<CloseHandler>,
|
||||
) -> AnyElement {
|
||||
Button::new((self.key.clone(), "tab-close"))
|
||||
) -> AnyElement {
|
||||
Button::new((tab.key.clone(), "tab-close"))
|
||||
.icon(IconRole::Close)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.small()
|
||||
.tab_stop(false)
|
||||
.debug_selector({
|
||||
let tabs_id = tabs_id.clone();
|
||||
let label = self.label.clone();
|
||||
let label = tab.label.clone();
|
||||
move || format!("{tabs_id}-close-{}", label)
|
||||
})
|
||||
.tooltip(Tooltip::text("Close"))
|
||||
.accessibility_label(SharedString::from(format!("Close {}", self.label)))
|
||||
.accessibility_label(SharedString::from(format!("Close {}", tab.label)))
|
||||
.on_click({
|
||||
let on_close = on_close.clone();
|
||||
move |_, window, cx| {
|
||||
@@ -399,5 +427,4 @@ impl Tab {
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
+79
-105
@@ -6,7 +6,7 @@ use gpui::{
|
||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_icons::{Icon, IconRole, icon};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
||||
|
||||
@@ -18,11 +18,9 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<TreeItem>>;
|
||||
|
||||
/// Tree item.
|
||||
#[derive(Clone)]
|
||||
#[must_use]
|
||||
pub struct TreeItem {
|
||||
key: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
depth: usize,
|
||||
expandable: bool,
|
||||
expanded: bool,
|
||||
@@ -30,14 +28,12 @@ pub struct TreeItem {
|
||||
}
|
||||
|
||||
impl TreeItem {
|
||||
/// Creates a leaf row shown as `label` at the root depth, identified by `key`.
|
||||
pub fn new<K, L>(key: K, label: L) -> Self
|
||||
/// Creates a leaf row with this label at the root depth.
|
||||
pub fn new<L>(label: L) -> Self
|
||||
where
|
||||
K: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
depth: 0,
|
||||
@@ -48,11 +44,8 @@ impl TreeItem {
|
||||
}
|
||||
|
||||
/// Shows an icon leading the label.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -83,7 +76,6 @@ impl TreeItem {
|
||||
|
||||
/// A tree.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Tree {
|
||||
id: ElementId,
|
||||
selected: Option<usize>,
|
||||
@@ -133,7 +125,7 @@ impl Tree {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the visible rows; the closure runs during render and must not mutate entities or notify.
|
||||
/// Sets the visible rows, built fresh each render.
|
||||
pub fn items<F>(mut self, items: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> Vec<TreeItem> + 'static,
|
||||
@@ -150,45 +142,6 @@ impl Tree {
|
||||
self.a11y_label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Maps each visible row to its 1-based position-in-set and set size among its siblings.
|
||||
fn sibling_sets(items: &[TreeItem]) -> Vec<(usize, usize)> {
|
||||
let mut sets = vec![(0usize, 0usize); items.len()];
|
||||
// Open sibling runs along the ancestry path, deeper runs above shallower ones.
|
||||
let mut runs: Vec<(usize, Vec<usize>)> = Vec::new();
|
||||
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
while runs
|
||||
.last()
|
||||
.is_some_and(|(run_depth, _)| *run_depth > item.depth)
|
||||
{
|
||||
if let Some((_, pending)) = runs.pop() {
|
||||
Self::stamp_sibling_run(&mut sets, pending);
|
||||
}
|
||||
}
|
||||
|
||||
match runs.last_mut() {
|
||||
// A row at the running depth extends the open sibling run.
|
||||
Some((run_depth, pending)) if *run_depth == item.depth => pending.push(index),
|
||||
_ => runs.push((item.depth, vec![index])),
|
||||
}
|
||||
}
|
||||
|
||||
for (_, pending) in runs {
|
||||
Self::stamp_sibling_run(&mut sets, pending);
|
||||
}
|
||||
|
||||
sets
|
||||
}
|
||||
|
||||
fn stamp_sibling_run(sets: &mut [(usize, usize)], pending: Vec<usize>) {
|
||||
let size_of_set = pending.len();
|
||||
for (position, index) in pending.into_iter().enumerate() {
|
||||
if let Some(set) = sets.get_mut(index) {
|
||||
*set = (position + 1, size_of_set);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Tree {
|
||||
@@ -213,15 +166,46 @@ impl RenderOnce for Tree {
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||
|
||||
let sibling_sets = Self::sibling_sets(&items);
|
||||
let sibling_sets: Vec<(usize, usize)> = {
|
||||
let mut sets = vec![(0usize, 0usize); items.len()];
|
||||
let mut start = 0usize;
|
||||
|
||||
while start < items.len() {
|
||||
let depth = items[start].depth;
|
||||
|
||||
let mut end = start + 1;
|
||||
while end < items.len() && items[end].depth > depth {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let mut position = 0usize;
|
||||
for index in start..end {
|
||||
if items[index].depth == depth {
|
||||
position += 1;
|
||||
sets[index] = (position, 0);
|
||||
}
|
||||
}
|
||||
|
||||
for index in start..end {
|
||||
if items[index].depth == depth {
|
||||
sets[index].1 = position;
|
||||
}
|
||||
}
|
||||
|
||||
start = end;
|
||||
}
|
||||
|
||||
sets
|
||||
};
|
||||
|
||||
let rows: Vec<AnyElement> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
.map(|(index, _item)| {
|
||||
let (position_in_set, size_of_set) = sibling_sets[index];
|
||||
item.render_row(
|
||||
render_row(
|
||||
cx,
|
||||
&items[index],
|
||||
index,
|
||||
position_in_set,
|
||||
size_of_set,
|
||||
@@ -279,11 +263,10 @@ impl RenderOnce for Tree {
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeItem {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_row(
|
||||
&self,
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_row(
|
||||
cx: &App,
|
||||
item: &TreeItem,
|
||||
index: usize,
|
||||
position_in_set: usize,
|
||||
size_of_set: usize,
|
||||
@@ -291,17 +274,15 @@ impl TreeItem {
|
||||
on_change: &Option<ChangeHandler>,
|
||||
on_disclosure: &Option<DisclosureHandler>,
|
||||
focus: &FocusHandle,
|
||||
) -> AnyElement {
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let label_color = selectable_label_color(cx, selected, self.disabled);
|
||||
let disabled = self.disabled;
|
||||
// usize can exceed f32's exact integer range; bound it via u32 before casting.
|
||||
let depth = u32::try_from(self.depth).unwrap_or(u32::MAX) as f32;
|
||||
let label_color = selectable_label_color(cx, selected, item.disabled);
|
||||
let disabled = item.disabled;
|
||||
// One indent step per depth level, on the theme's horizontal padding.
|
||||
let indent = theme.sizes.medium.padding_x * (depth + 1.0);
|
||||
let indent = theme.sizes.medium.padding_x * (item.depth as f32 + 1.0);
|
||||
|
||||
div()
|
||||
.id((self.key.clone(), "tree-item"))
|
||||
.id(("tree-item", index))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -311,10 +292,10 @@ impl TreeItem {
|
||||
.rounded(theme.radius.sm)
|
||||
.role(Role::TreeItem)
|
||||
.aria_selected(selected)
|
||||
.aria_level(self.depth + 1)
|
||||
.aria_level(item.depth + 1)
|
||||
.aria_position_in_set(position_in_set)
|
||||
.aria_size_of_set(size_of_set)
|
||||
.when(self.expandable, |row| row.aria_expanded(self.expanded))
|
||||
.when(item.expandable, |row| row.aria_expanded(item.expanded))
|
||||
.map(|row| paint_selectable_row(cx, row, selected, disabled))
|
||||
.when(!disabled, |row| {
|
||||
row.cursor_pointer().on_click({
|
||||
@@ -328,31 +309,46 @@ impl TreeItem {
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(self.render_disclosure(cx, index, selected, on_disclosure))
|
||||
.when_some(self.icon.clone(), |row, icon| {
|
||||
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
|
||||
.child(render_disclosure(
|
||||
cx,
|
||||
item,
|
||||
index,
|
||||
disabled,
|
||||
selected,
|
||||
on_disclosure,
|
||||
))
|
||||
.when_some(item.icon, |row, role| {
|
||||
row.child(
|
||||
icon(role)
|
||||
.size(theme.sizes.medium.icon_size)
|
||||
.color(label_color),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_color(label_color)
|
||||
.child(self.label.clone()),
|
||||
.child(item.label.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_disclosure(
|
||||
&self,
|
||||
/// The disclosure slot.
|
||||
fn render_disclosure(
|
||||
cx: &App,
|
||||
item: &TreeItem,
|
||||
index: usize,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
on_disclosure: &Option<DisclosureHandler>,
|
||||
) -> AnyElement {
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let glyph_color = if self.disabled || !selected {
|
||||
let glyph_color = if disabled {
|
||||
theme.colors.muted_foreground
|
||||
} else {
|
||||
} else if selected {
|
||||
theme.colors.primary_foreground
|
||||
} else {
|
||||
theme.colors.muted_foreground
|
||||
};
|
||||
|
||||
let cell = div()
|
||||
@@ -362,7 +358,7 @@ impl TreeItem {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
icon(if self.expanded {
|
||||
icon(if item.expanded {
|
||||
IconRole::ChevronDown
|
||||
} else {
|
||||
IconRole::ChevronRight
|
||||
@@ -371,8 +367,8 @@ impl TreeItem {
|
||||
.color(glyph_color),
|
||||
);
|
||||
|
||||
if self.expandable && !self.disabled {
|
||||
cell.id((self.key.clone(), "tree-disclosure"))
|
||||
if item.expandable && !disabled {
|
||||
cell.id(("tree-disclosure", index))
|
||||
.cursor_pointer()
|
||||
.on_click({
|
||||
let on_disclosure = on_disclosure.clone();
|
||||
@@ -388,26 +384,4 @@ impl TreeItem {
|
||||
} else {
|
||||
cell.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(depth: usize) -> TreeItem {
|
||||
TreeItem::new("item", "Item").depth(depth)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_rows_number_one_of_three() {
|
||||
let sets = Tree::sibling_sets(&[item(0), item(0), item(0)]);
|
||||
assert_eq!(sets, vec![(1, 3), (2, 3), (3, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_rows_number_per_level() {
|
||||
let sets = Tree::sibling_sets(&[item(0), item(1), item(2), item(1), item(0)]);
|
||||
assert_eq!(sets, vec![(1, 2), (1, 2), (1, 1), (2, 2), (2, 2)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ impl Render for Harness {
|
||||
})
|
||||
.items(|_, _| {
|
||||
vec![
|
||||
ListItem::new("one", "One"),
|
||||
ListItem::new("two", "Two"),
|
||||
ListItem::new("unavailable", "Unavailable").disabled(true),
|
||||
ListItem::new("three", "Three"),
|
||||
ListItem::new("One"),
|
||||
ListItem::new("Two"),
|
||||
ListItem::new("Unavailable").disabled(true),
|
||||
ListItem::new("Three"),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -31,10 +31,10 @@ impl Render for Harness {
|
||||
.entries(|_, _| {
|
||||
vec![
|
||||
SidebarEntry::header("Favorites"), // 0: never selectable
|
||||
SidebarEntry::row("documents", "Documents"), // 1
|
||||
SidebarEntry::row("locked", "Locked").disabled(true), // 2
|
||||
SidebarEntry::row("Documents"), // 1
|
||||
SidebarEntry::row("Locked").disabled(true), // 2
|
||||
SidebarEntry::header("Locations"), // 3: never selectable
|
||||
SidebarEntry::row("trash", "Trash"), // 4
|
||||
SidebarEntry::row("Trash"), // 4
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ use gpui_native_data::tabs::{Tab, Tabs};
|
||||
use gpui_native_theme::Theme;
|
||||
|
||||
type Changes = Rc<RefCell<Vec<usize>>>;
|
||||
type Closes = Rc<RefCell<Vec<usize>>>;
|
||||
|
||||
struct Harness {
|
||||
selected: usize,
|
||||
@@ -65,55 +64,6 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
||||
}
|
||||
|
||||
struct CloseHarness {
|
||||
changes: Changes,
|
||||
closes: Closes,
|
||||
}
|
||||
|
||||
impl Render for CloseHarness {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let changes = self.changes.clone();
|
||||
let closes = self.closes.clone();
|
||||
let view = cx.entity();
|
||||
let close_view = view.clone();
|
||||
div().size_full().child(
|
||||
Tabs::new("close-tabs")
|
||||
.on_change(move |next, _, cx| {
|
||||
changes.borrow_mut().push(next);
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
})
|
||||
.on_close(move |index, _, cx| {
|
||||
closes.borrow_mut().push(index);
|
||||
close_view.update(cx, |_, cx| cx.notify());
|
||||
})
|
||||
.tabs(|_, _| {
|
||||
vec![
|
||||
Tab::new("one", "One").closeable(true),
|
||||
Tab::new("two", "Two"),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn close_tabs_window() -> (TestAppContext, VisualTestContext, Changes, Closes) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let changes = Rc::new(RefCell::new(Vec::new()));
|
||||
let closes = Rc::new(RefCell::new(Vec::new()));
|
||||
let harness_changes = changes.clone();
|
||||
let harness_closes = closes.clone();
|
||||
let window = cx.add_window(move |_, _| CloseHarness {
|
||||
changes: harness_changes,
|
||||
closes: harness_closes,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, visual, changes, closes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_tab_selects_it_and_hands_the_strip_the_keyboard() {
|
||||
let (_cx, mut visual, changes) = tabs_window();
|
||||
@@ -150,19 +100,3 @@ fn arrow_keys_skip_disabled_tabs_and_never_wrap() {
|
||||
visual.simulate_keystrokes("end"); // End
|
||||
assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_close_control_reports_a_close_without_selecting() {
|
||||
let (_cx, mut visual, changes, closes) = close_tabs_window();
|
||||
|
||||
// Only the closeable tab ships a close control.
|
||||
let selector: &'static str = Box::leak(String::from("close-tabs-close-One").into_boxed_str());
|
||||
let close_bounds = visual
|
||||
.debug_bounds(selector)
|
||||
.expect("closeable tab renders a close control");
|
||||
assert!(visual.debug_bounds("close-tabs-close-Two").is_none());
|
||||
|
||||
click(&mut visual, close_bounds.center());
|
||||
assert_eq!(*closes.borrow(), vec![0]);
|
||||
assert!(changes.borrow().is_empty(), "a close is not a selection");
|
||||
}
|
||||
|
||||
@@ -38,18 +38,14 @@ impl Render for Harness {
|
||||
})
|
||||
.items(|_, _| {
|
||||
vec![
|
||||
TreeItem::new("root", "Root")
|
||||
TreeItem::new("Root")
|
||||
.depth(0)
|
||||
.expandable(true)
|
||||
.expanded(true),
|
||||
TreeItem::new("child", "Child").depth(1),
|
||||
TreeItem::new("collapsed", "Collapsed")
|
||||
.depth(0)
|
||||
.expandable(true),
|
||||
TreeItem::new("untouchable", "Untouchable")
|
||||
.depth(1)
|
||||
.disabled(true),
|
||||
TreeItem::new("last", "Last").depth(0),
|
||||
TreeItem::new("Child").depth(1),
|
||||
TreeItem::new("Collapsed").depth(0).expandable(true),
|
||||
TreeItem::new("Untouchable").depth(1).disabled(true),
|
||||
TreeItem::new("Last").depth(0),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,10 +7,10 @@ description = "Read-only code surfaces for gpui applications, one cargo feature
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
gpui-native-core = { workspace = true, optional = true }
|
||||
gpui-native-icons = { workspace = true, optional = true }
|
||||
gpui-native-icons = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
gpui-native-utils = { workspace = true }
|
||||
log = "0.4"
|
||||
tree-sitter = "0.25"
|
||||
similar = "2"
|
||||
pulldown-cmark = { version = "0.13", default-features = false, optional = true }
|
||||
tree-sitter-rust = { version = "0.24", optional = true }
|
||||
@@ -18,9 +18,6 @@ tree-sitter-typescript = { version = "0.23", optional = true }
|
||||
tree-sitter-javascript = { version = "0.23", optional = true }
|
||||
tree-sitter-python = { version = "0.23", optional = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tree-sitter = "0.25"
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -41,13 +38,7 @@ code_view = [
|
||||
"gpui-native-core/scrollbar",
|
||||
]
|
||||
diff_view = ["code_view"]
|
||||
markdown_view = [
|
||||
"dep:gpui-native-core",
|
||||
"gpui-native-core/button",
|
||||
"gpui-native-core/scrollbar",
|
||||
"dep:pulldown-cmark",
|
||||
"dep:gpui-native-icons",
|
||||
]
|
||||
markdown_view = ["code_view", "dep:pulldown-cmark"]
|
||||
rust = ["dep:tree-sitter-rust"]
|
||||
typescript = ["dep:tree-sitter-typescript"]
|
||||
javascript = ["dep:tree-sitter-javascript"]
|
||||
|
||||
@@ -11,12 +11,10 @@ use gpui::{
|
||||
actions, canvas, div, uniform_list,
|
||||
};
|
||||
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
|
||||
use gpui_native_theme::{
|
||||
ActiveTheme as _, ColorTokens, MONO_ADVANCE_RATIO, Ramps, WASH_ALPHA_SOFT,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, MONO_ADVANCE_RATIO, Ramps};
|
||||
|
||||
use crate::highlight::{HighlightedCode, Language, Role, highlight};
|
||||
use crate::utils::gutter_cell;
|
||||
use crate::utils::{gutter_cell, run_style};
|
||||
|
||||
actions!(code_view, [Copy]);
|
||||
|
||||
@@ -85,7 +83,7 @@ impl CodeViewState {
|
||||
active_line: None,
|
||||
selection: None,
|
||||
selecting: false,
|
||||
focus_handle: cx.focus_handle().tab_stop(true),
|
||||
focus_handle: cx.focus_handle(),
|
||||
a11y_label: None,
|
||||
}
|
||||
}
|
||||
@@ -225,17 +223,14 @@ impl CodeViewState {
|
||||
char_width: Pixels,
|
||||
text_x: Pixels,
|
||||
) -> SelectionPoint {
|
||||
// Saturating so an empty document cannot invert the clamp.
|
||||
let last_row = self.code.lines.len().saturating_sub(1);
|
||||
let row = (((position.y - bounds.origin.y - scroll_y) / line_height).floor() as isize)
|
||||
.clamp(0, last_row as isize) as usize;
|
||||
.clamp(0, self.code.lines.len() as isize - 1) as usize;
|
||||
|
||||
let chars = self.code.lines.get(row).map_or(0, |line| {
|
||||
line.spans
|
||||
let chars = self.code.lines[row]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum::<usize>()
|
||||
});
|
||||
.sum::<usize>();
|
||||
|
||||
let col = (((position.x - text_x) / char_width).round() as isize).clamp(0, chars as isize)
|
||||
as usize;
|
||||
@@ -508,7 +503,7 @@ fn render_row(
|
||||
if char_ix > 0 {
|
||||
highlights.push((
|
||||
run_start..run_end,
|
||||
run_role.run_style(run_selected, colors, ramps),
|
||||
run_style(run_role, run_selected, colors, ramps),
|
||||
));
|
||||
}
|
||||
run_start = byte_ix;
|
||||
@@ -521,7 +516,7 @@ fn render_row(
|
||||
if run_end > run_start || !text.is_empty() {
|
||||
highlights.push((
|
||||
run_start..run_end,
|
||||
run_role.run_style(run_selected, colors, ramps),
|
||||
run_style(run_role, run_selected, colors, ramps),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -544,7 +539,7 @@ fn render_row(
|
||||
.overflow_hidden()
|
||||
.when(active, |row| {
|
||||
row.bg(Hsla {
|
||||
a: colors.muted.a * WASH_ALPHA_SOFT,
|
||||
a: colors.muted.a * 0.45,
|
||||
..colors.muted
|
||||
})
|
||||
})
|
||||
|
||||
+88
-188
@@ -3,7 +3,7 @@
|
||||
//! bars instead of markers, tinted number gutters, word-level intra-line
|
||||
//! highlights and hunk separators. Rows reuse the code view's highlighting;
|
||||
//! consumers that already hold a git diff can build [`DiffRow`]s themselves
|
||||
//! instead of calling [`DiffViewState::diff_rows`].
|
||||
//! instead of calling [`diff_rows`].
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
@@ -14,12 +14,12 @@ use gpui::{
|
||||
};
|
||||
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
|
||||
use gpui_native_theme::{ActiveTheme as _, MONO_ADVANCE_RATIO};
|
||||
use similar::{Change, ChangeTag, TextDiff};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::highlight::{HighlightedCode, Language, Line, Role, highlight};
|
||||
use crate::utils::{gutter_cell, wash, wash_gutter, wash_strong};
|
||||
use crate::highlight::{Language, Line, Role, highlight};
|
||||
use crate::utils::{gutter_cell, line_row, role_color, wash, wash_gutter, wash_strong};
|
||||
|
||||
/// What a diff row did to the file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -96,155 +96,18 @@ impl Default for DiffViewOptions {
|
||||
}
|
||||
|
||||
impl DiffRow {
|
||||
/// Assembles a diff line row from a `similar` change.
|
||||
pub(crate) fn from_change(
|
||||
change: &Change<&str>,
|
||||
code: &HighlightedCode,
|
||||
changed_ranges: Vec<Range<usize>>,
|
||||
) -> DiffRow {
|
||||
let content = match change.tag() {
|
||||
ChangeTag::Equal => change
|
||||
.new_index()
|
||||
.and_then(|ix| code.lines.get(ix))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
ChangeTag::Delete => change
|
||||
.old_index()
|
||||
.and_then(|ix| code.lines.get(ix))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
ChangeTag::Insert => change
|
||||
.new_index()
|
||||
.and_then(|ix| code.lines.get(ix))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let (old_number, new_number) = match change.tag() {
|
||||
ChangeTag::Equal => (
|
||||
change.old_index().map(|ix| ix + 1),
|
||||
change.new_index().map(|ix| ix + 1),
|
||||
),
|
||||
ChangeTag::Delete => (change.old_index().map(|ix| ix + 1), None),
|
||||
ChangeTag::Insert => (None, change.new_index().map(|ix| ix + 1)),
|
||||
};
|
||||
|
||||
DiffRow {
|
||||
kind: match change.tag() {
|
||||
ChangeTag::Equal => DiffRowKind::Context,
|
||||
ChangeTag::Delete => DiffRowKind::Removed,
|
||||
ChangeTag::Insert => DiffRowKind::Added,
|
||||
},
|
||||
content,
|
||||
old_number,
|
||||
new_number,
|
||||
changed_ranges,
|
||||
header: Default::default(),
|
||||
hidden_lines: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's text length, in characters.
|
||||
fn char_count(&self) -> usize {
|
||||
self.content.text().chars().count()
|
||||
}
|
||||
|
||||
/// Stable element id: the kind and line numbers packed into one integer.
|
||||
fn element_id(&self, hunk_ordinal: usize) -> ElementId {
|
||||
let kind = match self.kind {
|
||||
DiffRowKind::Context => 0u64,
|
||||
DiffRowKind::Added => 1,
|
||||
DiffRowKind::Removed => 2,
|
||||
DiffRowKind::Hunk => {
|
||||
return ElementId::named_usize("diff-hunk", hunk_ordinal);
|
||||
}
|
||||
};
|
||||
// Line numbers saturate at 2^31 so the two fields never overlap.
|
||||
let old = self.old_number.unwrap_or(0).min((1 << 31) - 1) as u64;
|
||||
let new = self.new_number.unwrap_or(0).min((1 << 31) - 1) as u64;
|
||||
ElementId::named_usize("diff-row", ((kind << 62) | (old << 31) | new) as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retained state for a diff view.
|
||||
pub struct DiffViewState {
|
||||
id: ElementId,
|
||||
rows: Vec<DiffRow>,
|
||||
/// Cached per-row id and text length, parallel to `rows`.
|
||||
row_layouts: Vec<RowLayout>,
|
||||
/// The row whose width drives horizontal scroll extent, `rows`-derived.
|
||||
widest_row: Option<usize>,
|
||||
options: DiffViewOptions,
|
||||
/// Drives both scroll axes of the virtualized row list.
|
||||
handle: UniformListScrollHandle,
|
||||
a11y_label: Option<SharedString>,
|
||||
}
|
||||
|
||||
/// Cached per-row render data, computed once when the rows are set.
|
||||
struct RowLayout {
|
||||
id: ElementId,
|
||||
char_count: usize,
|
||||
}
|
||||
|
||||
impl DiffViewState {
|
||||
/// Builds the state, diffing `old` against `new`.
|
||||
pub fn new<I, O, N>(id: I, old: O, new: N, language: Language) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
O: AsRef<str>,
|
||||
N: AsRef<str>,
|
||||
{
|
||||
let mut state = Self {
|
||||
id: id.into(),
|
||||
rows: Vec::new(),
|
||||
row_layouts: Vec::new(),
|
||||
widest_row: None,
|
||||
options: DiffViewOptions::default(),
|
||||
handle: UniformListScrollHandle::new(),
|
||||
a11y_label: None,
|
||||
};
|
||||
state.set_rows(old, new, language);
|
||||
state
|
||||
}
|
||||
|
||||
/// Replaces the rows, recomputing the per-row layout cache with them.
|
||||
fn set_rows<O, N>(&mut self, old: O, new: N, language: Language)
|
||||
where
|
||||
O: AsRef<str>,
|
||||
N: AsRef<str>,
|
||||
{
|
||||
self.rows = Self::diff_rows(old.as_ref(), new.as_ref(), language);
|
||||
|
||||
let mut hunk_ordinal = 0usize;
|
||||
self.row_layouts = self
|
||||
.rows
|
||||
self.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let ordinal = if row.kind == DiffRowKind::Hunk {
|
||||
hunk_ordinal += 1;
|
||||
hunk_ordinal
|
||||
} else {
|
||||
0
|
||||
};
|
||||
RowLayout {
|
||||
id: row.element_id(ordinal),
|
||||
char_count: row.char_count(),
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
self.widest_row = self
|
||||
.rows
|
||||
.iter()
|
||||
.zip(&self.row_layouts)
|
||||
.enumerate()
|
||||
.filter(|(_, (row, _))| row.kind != DiffRowKind::Hunk)
|
||||
.max_by_key(|(_, (_, layout))| layout.char_count)
|
||||
.map(|(widest, _)| widest);
|
||||
}
|
||||
|
||||
/// Computes unified rows between two texts, highlighting every line with `language`.
|
||||
pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
||||
/// Computes unified rows between two texts, highlighting every line with `language`.
|
||||
pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
||||
let old_code = highlight(old, language);
|
||||
let new_code = highlight(new, language);
|
||||
let diff = TextDiff::from_lines(old, new);
|
||||
@@ -253,9 +116,8 @@ impl DiffViewState {
|
||||
let mut collapsed_before = 0usize;
|
||||
|
||||
for group in diff.grouped_ops(3) {
|
||||
let (Some(first), Some(last)) = (group.first(), group.last()) else {
|
||||
continue;
|
||||
};
|
||||
let first = group.first().expect("hunk groups are never empty");
|
||||
let last = group.last().expect("hunk groups are never empty");
|
||||
|
||||
let old_range = first.old_range().start..last.old_range().end;
|
||||
let new_range = first.new_range().start..last.new_range().end;
|
||||
@@ -273,7 +135,7 @@ impl DiffViewState {
|
||||
new_range.start + 1
|
||||
};
|
||||
|
||||
let collapsed = first.old_range().start.saturating_sub(collapsed_before);
|
||||
let collapsed = first.old_range().start - collapsed_before;
|
||||
if collapsed > 0 {
|
||||
rows.push(DiffRow {
|
||||
kind: DiffRowKind::Hunk,
|
||||
@@ -296,21 +158,21 @@ impl DiffViewState {
|
||||
|
||||
for op in &group {
|
||||
let changes: Vec<_> = diff.iter_changes(op).collect();
|
||||
let mut index = 0;
|
||||
let mut ix = 0;
|
||||
|
||||
while index < changes.len() {
|
||||
if changes[index].tag() == ChangeTag::Equal {
|
||||
rows.push(DiffRow::from_change(&changes[index], &new_code, Vec::new()));
|
||||
index += 1;
|
||||
while ix < changes.len() {
|
||||
if changes[ix].tag() == ChangeTag::Equal {
|
||||
rows.push(line_row(&changes[ix], &new_code, Vec::new()));
|
||||
ix += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let deletions = changes[index..]
|
||||
let deletions = changes[ix..]
|
||||
.iter()
|
||||
.take_while(|change| change.tag() == ChangeTag::Delete)
|
||||
.count();
|
||||
|
||||
let insertions = changes[index + deletions..]
|
||||
let insertions = changes[ix + deletions..]
|
||||
.iter()
|
||||
.take_while(|change| change.tag() == ChangeTag::Insert)
|
||||
.count();
|
||||
@@ -319,8 +181,8 @@ impl DiffViewState {
|
||||
let mut insertion_ranges = vec![Vec::new(); insertions];
|
||||
|
||||
for pair in 0..deletions.min(insertions) {
|
||||
let old_text = changes[index + pair].value().trim_end_matches('\n');
|
||||
let new_text = changes[index + deletions + pair]
|
||||
let old_text = changes[ix + pair].value().trim_end_matches('\n');
|
||||
let new_text = changes[ix + deletions + pair]
|
||||
.value()
|
||||
.trim_end_matches('\n');
|
||||
let words = TextDiff::from_words(old_text, new_text);
|
||||
@@ -348,27 +210,75 @@ impl DiffViewState {
|
||||
}
|
||||
|
||||
for pair in 0..deletions {
|
||||
rows.push(DiffRow::from_change(
|
||||
&changes[index + pair],
|
||||
rows.push(line_row(
|
||||
&changes[ix + pair],
|
||||
&old_code,
|
||||
deletion_ranges[pair].clone(),
|
||||
));
|
||||
}
|
||||
|
||||
for pair in 0..insertions {
|
||||
rows.push(DiffRow::from_change(
|
||||
&changes[index + deletions + pair],
|
||||
rows.push(line_row(
|
||||
&changes[ix + deletions + pair],
|
||||
&new_code,
|
||||
insertion_ranges[pair].clone(),
|
||||
));
|
||||
}
|
||||
|
||||
index += deletions + insertions;
|
||||
ix += deletions + insertions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
/// Retained state for a diff view.
|
||||
pub struct DiffViewState {
|
||||
id: ElementId,
|
||||
rows: Vec<DiffRow>,
|
||||
/// The row whose width drives horizontal scroll extent, `rows`-derived.
|
||||
widest_row: Option<usize>,
|
||||
options: DiffViewOptions,
|
||||
/// Drives both scroll axes of the virtualized row list.
|
||||
handle: UniformListScrollHandle,
|
||||
a11y_label: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl DiffViewState {
|
||||
/// Builds the state, diffing `old` against `new`.
|
||||
pub fn new<I, O, N>(id: I, old: O, new: N, language: Language) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
O: AsRef<str>,
|
||||
N: AsRef<str>,
|
||||
{
|
||||
let mut state = Self {
|
||||
id: id.into(),
|
||||
rows: Vec::new(),
|
||||
widest_row: None,
|
||||
options: DiffViewOptions::default(),
|
||||
handle: UniformListScrollHandle::new(),
|
||||
a11y_label: None,
|
||||
};
|
||||
state.set_rows(old, new, language);
|
||||
state
|
||||
}
|
||||
|
||||
/// Replaces the rows, recomputing `widest_row` with them.
|
||||
fn set_rows<O, N>(&mut self, old: O, new: N, language: Language)
|
||||
where
|
||||
O: AsRef<str>,
|
||||
N: AsRef<str>,
|
||||
{
|
||||
self.rows = diff_rows(old.as_ref(), new.as_ref(), language);
|
||||
self.widest_row = self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.kind != DiffRowKind::Hunk)
|
||||
.max_by_key(|(_, row)| row.char_count())
|
||||
.map(|(ix, _)| ix);
|
||||
}
|
||||
|
||||
/// Sets how changed rows are marked.
|
||||
@@ -445,15 +355,7 @@ impl Render for DiffViewState {
|
||||
move |range, _window, cx| {
|
||||
let diff = state.read(cx);
|
||||
range
|
||||
.map(|row_index| {
|
||||
render_row(
|
||||
&diff.rows[row_index],
|
||||
&diff.row_layouts[row_index],
|
||||
row_index,
|
||||
options,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.map(|row_ix| render_row(&diff.rows[row_ix], row_ix, options, cx))
|
||||
.collect()
|
||||
}
|
||||
})
|
||||
@@ -487,8 +389,7 @@ impl Render for DiffViewState {
|
||||
|
||||
fn render_row(
|
||||
row: &DiffRow,
|
||||
layout: &RowLayout,
|
||||
row_index: usize,
|
||||
row_ix: usize,
|
||||
options: DiffViewOptions,
|
||||
cx: &App,
|
||||
) -> gpui::AnyElement {
|
||||
@@ -501,8 +402,8 @@ fn render_row(
|
||||
let char_width = text_size * MONO_ADVANCE_RATIO;
|
||||
|
||||
let frame = div()
|
||||
.debug_selector(move || format!("diff-row-{row_index}"))
|
||||
.id(layout.id.clone())
|
||||
.debug_selector(move || format!("diff-row-{row_ix}"))
|
||||
.id(("diff-row", row_ix))
|
||||
.w_full()
|
||||
.h(line_height)
|
||||
.font_family(mono_font)
|
||||
@@ -595,7 +496,7 @@ fn render_row(
|
||||
&& *role != Role::Text
|
||||
{
|
||||
segment = segment.highlight(HighlightStyle {
|
||||
color: Some(role.color(colors, ramps)),
|
||||
color: Some(role_color(*role, colors, ramps)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -645,8 +546,7 @@ fn render_row(
|
||||
},
|
||||
)
|
||||
.when(options.line_numbers, |row_element| {
|
||||
row_element.child(
|
||||
gutter_cell(
|
||||
let mut number = gutter_cell(
|
||||
cx,
|
||||
colors.muted_foreground,
|
||||
row.new_number
|
||||
@@ -659,11 +559,11 @@ fn render_row(
|
||||
.bg(match row.kind {
|
||||
DiffRowKind::Context => wash_gutter(&colors),
|
||||
_ => wash_strong(change_color),
|
||||
})
|
||||
.when(options.indicators == DiffIndicators::Bars, |number| {
|
||||
number.border_l_4().border_color(change_color)
|
||||
}),
|
||||
)
|
||||
});
|
||||
if options.indicators == DiffIndicators::Bars {
|
||||
number = number.border_l_4().border_color(change_color);
|
||||
}
|
||||
row_element.child(number)
|
||||
})
|
||||
.when(
|
||||
options.indicators == DiffIndicators::Bars && !options.line_numbers,
|
||||
@@ -681,7 +581,7 @@ fn render_row(
|
||||
div()
|
||||
.flex_none()
|
||||
.ml_2()
|
||||
.w(char_width * layout.char_count as f32)
|
||||
.w(char_width * row.char_count() as f32)
|
||||
.whitespace_nowrap()
|
||||
.child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)),
|
||||
)
|
||||
|
||||
@@ -3,20 +3,16 @@
|
||||
//! code without re-parsing it.
|
||||
|
||||
use gpui::SharedString;
|
||||
use gpui_native_utils::ResultLogErr;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tree_sitter::StreamingIterator as _;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
thread_local! {
|
||||
static HIGHLIGHTERS: RefCell<HashMap<Language, Option<Highlighter>>> =
|
||||
RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// A grammar's parser and compiled highlight query.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Highlighter {
|
||||
parser: tree_sitter::Parser,
|
||||
query: tree_sitter::Query,
|
||||
@@ -33,7 +29,7 @@ pub enum Language {
|
||||
}
|
||||
|
||||
/// The semantic role a span plays.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Text,
|
||||
Keyword,
|
||||
@@ -51,37 +47,25 @@ pub enum Role {
|
||||
}
|
||||
|
||||
/// A run of text sharing one role.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub text: SharedString,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
/// One rendered line of code.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Line {
|
||||
pub spans: Vec<Span>,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// The line's flat text, in character order.
|
||||
pub fn text(&self) -> String {
|
||||
let mut text = String::new();
|
||||
for span in &self.spans {
|
||||
text.push_str(&span.text);
|
||||
}
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/// Source text split into role-styled lines.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HighlightedCode {
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
/// Highlights `source` with `language`'s tree-sitter grammar.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
||||
let Some((grammar, query_source)) = grammar_for(language) else {
|
||||
return plain(source);
|
||||
@@ -93,8 +77,10 @@ pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
||||
.entry(language)
|
||||
.or_insert_with(|| {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&grammar).log_err()?;
|
||||
let query = tree_sitter::Query::new(&grammar, query_source).log_err()?;
|
||||
if parser.set_language(&grammar).is_err() {
|
||||
return None;
|
||||
}
|
||||
let query = tree_sitter::Query::new(&grammar, query_source).ok()?;
|
||||
let roles = query
|
||||
.capture_names()
|
||||
.iter()
|
||||
@@ -113,7 +99,6 @@ pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
||||
};
|
||||
|
||||
let Some(tree) = highlighter.parser.parse(source, None) else {
|
||||
log::warn!("tree-sitter failed to parse {language:?}; rendering plain text");
|
||||
return plain(source);
|
||||
};
|
||||
|
||||
@@ -144,94 +129,39 @@ pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
||||
})
|
||||
}
|
||||
|
||||
/// The web target has no tree-sitter parsers.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn highlight(source: &str, _language: Language) -> HighlightedCode {
|
||||
plain(source)
|
||||
}
|
||||
|
||||
/// Source rendered without any grammar: plain text roles.
|
||||
pub(crate) fn plain(source: &str) -> HighlightedCode {
|
||||
build_lines(source, &[])
|
||||
}
|
||||
|
||||
/// The grammar and its highlight query for a language, when compiled in.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn grammar_for(language: Language) -> Option<(tree_sitter::Language, &'static str)> {
|
||||
match language {
|
||||
Language::Rust => rust_grammar(),
|
||||
Language::TypeScript => typescript_grammar(),
|
||||
Language::JavaScript => javascript_grammar(),
|
||||
Language::Python => python_grammar(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Rust grammar, compiled in only when the `rust` feature is on.
|
||||
#[cfg(feature = "rust")]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rust_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
Some((
|
||||
Language::Rust => cfg!(feature = "rust").then(|| {
|
||||
(
|
||||
tree_sitter_rust::LANGUAGE.into(),
|
||||
tree_sitter_rust::HIGHLIGHTS_QUERY,
|
||||
))
|
||||
}
|
||||
|
||||
/// Without the feature, Rust sources render as plain text.
|
||||
#[cfg(not(feature = "rust"))]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rust_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The TypeScript grammar, compiled in only when the `typescript` feature is on.
|
||||
#[cfg(feature = "typescript")]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn typescript_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
Some((
|
||||
)
|
||||
}),
|
||||
Language::TypeScript => cfg!(feature = "typescript").then(|| {
|
||||
(
|
||||
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
||||
tree_sitter_typescript::HIGHLIGHTS_QUERY,
|
||||
))
|
||||
}
|
||||
|
||||
/// Without the feature, TypeScript sources render as plain text.
|
||||
#[cfg(not(feature = "typescript"))]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn typescript_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The JavaScript grammar, compiled in only when the `javascript` feature is on.
|
||||
#[cfg(feature = "javascript")]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn javascript_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
Some((
|
||||
)
|
||||
}),
|
||||
Language::JavaScript => cfg!(feature = "javascript").then(|| {
|
||||
(
|
||||
tree_sitter_javascript::LANGUAGE.into(),
|
||||
tree_sitter_javascript::HIGHLIGHT_QUERY,
|
||||
))
|
||||
}
|
||||
|
||||
/// Without the feature, JavaScript sources render as plain text.
|
||||
#[cfg(not(feature = "javascript"))]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn javascript_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The Python grammar, compiled in only when the `python` feature is on.
|
||||
#[cfg(feature = "python")]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn python_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
Some((
|
||||
)
|
||||
}),
|
||||
Language::Python => cfg!(feature = "python").then(|| {
|
||||
(
|
||||
tree_sitter_python::LANGUAGE.into(),
|
||||
tree_sitter_python::HIGHLIGHTS_QUERY,
|
||||
))
|
||||
}
|
||||
|
||||
/// Without the feature, Python sources render as plain text.
|
||||
#[cfg(not(feature = "python"))]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn python_grammar() -> Option<(tree_sitter::Language, &'static str)> {
|
||||
None
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a query capture name to a role; unmapped captures render as text.
|
||||
|
||||
+135
-1102
File diff suppressed because it is too large
Load Diff
+89
-48
@@ -1,28 +1,27 @@
|
||||
//! Shared helpers for the editor's views: gutter cells, role colors and run
|
||||
//! styles, code line text, language mapping and change washes.
|
||||
//! Shared helpers for the editor's views: gutter cells, role colors, code
|
||||
//! line text, language mapping, change washes and diff row building.
|
||||
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
#[cfg(any(feature = "diff_view", feature = "markdown_view"))]
|
||||
use std::ops::Range;
|
||||
|
||||
#[cfg(feature = "markdown_view")]
|
||||
use crate::highlight::{Language, Line};
|
||||
#[cfg(feature = "markdown_view")]
|
||||
use gpui::StyledText;
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
|
||||
#[cfg(feature = "diff_view")]
|
||||
use gpui_native_theme::{WASH_ALPHA, WASH_ALPHA_GUTTER, WASH_ALPHA_STRONG};
|
||||
use similar::{Change, ChangeTag};
|
||||
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
#[cfg(feature = "diff_view")]
|
||||
use crate::diff_view::{DiffRow, DiffRowKind};
|
||||
#[cfg(feature = "diff_view")]
|
||||
use crate::highlight::HighlightedCode;
|
||||
use crate::highlight::Role;
|
||||
#[cfg(feature = "markdown_view")]
|
||||
use crate::highlight::{Language, Line};
|
||||
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
impl Role {
|
||||
/// Maps the role to the theme's semantic palette.
|
||||
pub(crate) fn color(self, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
||||
match self {
|
||||
/// Maps a highlight role to the theme's semantic palette.
|
||||
pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
||||
match role {
|
||||
Role::Text => colors.foreground,
|
||||
Role::Keyword => ramps.primary.base,
|
||||
Role::String | Role::Escape => ramps.success.base,
|
||||
@@ -33,39 +32,23 @@ impl Role {
|
||||
Role::Operator => colors.foreground,
|
||||
Role::Tag => ramps.destructive.base,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the highlight style for one run of a code view row.
|
||||
pub(crate) fn run_style(
|
||||
self,
|
||||
/// Builds the highlight style for one run of a code view row.
|
||||
pub(crate) fn run_style(
|
||||
role: Role,
|
||||
selected: bool,
|
||||
colors: ColorTokens,
|
||||
ramps: Ramps,
|
||||
) -> HighlightStyle {
|
||||
) -> HighlightStyle {
|
||||
HighlightStyle {
|
||||
color: Some(self.color(colors, ramps)),
|
||||
color: Some(role_color(role, colors, ramps)),
|
||||
background_color: selected.then_some(colors.selection),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "markdown_view")]
|
||||
impl Language {
|
||||
/// Maps a fence info string to a bundled grammar.
|
||||
pub(crate) fn from_fence_info(info: &str) -> Option<Language> {
|
||||
match info.to_ascii_lowercase().as_str() {
|
||||
"rust" | "rs" => Some(Language::Rust),
|
||||
"typescript" | "ts" => Some(Language::TypeScript),
|
||||
"javascript" | "js" => Some(Language::JavaScript),
|
||||
"python" | "py" => Some(Language::Python),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gutter cell sized to the whole row.
|
||||
#[cfg(feature = "code_view")]
|
||||
pub(crate) fn gutter_cell<E>(cx: &App, color: Hsla, content: E) -> gpui::Div
|
||||
where
|
||||
E: IntoElement,
|
||||
@@ -85,29 +68,75 @@ where
|
||||
.child(content)
|
||||
}
|
||||
|
||||
/// A change wash: the ramp color at the alpha rows tint with.
|
||||
/// Builds a diff line row from a `similar` change.
|
||||
#[cfg(feature = "diff_view")]
|
||||
pub(crate) fn line_row(
|
||||
change: &Change<&str>,
|
||||
code: &HighlightedCode,
|
||||
changed_ranges: Vec<Range<usize>>,
|
||||
) -> DiffRow {
|
||||
let content = match change.tag() {
|
||||
ChangeTag::Equal => change
|
||||
.new_index()
|
||||
.and_then(|ix| code.lines.get(ix))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
ChangeTag::Delete => change
|
||||
.old_index()
|
||||
.and_then(|ix| code.lines.get(ix))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
ChangeTag::Insert => change
|
||||
.new_index()
|
||||
.and_then(|ix| code.lines.get(ix))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let (old_number, new_number) = match change.tag() {
|
||||
ChangeTag::Equal => (
|
||||
change.old_index().map(|ix| ix + 1),
|
||||
change.new_index().map(|ix| ix + 1),
|
||||
),
|
||||
ChangeTag::Delete => (change.old_index().map(|ix| ix + 1), None),
|
||||
ChangeTag::Insert => (None, change.new_index().map(|ix| ix + 1)),
|
||||
};
|
||||
|
||||
DiffRow {
|
||||
kind: match change.tag() {
|
||||
ChangeTag::Equal => DiffRowKind::Context,
|
||||
ChangeTag::Delete => DiffRowKind::Removed,
|
||||
ChangeTag::Insert => DiffRowKind::Added,
|
||||
},
|
||||
content,
|
||||
old_number,
|
||||
new_number,
|
||||
changed_ranges,
|
||||
header: Default::default(),
|
||||
hidden_lines: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A change wash: the ramp color at the alpha rows tint with.
|
||||
pub(crate) fn wash(color: Hsla) -> Hsla {
|
||||
Hsla {
|
||||
a: color.a * WASH_ALPHA,
|
||||
a: color.a * 0.14,
|
||||
..color
|
||||
}
|
||||
}
|
||||
|
||||
/// The stronger wash under number gutters and intra-line word changes.
|
||||
#[cfg(feature = "diff_view")]
|
||||
pub(crate) fn wash_strong(color: Hsla) -> Hsla {
|
||||
Hsla {
|
||||
a: color.a * WASH_ALPHA_STRONG,
|
||||
a: color.a * 0.34,
|
||||
..color
|
||||
}
|
||||
}
|
||||
|
||||
/// The subtle wash that sets the number gutter off from the code.
|
||||
#[cfg(feature = "diff_view")]
|
||||
pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla {
|
||||
Hsla {
|
||||
a: colors.muted.a * WASH_ALPHA_GUTTER,
|
||||
a: colors.muted.a * 0.18,
|
||||
..colors.muted
|
||||
}
|
||||
}
|
||||
@@ -115,22 +144,34 @@ pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla {
|
||||
/// One highlighted code line as styled text, roles resolved from the theme.
|
||||
#[cfg(feature = "markdown_view")]
|
||||
pub(crate) fn code_line(line: &Line, colors: ColorTokens, ramps: Ramps) -> StyledText {
|
||||
let mut text = String::new();
|
||||
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
|
||||
for span in &line.spans {
|
||||
let end = offset + span.text.len();
|
||||
let start = text.len();
|
||||
text.push_str(&span.text);
|
||||
if span.role != Role::Text {
|
||||
highlights.push((
|
||||
offset..end,
|
||||
start..text.len(),
|
||||
HighlightStyle {
|
||||
color: Some(span.role.color(colors, ramps)),
|
||||
color: Some(role_color(span.role, colors, ramps)),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
|
||||
StyledText::new(line.text()).with_highlights(highlights)
|
||||
StyledText::new(text).with_highlights(highlights)
|
||||
}
|
||||
|
||||
/// Maps a fence info string to a bundled grammar.
|
||||
#[cfg(feature = "markdown_view")]
|
||||
pub(crate) fn grammar_language(info: &str) -> Option<Language> {
|
||||
match info.to_ascii_lowercase().as_str() {
|
||||
"rust" | "rs" => Some(Language::Rust),
|
||||
"typescript" | "ts" => Some(Language::TypeScript),
|
||||
"javascript" | "js" => Some(Language::JavaScript),
|
||||
"python" | "py" => Some(Language::Python),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::cell::RefCell;
|
||||
use std::hint::black_box;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -102,11 +101,11 @@ fn scrolling_a_large_file_is_not_pathologically_slow() {
|
||||
scroll_elapsed / 30
|
||||
);
|
||||
assert!(
|
||||
black_box(highlight_elapsed) < Duration::from_secs(10),
|
||||
highlight_elapsed < Duration::from_secs(3),
|
||||
"highlighting took {highlight_elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
black_box(scroll_elapsed) < Duration::from_secs(10),
|
||||
scroll_elapsed < Duration::from_secs(2),
|
||||
"scrolling 30 frames took {scroll_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
|
||||
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
use gpui_native_editor::diff_view::{DiffRow, DiffRowKind, DiffViewState};
|
||||
use gpui_native_editor::diff_view::{DiffRow, DiffRowKind, DiffViewState, diff_rows};
|
||||
use gpui_native_editor::highlight::{Language, Role};
|
||||
|
||||
const OLD: &str = "fn a() {}\nfn b() {}\nfn c() {}\n";
|
||||
@@ -13,7 +13,7 @@ const NEW: &str = "fn a() {}\nfn x() {}\nfn y() {}\nfn c() {}\n";
|
||||
|
||||
#[test]
|
||||
fn rows_mark_removals_additions_and_context_with_line_numbers() {
|
||||
let rows = DiffViewState::diff_rows(OLD, NEW, Language::Rust);
|
||||
let rows = diff_rows(OLD, NEW, Language::Rust);
|
||||
|
||||
let kinds: Vec<DiffRowKind> = rows.iter().map(|row| row.kind).collect();
|
||||
assert_eq!(
|
||||
@@ -72,7 +72,7 @@ fn hunk_separators_carry_the_collapsed_line_count_and_header() {
|
||||
new.push_str(&format!("line {line}\n"));
|
||||
}
|
||||
}
|
||||
let rows = DiffViewState::diff_rows(&old, &new, Language::Rust);
|
||||
let rows = diff_rows(&old, &new, Language::Rust);
|
||||
|
||||
let separators: Vec<&DiffRow> = rows
|
||||
.iter()
|
||||
@@ -91,7 +91,7 @@ fn hunk_separators_carry_the_collapsed_line_count_and_header() {
|
||||
|
||||
#[test]
|
||||
fn word_changes_tint_only_the_changed_words() {
|
||||
let rows = DiffViewState::diff_rows(OLD, NEW, Language::Rust);
|
||||
let rows = diff_rows(OLD, NEW, Language::Rust);
|
||||
|
||||
let removed = &rows[1];
|
||||
let removed_text: String = removed
|
||||
@@ -118,7 +118,7 @@ fn word_changes_tint_only_the_changed_words() {
|
||||
|
||||
#[test]
|
||||
fn an_added_line_carries_its_highlight_spans() {
|
||||
let rows = DiffViewState::diff_rows("", "fn zed() {}\n", Language::Rust);
|
||||
let rows = diff_rows("", "fn zed() {}\n", Language::Rust);
|
||||
let added = rows
|
||||
.iter()
|
||||
.find(|row| row.kind == DiffRowKind::Added)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::cell::RefCell;
|
||||
use std::hint::black_box;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -100,11 +99,11 @@ fn scrolling_a_large_diff_is_not_pathologically_slow() {
|
||||
scroll_elapsed / 30
|
||||
);
|
||||
assert!(
|
||||
black_box(diff_elapsed) < Duration::from_secs(10),
|
||||
diff_elapsed < Duration::from_secs(3),
|
||||
"diffing took {diff_elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
black_box(scroll_elapsed) < Duration::from_secs(10),
|
||||
scroll_elapsed < Duration::from_secs(2),
|
||||
"scrolling 30 frames took {scroll_elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
+34
-478
@@ -2,27 +2,15 @@ use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _,
|
||||
TestAppContext, VisualTestContext, Window, div, point, px, rems,
|
||||
AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent,
|
||||
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
||||
px,
|
||||
};
|
||||
use gpui_native_editor::highlight::Role;
|
||||
use gpui_native_editor::markdown::{
|
||||
InlineItem, InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading,
|
||||
MarkdownViewState, TextView, bind_text_view_keys,
|
||||
InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState,
|
||||
};
|
||||
|
||||
fn items_text(items: &[InlineItem]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
InlineItem::Text(span) => Some(span.text.as_ref()),
|
||||
InlineItem::Image { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn spans_text(spans: &[InlineSpan]) -> String {
|
||||
spans
|
||||
.iter()
|
||||
@@ -31,13 +19,13 @@ fn spans_text(spans: &[InlineSpan]) -> String {
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineItem>, usize, bool) {
|
||||
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineSpan>, usize, bool) {
|
||||
match &blocks[index] {
|
||||
MarkdownBlock::Paragraph {
|
||||
items,
|
||||
spans,
|
||||
indent,
|
||||
quote,
|
||||
} => (items, *indent, *quote),
|
||||
} => (spans, *indent, *quote),
|
||||
other => panic!("block {index} is {other:?}, not a paragraph"),
|
||||
}
|
||||
}
|
||||
@@ -46,10 +34,10 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
|
||||
match &blocks[index] {
|
||||
MarkdownBlock::ListItem {
|
||||
marker,
|
||||
items,
|
||||
spans,
|
||||
indent,
|
||||
quote,
|
||||
} => (*marker, items_text(items), *indent, *quote),
|
||||
} => (*marker, spans_text(spans), *indent, *quote),
|
||||
other => panic!("block {index} is {other:?}, not a list item"),
|
||||
}
|
||||
}
|
||||
@@ -58,14 +46,11 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
|
||||
fn inline_styles_survive_nesting_and_merge_runs() {
|
||||
let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let (items, ..) = paragraph_of(&blocks, 0);
|
||||
let (spans, ..) = paragraph_of(&blocks, 0);
|
||||
|
||||
let styled: Vec<(String, &InlineStyle)> = items
|
||||
let styled: Vec<(String, &InlineStyle)> = spans
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
InlineItem::Text(span) => Some((span.text.to_string(), &span.style)),
|
||||
InlineItem::Image { .. } => None,
|
||||
})
|
||||
.map(|span| (span.text.to_string(), &span.style))
|
||||
.collect();
|
||||
|
||||
assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong)));
|
||||
@@ -148,8 +133,8 @@ fn tight_items_stay_tight_but_block_content_flushes_before_them() {
|
||||
assert_eq!(text, "item text");
|
||||
assert_eq!(indent, 0);
|
||||
|
||||
let (items, indent, _) = paragraph_of(&blocks, 1);
|
||||
assert_eq!(items_text(items), "second paragraph");
|
||||
let (spans, indent, _) = paragraph_of(&blocks, 1);
|
||||
assert_eq!(spans_text(spans), "second paragraph");
|
||||
assert_eq!(
|
||||
indent, 1,
|
||||
"loose item continuations indent under the item's text"
|
||||
@@ -189,8 +174,8 @@ fn tight_item_content_after_a_nested_block_stays_in_the_item() {
|
||||
}
|
||||
));
|
||||
|
||||
let (items, indent, _) = paragraph_of(&blocks, 2);
|
||||
assert_eq!(items_text(items), "after");
|
||||
let (spans, indent, _) = paragraph_of(&blocks, 2);
|
||||
assert_eq!(spans_text(spans), "after");
|
||||
assert_eq!(indent, 1, "the continuation stays under the item's text");
|
||||
}
|
||||
|
||||
@@ -265,11 +250,10 @@ fn quotes_mark_their_paragraphs_and_lists() {
|
||||
assert!(!paragraph_of(&blocks, 2).2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust")]
|
||||
#[test]
|
||||
fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
||||
let blocks = parse_body("```rust\nfn main() {}\n```\n");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
|
||||
let MarkdownBlock::Code {
|
||||
language, lines, ..
|
||||
@@ -286,18 +270,12 @@ fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
||||
.any(|span| span.role == Role::Keyword),
|
||||
"rust fences highlight through the grammar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_keeps_the_info_string_and_renders_unknown_languages_plain() {
|
||||
let blocks = parse_body("```text\nplain\n```");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
|
||||
let MarkdownBlock::Code {
|
||||
language, lines, ..
|
||||
} = &blocks[0]
|
||||
} = &blocks[1]
|
||||
else {
|
||||
panic!("the block is code");
|
||||
panic!("the second block is code");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("text"));
|
||||
assert!(
|
||||
@@ -331,59 +309,11 @@ fn tables_parse_header_first_with_column_positions() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_render_inline_and_standalone_and_html_renders_nothing() {
|
||||
// An image alone in its paragraph becomes a standalone image block.
|
||||
let blocks = parse_body("");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let MarkdownBlock::Image { source, alt, .. } = &blocks[0] else {
|
||||
panic!("the block is an image");
|
||||
};
|
||||
assert_eq!(source.as_ref(), "logo.png");
|
||||
assert_eq!(alt.as_ref(), "the logo");
|
||||
|
||||
// Two consecutive images in one paragraph become two blocks.
|
||||
let blocks = parse_body("\n");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(
|
||||
blocks
|
||||
.iter()
|
||||
.filter(|block| matches!(block, MarkdownBlock::Image { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
// An image among text renders as an inline atom; the spaces around it
|
||||
// stay in their runs, and its alt text is dropped.
|
||||
fn images_render_their_alt_text_and_html_renders_nothing() {
|
||||
let blocks = parse_body("see  now\n\n<div>hidden</div>\n");
|
||||
assert_eq!(blocks.len(), 1, "the html block renders no block at all");
|
||||
let (items, ..) = paragraph_of(&blocks, 0);
|
||||
assert_eq!(items_text(items), "see now");
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, InlineItem::Image { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
// Images inside headings and table cells fall back to their alt text.
|
||||
let blocks = parse_body("#  head\n\n|  |\n|---|\n| x |\n");
|
||||
let MarkdownBlock::Heading { spans, .. } = &blocks[0] else {
|
||||
panic!("the first block is a heading");
|
||||
};
|
||||
assert!(
|
||||
spans_text(spans).contains("alt h"),
|
||||
"the heading image renders as its alt text"
|
||||
);
|
||||
let MarkdownBlock::Table { table, .. } = &blocks[1] else {
|
||||
panic!("the second block is a table");
|
||||
};
|
||||
let cell_text: String = table.rows[0].cells[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_ref())
|
||||
.collect();
|
||||
assert_eq!(cell_text, "cell");
|
||||
let (spans, ..) = paragraph_of(&blocks, 0);
|
||||
assert_eq!(spans_text(spans), "see the logo now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -433,19 +363,6 @@ fn view_window(source: &str) -> (TestAppContext, VisualTestContext, Entity<Markd
|
||||
(cx, visual, state)
|
||||
}
|
||||
|
||||
/// The stable id string of the block at `block_index`. Leaks so the id can
|
||||
/// feed `debug_bounds`, which requires `&'static str`.
|
||||
fn block_id(state: &Entity<MarkdownViewState>, cx: &App, block_index: usize) -> &'static str {
|
||||
Box::<str>::leak(
|
||||
state
|
||||
.read(cx)
|
||||
.block_id(block_index)
|
||||
.expect("the block has an id")
|
||||
.to_string()
|
||||
.into_boxed_str(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_render_virtualized_and_scroll_to_a_block() {
|
||||
let mut source = String::new();
|
||||
@@ -454,16 +371,12 @@ fn blocks_render_virtualized_and_scroll_to_a_block() {
|
||||
}
|
||||
let (_cx, mut visual, state) = view_window(&source);
|
||||
|
||||
let first_id = _cx.update(|cx| block_id(&state, cx, 0));
|
||||
let target_id = _cx.update(|cx| block_id(&state, cx, 150));
|
||||
let last_id = _cx.update(|cx| block_id(&state, cx, 199));
|
||||
|
||||
assert!(
|
||||
visual.debug_bounds(first_id).is_some(),
|
||||
visual.debug_bounds("test-md-block-0").is_some(),
|
||||
"the first block renders"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds(target_id).is_none(),
|
||||
visual.debug_bounds("test-md-block-150").is_none(),
|
||||
"blocks past the viewport never render"
|
||||
);
|
||||
|
||||
@@ -475,26 +388,25 @@ fn blocks_render_virtualized_and_scroll_to_a_block() {
|
||||
});
|
||||
|
||||
assert!(
|
||||
visual.debug_bounds(target_id).is_some(),
|
||||
visual.debug_bounds("test-md-block-150").is_some(),
|
||||
"scrolling brings the target block in"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds(first_id).is_none(),
|
||||
visual.debug_bounds("test-md-block-0").is_none(),
|
||||
"the first block scrolled out"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds(last_id).is_none(),
|
||||
visual.debug_bounds("test-md-block-199").is_none(),
|
||||
"blocks past the target stay unrendered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_link_opens_its_destination() {
|
||||
let (_cx, mut visual, state) = view_window("[click me](https://example.com/welcome)");
|
||||
let (_cx, mut visual, _state) = view_window("[click me](https://example.com/welcome)");
|
||||
|
||||
let first_id = _cx.update(|cx| block_id(&state, cx, 0));
|
||||
let block = visual
|
||||
.debug_bounds(first_id)
|
||||
.debug_bounds("test-md-block-0")
|
||||
.expect("the paragraph renders");
|
||||
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
@@ -549,377 +461,21 @@ fn a_large_document_swaps_and_renders_without_freezing() {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let elapsed = std::hint::black_box(started.elapsed());
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(30),
|
||||
elapsed < std::time::Duration::from_secs(15),
|
||||
"swapping to a large document took {elapsed:?}; the frame must not
|
||||
measure every block"
|
||||
);
|
||||
|
||||
let count = _cx.update(|cx| state.read(cx).blocks().len());
|
||||
assert_eq!(count, 5000, "five blocks per section");
|
||||
|
||||
let first_id = _cx.update(|cx| block_id(&state, cx, 0));
|
||||
let last_id = _cx.update(|cx| block_id(&state, cx, 4999));
|
||||
assert!(
|
||||
visual.debug_bounds(first_id).is_some(),
|
||||
visual.debug_bounds("test-md-block-0").is_some(),
|
||||
"the first block renders after the swap"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds(last_id).is_none(),
|
||||
visual.debug_bounds("test-md-block-4999").is_none(),
|
||||
"blocks past the viewport never render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_ids_survive_prepending_and_edits_around_them() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let state = cx.new(|_| MarkdownViewState::new("feed", "first\n\nsecond\n"));
|
||||
|
||||
let (first, second) = cx.update(|cx| {
|
||||
let state = state.read(cx);
|
||||
(
|
||||
state.block_id(0).expect("the first block has an id"),
|
||||
state.block_id(1).expect("the second block has an id"),
|
||||
)
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_source("new\n\nfirst\n\nsecond\n", cx)
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
let state = state.read(cx);
|
||||
assert_eq!(
|
||||
state.block_id(1),
|
||||
Some(first.clone()),
|
||||
"prepending a block keeps the id of the old first block"
|
||||
);
|
||||
assert_eq!(
|
||||
state.block_id(2),
|
||||
Some(second.clone()),
|
||||
"prepending a block keeps the id of the old second block"
|
||||
);
|
||||
});
|
||||
|
||||
// Editing a block's text changes only that block's id.
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_source("new\n\nfirst edited\n\nsecond\n", cx)
|
||||
});
|
||||
});
|
||||
cx.update(|cx| {
|
||||
let state = state.read(cx);
|
||||
assert_eq!(
|
||||
state.block_id(2),
|
||||
Some(second),
|
||||
"the untouched block keeps its id"
|
||||
);
|
||||
assert_ne!(
|
||||
state.block_id(1),
|
||||
Some(first),
|
||||
"the edited block gets a new id"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_spans_pieces_and_images_contribute_no_text() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let state = cx.new(|_| {
|
||||
MarkdownViewState::new(
|
||||
"sel",
|
||||
"alpha\n\nbravo  charlie\n\n- delta\n- echo\n",
|
||||
)
|
||||
});
|
||||
|
||||
let selected = cx.update(|cx| {
|
||||
state.update(cx, |view, cx| {
|
||||
view.select_range(0, 30, cx);
|
||||
view.selected_text()
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
selected.as_deref(),
|
||||
Some("alpha\nbravo \n charlie\ndelta\nec"),
|
||||
"pieces join with newlines and images contribute no text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_empty_past_the_document_end_and_clears_on_reparse() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let state = cx.new(|_| MarkdownViewState::new("sel", "only\n"));
|
||||
|
||||
let (overshoot, after_reparse) = cx.update(|cx| {
|
||||
state.update(cx, |view, cx| {
|
||||
view.select_range(0, 10_000, cx);
|
||||
let overshoot = view.selected_text();
|
||||
view.set_source("other\n", cx);
|
||||
(overshoot, view.selected_text())
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
overshoot.as_deref(),
|
||||
Some("only"),
|
||||
"ranges clamp to the document"
|
||||
);
|
||||
assert_eq!(after_reparse, None, "reparsing clears the selection");
|
||||
}
|
||||
|
||||
struct TextViewHost {
|
||||
link_handler: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Render for TextViewHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let clicks = self.link_handler.clone();
|
||||
div()
|
||||
.debug_selector(|| "text-view".to_string())
|
||||
.size_full()
|
||||
.child(
|
||||
TextView::markdown(
|
||||
"text-view",
|
||||
"[a very long link label that fills the line](https://example.com/welcome)",
|
||||
)
|
||||
.on_link_click(move |url, _window, _cx| {
|
||||
clicks.borrow_mut().push(url.to_string());
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_view_clicks_route_through_the_link_handler() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let clicks: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let clicks_cell = clicks.clone();
|
||||
let window = cx.add_window(move |_, _| TextViewHost {
|
||||
link_handler: clicks_cell,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("text-view")
|
||||
.expect("the text view renders");
|
||||
let position = point(bounds.left() + px(4.), bounds.top() + px(10.));
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
clicks.borrow().as_slice(),
|
||||
["https://example.com/welcome".to_string()],
|
||||
"the click lands on the link and routes through the handler"
|
||||
);
|
||||
}
|
||||
|
||||
struct SelectionHost {
|
||||
state: Entity<MarkdownViewState>,
|
||||
}
|
||||
|
||||
impl Render for SelectionHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(div().w(rems(30.)).child(TextView::new(&self.state)))
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_window(
|
||||
source: &str,
|
||||
) -> (TestAppContext, VisualTestContext, Entity<MarkdownViewState>) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
cx.update(bind_text_view_keys);
|
||||
let state = MarkdownViewState::new("sel-view", source);
|
||||
let state_cell: Rc<RefCell<Option<Entity<MarkdownViewState>>>> = Rc::new(RefCell::new(None));
|
||||
let cell = state_cell.clone();
|
||||
let window = cx.add_window(move |_, cx| {
|
||||
let view = cx.new(|_| state);
|
||||
cell.borrow_mut().replace(view.clone());
|
||||
SelectionHost { state: view }
|
||||
});
|
||||
let state = state_cell.borrow().clone().expect("state captured");
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, visual, state)
|
||||
}
|
||||
|
||||
/// Leaks a block id so it can feed `debug_bounds`, which needs `&'static str`.
|
||||
fn leaked_block_id(
|
||||
state: &Entity<MarkdownViewState>,
|
||||
cx: &App,
|
||||
block_index: usize,
|
||||
) -> &'static str {
|
||||
Box::<str>::leak(
|
||||
state
|
||||
.read(cx)
|
||||
.block_id(block_index)
|
||||
.expect("the block has an id")
|
||||
.to_string()
|
||||
.into_boxed_str(),
|
||||
)
|
||||
}
|
||||
|
||||
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_across_paragraphs_selects_and_copies() {
|
||||
let (cx, mut visual, state) = selection_window("alpha bravo\n\ncharlie delta\n");
|
||||
|
||||
let first = cx.update(|cx| leaked_block_id(&state, cx, 0));
|
||||
let second = cx.update(|cx| leaked_block_id(&state, cx, 1));
|
||||
|
||||
let start = {
|
||||
let bounds = visual.debug_bounds(first).expect("the first block renders");
|
||||
gpui::point(bounds.left() + px(4.), bounds.top() + px(8.))
|
||||
};
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position: start,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
let end = {
|
||||
let bounds = visual
|
||||
.debug_bounds(second)
|
||||
.expect("the second block renders");
|
||||
gpui::point(bounds.left() + px(40.), bounds.top() + px(8.))
|
||||
};
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: end,
|
||||
..Default::default()
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position: end,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let selected = cx.update(|cx| state.read(cx).selected_text());
|
||||
assert_eq!(
|
||||
selected.as_deref(),
|
||||
Some("alpha bravo\ncharli"),
|
||||
"the drag selects from the press through the pointer"
|
||||
);
|
||||
|
||||
visual.dispatch_action(gpui_native_editor::markdown::Copy);
|
||||
let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(
|
||||
clipboard.as_deref(),
|
||||
selected.as_deref(),
|
||||
"the copy action copies the selected text"
|
||||
);
|
||||
}
|
||||
|
||||
struct TaskHost {
|
||||
toggles: Rc<RefCell<Vec<(usize, bool)>>>,
|
||||
}
|
||||
|
||||
impl Render for TaskHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let toggles = self.toggles.clone();
|
||||
div().size_full().child(
|
||||
TextView::markdown("task-view", "- [ ] first\n- [x] second\n").on_task_toggle(
|
||||
move |block, checked, _window, _cx| {
|
||||
toggles.borrow_mut().push((block, checked));
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_task_checkbox_toggles_it_through_the_handler() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let toggles: Rc<RefCell<Vec<(usize, bool)>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let toggles_cell = toggles.clone();
|
||||
let window = cx.add_window(move |_, _| TaskHost {
|
||||
toggles: toggles_cell,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("task-toggle-0")
|
||||
.expect("the task renders");
|
||||
click(
|
||||
&mut visual,
|
||||
gpui::point(bounds.center().x, bounds.center().y),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
toggles.borrow().as_slice(),
|
||||
[(0, true)],
|
||||
"checking the first task reports its block and the new state"
|
||||
);
|
||||
}
|
||||
|
||||
struct CodeHost;
|
||||
|
||||
impl Render for CodeHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(TextView::markdown(
|
||||
"code-host",
|
||||
"```text\nfn main() {}\n```\n",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_code_block_copy_button_copies_the_fenced_source() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let window = cx.add_window(|_, _| CodeHost);
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("copy-code")
|
||||
.expect("the copy button renders");
|
||||
click(
|
||||
&mut visual,
|
||||
gpui::point(bounds.center().x, bounds.center().y),
|
||||
);
|
||||
|
||||
let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(
|
||||
clipboard.as_deref(),
|
||||
Some("fn main() {}"),
|
||||
"the copy button copies the fenced source"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "gpui-native-icons"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "System icons for GPUI: semantic roles resolved per platform, a bundled Lucide fallback, and app-defined icon sets"
|
||||
description = "System icons for GPUI: semantic roles resolved per platform, with a bundled Lucide fallback"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 361 B |
@@ -4,9 +4,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use gpui::SharedString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::IconRole;
|
||||
|
||||
@@ -146,33 +144,14 @@ fn icon_score(path: &Path, requested: u32) -> SizeScore {
|
||||
.unwrap_or(SizeScore::Any)
|
||||
}
|
||||
|
||||
/// A resolved icon file and the asset key its bytes are served under.
|
||||
struct CachedIcon {
|
||||
path: PathBuf,
|
||||
asset_key: SharedString,
|
||||
}
|
||||
|
||||
/// The discovered icons, cached for the process.
|
||||
type IconCache = Mutex<HashMap<(&'static str, u32), Option<CachedIcon>>>;
|
||||
/// The discovered icon file for a role at a size, cached for the process.
|
||||
type IconCache = Mutex<HashMap<(&'static str, u32), Option<PathBuf>>>;
|
||||
|
||||
pub(crate) fn cached_file(role: IconRole, requested: u32) -> Option<PathBuf> {
|
||||
cached_icon(role, requested, |icon| icon.path.clone())
|
||||
}
|
||||
|
||||
/// The asset key a resolved freedesktop file is served under.
|
||||
pub(crate) fn cached_asset_key(role: IconRole, requested: u32) -> Option<SharedString> {
|
||||
cached_icon(role, requested, |icon| icon.asset_key.clone())
|
||||
}
|
||||
|
||||
fn cached_icon<T, P>(role: IconRole, requested: u32, project: P) -> Option<T>
|
||||
where
|
||||
P: FnOnce(&CachedIcon) -> T,
|
||||
{
|
||||
static CACHE: OnceLock<IconCache> = OnceLock::new();
|
||||
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
// A poisoned lock means a lookup panicked; its stale data beats a panic.
|
||||
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut cache = cache.lock().expect("icon cache poisoned");
|
||||
|
||||
cache
|
||||
.entry((role.slug(), requested))
|
||||
@@ -183,16 +162,8 @@ where
|
||||
requested,
|
||||
&base_dirs(),
|
||||
)
|
||||
.map(|path| CachedIcon {
|
||||
asset_key: SharedString::from(format!(
|
||||
"icons/freedesktop/{}-{requested}.svg",
|
||||
role.slug()
|
||||
)),
|
||||
path,
|
||||
})
|
||||
})
|
||||
.as_ref()
|
||||
.map(project)
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -282,12 +253,7 @@ mod tests {
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("gpui-native-icons-{name}-{}", std::process::id()));
|
||||
// NotFound is the expected first run; anything else is a setup failure.
|
||||
if let Err(error) = std::fs::remove_dir_all(&root)
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
panic!("failed to clear scratch dir {root:?}: {error}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("scratch dir");
|
||||
root
|
||||
}
|
||||
|
||||
+45
-138
@@ -1,11 +1,10 @@
|
||||
//! System icons for GPUI: components and apps reference semantic roles,
|
||||
//! and the crate resolves each role to a glyph for the current platform.
|
||||
//! Apps ship their own icon set by implementing [`IconNamed`].
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce,
|
||||
SharedString, Styled as _, Transformation, Window, div, px, svg,
|
||||
AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
|
||||
Styled as _, Transformation, Window, div, px, svg,
|
||||
};
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
@@ -37,8 +36,6 @@ pub enum IconRole {
|
||||
WindowMaximize,
|
||||
WindowRestore,
|
||||
Loader,
|
||||
Microphone,
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl IconRole {
|
||||
@@ -65,8 +62,6 @@ impl IconRole {
|
||||
Self::WindowMaximize,
|
||||
Self::WindowRestore,
|
||||
Self::Loader,
|
||||
Self::Microphone,
|
||||
Self::Stop,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -93,13 +88,10 @@ impl IconRole {
|
||||
Self::WindowMaximize => "icons/square.svg",
|
||||
Self::WindowRestore => "icons/copy.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Microphone => "icons/mic.svg",
|
||||
Self::Stop => "icons/square.svg",
|
||||
}
|
||||
}
|
||||
|
||||
/// The stable identifier used for element and debug ids.
|
||||
pub fn slug(self) -> &'static str {
|
||||
fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Check => "check",
|
||||
Self::Close => "close",
|
||||
@@ -122,8 +114,6 @@ impl IconRole {
|
||||
Self::WindowMaximize => "window-maximize",
|
||||
Self::WindowRestore => "window-restore",
|
||||
Self::Loader => "loader",
|
||||
Self::Microphone => "microphone",
|
||||
Self::Stop => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,67 +190,30 @@ impl IconRole {
|
||||
Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"],
|
||||
Self::WindowRestore => &["window-restore-symbolic", "window-restore"],
|
||||
Self::Loader => &["process-working-symbolic", "process-working"],
|
||||
Self::Microphone => &["audio-input-microphone-symbolic", "audio-input-microphone"],
|
||||
Self::Stop => &["process-stop-symbolic", "process-stop"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph named by an application's own icon set.
|
||||
pub trait IconNamed {
|
||||
/// The SVG asset path, e.g. `icons/commit.svg`.
|
||||
fn path(self) -> SharedString;
|
||||
}
|
||||
|
||||
impl IconNamed for IconRole {
|
||||
fn path(self) -> SharedString {
|
||||
SharedString::from(self.asset_path())
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph an icon draws.
|
||||
#[derive(Clone)]
|
||||
enum Glyph {
|
||||
Role(IconRole),
|
||||
Named(SharedString),
|
||||
}
|
||||
|
||||
/// An icon element. Unpainted unless a color is set.
|
||||
#[derive(Clone, IntoElement)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct Icon {
|
||||
glyph: Glyph,
|
||||
role: IconRole,
|
||||
size: Pixels,
|
||||
color: Option<Hsla>,
|
||||
transformation: Option<Transformation>,
|
||||
}
|
||||
|
||||
/// The default icon size; this crate sits beside the theme crate without depending on it.
|
||||
const DEFAULT_ICON_SIZE: Pixels = px(16.0);
|
||||
|
||||
/// Creates an icon for the role at the default size.
|
||||
/// Creates an icon for the role at the default size of 16px.
|
||||
pub fn icon(role: IconRole) -> Icon {
|
||||
Icon {
|
||||
glyph: Glyph::Role(role),
|
||||
size: DEFAULT_ICON_SIZE,
|
||||
role,
|
||||
size: px(16.0),
|
||||
color: None,
|
||||
transformation: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
/// Creates an icon from an application-defined icon set.
|
||||
pub fn named<I>(named: I) -> Self
|
||||
where
|
||||
I: IconNamed,
|
||||
{
|
||||
Self {
|
||||
glyph: Glyph::Named(named.path()),
|
||||
size: DEFAULT_ICON_SIZE,
|
||||
color: None,
|
||||
transformation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: Pixels) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
@@ -279,97 +232,65 @@ impl Icon {
|
||||
|
||||
/// The asset key a resolved freedesktop file is served under.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn freedesktop_asset_path(size: Pixels, role: &IconRole) -> Option<SharedString> {
|
||||
let size = size.as_f32().max(1.0).round() as u32;
|
||||
freedesktop::cached_asset_key(*role, size)
|
||||
fn freedesktop_asset_path(&self) -> Option<SharedString> {
|
||||
let size = self.size.as_f32().max(1.0).round() as u32;
|
||||
freedesktop::cached_file(self.role, size).map(|_| {
|
||||
SharedString::from(format!("icons/freedesktop/{}-{size}.svg", self.role.slug()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Roles resolve per platform.
|
||||
fn render_role(
|
||||
size: Pixels,
|
||||
color: Option<Hsla>,
|
||||
transformation: Option<Transformation>,
|
||||
role: IconRole,
|
||||
) -> AnyElement {
|
||||
impl RenderOnce for Icon {
|
||||
fn render(self, _window: &mut Window, _cx: &mut gpui::App) -> impl IntoElement {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(glyph) = segoe::glyph(role) {
|
||||
return Self::render_glyph(size, color, glyph).into_any_element();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(path) = Self::freedesktop_asset_path(size, &role) {
|
||||
return Self::render_svg_path(size, color, transformation, path).into_any_element();
|
||||
}
|
||||
Self::render_svg_path(
|
||||
size,
|
||||
color,
|
||||
transformation,
|
||||
SharedString::from(role.asset_path()),
|
||||
)
|
||||
.into_any_element()
|
||||
return self.render_glyph();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return self.render_svg();
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: the Segoe Fluent glyph drawn as text in the system font.
|
||||
impl Icon {
|
||||
/// Windows: the Segoe Fluent codepoint drawn as text in the system font;
|
||||
/// roles without a codepoint fall back to the bundled SVG.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn render_glyph(size: Pixels, color: Option<Hsla>, glyph: &'static str) -> AnyElement {
|
||||
div()
|
||||
fn render_glyph(self) -> impl IntoElement {
|
||||
match segoe::glyph(self.role) {
|
||||
Some(glyph) => div()
|
||||
.flex_none()
|
||||
.size(size)
|
||||
.size(self.size)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.font_family(segoe::FONT_FAMILY)
|
||||
.text_size(size)
|
||||
.when_some(color, |el, color| el.text_color(color))
|
||||
.child(SharedString::new_static(glyph)),
|
||||
.text_size(self.size)
|
||||
.when_some(self.color, |el, color| el.text_color(color))
|
||||
.child(glyph.to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
.into_any_element(),
|
||||
None => self.render_svg().into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_svg_path(
|
||||
size: Pixels,
|
||||
color: Option<Hsla>,
|
||||
transformation: Option<Transformation>,
|
||||
path: SharedString,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.flex_none()
|
||||
.size(size)
|
||||
.child(
|
||||
fn render_svg(self) -> impl IntoElement {
|
||||
#[cfg(target_os = "linux")]
|
||||
let path: SharedString = self
|
||||
.freedesktop_asset_path()
|
||||
.unwrap_or_else(|| SharedString::from(self.role.asset_path()));
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let path: SharedString = SharedString::from(self.role.asset_path());
|
||||
|
||||
div().flex_none().size(self.size).child(
|
||||
svg()
|
||||
.size_full()
|
||||
.path(path)
|
||||
.when_some(color, |el, color| el.text_color(color))
|
||||
.when_some(transformation, |el, transformation| {
|
||||
.when_some(self.color, |el, color| el.text_color(color))
|
||||
.when_some(self.transformation, |el, transformation| {
|
||||
el.with_transformation(transformation)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<I> for Icon
|
||||
where
|
||||
I: IconNamed,
|
||||
{
|
||||
fn from(named: I) -> Self {
|
||||
Icon::named(named)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Icon {
|
||||
fn render(self, _window: &mut Window, _cx: &mut gpui::App) -> impl IntoElement {
|
||||
let Icon {
|
||||
glyph,
|
||||
size,
|
||||
color,
|
||||
transformation,
|
||||
} = self;
|
||||
match glyph {
|
||||
Glyph::Named(path) => Self::render_svg_path(size, color, transformation, path),
|
||||
Glyph::Role(role) => Self::render_role(size, color, transformation, role),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +318,6 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")),
|
||||
"icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")),
|
||||
"icons/loader.svg" => Some(include_bytes!("../assets/loader.svg")),
|
||||
"icons/mic.svg" => Some(include_bytes!("../assets/mic.svg")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -438,19 +358,6 @@ impl AssetSource for IconAssets {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_role_has_a_unique_slug() {
|
||||
for (index, role) in IconRole::all().iter().enumerate() {
|
||||
for other in &IconRole::all()[index + 1..] {
|
||||
assert_ne!(
|
||||
role.slug(),
|
||||
other.slug(),
|
||||
"{role:?} and {other:?} share a slug"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_role_resolves_to_a_bundled_asset() {
|
||||
for role in IconRole::all() {
|
||||
|
||||
+26
-27
@@ -3,35 +3,34 @@
|
||||
|
||||
use crate::IconRole;
|
||||
|
||||
/// The Segoe Fluent Icons glyph for the role.
|
||||
pub(crate) fn glyph(role: IconRole) -> Option<&'static str> {
|
||||
let glyph = match role {
|
||||
IconRole::Check => "\u{E73E}", // CheckMark
|
||||
IconRole::Close => "\u{E8BB}", // ChromeClose
|
||||
IconRole::ChevronDown => "\u{E70D}", // ChevronDown
|
||||
IconRole::ChevronLeft => "\u{E76B}", // ChevronLeft
|
||||
IconRole::ChevronRight => "\u{E76C}", // ChevronRight
|
||||
IconRole::ChevronUp => "\u{E70E}", // ChevronUp
|
||||
IconRole::Plus => "\u{E710}", // Add
|
||||
IconRole::Minus => "\u{E738}", // Remove
|
||||
IconRole::Search => "\u{E721}", // Search
|
||||
IconRole::Folder => "\u{E8B7}", // Folder
|
||||
IconRole::Warning => "\u{E7BA}", // Warning
|
||||
IconRole::Info => "\u{E946}", // Info
|
||||
IconRole::Copy => "\u{E8C8}", // Copy
|
||||
IconRole::Eye => "\u{E7B3}", // RedEye
|
||||
IconRole::EyeOff => "\u{ED1A}", // Hide
|
||||
IconRole::PanelLeft => "\u{E90C}", // DockLeft
|
||||
IconRole::PanelRight => "\u{E90D}", // DockRight
|
||||
IconRole::WindowMinimize => "\u{E921}", // ChromeMinimize
|
||||
IconRole::WindowMaximize => "\u{E922}", // ChromeMaximize
|
||||
IconRole::WindowRestore => "\u{E923}", // ChromeRestore
|
||||
IconRole::Microphone => "\u{E720}", // Microphone
|
||||
IconRole::Stop => "\u{E71A}", // Stop
|
||||
// No Segoe spinner glyph, the icon falls back to the bundled SVG.
|
||||
/// The Segoe Fluent Icons codepoint for the role.
|
||||
pub(crate) fn glyph(role: IconRole) -> Option<char> {
|
||||
let codepoint = match role {
|
||||
// No Segoe spinner glyph; the icon falls back to the bundled SVG,
|
||||
// which the spinner rotates.
|
||||
IconRole::Loader => return None,
|
||||
IconRole::Check => 0xE73E, // CheckMark
|
||||
IconRole::Close => 0xE8BB, // ChromeClose
|
||||
IconRole::ChevronDown => 0xE70D, // ChevronDown
|
||||
IconRole::ChevronLeft => 0xE76B, // ChevronLeft
|
||||
IconRole::ChevronRight => 0xE76C, // ChevronRight
|
||||
IconRole::ChevronUp => 0xE70E, // ChevronUp
|
||||
IconRole::Plus => 0xE710, // Add
|
||||
IconRole::Minus => 0xE738, // Remove
|
||||
IconRole::Search => 0xE721, // Search
|
||||
IconRole::Folder => 0xE8B7, // Folder
|
||||
IconRole::Warning => 0xE7BA, // Warning
|
||||
IconRole::Info => 0xE946, // Info
|
||||
IconRole::Copy => 0xE8C8, // Copy
|
||||
IconRole::Eye => 0xE7B3, // RedEye
|
||||
IconRole::EyeOff => 0xED1A, // Hide
|
||||
IconRole::PanelLeft => 0xE90C, // DockLeft
|
||||
IconRole::PanelRight => 0xE90D, // DockRight
|
||||
IconRole::WindowMinimize => 0xE921, // ChromeMinimize
|
||||
IconRole::WindowMaximize => 0xE922, // ChromeMaximize
|
||||
IconRole::WindowRestore => 0xE923, // ChromeRestore
|
||||
};
|
||||
Some(glyph)
|
||||
char::from_u32(codepoint)
|
||||
}
|
||||
|
||||
/// The system font the codepoints resolve against (Windows 11+).
|
||||
|
||||
@@ -8,7 +8,7 @@ description = "Application layout bootstrap: sidebars and a center dock, one car
|
||||
gpui = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
gpui-native-utils = { workspace = true }
|
||||
gpui-native-core = { workspace = true, optional = true }
|
||||
gpui-native-core = { workspace = true, optional = true, features = ["button"] }
|
||||
gpui-native-data = { workspace = true, optional = true, features = ["sidebar"] }
|
||||
gpui-native-icons = { workspace = true, optional = true }
|
||||
|
||||
@@ -33,7 +33,6 @@ shell = [
|
||||
"resize_handle",
|
||||
"window_controls",
|
||||
"dep:gpui-native-core",
|
||||
"gpui-native-core/button",
|
||||
"dep:gpui-native-data",
|
||||
"dep:gpui-native-icons",
|
||||
]
|
||||
|
||||
+56
-55
@@ -15,7 +15,7 @@ use gpui_native_core::common::{h_flex, v_flex};
|
||||
use gpui_native_core::context_menu::ContextMenu;
|
||||
use gpui_native_core::menu::MenuItem;
|
||||
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
||||
use gpui_native_icons::{Icon, IconRole};
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::resize_handle::ResizeHandle;
|
||||
@@ -32,11 +32,10 @@ type PanelBuilder = Box<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
||||
type OpenRightSidebarHandler = Rc<dyn Fn(&mut App)>;
|
||||
|
||||
/// A registered panel: its identity, chrome, and how its view is built.
|
||||
#[must_use]
|
||||
pub struct PanelDescriptor {
|
||||
id: SharedString,
|
||||
title: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
build: PanelBuilder,
|
||||
right_sidebar: Option<PanelBuilder>,
|
||||
}
|
||||
@@ -59,11 +58,8 @@ impl PanelDescriptor {
|
||||
}
|
||||
|
||||
/// Shows an icon leading the tab label.
|
||||
pub fn icon<I>(mut self, icon: I) -> Self
|
||||
where
|
||||
I: Into<Icon>,
|
||||
{
|
||||
self.icon = Some(icon.into());
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -134,7 +130,7 @@ impl Render for DragPreview {
|
||||
struct PanelTab {
|
||||
panel: SharedString,
|
||||
title: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
view: AnyView,
|
||||
right_view: Option<AnyView>,
|
||||
}
|
||||
@@ -219,7 +215,7 @@ impl DockState {
|
||||
let tab = PanelTab {
|
||||
panel: SharedString::from(id),
|
||||
title: panel.title.clone(),
|
||||
icon: panel.icon.clone(),
|
||||
icon: panel.icon,
|
||||
view: (panel.build)(window, cx),
|
||||
right_view,
|
||||
};
|
||||
@@ -238,7 +234,7 @@ impl DockState {
|
||||
|
||||
/// Points the right sidebar at the panel's own view and asks the app to open it.
|
||||
fn show_right_view_of(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
let right_view = Self::tab_in(&self.root, id).and_then(|tab| tab.right_view.clone());
|
||||
let right_view = tab_in(&self.root, id).and_then(|tab| tab.right_view.clone());
|
||||
let Some(right_view) = right_view else {
|
||||
return;
|
||||
};
|
||||
@@ -301,7 +297,7 @@ impl DockState {
|
||||
|
||||
// The sidebar showed the closed panel's view.
|
||||
if self.right_view.as_ref() == closed_right_view.as_ref() {
|
||||
self.right_view = Self::first_right_view(&self.root);
|
||||
self.right_view = first_right_view(&self.root);
|
||||
}
|
||||
|
||||
if emptied {
|
||||
@@ -342,7 +338,7 @@ impl DockState {
|
||||
})
|
||||
})
|
||||
.and_then(|tab| tab.right_view.clone())
|
||||
.or_else(|| Self::first_right_view(&self.root));
|
||||
.or_else(|| first_right_view(&self.root));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -373,7 +369,7 @@ impl DockState {
|
||||
}
|
||||
|
||||
// The closed pane's views are gone; show the first remaining one.
|
||||
self.right_view = Self::first_right_view(&self.root);
|
||||
self.right_view = first_right_view(&self.root);
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
@@ -496,7 +492,7 @@ impl DockState {
|
||||
|
||||
/// The active tab of the pane new panels open into.
|
||||
pub fn active_panel(&self) -> Option<SharedString> {
|
||||
let pane = Self::pane_in(&self.root, self.target_pane_key())?;
|
||||
let pane = pane_in(&self.root, self.target_pane_key())?;
|
||||
pane.active.clone()
|
||||
}
|
||||
|
||||
@@ -523,7 +519,7 @@ impl DockState {
|
||||
}
|
||||
|
||||
fn target_pane_key(&self) -> u64 {
|
||||
if Self::pane_in(&self.root, self.active_pane).is_some() {
|
||||
if pane_in(&self.root, self.active_pane).is_some() {
|
||||
self.active_pane
|
||||
} else {
|
||||
self.first_pane_key().unwrap_or(0)
|
||||
@@ -614,7 +610,7 @@ impl DockState {
|
||||
) {
|
||||
match node {
|
||||
Node::Pane(pane) if pane.key == key => {
|
||||
let old_pane = Self::take_node(node);
|
||||
let old_pane = take_node(node);
|
||||
let split = if direction.leads() {
|
||||
Split {
|
||||
key: split_key,
|
||||
@@ -636,9 +632,9 @@ impl DockState {
|
||||
}
|
||||
Node::Pane(_) => {}
|
||||
Node::Split(split) => {
|
||||
if Self::pane_in(&split.a, key).is_some() {
|
||||
if pane_in(&split.a, key).is_some() {
|
||||
Self::wrap_in(&mut split.a, key, new_pane, direction, split_key);
|
||||
} else if Self::pane_in(&split.b, key).is_some() {
|
||||
} else if pane_in(&split.b, key).is_some() {
|
||||
Self::wrap_in(&mut split.b, key, new_pane, direction, split_key);
|
||||
}
|
||||
}
|
||||
@@ -655,14 +651,14 @@ impl DockState {
|
||||
return;
|
||||
};
|
||||
|
||||
let a_direct = Self::direct_pane_key(&split.a) == Some(key);
|
||||
let b_direct = Self::direct_pane_key(&split.b) == Some(key);
|
||||
let a_direct = direct_pane_key(&split.a) == Some(key);
|
||||
let b_direct = direct_pane_key(&split.b) == Some(key);
|
||||
|
||||
if a_direct != b_direct {
|
||||
let survivor = if a_direct {
|
||||
Self::take_node(&mut split.b)
|
||||
take_node(&mut split.b)
|
||||
} else {
|
||||
Self::take_node(&mut split.a)
|
||||
take_node(&mut split.a)
|
||||
};
|
||||
*node = survivor;
|
||||
} else {
|
||||
@@ -683,7 +679,7 @@ impl DockState {
|
||||
.map(|tab| TabView {
|
||||
panel: tab.panel.clone(),
|
||||
title: tab.title.clone(),
|
||||
icon: tab.icon.clone(),
|
||||
icon: tab.icon,
|
||||
view: tab.view.clone(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -700,35 +696,42 @@ impl DockState {
|
||||
}
|
||||
snap(&self.root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an open tab by panel id, anywhere in the tree.
|
||||
fn tab_in<'a>(node: &'a Node, id: &str) -> Option<&'a PanelTab> {
|
||||
/// Finds a pane by key anywhere in the tree.
|
||||
fn pane_in(node: &Node, key: u64) -> Option<&Pane> {
|
||||
match node {
|
||||
Node::Pane(pane) => (pane.key == key).then_some(pane),
|
||||
Node::Split(split) => pane_in(&split.a, key).or_else(|| pane_in(&split.b, key)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an open tab by panel id, anywhere in the tree.
|
||||
fn tab_in<'a>(node: &'a Node, id: &str) -> Option<&'a PanelTab> {
|
||||
match node {
|
||||
Node::Pane(pane) => pane.tabs.iter().find(|tab| tab.panel.as_ref() == id),
|
||||
Node::Split(split) => Self::tab_in(&split.a, id).or_else(|| Self::tab_in(&split.b, id)),
|
||||
}
|
||||
Node::Split(split) => tab_in(&split.a, id).or_else(|| tab_in(&split.b, id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first open tab's right sidebar view, in display order.
|
||||
fn first_right_view(node: &Node) -> Option<AnyView> {
|
||||
/// The first open tab's right sidebar view, in display order.
|
||||
fn first_right_view(node: &Node) -> Option<AnyView> {
|
||||
match node {
|
||||
Node::Pane(pane) => pane.tabs.iter().find_map(|tab| tab.right_view.clone()),
|
||||
Node::Split(split) => {
|
||||
Self::first_right_view(&split.a).or_else(|| Self::first_right_view(&split.b))
|
||||
}
|
||||
}
|
||||
Node::Split(split) => first_right_view(&split.a).or_else(|| first_right_view(&split.b)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane key of a node that is a pane, for seam-level tree edits.
|
||||
fn direct_pane_key(node: &Node) -> Option<u64> {
|
||||
/// The pane key of a node that is a pane, for seam-level tree edits.
|
||||
fn direct_pane_key(node: &Node) -> Option<u64> {
|
||||
match node {
|
||||
Node::Pane(pane) => Some(pane.key),
|
||||
Node::Split(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a node out, leaving a placeholder that is never read again.
|
||||
fn take_node(node: &mut Node) -> Node {
|
||||
/// Takes a node out, leaving a placeholder that is never read again.
|
||||
fn take_node(node: &mut Node) -> Node {
|
||||
std::mem::replace(
|
||||
node,
|
||||
Node::Pane(Pane {
|
||||
@@ -737,7 +740,6 @@ impl DockState {
|
||||
active: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One open panel view, per frame.
|
||||
@@ -745,7 +747,7 @@ impl DockState {
|
||||
struct TabView {
|
||||
panel: SharedString,
|
||||
title: SharedString,
|
||||
icon: Option<Icon>,
|
||||
icon: Option<IconRole>,
|
||||
view: AnyView,
|
||||
}
|
||||
|
||||
@@ -771,7 +773,6 @@ enum TreeView {
|
||||
|
||||
/// A dock: renders the pane tree of a [`DockState`].
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Dock {
|
||||
id: ElementId,
|
||||
state: Entity<DockState>,
|
||||
@@ -1034,18 +1035,14 @@ fn render_pane(
|
||||
.selected(selected)
|
||||
.tabs({
|
||||
let tabs = pane.tabs.clone();
|
||||
move |_, cx| {
|
||||
let theme = cx.theme();
|
||||
let metrics = theme.sizes.resolve(Size::default());
|
||||
|
||||
move |_, _| {
|
||||
tabs.iter()
|
||||
.map(|tab| {
|
||||
Tab::new(tab.panel.clone(), tab.title.clone())
|
||||
.when_some(tab.icon.clone(), |entry, icon| {
|
||||
entry.lead(icon.size(metrics.icon_size))
|
||||
})
|
||||
.closeable(true)
|
||||
.wrap({
|
||||
let mut entry = Tab::new(tab.panel.clone(), tab.title.clone());
|
||||
if let Some(role) = tab.icon {
|
||||
entry = entry.icon(role);
|
||||
}
|
||||
entry.closeable(true).wrap({
|
||||
let panel = tab.panel.clone();
|
||||
let title = tab.title.clone();
|
||||
move |tab| {
|
||||
@@ -1091,7 +1088,6 @@ fn render_pane(
|
||||
});
|
||||
|
||||
let bar_id = SharedString::from(format!("{dock_id}-pane-{}-bar", pane.key));
|
||||
let strip_id = SharedString::from(format!("{dock_id}-pane-{}-strip", pane.key));
|
||||
|
||||
h_flex()
|
||||
.id(bar_id.clone())
|
||||
@@ -1108,8 +1104,13 @@ fn render_pane(
|
||||
.pr(padding_x)
|
||||
.child(tabs)
|
||||
.child(titlebar_strip(
|
||||
div().id(strip_id.clone()).flex_1().self_stretch(),
|
||||
strip_id,
|
||||
div()
|
||||
.id(SharedString::from(format!(
|
||||
"{dock_id}-pane-{}-strip",
|
||||
pane.key
|
||||
)))
|
||||
.flex_1()
|
||||
.self_stretch(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
|
||||
@@ -6,11 +6,15 @@ use gpui::{
|
||||
InteractiveElement as _, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, Window, canvas, div, px,
|
||||
};
|
||||
use gpui_native_theme::{PANE_DEFAULT_SIZE, PANE_MIN_SIZE};
|
||||
use gpui_native_utils::StyledExt as _;
|
||||
|
||||
use crate::resize_handle::ResizeHandle;
|
||||
|
||||
/// The size a pane starts at before the group has measured its extent.
|
||||
const DEFAULT_PANE_SIZE: f32 = 200.0;
|
||||
/// The floor a pane keeps while dragging unless the caller lowers it.
|
||||
const DEFAULT_PANE_MIN: f32 = 48.0;
|
||||
|
||||
type DebugSelector = std::rc::Rc<dyn Fn() -> String>;
|
||||
|
||||
/// The retained pane sizes of one resizable group.
|
||||
@@ -52,12 +56,11 @@ impl ResizableState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The equal split of the measured extent, or the default pane size unmeasured.
|
||||
/// The equal split of the measured extent.
|
||||
fn equal_split(&self, pane_count: usize) -> Pixels {
|
||||
match self.measured {
|
||||
// usize can exceed f32's exact integer range; bound it via u32 before casting.
|
||||
Some(extent) => extent / u32::try_from(pane_count).unwrap_or(u32::MAX) as f32,
|
||||
None => PANE_DEFAULT_SIZE,
|
||||
Some(extent) => extent / pane_count as f32,
|
||||
None => px(DEFAULT_PANE_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +93,8 @@ impl ResizableState {
|
||||
size = size.min(ceiling);
|
||||
}
|
||||
|
||||
// Earlier panes hold the equal split they already render at, so the
|
||||
// first drag does not snap them to a backfilled default.
|
||||
let backfill = self.equal_split(pane_count);
|
||||
while self.sizes.len() <= ix {
|
||||
self.sizes.push(backfill);
|
||||
self.sizes.push(px(DEFAULT_PANE_SIZE));
|
||||
}
|
||||
|
||||
self.sizes[ix] = size;
|
||||
@@ -105,7 +105,7 @@ impl ResizableState {
|
||||
fn reset_pane(&mut self, ix: usize, pane_count: usize, cx: &mut Context<Self>) {
|
||||
let split = self.equal_split(pane_count);
|
||||
while self.sizes.len() <= ix {
|
||||
self.sizes.push(split);
|
||||
self.sizes.push(px(DEFAULT_PANE_SIZE));
|
||||
}
|
||||
self.sizes[ix] = split;
|
||||
cx.notify();
|
||||
@@ -113,7 +113,6 @@ impl ResizableState {
|
||||
}
|
||||
|
||||
/// One pane of a resizable group: its content and its drag floor.
|
||||
#[must_use]
|
||||
pub struct ResizablePane {
|
||||
content: AnyElement,
|
||||
min: Pixels,
|
||||
@@ -121,7 +120,7 @@ pub struct ResizablePane {
|
||||
|
||||
impl ResizablePane {
|
||||
/// The floor a pane keeps while dragging unless the caller lowers it.
|
||||
pub const DEFAULT_MIN: Pixels = PANE_MIN_SIZE;
|
||||
pub const DEFAULT_MIN: Pixels = px(DEFAULT_PANE_MIN);
|
||||
|
||||
/// Wraps this element as a pane.
|
||||
pub fn new<E>(content: E) -> Self
|
||||
@@ -143,7 +142,6 @@ impl ResizablePane {
|
||||
|
||||
/// A group of panes stacked along one axis, separated by drag dividers.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Resizable {
|
||||
id: ElementId,
|
||||
state: Entity<ResizableState>,
|
||||
@@ -222,37 +220,39 @@ impl RenderOnce for Resizable {
|
||||
.map(|ix| state.read(cx).pane_size(ix, pane_count))
|
||||
.collect();
|
||||
|
||||
div()
|
||||
let mut group = div()
|
||||
.flex()
|
||||
.overflow_hidden()
|
||||
.when(axis == Axis::Vertical, |group| group.flex_col())
|
||||
.when(axis == Axis::Horizontal, |group| group.flex_row())
|
||||
.refine_style(&self.style)
|
||||
.children(self.panes.into_iter().enumerate().flat_map(|(ix, pane)| {
|
||||
.refine_style(&self.style);
|
||||
|
||||
for (ix, pane) in self.panes.into_iter().enumerate() {
|
||||
let remainder = ix + 1 == pane_count;
|
||||
let pane_element = div()
|
||||
.overflow_hidden()
|
||||
.when(remainder, |pane_element| {
|
||||
pane_element
|
||||
let content = pane.content;
|
||||
let mut pane_element = if remainder {
|
||||
div()
|
||||
.flex_1()
|
||||
.overflow_hidden()
|
||||
.when(axis == Axis::Vertical, |p| p.min_h(pane.min))
|
||||
.when(axis == Axis::Horizontal, |p| p.min_w(pane.min))
|
||||
})
|
||||
.when(!remainder, |pane_element| {
|
||||
pane_element
|
||||
} else {
|
||||
div()
|
||||
.flex_none()
|
||||
.overflow_hidden()
|
||||
.when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full())
|
||||
.when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full())
|
||||
})
|
||||
.when_some(debug_selector.clone(), |pane_element, selector| {
|
||||
let prefix = selector();
|
||||
pane_element.debug_selector(move || format!("{prefix}-{ix}"))
|
||||
})
|
||||
.child(pane.content)
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let divider = (!remainder).then(|| {
|
||||
// The positional index is a stable id: panes are append-only, never reordered.
|
||||
if let Some(selector) = &debug_selector {
|
||||
let prefix = selector();
|
||||
pane_element = pane_element.debug_selector(move || format!("{prefix}-{ix}"));
|
||||
}
|
||||
|
||||
group = group.child(pane_element.child(content));
|
||||
|
||||
if !remainder {
|
||||
let divider =
|
||||
ResizeHandle::new(SharedString::from(format!("{}-divider-{ix}", self.id)))
|
||||
.axis(axis)
|
||||
.on_resize({
|
||||
@@ -268,14 +268,14 @@ impl RenderOnce for Resizable {
|
||||
move |_, cx| {
|
||||
state.update(cx, |state, cx| state.reset_pane(ix, mins.len(), cx));
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
});
|
||||
|
||||
std::iter::once(pane_element).chain(divider)
|
||||
}))
|
||||
.when(pane_count > 0, |group| {
|
||||
group.child(
|
||||
group = group.child(divider);
|
||||
}
|
||||
}
|
||||
|
||||
if pane_count > 0 {
|
||||
group = group.child(
|
||||
canvas(
|
||||
{
|
||||
let state = state.clone();
|
||||
@@ -291,7 +291,9 @@ impl RenderOnce for Resizable {
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
group
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ use gpui::{
|
||||
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, deferred, div,
|
||||
fill, point, px,
|
||||
};
|
||||
use gpui_native_theme::{
|
||||
ActiveTheme as _, BORDER_WIDTH, HAIRLINE_WIDTH, RESIZE_KEYBOARD_STEP, Size,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, RESIZE_KEYBOARD_STEP, Size};
|
||||
use gpui_native_utils::{focus_handle, focus_ring};
|
||||
|
||||
/// How far one keyboard arrow step moves the seam.
|
||||
@@ -30,13 +28,12 @@ struct DragState {
|
||||
|
||||
/// A thin drag divider that resizes the adjacent region.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct ResizeHandle {
|
||||
id: ElementId,
|
||||
/// The axis the drag moves along: a vertical seam drags horizontally.
|
||||
axis: Axis,
|
||||
disabled: bool,
|
||||
accessibility_label: Option<SharedString>,
|
||||
a11y_label: Option<SharedString>,
|
||||
on_resize: Option<ResizeHandler>,
|
||||
on_reset: Option<ResetHandler>,
|
||||
}
|
||||
@@ -51,7 +48,7 @@ impl ResizeHandle {
|
||||
id: id.into(),
|
||||
axis: Axis::Horizontal,
|
||||
disabled: false,
|
||||
accessibility_label: None,
|
||||
a11y_label: None,
|
||||
on_resize: None,
|
||||
on_reset: None,
|
||||
}
|
||||
@@ -74,7 +71,7 @@ impl ResizeHandle {
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
self.accessibility_label = Some(label.into());
|
||||
self.a11y_label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -108,7 +105,7 @@ impl RenderOnce for ResizeHandle {
|
||||
let straddle = -thickness / 2.0;
|
||||
|
||||
let label = self
|
||||
.accessibility_label
|
||||
.a11y_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from("Resize"));
|
||||
|
||||
@@ -224,17 +221,17 @@ impl RenderOnce for ResizeHandle {
|
||||
|
||||
let line = match axis {
|
||||
Axis::Horizontal => {
|
||||
let x = bounds.center().x - HAIRLINE_WIDTH;
|
||||
let x = bounds.center().x - px(0.5);
|
||||
Bounds::from_corners(
|
||||
point(x, bounds.top()),
|
||||
point(x + BORDER_WIDTH, bounds.bottom()),
|
||||
point(x + px(1.), bounds.bottom()),
|
||||
)
|
||||
}
|
||||
Axis::Vertical => {
|
||||
let y = bounds.center().y - HAIRLINE_WIDTH;
|
||||
let y = bounds.center().y - px(0.5);
|
||||
Bounds::from_corners(
|
||||
point(bounds.left(), y),
|
||||
point(bounds.right(), y + BORDER_WIDTH),
|
||||
point(bounds.right(), y + px(1.)),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
+40
-53
@@ -14,14 +14,19 @@ use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_core::toast::{Toast, Toasts, ToastsView};
|
||||
use gpui_native_data::sidebar::Sidebar;
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::{
|
||||
ActiveTheme as _, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, Size,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::resize_handle::ResizeHandle;
|
||||
use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, titlebar_strip};
|
||||
use crate::window_frame::frame;
|
||||
|
||||
/// The width a sidebar opens at.
|
||||
const DEFAULT_SIDEBAR_WIDTH: f32 = 240.0;
|
||||
/// The narrowest a sidebar can be dragged to.
|
||||
const MIN_SIDEBAR_WIDTH: f32 = 160.0;
|
||||
/// The widest a sidebar can be dragged to.
|
||||
const MAX_SIDEBAR_WIDTH: f32 = 480.0;
|
||||
|
||||
/// The layout the application owns.
|
||||
pub struct LayoutState {
|
||||
left_visible: bool,
|
||||
@@ -37,15 +42,15 @@ pub struct LayoutState {
|
||||
impl LayoutState {
|
||||
/// Creates the state: left sidebar visible, right sidebar hidden.
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_cx| Self {
|
||||
cx.new(|cx| Self {
|
||||
left_visible: true,
|
||||
right_visible: false,
|
||||
left_width: SIDEBAR_DEFAULT_WIDTH,
|
||||
right_width: SIDEBAR_DEFAULT_WIDTH,
|
||||
left_width: px(DEFAULT_SIDEBAR_WIDTH),
|
||||
right_width: px(DEFAULT_SIDEBAR_WIDTH),
|
||||
#[cfg(feature = "toast")]
|
||||
toasts: _cx.new(|_| Toasts::default()),
|
||||
toasts: cx.new(|_| Toasts::default()),
|
||||
#[cfg(feature = "alert")]
|
||||
alerts: _cx.new(|_| Alerts::default()),
|
||||
alerts: cx.new(|_| Alerts::default()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,21 +92,17 @@ impl LayoutState {
|
||||
|
||||
/// Applies a drag delta to the left sidebar's width, clamped.
|
||||
fn resize_left(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
||||
let width = (self.left_width + delta).clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
||||
if (self.left_width - width).abs() > px(f32::EPSILON) {
|
||||
self.left_width = width;
|
||||
self.left_width =
|
||||
(self.left_width + delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a drag delta to the right sidebar's width, clamped.
|
||||
fn resize_right(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
||||
let width = (self.right_width - delta).clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
||||
if (self.right_width - width).abs() > px(f32::EPSILON) {
|
||||
self.right_width = width;
|
||||
self.right_width =
|
||||
(self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Raises a toast in the shell's overlay. Returns its id.
|
||||
#[cfg(feature = "toast")]
|
||||
@@ -158,7 +159,6 @@ impl Side {
|
||||
|
||||
/// The application shell.
|
||||
#[derive(IntoElement)]
|
||||
#[must_use]
|
||||
pub struct Shell {
|
||||
id: ElementId,
|
||||
state: Entity<LayoutState>,
|
||||
@@ -242,13 +242,11 @@ impl RenderOnce for Shell {
|
||||
left.take()
|
||||
.map(|sidebar| sidebar.into_any_element())
|
||||
.unwrap_or_else(|| div().into_any_element()),
|
||||
Side::Left,
|
||||
left_material,
|
||||
&state,
|
||||
Titlebar {
|
||||
side: Side::Left,
|
||||
material: left_material,
|
||||
height: titlebar_height,
|
||||
padding_x: metrics.dense_padding_x,
|
||||
},
|
||||
titlebar_height,
|
||||
metrics.dense_padding_x,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
@@ -281,13 +279,11 @@ impl RenderOnce for Shell {
|
||||
(id.clone(), "right"),
|
||||
width,
|
||||
right.take().unwrap_or_else(|| div().into_any_element()),
|
||||
Side::Right,
|
||||
false,
|
||||
&state,
|
||||
Titlebar {
|
||||
side: Side::Right,
|
||||
material: false,
|
||||
height: titlebar_height,
|
||||
padding_x: metrics.dense_padding_x,
|
||||
},
|
||||
titlebar_height,
|
||||
metrics.dense_padding_x,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
@@ -306,57 +302,48 @@ impl RenderOnce for Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which titlebar duties a sidebar's header strip carries.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Titlebar {
|
||||
side: Side,
|
||||
material: bool,
|
||||
height: Pixels,
|
||||
padding_x: Pixels,
|
||||
}
|
||||
|
||||
/// One sidebar: a header strip with the hide toggle over the app's content.
|
||||
fn sidebar_region<I>(
|
||||
id: I,
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sidebar_region(
|
||||
id: impl Into<ElementId>,
|
||||
width: Pixels,
|
||||
content: AnyElement,
|
||||
side: Side,
|
||||
material: bool,
|
||||
state: &Entity<LayoutState>,
|
||||
titlebar: Titlebar,
|
||||
strip_height: Pixels,
|
||||
padding_x: Pixels,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
{
|
||||
) -> AnyElement {
|
||||
let id = id.into();
|
||||
let theme = cx.theme();
|
||||
let background = theme.colors.background;
|
||||
let strip_background = match (titlebar.side, titlebar.material) {
|
||||
let strip_background = match (side, material) {
|
||||
(Side::Left, true) => theme.material.background,
|
||||
_ => theme.colors.background,
|
||||
};
|
||||
|
||||
let strip = h_flex()
|
||||
.id((id.clone(), "strip"))
|
||||
.debug_selector(move || format!("sidebar-strip-{}", titlebar.side.name()))
|
||||
.debug_selector(move || format!("sidebar-strip-{}", side.name()))
|
||||
.flex_none()
|
||||
.h(titlebar.height)
|
||||
.h(strip_height)
|
||||
.bg(strip_background)
|
||||
.items_center()
|
||||
.map(|strip| match titlebar.side {
|
||||
.map(|strip| match side {
|
||||
Side::Left => strip.when(cfg!(target_os = "macos"), |strip| {
|
||||
strip.pl(TRAFFIC_LIGHT_PAD)
|
||||
}),
|
||||
Side::Right => strip,
|
||||
})
|
||||
.px(titlebar.padding_x)
|
||||
.px(padding_x)
|
||||
.child(titlebar_strip(
|
||||
div().id((id.clone(), "strip-drag")).flex_1().self_stretch(),
|
||||
(id.clone(), "strip-drag"),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.map(|strip| match titlebar.side {
|
||||
.map(|strip| match side {
|
||||
Side::Left => strip.child(
|
||||
Button::new((id, "sidebar-toggle"))
|
||||
.icon(IconRole::PanelLeft)
|
||||
@@ -383,7 +370,7 @@ where
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.when(!titlebar.material, |content| content.bg(background))
|
||||
.when(!material, |content| content.bg(background))
|
||||
.child(content),
|
||||
)
|
||||
.into_any_element()
|
||||
|
||||
@@ -8,8 +8,6 @@ use gpui::WindowControlArea;
|
||||
use gpui::{App, ClickEvent, Div, ElementId, IntoElement, RenderOnce, Stateful, Window, div};
|
||||
use gpui::{InteractiveElement as _, MouseButton, StatefulInteractiveElement as _};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use gpui::Styled as _;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
@@ -21,18 +19,13 @@ use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::Decorations;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use gpui::SharedString;
|
||||
#[cfg(target_os = "windows")]
|
||||
use gpui::px;
|
||||
use gpui::{SharedString, px, rgba};
|
||||
|
||||
/// macOS traffic-lights zone: the pad a leading titlebar strip reserves.
|
||||
pub use gpui_native_theme::TRAFFIC_LIGHT_PAD;
|
||||
#[cfg(target_os = "windows")]
|
||||
use gpui_native_theme::{
|
||||
WINDOWS_CAPTION_WIDTH as CAPTION_WIDTH, WINDOWS_CLOSE_FOREGROUND, WINDOWS_CLOSE_HOVER,
|
||||
WINDOWS_CLOSE_PRESSED,
|
||||
};
|
||||
use gpui_native_theme::{WINDOWS_CAPTION_WIDTH as CAPTION_WIDTH, WINDOWS_CLOSE_HOVER_HEX};
|
||||
|
||||
/// The window controls for the trailing end of a titlebar strip.
|
||||
#[derive(IntoElement)]
|
||||
@@ -56,7 +49,6 @@ impl RenderOnce for WindowControls {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let controls = {
|
||||
// macOS draws native traffic lights in the shell's reserved pad; nothing to render.
|
||||
let _ = (id, window, cx);
|
||||
div()
|
||||
};
|
||||
@@ -105,6 +97,7 @@ impl RenderOnce for WindowControls {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let controls = {
|
||||
let _ = id;
|
||||
let controls = window.window_controls();
|
||||
let client_decorated =
|
||||
matches!(window.window_decorations(), Decorations::Client { .. });
|
||||
@@ -118,7 +111,7 @@ impl RenderOnce for WindowControls {
|
||||
.when(client_decorated && controls.minimize, |row| {
|
||||
row.child(circle_button(
|
||||
cx,
|
||||
SharedString::from(format!("{id}-minimize")),
|
||||
"window-minimize",
|
||||
IconRole::WindowMinimize,
|
||||
window.is_minimizable(),
|
||||
|window, _cx| window.minimize_window(),
|
||||
@@ -127,7 +120,7 @@ impl RenderOnce for WindowControls {
|
||||
.when(client_decorated && controls.maximize, |row| {
|
||||
row.child(circle_button(
|
||||
cx,
|
||||
SharedString::from(format!("{id}-maximize")),
|
||||
"window-maximize",
|
||||
if window.is_maximized() {
|
||||
IconRole::WindowRestore
|
||||
} else {
|
||||
@@ -140,7 +133,7 @@ impl RenderOnce for WindowControls {
|
||||
.when(client_decorated, |row| {
|
||||
row.child(circle_button(
|
||||
cx,
|
||||
SharedString::from(format!("{id}-close")),
|
||||
"window-close",
|
||||
IconRole::Close,
|
||||
true,
|
||||
|window, _cx| window.remove_window(),
|
||||
@@ -148,12 +141,6 @@ impl RenderOnce for WindowControls {
|
||||
})
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
let controls = {
|
||||
let _ = (id, window, cx);
|
||||
div()
|
||||
};
|
||||
|
||||
controls
|
||||
}
|
||||
}
|
||||
@@ -168,30 +155,16 @@ fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) + '
|
||||
window.titlebar_double_click();
|
||||
#[cfg(target_os = "linux")]
|
||||
window.zoom_window();
|
||||
// Windows handles titlebar double-click natively via WindowControlArea::Drag.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (event, window);
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = (event, window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes an element a titlebar strip; `id` keys its drag state, which
|
||||
/// `use_state` would otherwise share among every strip in the window.
|
||||
pub fn titlebar_strip<I>(
|
||||
this: Stateful<Div>,
|
||||
id: I,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div>
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
{
|
||||
let should_move = window.use_keyed_state(id.into(), cx, |_, _| false);
|
||||
/// Makes an element a titlebar strip.
|
||||
pub fn titlebar_strip(this: Stateful<Div>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let should_move = window.use_state(cx, |_, _| false);
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down_out(window.listener_for(&should_move, |should_move, _, _, _| {
|
||||
*should_move = false;
|
||||
@@ -235,7 +208,7 @@ fn caption_button(
|
||||
let glyph_size = theme.sizes.small.caption_size;
|
||||
|
||||
let hover = if close {
|
||||
WINDOWS_CLOSE_HOVER
|
||||
rgba(WINDOWS_CLOSE_HOVER_HEX)
|
||||
} else {
|
||||
theme.colors.muted_hover
|
||||
};
|
||||
@@ -263,7 +236,7 @@ fn caption_button(
|
||||
.cursor_pointer()
|
||||
.hover(|hovered| {
|
||||
hovered.bg(hover).text_color(if close {
|
||||
WINDOWS_CLOSE_FOREGROUND
|
||||
gpui::white()
|
||||
} else {
|
||||
theme.colors.foreground
|
||||
})
|
||||
@@ -271,7 +244,7 @@ fn caption_button(
|
||||
.active(|pressed| {
|
||||
pressed
|
||||
.bg(if close {
|
||||
WINDOWS_CLOSE_PRESSED
|
||||
hover.opacity(0.8)
|
||||
} else {
|
||||
theme.ramps.secondary.pressed
|
||||
})
|
||||
@@ -284,9 +257,14 @@ fn caption_button(
|
||||
|
||||
/// Adwaita's headerbar control: a small circle around a symbolic glyph.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn circle_button<I, F>(cx: &App, id: I, role: IconRole, enabled: bool, action: F) -> AnyElement
|
||||
fn circle_button<F>(
|
||||
cx: &App,
|
||||
id: &'static str,
|
||||
role: IconRole,
|
||||
enabled: bool,
|
||||
action: F,
|
||||
) -> AnyElement
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
let theme = cx.theme();
|
||||
|
||||
@@ -7,15 +7,13 @@ use gpui::{
|
||||
IntoElement, MouseButton, ParentElement as _, Pixels, Point, ResizeEdge, Size, Styled as _,
|
||||
Tiling, Window, canvas, div, point, px, size,
|
||||
};
|
||||
use gpui_native_theme::{
|
||||
ActiveTheme as _, BORDER_WIDTH, INACTIVE_SHADOW_OPACITY, WINDOW_RESIZE_INSET,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, WINDOW_RESIZE_INSET};
|
||||
|
||||
/// How much of the shadow's strength survives losing window focus.
|
||||
const INACTIVE_SHADOW_OPACITY: f32 = 0.7;
|
||||
|
||||
/// Wraps a window's root in the frame it owes.
|
||||
pub fn frame<E>(child: E, window: &mut Window, cx: &App) -> Div
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
pub fn frame(child: impl IntoElement, window: &mut Window, cx: &App) -> Div {
|
||||
let Decorations::Client { tiling } = window.window_decorations() else {
|
||||
return div().size_full().child(child);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![cfg(feature = "dock")]
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![cfg(feature = "resizable")]
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, ParentElement as _, Pixels, Render, Styled as _, Subscription, TestAppContext,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![cfg(feature = "resize_handle")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![cfg(feature = "shell")]
|
||||
|
||||
use gpui::{
|
||||
Entity, InteractiveElement as _, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, TestAppContext,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! The titlebar duties: traffic-light pads and window controls, asserted per
|
||||
//! platform — macOS draws only pads, Windows/Linux only draw their controls.
|
||||
|
||||
#![cfg(feature = "dock")]
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Entity, IntoElement, ParentElement as _, Pixels, Render, Styled as _,
|
||||
TestAppContext, VisualTestContext, Window, div, px,
|
||||
|
||||
@@ -7,7 +7,6 @@ description = "Native theme engine: reads the OS theme and resolves it into sema
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
gpui = { workspace = true }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
|
||||
@@ -9,37 +9,37 @@ use crate::ThemeVariant;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
|
||||
pub struct ThemeDefinition {
|
||||
mode: Option<ThemeVariant>,
|
||||
control_radius: Option<f32>,
|
||||
overlay_radius: Option<f32>,
|
||||
sm_radius: Option<f32>,
|
||||
accent: Option<String>,
|
||||
accent_foreground: Option<String>,
|
||||
background: Option<String>,
|
||||
foreground: Option<String>,
|
||||
surface: Option<String>,
|
||||
surface_foreground: Option<String>,
|
||||
primary: Option<String>,
|
||||
primary_foreground: Option<String>,
|
||||
secondary: Option<String>,
|
||||
secondary_foreground: Option<String>,
|
||||
muted: Option<String>,
|
||||
muted_hover: Option<String>,
|
||||
muted_foreground: Option<String>,
|
||||
destructive: Option<String>,
|
||||
destructive_foreground: Option<String>,
|
||||
success: Option<String>,
|
||||
success_foreground: Option<String>,
|
||||
warning: Option<String>,
|
||||
warning_foreground: Option<String>,
|
||||
info: Option<String>,
|
||||
info_foreground: Option<String>,
|
||||
border: Option<String>,
|
||||
input: Option<String>,
|
||||
ring: Option<String>,
|
||||
selection: Option<String>,
|
||||
overlay: Option<String>,
|
||||
scrollbar_thumb: Option<String>,
|
||||
pub mode: Option<ThemeVariant>,
|
||||
pub control_radius: Option<f32>,
|
||||
pub overlay_radius: Option<f32>,
|
||||
pub sm_radius: Option<f32>,
|
||||
pub accent: Option<String>,
|
||||
pub accent_foreground: Option<String>,
|
||||
pub background: Option<String>,
|
||||
pub foreground: Option<String>,
|
||||
pub surface: Option<String>,
|
||||
pub surface_foreground: Option<String>,
|
||||
pub primary: Option<String>,
|
||||
pub primary_foreground: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
pub secondary_foreground: Option<String>,
|
||||
pub muted: Option<String>,
|
||||
pub muted_hover: Option<String>,
|
||||
pub muted_foreground: Option<String>,
|
||||
pub destructive: Option<String>,
|
||||
pub destructive_foreground: Option<String>,
|
||||
pub success: Option<String>,
|
||||
pub success_foreground: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub warning_foreground: Option<String>,
|
||||
pub info: Option<String>,
|
||||
pub info_foreground: Option<String>,
|
||||
pub border: Option<String>,
|
||||
pub input: Option<String>,
|
||||
pub ring: Option<String>,
|
||||
pub selection: Option<String>,
|
||||
pub overlay: Option<String>,
|
||||
pub scrollbar_thumb: Option<String>,
|
||||
}
|
||||
|
||||
impl ThemeDefinition {
|
||||
@@ -47,140 +47,112 @@ impl ThemeDefinition {
|
||||
Ok(toml::from_str(source)?)
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Option<ThemeVariant> {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn control_radius(&self) -> Option<f32> {
|
||||
self.control_radius
|
||||
}
|
||||
|
||||
pub fn overlay_radius(&self) -> Option<f32> {
|
||||
self.overlay_radius
|
||||
}
|
||||
|
||||
pub fn sm_radius(&self) -> Option<f32> {
|
||||
self.sm_radius
|
||||
}
|
||||
|
||||
pub fn accent(&self) -> Option<Hsla> {
|
||||
self.color(&self.accent, "accent")
|
||||
parse_hex(self.accent.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn accent_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.accent_foreground, "accent_foreground")
|
||||
parse_hex(self.accent_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn background(&self) -> Option<Hsla> {
|
||||
self.color(&self.background, "background")
|
||||
parse_hex(self.background.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.foreground, "foreground")
|
||||
parse_hex(self.foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> Option<Hsla> {
|
||||
self.color(&self.surface, "surface")
|
||||
parse_hex(self.surface.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn surface_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.surface_foreground, "surface_foreground")
|
||||
parse_hex(self.surface_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<Hsla> {
|
||||
self.color(&self.primary, "primary")
|
||||
parse_hex(self.primary.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn primary_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.primary_foreground, "primary_foreground")
|
||||
parse_hex(self.primary_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn secondary(&self) -> Option<Hsla> {
|
||||
self.color(&self.secondary, "secondary")
|
||||
parse_hex(self.secondary.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn secondary_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.secondary_foreground, "secondary_foreground")
|
||||
parse_hex(self.secondary_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn muted(&self) -> Option<Hsla> {
|
||||
self.color(&self.muted, "muted")
|
||||
parse_hex(self.muted.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn muted_hover(&self) -> Option<Hsla> {
|
||||
self.color(&self.muted_hover, "muted_hover")
|
||||
parse_hex(self.muted_hover.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn muted_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.muted_foreground, "muted_foreground")
|
||||
parse_hex(self.muted_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn destructive(&self) -> Option<Hsla> {
|
||||
self.color(&self.destructive, "destructive")
|
||||
parse_hex(self.destructive.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn destructive_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.destructive_foreground, "destructive_foreground")
|
||||
parse_hex(self.destructive_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn success(&self) -> Option<Hsla> {
|
||||
self.color(&self.success, "success")
|
||||
parse_hex(self.success.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn success_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.success_foreground, "success_foreground")
|
||||
parse_hex(self.success_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn warning(&self) -> Option<Hsla> {
|
||||
self.color(&self.warning, "warning")
|
||||
parse_hex(self.warning.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn warning_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.warning_foreground, "warning_foreground")
|
||||
parse_hex(self.warning_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn info(&self) -> Option<Hsla> {
|
||||
self.color(&self.info, "info")
|
||||
parse_hex(self.info.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn info_foreground(&self) -> Option<Hsla> {
|
||||
self.color(&self.info_foreground, "info_foreground")
|
||||
parse_hex(self.info_foreground.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn border(&self) -> Option<Hsla> {
|
||||
self.color(&self.border, "border")
|
||||
parse_hex(self.border.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn input(&self) -> Option<Hsla> {
|
||||
self.color(&self.input, "input")
|
||||
parse_hex(self.input.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> Option<Hsla> {
|
||||
self.color(&self.ring, "ring")
|
||||
parse_hex(self.ring.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn selection(&self) -> Option<Hsla> {
|
||||
self.color(&self.selection, "selection")
|
||||
parse_hex(self.selection.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn overlay(&self) -> Option<Hsla> {
|
||||
self.color(&self.overlay, "overlay")
|
||||
parse_hex(self.overlay.as_deref()?)
|
||||
}
|
||||
|
||||
pub fn scrollbar_thumb(&self) -> Option<Hsla> {
|
||||
self.color(&self.scrollbar_thumb, "scrollbar_thumb")
|
||||
}
|
||||
|
||||
/// Parses a color field, warning when a stated value is malformed.
|
||||
fn color(&self, field: &Option<String>, role: &str) -> Option<Hsla> {
|
||||
let value = field.as_deref()?;
|
||||
match parse_hex(value) {
|
||||
Some(color) => Some(color),
|
||||
None => {
|
||||
log::warn!("theme definition {role} is not #rgb/#rrggbb: {value:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
parse_hex(self.scrollbar_thumb.as_deref()?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,12 +226,12 @@ mod tests {
|
||||
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\noverlay_radius = 15.0\nsm_radius = 6.0\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(def.mode(), Some(crate::ThemeVariant::Dark));
|
||||
assert_eq!(def.mode, Some(crate::ThemeVariant::Dark));
|
||||
assert!(def.accent().is_some());
|
||||
assert!(def.info().is_some());
|
||||
assert!(def.foreground().is_none());
|
||||
assert_eq!(def.overlay_radius(), Some(15.0));
|
||||
assert_eq!(def.sm_radius(), Some(6.0));
|
||||
assert_eq!(def.overlay_radius, Some(15.0));
|
||||
assert_eq!(def.sm_radius, Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+43
-119
@@ -20,9 +20,7 @@ pub use ramps::{AccentShades, Oklch};
|
||||
pub use resolve::{Ramps, ResolvedTheme};
|
||||
pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
||||
|
||||
use gpui::{
|
||||
App, Global, Hsla, Pixels, SpringConfig, WindowAppearance, WindowBackgroundAppearance, hsla, px,
|
||||
};
|
||||
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, WindowBackgroundAppearance, px};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The hairline width of the control border every button and field paints.
|
||||
@@ -48,51 +46,7 @@ pub const TRAFFIC_LIGHT_PAD: Pixels = px(78.0);
|
||||
/// The width of a Windows caption button.
|
||||
pub const WINDOWS_CAPTION_WIDTH: Pixels = px(36.0);
|
||||
/// Windows paints its caption close-button hover red in every theme.
|
||||
pub const WINDOWS_CLOSE_HOVER: Hsla = hsla(0.986043, 0.863406, 0.488235, 1.0);
|
||||
/// The close-button hover dimmed while pressed.
|
||||
pub const WINDOWS_CLOSE_PRESSED: Hsla = hsla(0.986043, 0.863406, 0.488235, 0.8);
|
||||
/// The close glyph's color over the red hover.
|
||||
pub const WINDOWS_CLOSE_FOREGROUND: Hsla = gpui::white();
|
||||
/// The width a sidebar opens at.
|
||||
pub const SIDEBAR_DEFAULT_WIDTH: Pixels = px(240.0);
|
||||
/// The narrowest a sidebar may be dragged.
|
||||
pub const SIDEBAR_MIN_WIDTH: Pixels = px(160.0);
|
||||
/// The widest a sidebar may be dragged.
|
||||
pub const SIDEBAR_MAX_WIDTH: Pixels = px(480.0);
|
||||
/// The size a pane starts at before its group has measured the extent.
|
||||
pub const PANE_DEFAULT_SIZE: Pixels = px(200.0);
|
||||
/// The floor a pane keeps while dragging unless the caller lowers it.
|
||||
pub const PANE_MIN_SIZE: Pixels = px(48.0);
|
||||
/// The hairline a resize seam paints on a shared boundary.
|
||||
pub const HAIRLINE_WIDTH: Pixels = px(0.5);
|
||||
/// How much of the window shadow's strength survives losing focus.
|
||||
pub const INACTIVE_SHADOW_OPACITY: f32 = 0.7;
|
||||
/// The spread of the keyboard focus ring.
|
||||
pub const FOCUS_RING_SPREAD: Pixels = px(3.0);
|
||||
/// How far a pushed alert slides in on entry.
|
||||
pub const ENTER_SLIDE_DISTANCE: Pixels = px(24.0);
|
||||
/// The widest a toast may grow.
|
||||
pub const TOAST_MAX_WIDTH: Pixels = px(480.0);
|
||||
/// One revolution of the spinner.
|
||||
pub const SPINNER_PERIOD: Duration = Duration::from_millis(800);
|
||||
/// One sweep of the indeterminate progress bar.
|
||||
pub const PROGRESS_ANIMATION_PERIOD: Duration = Duration::from_millis(1500);
|
||||
/// How long an autohide scrollbar thumb stays fully visible before fading.
|
||||
pub const SCROLLBAR_AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
|
||||
/// How long the autohide scrollbar's fade runs.
|
||||
pub const SCROLLBAR_FADE_DURATION: Duration = Duration::from_millis(250);
|
||||
/// How much a change wash tints the rows it covers.
|
||||
pub const WASH_ALPHA: f32 = 0.14;
|
||||
/// The stronger wash under number gutters and intra-line word changes.
|
||||
pub const WASH_ALPHA_STRONG: f32 = 0.34;
|
||||
/// The subtle wash that sets the number gutter off from the code.
|
||||
pub const WASH_ALPHA_GUTTER: f32 = 0.18;
|
||||
/// The soft wash that marks the active code row.
|
||||
pub const WASH_ALPHA_SOFT: f32 = 0.45;
|
||||
/// How far a scrolling list renders past its viewport.
|
||||
pub const CODE_OVERSCROLL_MAX: Pixels = px(400.0);
|
||||
/// The spring every control's toggle and segment animations ride.
|
||||
pub const CONTROL_SPRING: SpringConfig = SpringConfig::new(500., 35., 1.);
|
||||
pub const WINDOWS_CLOSE_HOVER_HEX: u32 = 0xE81120FF;
|
||||
/// The monospace advance estimate as a share of the text size.
|
||||
pub const MONO_ADVANCE_RATIO: f32 = 0.62;
|
||||
/// How much a pixel avatar's tint washes over its background.
|
||||
@@ -119,15 +73,6 @@ impl ThemeVariant {
|
||||
WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies a surface by its lightness, for platforms that state no mode.
|
||||
pub fn by_lightness(background: Hsla) -> Self {
|
||||
if background.l >= 0.5 {
|
||||
Self::Light
|
||||
} else {
|
||||
Self::Dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved theme, installed as the app-wide global.
|
||||
@@ -479,7 +424,7 @@ impl SizeTable {
|
||||
padding: 0.75,
|
||||
text: 0.85,
|
||||
icon: 0.875,
|
||||
avatar: 1.125,
|
||||
avatar: 1.5,
|
||||
strip: 1.5,
|
||||
inset: 0.125,
|
||||
floor_icon: false,
|
||||
@@ -490,7 +435,7 @@ impl SizeTable {
|
||||
padding: 1.0,
|
||||
text: 1.0,
|
||||
icon: 1.0,
|
||||
avatar: 1.5,
|
||||
avatar: 2.0,
|
||||
strip: 2.0,
|
||||
inset: 0.25,
|
||||
floor_icon: true,
|
||||
@@ -501,7 +446,7 @@ impl SizeTable {
|
||||
padding: 1.25,
|
||||
text: 1.15,
|
||||
icon: 1.25,
|
||||
avatar: 2.0,
|
||||
avatar: 3.0,
|
||||
strip: 3.0,
|
||||
inset: 0.375,
|
||||
floor_icon: true,
|
||||
@@ -642,23 +587,21 @@ impl Theme {
|
||||
reapply(cx);
|
||||
}
|
||||
|
||||
/// Re-resolves when no override is set and a window's appearance differs
|
||||
/// from the resolved variant; an override pins the variant instead.
|
||||
/// Re-resolves when a window's appearance differs from the resolved variant.
|
||||
///
|
||||
/// Wire to `Window::observe_window_appearance`.
|
||||
pub fn sync_from_window(window: &mut gpui::Window, cx: &mut App) {
|
||||
let window_variant = ThemeVariant::from_appearance(window.appearance());
|
||||
let theme = cx.global::<Theme>();
|
||||
let engine = cx.global::<ThemeEngine>();
|
||||
if engine.mode_override.is_none() && theme.variant != window_variant {
|
||||
let variant = ThemeVariant::from_appearance(window.appearance());
|
||||
if cx.global::<Theme>().variant != variant
|
||||
|| cx.global::<ThemeEngine>().mode_override.is_none()
|
||||
{
|
||||
reapply(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sizes the window's rem to the theme's root font size.
|
||||
pub fn sync_window_rem_size(window: &mut gpui::Window, cx: &App) {
|
||||
let theme = cx.global::<Theme>();
|
||||
window.set_rem_size(theme.root_font_size);
|
||||
window.set_rem_size(cx.global::<Theme>().root_font_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,17 +617,20 @@ impl Global for ThemeEngine {}
|
||||
/// Reads the system, resolves through the current knobs,
|
||||
/// installs the theme global, and repaints every window.
|
||||
pub(crate) fn reapply(cx: &mut App) {
|
||||
let (preset, mode_override, system) = {
|
||||
let (system_appearance, preset, mode_override) = {
|
||||
let engine = cx.global::<ThemeEngine>();
|
||||
let system_appearance = ThemeVariant::from_appearance(cx.window_appearance());
|
||||
let preset = engine.preset.as_ref().map(|(_, def)| def.clone());
|
||||
let mode_override = engine.mode_override;
|
||||
let variant = mode_override
|
||||
.or(preset.as_ref().and_then(|def| def.mode()))
|
||||
.unwrap_or(system_appearance);
|
||||
(preset, mode_override, engine.source.snapshot(variant))
|
||||
(
|
||||
ThemeVariant::from_appearance(cx.window_appearance()),
|
||||
engine.preset.as_ref().map(|(_, def)| def.clone()),
|
||||
engine.mode_override,
|
||||
)
|
||||
};
|
||||
|
||||
let variant = mode_override
|
||||
.or(preset.as_ref().and_then(|def| def.mode))
|
||||
.unwrap_or(system_appearance);
|
||||
|
||||
let system = cx.global::<ThemeEngine>().source.snapshot(variant);
|
||||
let resolved = resolve::resolve(&system, preset.as_ref(), mode_override);
|
||||
|
||||
cx.set_reduce_motion(resolved.accessibility.reduce_motion);
|
||||
@@ -694,39 +640,41 @@ pub(crate) fn reapply(cx: &mut App) {
|
||||
|
||||
/// Forces a variant, independent of the OS. `None` follows the system.
|
||||
pub fn set_mode_override(variant: Option<ThemeVariant>, cx: &mut App) {
|
||||
let engine = cx.global_mut::<ThemeEngine>();
|
||||
engine.mode_override = variant;
|
||||
cx.global_mut::<ThemeEngine>().mode_override = variant;
|
||||
reapply(cx);
|
||||
}
|
||||
|
||||
/// Installs a built-in preset, or `None` to follow the system again.
|
||||
pub fn set_preset(name: Option<&str>, cx: &mut App) -> anyhow::Result<()> {
|
||||
let preset = name
|
||||
.map(|name| {
|
||||
presets::builtin(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown preset: {name}; available: {}",
|
||||
presets::preset_names().collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let engine = cx.global_mut::<ThemeEngine>();
|
||||
engine.preset = preset;
|
||||
let preset = match name {
|
||||
Some(name) => {
|
||||
let def =
|
||||
presets::builtin(name).ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?;
|
||||
let static_name = presets::preset_names()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| *candidate == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?;
|
||||
Some((static_name, def))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
cx.global_mut::<ThemeEngine>().preset = preset;
|
||||
reapply(cx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The variant the mode override forces, if any.
|
||||
pub fn mode_override(cx: &App) -> Option<ThemeVariant> {
|
||||
let engine = cx.global::<ThemeEngine>();
|
||||
engine.mode_override
|
||||
cx.global::<ThemeEngine>().mode_override
|
||||
}
|
||||
|
||||
/// The active preset name, if one is installed.
|
||||
pub fn active_preset(cx: &App) -> Option<&'static str> {
|
||||
let engine = cx.global::<ThemeEngine>();
|
||||
engine.preset.as_ref().map(|(name, _)| *name)
|
||||
cx.global::<ThemeEngine>()
|
||||
.preset
|
||||
.as_ref()
|
||||
.map(|(name, _)| *name)
|
||||
}
|
||||
|
||||
/// Clears the mode override and preset; the theme follows the system again.
|
||||
@@ -739,29 +687,5 @@ pub fn follow_system(cx: &mut App) {
|
||||
|
||||
/// Which adapter produced the current system snapshot.
|
||||
pub fn detected_source(cx: &App) -> &'static str {
|
||||
let engine = cx.global::<ThemeEngine>();
|
||||
engine.source.label()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::Rgba;
|
||||
|
||||
#[test]
|
||||
fn the_windows_close_colors_match_the_legacy_hex() {
|
||||
let hover = Hsla::from(gpui::rgba(0xE81123FF));
|
||||
let pressed = hover.opacity(0.8);
|
||||
for (token, expected) in [
|
||||
(WINDOWS_CLOSE_HOVER, hover),
|
||||
(WINDOWS_CLOSE_PRESSED, pressed),
|
||||
] {
|
||||
let (token, expected) = (Rgba::from(token), Rgba::from(expected));
|
||||
assert!((token.r - expected.r).abs() < 1e-4);
|
||||
assert!((token.g - expected.g).abs() < 1e-4);
|
||||
assert!((token.b - expected.b).abs() < 1e-4);
|
||||
assert!((token.a - expected.a).abs() < 1e-4);
|
||||
}
|
||||
assert_eq!(WINDOWS_CLOSE_FOREGROUND, gpui::white());
|
||||
}
|
||||
cx.global::<ThemeEngine>().source.label()
|
||||
}
|
||||
|
||||
+12
-29
@@ -2,39 +2,22 @@
|
||||
|
||||
use crate::definition::ThemeDefinition;
|
||||
|
||||
/// The bundled presets: `(name, TOML source)`. Enumeration and lookup both
|
||||
/// read this one table.
|
||||
const PRESETS: &[(&str, &str)] = &[("tokyo-night", include_str!("../presets/tokyo-night.toml"))];
|
||||
const TOKYO_NIGHT: &str = include_str!("../presets/tokyo-night.toml");
|
||||
|
||||
/// Every bundled preset's name.
|
||||
pub fn preset_names() -> impl Iterator<Item = &'static str> {
|
||||
PRESETS.iter().map(|(name, _)| *name)
|
||||
pub fn preset_names() -> &'static [&'static str] {
|
||||
&["tokyo-night"]
|
||||
}
|
||||
|
||||
/// Parses the named preset, returning its static name and definition.
|
||||
pub fn builtin(name: &str) -> Option<(&'static str, ThemeDefinition)> {
|
||||
let (name, source) = PRESETS
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == name)
|
||||
.copied()?;
|
||||
match ThemeDefinition::parse_toml(source) {
|
||||
Ok(definition) => Some((name, definition)),
|
||||
Err(error) => {
|
||||
log::error!("bundled preset {name} failed to parse: {error}");
|
||||
/// Parses the named preset.
|
||||
pub fn builtin(name: &str) -> Option<ThemeDefinition> {
|
||||
match name {
|
||||
"tokyo-night" => match ThemeDefinition::parse_toml(TOKYO_NIGHT) {
|
||||
Ok(def) => Some(def),
|
||||
Err(err) => {
|
||||
eprintln!("bundled preset tokyo-night failed to parse: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_name_table_and_lookup_agree() {
|
||||
for name in preset_names() {
|
||||
assert!(builtin(name).is_some(), "{name} fails to parse");
|
||||
}
|
||||
assert!(builtin("no-such-preset").is_none());
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,6 @@ pub struct AccentShades {
|
||||
/// Derives hover/pressed/subtle shades for an accent against its background.
|
||||
///
|
||||
/// Where a platform states ramps (WinUI, KDE) those are used instead,
|
||||
/// every other platform derives its shades here.
|
||||
pub fn derive_shades(accent: Hsla, background: Hsla, is_dark: bool) -> AccentShades {
|
||||
let accent_ok = Oklch::from_hsla(accent);
|
||||
let step = if is_dark { 0.05 } else { -0.05 };
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn resolve(
|
||||
mode_override: Option<ThemeVariant>,
|
||||
) -> ResolvedTheme {
|
||||
let variant = mode_override
|
||||
.or(preset.and_then(|def| def.mode()))
|
||||
.or(preset.and_then(|def| def.mode))
|
||||
.unwrap_or(system.variant);
|
||||
|
||||
let base = match variant {
|
||||
@@ -246,7 +246,7 @@ pub(crate) fn resolve(
|
||||
let mut radius = Radius::default();
|
||||
|
||||
if let Some(control) = preset
|
||||
.and_then(|def| def.control_radius())
|
||||
.and_then(|def| def.control_radius)
|
||||
.map(px)
|
||||
.or(system.control_radius)
|
||||
{
|
||||
@@ -254,7 +254,7 @@ pub(crate) fn resolve(
|
||||
}
|
||||
|
||||
if let Some(overlay) = preset
|
||||
.and_then(|def| def.overlay_radius())
|
||||
.and_then(|def| def.overlay_radius)
|
||||
.map(px)
|
||||
.or(system.overlay_radius)
|
||||
{
|
||||
@@ -262,7 +262,7 @@ pub(crate) fn resolve(
|
||||
}
|
||||
|
||||
if let Some(sm) = preset
|
||||
.and_then(|def| def.sm_radius())
|
||||
.and_then(|def| def.sm_radius)
|
||||
.map(px)
|
||||
.or(system.sm_radius)
|
||||
{
|
||||
@@ -390,9 +390,7 @@ mod tests {
|
||||
assert_eq!(resolved.sizes.medium.control_height, px(28.));
|
||||
assert_eq!(resolved.sizes.medium.input_height, px(30.));
|
||||
assert_eq!(resolved.sizes.medium.text_size, px(16.));
|
||||
assert_eq!(resolved.sizes.small.avatar_size, px(18.));
|
||||
assert_eq!(resolved.sizes.medium.avatar_size, px(24.));
|
||||
assert_eq!(resolved.sizes.large.avatar_size, px(32.));
|
||||
assert_eq!(resolved.sizes.large.avatar_size, px(48.));
|
||||
assert_eq!(resolved.sizes.small.caption_size, px(13.6 * 0.85));
|
||||
assert_eq!(resolved.sizes.medium.caption_size, px(16.));
|
||||
assert_eq!(resolved.sizes.large.caption_size, px(16.));
|
||||
|
||||
@@ -12,16 +12,6 @@ pub(crate) trait ThemeSource: 'static {
|
||||
fn label(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Narrows an f64 color component to f32, clamping to the 0..=1 range and
|
||||
/// mapping non-finite values to 0.0 rather than truncating garbage silently.
|
||||
pub(crate) fn narrow_component(value: f64) -> f32 {
|
||||
if value.is_finite() {
|
||||
value.clamp(0.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
Box::new(macos::MacosSource::default())
|
||||
|
||||
@@ -77,7 +77,7 @@ impl KdeScheme {
|
||||
//
|
||||
// Older setups fall back to the scheme's background lightness.
|
||||
variant: portal_variant
|
||||
.or(self.background.map(crate::ThemeVariant::by_lightness))
|
||||
.or(self.background.map(variant_by_lightness))
|
||||
.unwrap_or(crate::ThemeVariant::Light),
|
||||
accent: self.accent.or(portal_accent),
|
||||
palette: self.palette,
|
||||
@@ -99,6 +99,14 @@ impl KdeScheme {
|
||||
}
|
||||
}
|
||||
|
||||
fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant {
|
||||
if background.l >= 0.5 {
|
||||
crate::ThemeVariant::Light
|
||||
} else {
|
||||
crate::ThemeVariant::Dark
|
||||
}
|
||||
}
|
||||
|
||||
/// The root font size of a `[General]` group's `font=` QFont string.
|
||||
fn root_font_size(group: Option<&HashMap<&str, &str>>) -> Option<Pixels> {
|
||||
let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect();
|
||||
@@ -150,10 +158,7 @@ fn color_list(value: &str) -> Option<Hsla> {
|
||||
let green = components.next()?.parse::<f32>().ok()?;
|
||||
let blue = components.next()?.parse::<f32>().ok()?;
|
||||
let alpha = components.next().and_then(|a| a.parse::<f32>().ok());
|
||||
if !(0.0..=255.0).contains(&red)
|
||||
|| !(0.0..=255.0).contains(&green)
|
||||
|| !(0.0..=255.0).contains(&blue)
|
||||
{
|
||||
if red < 0.0 || green < 0.0 || blue < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let scale = |component: f32| component / 255.0;
|
||||
@@ -330,18 +335,19 @@ activeBackground=39,44,49
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_font_applies_without_a_stated_font() {
|
||||
fn the_scheme_states_breeze_metrics_and_the_default_font() {
|
||||
let system = KdeScheme::parse(BREEZE_DARK).into_system(Default::default(), None, None);
|
||||
assert!((system.root_font_size.expect("default font") - px(13.33)).abs() < px(0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_components_outside_0_to_255_are_rejected() {
|
||||
let above = KdeScheme::parse("[Colors:Window]\nBackgroundNormal=300,20,20\n");
|
||||
assert!(above.palette.background.is_none());
|
||||
|
||||
let below = KdeScheme::parse("[Colors:Window]\nBackgroundNormal=-5,20,20\n");
|
||||
assert!(below.palette.background.is_none());
|
||||
assert_eq!(system.control_height, Some(px(33.)));
|
||||
assert_eq!(system.input_height, Some(px(29.)));
|
||||
assert_eq!(system.menu_item_height, Some(px(29.)));
|
||||
assert_eq!(system.control_radius, Some(px(5.)));
|
||||
assert_eq!(system.overlay_radius, Some(px(5.)));
|
||||
assert_eq!(system.switch_height, Some(px(18.)));
|
||||
assert_eq!(system.switch_width, Some(px(36.)));
|
||||
assert_eq!(system.switch_knob, Some(px(18.)));
|
||||
assert_eq!(system.titlebar_height, Some(px(44.)));
|
||||
assert!((system.root_font_size.unwrap() - px(13.33)).abs() < px(0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,30 +13,9 @@ use super::kde;
|
||||
use super::omarchy;
|
||||
use super::portal;
|
||||
use super::signal::ReapplySignal;
|
||||
|
||||
use crate::ThemeVariant;
|
||||
|
||||
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
||||
|
||||
/// libadwaita's control corner radius, in px at 100% scaling.
|
||||
const CONTROL_RADIUS_PX: f32 = 9.0;
|
||||
/// libadwaita's popover corner radius.
|
||||
const OVERLAY_RADIUS_PX: f32 = 15.0;
|
||||
/// The checkbox indicator's corner radius.
|
||||
const SM_RADIUS_PX: f32 = 6.0;
|
||||
/// libadwaita's standard control and text-field height.
|
||||
const CONTROL_HEIGHT_PX: f32 = 34.0;
|
||||
/// The GTK menu row height.
|
||||
const MENU_ITEM_HEIGHT_PX: f32 = 32.0;
|
||||
/// The GTK switch: a 46x26 track with a 20px knob.
|
||||
const SWITCH_HEIGHT_PX: f32 = 26.0;
|
||||
const SWITCH_WIDTH_PX: f32 = 46.0;
|
||||
const SWITCH_KNOB_PX: f32 = 20.0;
|
||||
/// GNOME's headerbar height, in px at 100% scaling.
|
||||
const TITLEBAR_HEIGHT_PX: f32 = 47.0;
|
||||
/// The default 11pt interface font, in px.
|
||||
const ROOT_FONT_SIZE_PX: f32 = 14.67;
|
||||
|
||||
pub(crate) struct LinuxSource {
|
||||
backend: Backend,
|
||||
portal: Option<portal::io::PortalClient>,
|
||||
@@ -175,20 +154,20 @@ impl LinuxSource {
|
||||
variant,
|
||||
accent,
|
||||
palette: PartialPalette::default(),
|
||||
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
|
||||
overlay_radius: Some(gpui::px(OVERLAY_RADIUS_PX)),
|
||||
sm_radius: Some(gpui::px(SM_RADIUS_PX)),
|
||||
control_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
|
||||
input_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
|
||||
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PX)),
|
||||
switch_height: Some(gpui::px(SWITCH_HEIGHT_PX)),
|
||||
switch_width: Some(gpui::px(SWITCH_WIDTH_PX)),
|
||||
switch_knob: Some(gpui::px(SWITCH_KNOB_PX)),
|
||||
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
|
||||
control_radius: Some(gpui::px(9.)),
|
||||
overlay_radius: Some(gpui::px(15.)),
|
||||
sm_radius: Some(gpui::px(6.)),
|
||||
control_height: Some(gpui::px(34.)),
|
||||
input_height: Some(gpui::px(34.)),
|
||||
menu_item_height: Some(gpui::px(32.)),
|
||||
switch_height: Some(gpui::px(26.)),
|
||||
switch_width: Some(gpui::px(46.)),
|
||||
switch_knob: Some(gpui::px(20.)),
|
||||
titlebar_height: Some(gpui::px(47.)),
|
||||
root_font_size: Some(
|
||||
self.gsettings
|
||||
.and_then(|gsettings| gsettings.root_font_size)
|
||||
.unwrap_or(gpui::px(ROOT_FONT_SIZE_PX)),
|
||||
.unwrap_or(gpui::px(14.67)),
|
||||
),
|
||||
accessibility,
|
||||
window_background: None,
|
||||
@@ -258,44 +237,41 @@ impl ThemeSource for LinuxSource {
|
||||
|
||||
fn install_watchers(&mut self, async_cx: gpui::AsyncApp) {
|
||||
let signal = ReapplySignal::install(&async_cx);
|
||||
let file_signal = signal.sender();
|
||||
|
||||
if let Some(portal) = &self.portal {
|
||||
portal.spawn_watch(signal.sender());
|
||||
}
|
||||
|
||||
// Stored before any fallible step: an early return below
|
||||
// must not drop and cancel the reapply loop.
|
||||
self._signal = Some(signal);
|
||||
|
||||
match self.backend {
|
||||
Backend::Omarchy => {
|
||||
let Some(current) = omarchy::io::current_dir() else {
|
||||
return;
|
||||
};
|
||||
let reapply_signal = signal.sender();
|
||||
|
||||
let watcher = notify::recommended_watcher(
|
||||
move |event: std::result::Result<notify::Event, notify::Error>| match event {
|
||||
Ok(_) => ReapplySignal::poke(&file_signal),
|
||||
Err(error) => log::warn!("theme: omarchy watch error: {error}"),
|
||||
Ok(_) => ReapplySignal::poke(&reapply_signal),
|
||||
Err(error) => eprintln!("theme: omarchy watch error: {error}"),
|
||||
},
|
||||
);
|
||||
|
||||
match watcher {
|
||||
Ok(mut watcher) => {
|
||||
if let Err(error) = watcher.watch(¤t, RecursiveMode::NonRecursive) {
|
||||
log::warn!("theme: omarchy watch failed: {error}");
|
||||
eprintln!("theme: omarchy watch failed: {error}");
|
||||
return;
|
||||
}
|
||||
self.file_watcher = Some(watcher);
|
||||
}
|
||||
Err(error) => log::warn!("theme: omarchy watcher unavailable: {error}"),
|
||||
Err(error) => eprintln!("theme: omarchy watcher unavailable: {error}"),
|
||||
}
|
||||
}
|
||||
Backend::Kde => {
|
||||
let Some(config) = home_dir().map(|home| home.join(".config")) else {
|
||||
return;
|
||||
};
|
||||
let reapply_signal = signal.sender();
|
||||
|
||||
let watcher = notify::recommended_watcher(
|
||||
move |event: std::result::Result<notify::Event, notify::Error>| match event {
|
||||
@@ -303,25 +279,27 @@ impl ThemeSource for LinuxSource {
|
||||
if event.paths.iter().any(|path| {
|
||||
path.file_name().is_some_and(|name| name == "kdeglobals")
|
||||
}) {
|
||||
ReapplySignal::poke(&file_signal);
|
||||
ReapplySignal::poke(&reapply_signal);
|
||||
}
|
||||
}
|
||||
Err(error) => log::warn!("theme: kde watch error: {error}"),
|
||||
Err(error) => eprintln!("theme: kde watch error: {error}"),
|
||||
},
|
||||
);
|
||||
match watcher {
|
||||
Ok(mut watcher) => {
|
||||
if let Err(error) = watcher.watch(&config, RecursiveMode::NonRecursive) {
|
||||
log::warn!("theme: kde watch failed: {error}");
|
||||
eprintln!("theme: kde watch failed: {error}");
|
||||
return;
|
||||
}
|
||||
self.file_watcher = Some(watcher);
|
||||
}
|
||||
Err(error) => log::warn!("theme: kde watcher unavailable: {error}"),
|
||||
Err(error) => eprintln!("theme: kde watcher unavailable: {error}"),
|
||||
}
|
||||
}
|
||||
Backend::Portal | Backend::Fallback => {}
|
||||
}
|
||||
|
||||
self._signal = Some(signal);
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -376,7 +354,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gnome_snapshot_labels_gnome_and_defaults_the_root_font() {
|
||||
fn the_gnome_snapshot_states_the_libadwaita_metrics() {
|
||||
let source = LinuxSource {
|
||||
backend: Backend::Portal,
|
||||
portal: None,
|
||||
@@ -391,7 +369,17 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(system.source, SourceKind::Gnome);
|
||||
assert_eq!(system.control_height, Some(gpui::px(34.)));
|
||||
assert_eq!(system.input_height, Some(gpui::px(34.)));
|
||||
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
|
||||
assert_eq!(system.control_radius, Some(gpui::px(9.)));
|
||||
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
|
||||
assert_eq!(system.sm_radius, Some(gpui::px(6.)));
|
||||
assert_eq!(system.switch_height, Some(gpui::px(26.)));
|
||||
assert_eq!(system.switch_width, Some(gpui::px(46.)));
|
||||
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
|
||||
assert_eq!(system.titlebar_height, Some(gpui::px(47.)));
|
||||
// The default 11pt font when gsettings states none.
|
||||
assert_eq!(system.root_font_size, Some(gpui::px(ROOT_FONT_SIZE_PX)));
|
||||
assert_eq!(system.root_font_size, Some(gpui::px(14.67)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ use objc2_foundation::{
|
||||
};
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use super::narrow_component;
|
||||
|
||||
use super::ThemeSource;
|
||||
use crate::ThemeVariant;
|
||||
use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
||||
@@ -48,10 +46,10 @@ impl MacosSource {
|
||||
let converted = color.colorUsingColorSpace(&srgb)?;
|
||||
|
||||
Some(gpui::Hsla::from(gpui::Rgba {
|
||||
r: narrow_component(converted.redComponent()),
|
||||
g: narrow_component(converted.greenComponent()),
|
||||
b: narrow_component(converted.blueComponent()),
|
||||
a: narrow_component(converted.alphaComponent()),
|
||||
r: converted.redComponent() as f32,
|
||||
g: converted.greenComponent() as f32,
|
||||
b: converted.blueComponent() as f32,
|
||||
a: converted.alphaComponent() as f32,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ impl OmarchyTheme {
|
||||
.get("mode")
|
||||
.and_then(toml::Value::as_str)
|
||||
.and_then(mode_variant)
|
||||
.unwrap_or_else(|| ThemeVariant::by_lightness(background));
|
||||
.unwrap_or_else(|| variant_by_lightness(background));
|
||||
|
||||
let accent = hex_color(&table, "accent").or_else(|| hex_color(&table, "color4"));
|
||||
let selection = hex_color(&table, "selection");
|
||||
@@ -86,6 +86,14 @@ fn mode_variant(mode: &str) -> Option<ThemeVariant> {
|
||||
}
|
||||
}
|
||||
|
||||
fn variant_by_lightness(background: Hsla) -> ThemeVariant {
|
||||
if background.l >= 0.5 {
|
||||
ThemeVariant::Light
|
||||
} else {
|
||||
ThemeVariant::Dark
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_color(table: &Table, key: &str) -> Option<Hsla> {
|
||||
let value = table.get(key)?.as_str()?;
|
||||
let digits = value.trim().strip_prefix('#')?;
|
||||
@@ -216,10 +224,18 @@ color15 = \"#635d6f\"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omarchy_states_no_titlebar_or_root_font() {
|
||||
fn omarchy_shares_the_gnome_control_metrics() {
|
||||
let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("parses");
|
||||
let system = theme.into_system(Default::default());
|
||||
|
||||
assert_eq!(system.control_height, Some(gpui::px(34.)));
|
||||
assert_eq!(system.input_height, Some(gpui::px(34.)));
|
||||
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
|
||||
assert_eq!(system.control_radius, Some(gpui::px(9.)));
|
||||
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
|
||||
assert_eq!(system.switch_height, Some(gpui::px(26.)));
|
||||
assert_eq!(system.switch_width, Some(gpui::px(46.)));
|
||||
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
|
||||
assert_eq!(system.titlebar_height, None);
|
||||
assert_eq!(system.root_font_size, None);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
use gpui::Hsla;
|
||||
|
||||
use super::narrow_component;
|
||||
|
||||
use crate::{ThemeVariant, system::AccessibilityPreferences};
|
||||
|
||||
/// The raw portal values, already converted off the DBus wire types.
|
||||
@@ -28,9 +26,9 @@ impl AppearanceValues {
|
||||
color_scheme: self.color_scheme,
|
||||
accent: self.accent_rgb.map(|(r, g, b)| {
|
||||
Hsla::from(gpui::Rgba {
|
||||
r: narrow_component(r),
|
||||
g: narrow_component(g),
|
||||
b: narrow_component(b),
|
||||
r: r as f32,
|
||||
g: g as f32,
|
||||
b: b as f32,
|
||||
a: 1.0,
|
||||
})
|
||||
}),
|
||||
@@ -100,9 +98,7 @@ pub(crate) mod io {
|
||||
|
||||
/// The most recently read appearance.
|
||||
pub(crate) fn appearance(&self) -> Appearance {
|
||||
// A watch-thread panic must not crash the UI thread.
|
||||
let cache = self.appearance.lock();
|
||||
*cache.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
*self.appearance.lock().expect("portal cache poisoned")
|
||||
}
|
||||
|
||||
/// Forwards portal `SettingChanged` events to the reapply signal from a dedicated thread.
|
||||
@@ -111,17 +107,17 @@ pub(crate) mod io {
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let Some(connection) = Connection::session().ok() else {
|
||||
log::warn!("theme: portal watch skipped, no session bus");
|
||||
eprintln!("theme: portal watch skipped, no session bus");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(proxy) = settings_proxy(connection) else {
|
||||
log::warn!("theme: portal watch skipped, settings proxy unavailable");
|
||||
eprintln!("theme: portal watch skipped, settings proxy unavailable");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(signals) = proxy.receive_signal("SettingChanged") else {
|
||||
log::warn!("theme: portal watch skipped, signal subscription failed");
|
||||
eprintln!("theme: portal watch skipped, signal subscription failed");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -138,13 +134,10 @@ pub(crate) mod io {
|
||||
|
||||
match read_appearance(&proxy) {
|
||||
Some(latest) => {
|
||||
let mut cache = appearance
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache = latest;
|
||||
*appearance.lock().expect("portal cache poisoned") = latest;
|
||||
super::super::signal::ReapplySignal::poke(&reapply_signal);
|
||||
}
|
||||
None => log::warn!("theme: portal read failed on SettingChanged"),
|
||||
None => eprintln!("theme: portal read failed on SettingChanged"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ impl ReapplySignal {
|
||||
/// Pokes the pipeline from a thread holding a [`Self::sender`] clone.
|
||||
pub(crate) fn poke(sender: &async_channel::Sender<()>) {
|
||||
if let Err(error) = sender.try_send(()) {
|
||||
log::warn!("theme: reapply signal failed: {error}");
|
||||
eprintln!("theme: reapply signal failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,19 +74,16 @@ pub(crate) struct WindowsSource {
|
||||
}
|
||||
|
||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||
// SAFETY: WinRT activation requires COM on this thread; the STA matches
|
||||
// the UI thread's message pump, and RPC_E_CHANGED_MODE means COM is
|
||||
// already initialized in another apartment.
|
||||
let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
|
||||
if initialized.is_err() && initialized != RPC_E_CHANGED_MODE {
|
||||
log::error!("theme: COM initialization failed: {initialized:?}");
|
||||
eprintln!("theme: COM initialization failed: {initialized:?}");
|
||||
}
|
||||
|
||||
match WindowsSource::new() {
|
||||
Some(source) => Box::new(source),
|
||||
None => {
|
||||
log::warn!("theme: Windows system theme unavailable; using fallback");
|
||||
eprintln!("theme: Windows system theme unavailable; using fallback");
|
||||
Box::new(super::fallback::FallbackSource)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +94,7 @@ impl WindowsSource {
|
||||
let ui_settings = match UISettings::new() {
|
||||
Ok(ui_settings) => ui_settings,
|
||||
Err(error) => {
|
||||
log::warn!("theme: UISettings unavailable: {error}");
|
||||
eprintln!("theme: UISettings unavailable: {error}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -105,7 +102,7 @@ impl WindowsSource {
|
||||
let accessibility_settings = match AccessibilitySettings::new() {
|
||||
Ok(accessibility_settings) => accessibility_settings,
|
||||
Err(error) => {
|
||||
log::warn!("theme: AccessibilitySettings unavailable: {error}");
|
||||
eprintln!("theme: AccessibilitySettings unavailable: {error}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -160,7 +157,13 @@ impl ThemeSource for WindowsSource {
|
||||
let foreground = self.color(UIColorType::Foreground);
|
||||
|
||||
let variant = background
|
||||
.map(ThemeVariant::by_lightness)
|
||||
.map(|background| {
|
||||
if background.l >= 0.5 {
|
||||
ThemeVariant::Light
|
||||
} else {
|
||||
ThemeVariant::Dark
|
||||
}
|
||||
})
|
||||
.or(self.registry.apps_use_light_theme.map(|light| {
|
||||
if light {
|
||||
ThemeVariant::Light
|
||||
@@ -209,7 +212,7 @@ impl ThemeSource for WindowsSource {
|
||||
});
|
||||
|
||||
if let Err(error) = self.ui_settings.ColorValuesChanged(&color_handler) {
|
||||
log::warn!("theme: color change watch failed: {error}");
|
||||
eprintln!("theme: color change watch failed: {error}");
|
||||
}
|
||||
|
||||
let contrast_signal = signal.sender();
|
||||
@@ -223,7 +226,7 @@ impl ThemeSource for WindowsSource {
|
||||
.accessibility_settings
|
||||
.HighContrastChanged(&contrast_handler)
|
||||
{
|
||||
log::warn!("theme: high contrast watch failed: {error}");
|
||||
eprintln!("theme: high contrast watch failed: {error}");
|
||||
}
|
||||
|
||||
self._signal = Some(signal);
|
||||
|
||||
@@ -7,4 +7,3 @@ description = "Small helpers shared by the component crates: layout shorthands,
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
log = "0.4"
|
||||
|
||||
+2
-25
@@ -4,42 +4,21 @@ use gpui::{
|
||||
Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, WindowId, div,
|
||||
px,
|
||||
};
|
||||
use gpui_native_theme::{ActiveTheme as _, FOCUS_RING_SPREAD};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A row whose children center on the cross axis.
|
||||
#[must_use]
|
||||
pub fn h_flex() -> Div {
|
||||
div().flex().flex_row().items_center()
|
||||
}
|
||||
|
||||
/// A column whose children stack on the main axis.
|
||||
#[must_use]
|
||||
pub fn v_flex() -> Div {
|
||||
div().flex().flex_col()
|
||||
}
|
||||
|
||||
/// Logs the error and yields the success value, if any.
|
||||
pub trait ResultLogErr<T> {
|
||||
/// Logs the error and yields the success value, if any.
|
||||
fn log_err(self) -> Option<T>;
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Display> ResultLogErr<T> for Result<T, E> {
|
||||
fn log_err(self) -> Option<T> {
|
||||
match self {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => {
|
||||
log::warn!("{error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions over [`Styled`], shared by the components.
|
||||
pub trait StyledExt: Styled + Sized {
|
||||
#[must_use]
|
||||
fn refine_style(mut self, style: &StyleRefinement) -> Self {
|
||||
self.style().refine(style);
|
||||
self
|
||||
@@ -59,7 +38,6 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH
|
||||
}
|
||||
|
||||
/// The painted bounds of elements that tooltips anchor to, per window and element id.
|
||||
/// Entries are never evicted: bounds are bounded by live windows, though long-lived processes minting dynamic per-item ids could accumulate.
|
||||
#[derive(Default)]
|
||||
struct AnchorBoundsRegistry(HashMap<(WindowId, ElementId), Bounds<Pixels>>);
|
||||
|
||||
@@ -94,7 +72,7 @@ pub fn record_anchor_bounds(
|
||||
|
||||
/// Returns a focus ring shadow with the given color at half ink.
|
||||
pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
|
||||
vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(FOCUS_RING_SPREAD)]
|
||||
vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))]
|
||||
}
|
||||
|
||||
/// The next selectable row in the direction, or `None` at the ends.
|
||||
@@ -150,7 +128,6 @@ pub fn selectable_label_color(cx: &App, selected: bool, disabled: bool) -> Hsla
|
||||
}
|
||||
|
||||
/// A selectable row's background.
|
||||
#[must_use]
|
||||
pub fn paint_selectable_row<R>(cx: &App, row: R, selected: bool, disabled: bool) -> R
|
||||
where
|
||||
R: IntoElement + Styled + StatefulInteractiveElement,
|
||||
|
||||
@@ -9,17 +9,6 @@ is the first downstream consumer; its screens drive the order.
|
||||
|
||||
## Next
|
||||
|
||||
- [x] **Declarative render audit** — the AGENTS.md render rule swept the
|
||||
landed components: `let mut` element variables reassigned under `if` /
|
||||
`if let` now build one chained expression with `.when(...)` /
|
||||
`.when_some(...)` / `.map(...)`. Converted: Tabs strip root and tab,
|
||||
Menu / DropdownMenu / Popover / Combobox wrappers, ScrollArea pane and
|
||||
scrollbars, Select rows, Dock tab entries, Resizable panes and dividers,
|
||||
the Markdown text / code / table rows, and the Diff gutter cell.
|
||||
`MenuItem` and `Tab` adopted `FluentBuilder` so their builders chain
|
||||
too; data accumulators (`Vec`/`String` builders, style clones) stay
|
||||
imperative — they are state, not element refinement.
|
||||
|
||||
## Queue — to follow
|
||||
|
||||
Platform metric adoption continues one component at a time; each stop goes
|
||||
@@ -72,9 +61,6 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
||||
|
||||
- [x] `IconRole` + bundled Lucide fallback, Segoe Fluent codepoints (Windows),
|
||||
freedesktop theme walker (Linux/Omarchy)
|
||||
- [x] `IconNamed` — app-defined icon sets: implement the trait for an enum of
|
||||
asset paths, render it with `Icon::named`, and hand it to any icon
|
||||
builder (`impl Into<Icon>`) alongside the built-in roles
|
||||
|
||||
### core (`crates/core`)
|
||||
|
||||
|
||||
@@ -11,10 +11,9 @@ reqwest_client = { workspace = true }
|
||||
gpui-native-core = { workspace = true, features = ["full"] }
|
||||
gpui-native-data = { workspace = true, features = ["full"] }
|
||||
gpui-native-editor = { workspace = true, features = ["full"] }
|
||||
gpui-native-layout = { workspace = true, features = ["full"] }
|
||||
gpui-native-layout = { workspace = true }
|
||||
gpui-native-icons = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
gpui-native-utils = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "gallery"
|
||||
|
||||
+234
-370
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user