Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21646952ce | ||
|
|
0192c50157 | ||
|
|
0859e27290 | ||
|
|
2aa2d7034f | ||
|
|
67f34ead74 | ||
|
|
a6b6cfd5a4 | ||
|
|
7c061edfba | ||
|
|
d054300b7c | ||
|
|
03369f501e | ||
|
|
af2177c6c8 | ||
|
|
6ad582ba84 | ||
|
|
7335525ecb | ||
|
|
44abaf47a9 | ||
|
|
fbc4f24d16 | ||
|
|
322f037289 | ||
|
|
dc2c612cff | ||
|
|
ec906839ed | ||
|
|
1c091db699 |
@@ -1,5 +1,6 @@
|
|||||||
# Coding guidelines
|
# 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.
|
- 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.
|
- 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.
|
- Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||||
@@ -63,7 +64,7 @@ GPUI is a UI framework which also provides primitives for state and concurrency
|
|||||||
## Rendering and state
|
## 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`.
|
- `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; use `.when(...)` / `.when_some(...)` for small conditional refinements.
|
- 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`.
|
||||||
- Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth.
|
- 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`.
|
- 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.
|
- `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
+1069
-863
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" }
|
gpui-native-theme = { path = "crates/theme" }
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui = { package = "gpui-pre", version = "=0.3.10" }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] }
|
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.10", features = ["font-kit"] }
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
reqwest_client = { package = "gpui-pre-reqwest-client", version = "=0.3.10" }
|
||||||
|
|
||||||
gpui-native-core = { path = "crates/core", default-features = false }
|
gpui-native-core = { path = "crates/core", default-features = false }
|
||||||
gpui-native-data = { path = "crates/data", default-features = false }
|
gpui-native-data = { path = "crates/data", default-features = false }
|
||||||
gpui-native-editor = { path = "crates/editor", default-features = false }
|
gpui-native-editor = { path = "crates/editor", default-features = false }
|
||||||
gpui-native-layout = { path = "crates/layout" }
|
gpui-native-layout = { path = "crates/layout", default-features = false }
|
||||||
gpui-native-icons = { path = "crates/icons" }
|
gpui-native-icons = { path = "crates/icons" }
|
||||||
gpui-native-theme = { path = "crates/theme" }
|
gpui-native-theme = { path = "crates/theme" }
|
||||||
gpui-native-utils = { path = "crates/utils" }
|
gpui-native-utils = { path = "crates/utils" }
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ gpui = { workspace = true }
|
|||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
gpui-native-utils = { workspace = true }
|
gpui-native-utils = { workspace = true }
|
||||||
|
log = "0.4"
|
||||||
unicode-segmentation = "1.12"
|
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]
|
[dev-dependencies]
|
||||||
gpui = { workspace = true, features = ["test-support"] }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
@@ -48,6 +53,7 @@ full = [
|
|||||||
"empty_state",
|
"empty_state",
|
||||||
"alert",
|
"alert",
|
||||||
"toast",
|
"toast",
|
||||||
|
"speech",
|
||||||
]
|
]
|
||||||
button = ["spinner"]
|
button = ["spinner"]
|
||||||
checkbox = []
|
checkbox = []
|
||||||
@@ -76,7 +82,8 @@ tooltip = []
|
|||||||
menu = ["scroll_area"]
|
menu = ["scroll_area"]
|
||||||
dropdown_menu = ["menu", "button"]
|
dropdown_menu = ["menu", "button"]
|
||||||
select = ["menu"]
|
select = ["menu"]
|
||||||
dialog = []
|
dialog = ["button"]
|
||||||
empty_state = []
|
empty_state = []
|
||||||
alert = []
|
alert = []
|
||||||
toast = []
|
toast = []
|
||||||
|
speech = ["spinner", "tooltip", "dep:flume", "dep:cpal"]
|
||||||
|
|||||||
+33
-25
@@ -3,13 +3,16 @@ use std::rc::Rc;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
||||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
|
IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
|
||||||
Styled as _, Window, deferred, div, ease_out_quint, px,
|
StatefulInteractiveElement as _, Styled as _, Window, deferred, div, ease_out_quint,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{Icon, IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, ENTER_SLIDE_DISTANCE, Size};
|
||||||
|
use gpui_native_utils::ResultLogErr as _;
|
||||||
|
|
||||||
|
use crate::button::{Button, ButtonVariant};
|
||||||
use crate::common::DismissHandler;
|
use crate::common::DismissHandler;
|
||||||
|
use crate::common::Sizable as _;
|
||||||
use crate::common::v_flex;
|
use crate::common::v_flex;
|
||||||
|
|
||||||
/// How alarming an [`Alert`] is; picks the icon and the accent color.
|
/// How alarming an [`Alert`] is; picks the icon and the accent color.
|
||||||
@@ -28,12 +31,13 @@ pub enum AlertSeverity {
|
|||||||
|
|
||||||
/// A static callout banner.
|
/// A static callout banner.
|
||||||
#[derive(Clone, IntoElement)]
|
#[derive(Clone, IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Alert {
|
pub struct Alert {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
title: Option<SharedString>,
|
title: Option<SharedString>,
|
||||||
message: Option<SharedString>,
|
message: Option<SharedString>,
|
||||||
severity: AlertSeverity,
|
severity: AlertSeverity,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
show_icon: bool,
|
show_icon: bool,
|
||||||
on_dismiss: Option<DismissHandler>,
|
on_dismiss: Option<DismissHandler>,
|
||||||
}
|
}
|
||||||
@@ -78,9 +82,12 @@ impl Alert {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overrides the severity-derived icon with this glyph.
|
/// Overrides the severity-derived icon.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,15 +123,16 @@ impl RenderOnce for Alert {
|
|||||||
AlertSeverity::Danger => theme.ramps.destructive,
|
AlertSeverity::Danger => theme.ramps.destructive,
|
||||||
};
|
};
|
||||||
|
|
||||||
let icon_role = self.icon.unwrap_or(match self.severity {
|
let icon_glyph = self.icon.unwrap_or_else(|| {
|
||||||
|
icon(match self.severity {
|
||||||
AlertSeverity::Info => IconRole::Info,
|
AlertSeverity::Info => IconRole::Info,
|
||||||
AlertSeverity::Success => IconRole::Check,
|
AlertSeverity::Success => IconRole::Check,
|
||||||
AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning,
|
AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning,
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let foreground = theme.colors.foreground;
|
let foreground = theme.colors.foreground;
|
||||||
let muted_foreground = theme.colors.muted_foreground;
|
let muted_foreground = theme.colors.muted_foreground;
|
||||||
let muted = theme.colors.muted;
|
|
||||||
let surface = theme.colors.surface;
|
let surface = theme.colors.surface;
|
||||||
|
|
||||||
let border = if theme.borders.overlays {
|
let border = if theme.borders.overlays {
|
||||||
@@ -140,6 +148,8 @@ impl RenderOnce for Alert {
|
|||||||
let caption_size = metrics.caption_size;
|
let caption_size = metrics.caption_size;
|
||||||
|
|
||||||
div()
|
div()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.role(Role::Alert)
|
||||||
.w_full()
|
.w_full()
|
||||||
.flex()
|
.flex()
|
||||||
.items_start()
|
.items_start()
|
||||||
@@ -158,7 +168,7 @@ impl RenderOnce for Alert {
|
|||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.h(window.line_height())
|
.h(window.line_height())
|
||||||
.child(icon(icon_role).size(icon_size).color(ramp.base)),
|
.child(icon_glyph.size(icon_size).color(ramp.base)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -192,18 +202,11 @@ impl RenderOnce for Alert {
|
|||||||
)
|
)
|
||||||
.when_some(self.on_dismiss, |alert, on_dismiss| {
|
.when_some(self.on_dismiss, |alert, on_dismiss| {
|
||||||
alert.child(
|
alert.child(
|
||||||
div()
|
Button::new((self.id.clone(), "alert-close"))
|
||||||
.id((self.id, "alert-close"))
|
.icon(IconRole::Close)
|
||||||
.flex_none()
|
.variant(ButtonVariant::Ghost)
|
||||||
.cursor_pointer()
|
.small()
|
||||||
.p_1()
|
.accessibility_label("Dismiss")
|
||||||
.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)),
|
.on_click(move |_, _window, cx| on_dismiss(cx)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -235,7 +238,9 @@ impl Alerts {
|
|||||||
let stack = cx.entity().downgrade();
|
let stack = cx.entity().downgrade();
|
||||||
|
|
||||||
alert.on_dismiss = Some(Rc::new(move |cx: &mut App| {
|
alert.on_dismiss = Some(Rc::new(move |cx: &mut App| {
|
||||||
stack.update(cx, |alerts, cx| alerts.dismiss(id, cx)).ok();
|
stack
|
||||||
|
.update(cx, |alerts, cx| alerts.dismiss(id, cx))
|
||||||
|
.log_err();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
self.entries.push(AlertEntry {
|
self.entries.push(AlertEntry {
|
||||||
@@ -289,6 +294,7 @@ impl Alerts {
|
|||||||
|
|
||||||
/// The floating stack of pushed alerts, topmost right.
|
/// The floating stack of pushed alerts, topmost right.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct AlertsView {
|
pub struct AlertsView {
|
||||||
alerts: Entity<Alerts>,
|
alerts: Entity<Alerts>,
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
@@ -337,7 +343,9 @@ impl RenderOnce for AlertsView {
|
|||||||
.with_animation(
|
.with_animation(
|
||||||
("alert-enter", id),
|
("alert-enter", id),
|
||||||
Animation::new(enter_duration),
|
Animation::new(enter_duration),
|
||||||
move |layer, delta| layer.left(px(24. * (1. - ease_slide(delta)))),
|
move |layer, delta| {
|
||||||
|
layer.left(ENTER_SLIDE_DISTANCE * (1. - ease_slide(delta)))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const MIN_FILLED: usize = 5;
|
|||||||
|
|
||||||
/// An avatar for a user or other entity.
|
/// An avatar for a user or other entity.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
name: Option<SharedString>,
|
name: Option<SharedString>,
|
||||||
@@ -210,9 +211,9 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ix = (start + offset) % total;
|
let flat_index = (start + offset) % total;
|
||||||
let row = MARGIN + ix / art_columns;
|
let row = MARGIN + flat_index / art_columns;
|
||||||
let col = MARGIN + ix % art_columns;
|
let col = MARGIN + flat_index % art_columns;
|
||||||
|
|
||||||
if pattern[row * GRID_SIZE + col] == 0 {
|
if pattern[row * GRID_SIZE + col] == 0 {
|
||||||
set_cell(&mut pattern, row, col, 1);
|
set_cell(&mut pattern, row, col, 1);
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Hsla, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement,
|
AnyView, App, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||||
Styled as _, Window,
|
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
|
||||||
|
Window, transparent_black,
|
||||||
};
|
};
|
||||||
|
use gpui_native_icons::Icon;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||||
|
|
||||||
use crate::common::{Sizable, StyledExt, h_flex};
|
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.
|
/// How loud a badge is.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum BadgeVariant {
|
pub enum BadgeVariant {
|
||||||
@@ -20,18 +28,42 @@ pub enum BadgeVariant {
|
|||||||
Warning,
|
Warning,
|
||||||
/// The destructive ramp.
|
/// The destructive ramp.
|
||||||
Danger,
|
Danger,
|
||||||
|
/// No fill, just a border; the quietest badge.
|
||||||
|
Outline,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BadgeVariant {
|
impl BadgeVariant {
|
||||||
fn tokens(self, theme: &Theme) -> (Hsla, Hsla) {
|
fn tokens(self, theme: &Theme) -> (Hsla, Hsla, Hsla) {
|
||||||
match self {
|
match self {
|
||||||
Self::Neutral => (theme.colors.muted, theme.colors.muted_foreground),
|
Self::Neutral => (
|
||||||
Self::Accent => (theme.ramps.primary.base, theme.colors.primary_foreground),
|
theme.colors.muted,
|
||||||
Self::Success => (theme.ramps.success.base, theme.colors.success_foreground),
|
theme.colors.muted_foreground,
|
||||||
Self::Warning => (theme.ramps.warning.base, theme.colors.warning_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::Danger => (
|
Self::Danger => (
|
||||||
theme.ramps.destructive.base,
|
theme.ramps.destructive.base,
|
||||||
theme.colors.destructive_foreground,
|
theme.colors.destructive_foreground,
|
||||||
|
transparent_black(),
|
||||||
|
),
|
||||||
|
Self::Outline => (
|
||||||
|
transparent_black(),
|
||||||
|
theme.colors.foreground,
|
||||||
|
theme.colors.border,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,23 +71,31 @@ impl BadgeVariant {
|
|||||||
|
|
||||||
/// A small pill for a count or a state.
|
/// A small pill for a count or a state.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Badge {
|
pub struct Badge {
|
||||||
|
id: ElementId,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
|
icon: Option<Icon>,
|
||||||
variant: BadgeVariant,
|
variant: BadgeVariant,
|
||||||
size: Size,
|
size: Size,
|
||||||
|
tooltip: Option<TooltipBuilder>,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Badge {
|
impl Badge {
|
||||||
/// Creates a badge showing `label` — a count or state word the caller formats.
|
/// Creates a badge showing `label` — a count or state word the caller formats.
|
||||||
pub fn new<L>(label: L) -> Self
|
pub fn new<I, L>(id: I, label: L) -> Self
|
||||||
where
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
|
id: id.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
|
icon: None,
|
||||||
variant: BadgeVariant::default(),
|
variant: BadgeVariant::default(),
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
|
tooltip: None,
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +105,24 @@ impl Badge {
|
|||||||
self.variant = variant;
|
self.variant = variant;
|
||||||
self
|
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 {
|
impl Sizable for Badge {
|
||||||
@@ -84,18 +142,28 @@ impl RenderOnce for Badge {
|
|||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
let metrics = theme.sizes.resolve(self.size);
|
||||||
let label_size = metrics.caption_size;
|
let label_size = metrics.caption_size;
|
||||||
let (background, foreground) = self.variant.tokens(theme);
|
let (background, foreground, border) = self.variant.tokens(theme);
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.id(self.id.clone())
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
|
.gap_1()
|
||||||
.rounded(theme.radius.full)
|
.rounded(theme.radius.full)
|
||||||
|
.border_1()
|
||||||
|
.border_color(border)
|
||||||
.bg(background)
|
.bg(background)
|
||||||
.text_color(foreground)
|
.text_color(foreground)
|
||||||
.text_size(label_size)
|
.text_size(label_size)
|
||||||
.px(metrics.dense_padding_x)
|
.px(metrics.dense_padding_x)
|
||||||
.py_0p5()
|
.py_0p5()
|
||||||
|
.when_some(self.icon, |badge, icon| {
|
||||||
|
badge.child(icon.size(metrics.dense_icon_size).color(foreground))
|
||||||
|
})
|
||||||
.child(self.label)
|
.child(self.label)
|
||||||
.refine_style(&self.style)
|
.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,
|
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
|
||||||
relative, transparent_black,
|
relative, transparent_black,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::Icon;
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||||
use gpui_native_utils::{focus_ring, record_anchor_bounds};
|
use gpui_native_utils::{focus_ring, record_anchor_bounds};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -35,10 +35,11 @@ pub enum ButtonVariant {
|
|||||||
|
|
||||||
/// A push button: pointer, Enter, and Space all fire the single `on_click`.
|
/// A push button: pointer, Enter, and Space all fire the single `on_click`.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Button {
|
pub struct Button {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
size: Size,
|
size: Size,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
@@ -82,8 +83,11 @@ impl Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon beside the label.
|
/// Shows an icon beside the label.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +240,8 @@ impl RenderOnce for Button {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!self.loading, |button| {
|
.when(!self.loading, |button| {
|
||||||
button.when_some(self.icon, |button, role| {
|
button.when_some(self.icon, |button, icon| {
|
||||||
button.child(icon(role).size(metrics.icon_size).color(foreground))
|
button.child(icon.size(metrics.icon_size).color(foreground))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.when_some(self.label, |button, label| button.child(label))
|
.when_some(self.label, |button, label| button.child(label))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
|
|||||||
|
|
||||||
/// A controlled checkbox component.
|
/// A controlled checkbox component.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Checkbox {
|
pub struct Checkbox {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_native_icons::IconRole;
|
use gpui_native_icons::IconRole;
|
||||||
use gpui_native_theme::Size;
|
use gpui_native_theme::Size;
|
||||||
|
use gpui_native_utils::ResultLogErr as _;
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariant};
|
use crate::button::{Button, ButtonVariant};
|
||||||
use crate::common::Sizable;
|
use crate::common::Sizable;
|
||||||
@@ -25,6 +26,7 @@ type CopiedHandler = Box<dyn Fn(&mut App)>;
|
|||||||
|
|
||||||
/// A quiet icon button that copies a value to the system clipboard.
|
/// A quiet icon button that copies a value to the system clipboard.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct ClipboardButton {
|
pub struct ClipboardButton {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
value: SharedString,
|
value: SharedString,
|
||||||
@@ -153,9 +155,9 @@ impl RenderOnce for ClipboardButton {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.ok();
|
.log_err();
|
||||||
})
|
})
|
||||||
.ok();
|
.log_err();
|
||||||
});
|
});
|
||||||
|
|
||||||
copied.update(cx, |state, _| {
|
copied.update(cx, |state, _| {
|
||||||
|
|||||||
+53
-34
@@ -16,7 +16,9 @@ use gpui::{
|
|||||||
Window, actions, anchored, canvas, deferred, div, point, px,
|
Window, actions, anchored, canvas, deferred, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
use gpui_native_theme::{BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
|
||||||
|
use gpui_native_utils::ResultLogErr as _;
|
||||||
|
|
||||||
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
|
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
|
||||||
use crate::input::{Input, InputState, InputStateEvent};
|
use crate::input::{Input, InputState, InputStateEvent};
|
||||||
@@ -67,6 +69,7 @@ pub type ComboboxCreateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>
|
|||||||
pub type ComboboxFilter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
|
pub type ComboboxFilter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
|
||||||
|
|
||||||
/// Builder for a combobox. Pass it to [`ComboboxState::new`].
|
/// Builder for a combobox. Pass it to [`ComboboxState::new`].
|
||||||
|
#[must_use]
|
||||||
pub struct Combobox<T>
|
pub struct Combobox<T>
|
||||||
where
|
where
|
||||||
T: Clone + PartialEq + 'static,
|
T: Clone + PartialEq + 'static,
|
||||||
@@ -109,7 +112,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the initially selected value; its label seeds the field.
|
/// Sets the initially selected value; its label seeds the field.
|
||||||
pub fn selected(mut self, selected: impl Into<Option<T>>) -> Self {
|
pub fn selected<S>(mut self, selected: S) -> Self
|
||||||
|
where
|
||||||
|
S: Into<Option<T>>,
|
||||||
|
{
|
||||||
self.selected = selected.into();
|
self.selected = selected.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -337,7 +343,8 @@ where
|
|||||||
if let Some(on_change) = self.on_change.clone() {
|
if let Some(on_change) = self.on_change.clone() {
|
||||||
let value = value.clone();
|
let value = value.clone();
|
||||||
let task = window.spawn(cx, async move |cx| {
|
let task = window.spawn(cx, async move |cx| {
|
||||||
cx.update(|window, cx| on_change(value, window, cx)).ok();
|
cx.update(|window, cx| on_change(value, window, cx))
|
||||||
|
.log_err();
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
@@ -404,7 +411,8 @@ where
|
|||||||
{
|
{
|
||||||
let text = SharedString::from(text);
|
let text = SharedString::from(text);
|
||||||
let task = window.spawn(cx, async move |cx| {
|
let task = window.spawn(cx, async move |cx| {
|
||||||
cx.update(|window, cx| on_create(text, window, cx)).ok();
|
cx.update(|window, cx| on_create(text, window, cx))
|
||||||
|
.log_err();
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
@@ -418,17 +426,20 @@ where
|
|||||||
if self.visible.is_empty() {
|
if self.visible.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let current = self.highlight.filter(|row| *row < self.visible.len());
|
|
||||||
|
let count = self.visible.len();
|
||||||
|
let current = self.highlight.filter(|row| *row < count);
|
||||||
let next = match current {
|
let next = match current {
|
||||||
Some(row) => {
|
Some(row) => match row.checked_add_signed(isize::try_from(delta).unwrap_or(0)) {
|
||||||
let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32);
|
Some(next) if next < count => next,
|
||||||
row as usize
|
Some(_) => 0,
|
||||||
}
|
None => count - 1,
|
||||||
|
},
|
||||||
None => {
|
None => {
|
||||||
if delta > 0 {
|
if delta > 0 {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
self.visible.len() - 1
|
count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -518,7 +529,7 @@ where
|
|||||||
|
|
||||||
let wrapper_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
let wrapper_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
let mut wrapper = div()
|
div()
|
||||||
.id(id.clone())
|
.id(id.clone())
|
||||||
.key_context(COMBOBOX_CONTEXT)
|
.key_context(COMBOBOX_CONTEXT)
|
||||||
.relative()
|
.relative()
|
||||||
@@ -575,26 +586,30 @@ where
|
|||||||
.right(metrics.dense_padding_x)
|
.right(metrics.dense_padding_x)
|
||||||
.top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.)
|
.top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.)
|
||||||
.child(chevron),
|
.child(chevron),
|
||||||
);
|
)
|
||||||
|
.when(open, |wrapper| {
|
||||||
if open {
|
|
||||||
let rows: Vec<gpui::AnyElement> = self
|
let rows: Vec<gpui::AnyElement> = self
|
||||||
.visible
|
.visible
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(row, option_ix)| {
|
.map(|(row, option_index)| {
|
||||||
let (value, label) = &self.options[*option_ix];
|
let Some((value, label)) = self.options.get(*option_index) else {
|
||||||
|
return div().into_any_element();
|
||||||
|
};
|
||||||
|
|
||||||
let highlighted = self.highlight == Some(row);
|
let highlighted = self.highlight == Some(row);
|
||||||
let checked = self.selected.as_ref() == Some(value);
|
let checked = self.selected.as_ref() == Some(value);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let row_ix = row;
|
let row_index = row;
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id(("option", row))
|
.id(("option", *option_index))
|
||||||
.debug_selector({
|
.debug_selector({
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
move || format!("{id}-option-{row}")
|
move || format!("{id}-option-{row}")
|
||||||
})
|
})
|
||||||
|
.role(Role::ListBoxOption)
|
||||||
|
.aria_label(label.clone())
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -606,9 +621,9 @@ where
|
|||||||
.on_mouse_move({
|
.on_mouse_move({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |_, _window, cx| {
|
move |_, _window, cx| {
|
||||||
if state.read(cx).highlight != Some(row_ix) {
|
if state.read(cx).highlight != Some(row_index) {
|
||||||
state.update(cx, |state, cx| {
|
state.update(cx, |state, cx| {
|
||||||
state.highlight = Some(row_ix);
|
state.highlight = Some(row_index);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -617,7 +632,9 @@ where
|
|||||||
.on_click({
|
.on_click({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
state.update(cx, |state, cx| {
|
||||||
|
state.choose_row(row_index, window, cx)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -649,8 +666,9 @@ where
|
|||||||
.child("No matches")
|
.child("No matches")
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
} else {
|
} else {
|
||||||
let scroll = window
|
let scroll = window.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| {
|
||||||
.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new());
|
ScrollHandle::new()
|
||||||
|
});
|
||||||
|
|
||||||
ScrollArea::new((id.clone(), "rows"))
|
ScrollArea::new((id.clone(), "rows"))
|
||||||
.handle(scroll.read(cx).clone())
|
.handle(scroll.read(cx).clone())
|
||||||
@@ -661,6 +679,7 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
let popup_width = wrapper_bounds
|
let popup_width = wrapper_bounds
|
||||||
.borrow()
|
.borrow()
|
||||||
.map(|bounds| bounds.size.width)
|
.map(|bounds| bounds.size.width)
|
||||||
@@ -701,12 +720,14 @@ where
|
|||||||
if phase != gpui::DispatchPhase::Bubble {
|
if phase != gpui::DispatchPhase::Bubble {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let on_panel = panel_bounds
|
let on_panel =
|
||||||
.borrow()
|
panel_bounds.borrow().is_some_and(|bounds| {
|
||||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
bounds.contains(&event.position)
|
||||||
let on_wrapper = wrapper_bounds
|
});
|
||||||
.borrow()
|
let on_wrapper =
|
||||||
.is_some_and(|bounds| bounds.contains(&event.position));
|
wrapper_bounds.borrow().is_some_and(|bounds| {
|
||||||
|
bounds.contains(&event.position)
|
||||||
|
});
|
||||||
if on_panel || on_wrapper {
|
if on_panel || on_wrapper {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -723,11 +744,9 @@ where
|
|||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
);
|
||||||
|
|
||||||
wrapper = wrapper.child(deferred(
|
wrapper.child(deferred(
|
||||||
anchored().offset(point(px(0.), spacing_unit)).child(panel),
|
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,
|
StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, deferred,
|
||||||
div, relative,
|
div, relative,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::Icon;
|
||||||
use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT, Size};
|
use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT, Size};
|
||||||
|
|
||||||
use crate::common::{h_flex, v_flex};
|
use crate::common::{h_flex, v_flex};
|
||||||
@@ -39,8 +39,9 @@ pub fn bind_command_palette_keys(cx: &mut App) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One action the palette can run: a stable id and the label the matcher sees.
|
/// A command the palette can run: a stable id and the label the matcher sees.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[must_use]
|
||||||
pub struct Command {
|
pub struct Command {
|
||||||
/// The stable domain id handed to `on_execute`.
|
/// The stable domain id handed to `on_execute`.
|
||||||
pub id: SharedString,
|
pub id: SharedString,
|
||||||
@@ -49,12 +50,16 @@ pub struct Command {
|
|||||||
/// Gray text on the row's right edge, e.g. the keybinding.
|
/// Gray text on the row's right edge, e.g. the keybinding.
|
||||||
pub hint: Option<SharedString>,
|
pub hint: Option<SharedString>,
|
||||||
/// Leading glyph shown before the label.
|
/// Leading glyph shown before the label.
|
||||||
pub icon: Option<IconRole>,
|
pub icon: Option<Icon>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
/// Creates a command without a hint or icon.
|
/// Creates a command without a hint or icon.
|
||||||
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
pub fn new<I, L>(id: I, label: L) -> Self
|
||||||
|
where
|
||||||
|
I: Into<SharedString>,
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
@@ -64,13 +69,19 @@ impl Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon before the label.
|
/// Shows an icon before the label.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the row's right-edge hint.
|
/// Sets the row's right-edge hint.
|
||||||
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
|
pub fn hint<H>(mut self, hint: H) -> Self
|
||||||
|
where
|
||||||
|
H: Into<SharedString>,
|
||||||
|
{
|
||||||
self.hint = Some(hint.into());
|
self.hint = Some(hint.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -82,6 +93,7 @@ pub type CommandMatcher = Rc<dyn Fn(&str, &[Command]) -> Vec<Command>>;
|
|||||||
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
|
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
|
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
|
||||||
|
#[must_use]
|
||||||
pub struct CommandPalette {
|
pub struct CommandPalette {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
@@ -258,10 +270,15 @@ impl CommandPaletteState {
|
|||||||
if self.matches.is_empty() {
|
if self.matches.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let count = self.matches.len();
|
||||||
let next = match self.highlight {
|
let next = match self.highlight {
|
||||||
Some(row) => (row as i32 + delta).rem_euclid(self.matches.len() as i32) as usize,
|
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,
|
||||||
|
},
|
||||||
None if delta > 0 => 0,
|
None if delta > 0 => 0,
|
||||||
None => self.matches.len() - 1,
|
None => count - 1,
|
||||||
};
|
};
|
||||||
self.highlight = Some(next);
|
self.highlight = Some(next);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -330,7 +347,7 @@ impl Render for CommandPaletteState {
|
|||||||
.map(|(row, command)| {
|
.map(|(row, command)| {
|
||||||
let highlighted = self.highlight == Some(row);
|
let highlighted = self.highlight == Some(row);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let row_ix = row;
|
let row_index = row;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id((ElementId::from(command.id.clone()), "command"))
|
.id((ElementId::from(command.id.clone()), "command"))
|
||||||
@@ -338,6 +355,8 @@ impl Render for CommandPaletteState {
|
|||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
move || format!("{id}-command-{row}")
|
move || format!("{id}-command-{row}")
|
||||||
})
|
})
|
||||||
|
.role(Role::ListBoxOption)
|
||||||
|
.aria_label(command.label.clone())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.px_2()
|
.px_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
@@ -347,9 +366,9 @@ impl Render for CommandPaletteState {
|
|||||||
.on_mouse_move({
|
.on_mouse_move({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |_, _window, cx| {
|
move |_, _window, cx| {
|
||||||
if state.read(cx).highlight != Some(row_ix) {
|
if state.read(cx).highlight != Some(row_index) {
|
||||||
state.update(cx, |state, cx| {
|
state.update(cx, |state, cx| {
|
||||||
state.highlight = Some(row_ix);
|
state.highlight = Some(row_index);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -358,11 +377,11 @@ impl Render for CommandPaletteState {
|
|||||||
.on_click({
|
.on_click({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
|
state.update(cx, |state, cx| state.choose_row(row_index, window, cx));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when_some(command.icon, |row, role| {
|
.when_some(command.icon.clone(), |row, icon| {
|
||||||
row.child(icon(role).size(metrics.icon_size).color(colors.foreground))
|
row.child(icon.size(metrics.icon_size).color(colors.foreground))
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ type TargetFn = Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyElement>;
|
|||||||
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
|
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
|
||||||
|
|
||||||
/// A menu that opens at the pointer when its target is right-clicked.
|
/// 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)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct ContextMenu {
|
pub struct ContextMenu {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
target: Option<TargetFn>,
|
target: Option<TargetFn>,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ use gpui::{
|
|||||||
IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
|
IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
|
||||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
|
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
|
||||||
};
|
};
|
||||||
|
use gpui_native_icons::IconRole;
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
use crate::common::v_flex;
|
use crate::button::{Button, ButtonVariant};
|
||||||
|
use crate::common::{Sizable, v_flex};
|
||||||
|
|
||||||
/// Requests the dialog's close: `false` closes.
|
/// Requests the dialog's close: `false` closes.
|
||||||
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||||
@@ -17,6 +19,7 @@ type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
|
|||||||
|
|
||||||
/// A centred panel over a dim scrim: the platform's modal.
|
/// A centred panel over a dim scrim: the platform's modal.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Dialog {
|
pub struct Dialog {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
open: bool,
|
open: bool,
|
||||||
@@ -58,7 +61,7 @@ impl Dialog {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests close through `on_change` on escape and scrim clicks.
|
/// Requests close through `on_change` on escape, scrim clicks, and the close button.
|
||||||
pub fn on_change<F>(mut self, handler: F) -> Self
|
pub fn on_change<F>(mut self, handler: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(bool, &mut Window, &mut App) + 'static,
|
F: Fn(bool, &mut Window, &mut App) + 'static,
|
||||||
@@ -155,6 +158,7 @@ impl RenderOnce for Dialog {
|
|||||||
|
|
||||||
let on_change = self.on_change.clone();
|
let on_change = self.on_change.clone();
|
||||||
let alert = self.alert;
|
let alert = self.alert;
|
||||||
|
let show_close = !alert && on_change.is_some();
|
||||||
|
|
||||||
// Keyboard focus rides the panel across frames.
|
// Keyboard focus rides the panel across frames.
|
||||||
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
||||||
@@ -191,14 +195,42 @@ impl RenderOnce for Dialog {
|
|||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when_some(self.title, |panel, title| {
|
.when(self.title.is_some() || show_close, |panel| {
|
||||||
panel.child(
|
panel.child(
|
||||||
div()
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_end()
|
||||||
|
.gap_2()
|
||||||
|
.when_some(self.title.clone(), |row, title| {
|
||||||
|
row.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
.text_size(title_size)
|
.text_size(title_size)
|
||||||
.font_weight(FontWeight::SEMIBOLD)
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
.child(title),
|
.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| {
|
.when_some(content, |panel, content| {
|
||||||
panel.child(div().text_color(muted_foreground).child(content))
|
panel.child(div().text_color(muted_foreground).child(content))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
|
App, ClickEvent, DefiniteLength, DispatchPhase, ElementId, InteractiveElement as _,
|
||||||
MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString,
|
IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role,
|
||||||
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
|
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{Icon, IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||||
use gpui_native_utils::{focus_handle, focus_ring};
|
use gpui_native_utils::{focus_handle, focus_ring};
|
||||||
|
|
||||||
use crate::button::ButtonVariant;
|
use crate::button::ButtonVariant;
|
||||||
use crate::common::{Sizable, h_flex};
|
use crate::common::{Sizable, h_flex};
|
||||||
use crate::menu::{ChangeHandler, MenuChrome, MenuItem, TriggerBoundsCell, anchored_menu_panel};
|
use crate::menu::{
|
||||||
|
ChangeHandler, MenuChrome, MenuItem, MenuPanelSpec, TriggerBoundsCell, anchored_menu_panel,
|
||||||
|
};
|
||||||
use crate::spinner::Spinner;
|
use crate::spinner::Spinner;
|
||||||
|
|
||||||
/// The main zone's activation handler, shared by pointer, Enter, and Space.
|
/// The main zone's activation handler, shared by pointer, Enter, and Space.
|
||||||
@@ -24,10 +26,11 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
|
|||||||
|
|
||||||
/// A push button with an attached dropdown menu.
|
/// A push button with an attached dropdown menu.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct DropdownMenu {
|
pub struct DropdownMenu {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
size: Size,
|
size: Size,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
@@ -38,6 +41,7 @@ pub struct DropdownMenu {
|
|||||||
on_click: Option<ClickHandler>,
|
on_click: Option<ClickHandler>,
|
||||||
on_open_change: Option<ChangeHandler>,
|
on_open_change: Option<ChangeHandler>,
|
||||||
items: Option<ItemsFn>,
|
items: Option<ItemsFn>,
|
||||||
|
min_width: Option<DefiniteLength>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DropdownMenu {
|
impl DropdownMenu {
|
||||||
@@ -60,6 +64,7 @@ impl DropdownMenu {
|
|||||||
on_click: None,
|
on_click: None,
|
||||||
on_open_change: None,
|
on_open_change: None,
|
||||||
items: None,
|
items: None,
|
||||||
|
min_width: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +78,11 @@ impl DropdownMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon beside the label.
|
/// Shows an icon beside the label.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +151,15 @@ impl DropdownMenu {
|
|||||||
self.items = Some(Box::new(items));
|
self.items = Some(Box::new(items));
|
||||||
self
|
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 {
|
impl Sizable for DropdownMenu {
|
||||||
@@ -267,8 +284,8 @@ impl RenderOnce for DropdownMenu {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!self.loading, |zone| {
|
.when(!self.loading, |zone| {
|
||||||
zone.when_some(self.icon, |zone, role| {
|
zone.when_some(self.icon, |zone, icon| {
|
||||||
zone.child(icon(role).size(metrics.icon_size).color(foreground))
|
zone.child(icon.size(metrics.icon_size).color(foreground))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.when_some(self.label, |zone, label| zone.child(label));
|
.when_some(self.label, |zone, label| zone.child(label));
|
||||||
@@ -363,7 +380,30 @@ impl RenderOnce for DropdownMenu {
|
|||||||
trigger
|
trigger
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut wrapper = div()
|
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()
|
||||||
.relative()
|
.relative()
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
canvas(
|
||||||
@@ -418,34 +458,10 @@ impl RenderOnce for DropdownMenu {
|
|||||||
.absolute()
|
.absolute()
|
||||||
.inset_0(),
|
.inset_0(),
|
||||||
)
|
)
|
||||||
.child(trigger);
|
.child(trigger)
|
||||||
|
.when_some(
|
||||||
let items = if self.open {
|
anchored_menu_panel(window, cx, &self.id, spec, &panel_chrome, enter_duration),
|
||||||
self.items
|
|wrapper, panel| wrapper.child(panel),
|
||||||
.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,15 +3,16 @@ use gpui::{
|
|||||||
App, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled as _,
|
App, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled as _,
|
||||||
Window, div, relative,
|
Window, div, relative,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::Icon;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
|
||||||
use crate::common::{StyledExt, v_flex};
|
use crate::common::{StyledExt, v_flex};
|
||||||
|
|
||||||
/// The centered "nothing here yet" panel for lists, tables, and searches.
|
/// The centered "nothing here yet" panel for lists, tables, and searches.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct EmptyState {
|
pub struct EmptyState {
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
headline: SharedString,
|
headline: SharedString,
|
||||||
hint: Option<SharedString>,
|
hint: Option<SharedString>,
|
||||||
action: Option<gpui::AnyElement>,
|
action: Option<gpui::AnyElement>,
|
||||||
@@ -34,8 +35,11 @@ impl EmptyState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a large muted icon above the headline.
|
/// Sets a large muted icon above the headline.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +82,11 @@ impl RenderOnce for EmptyState {
|
|||||||
.justify_center()
|
.justify_center()
|
||||||
.text_center()
|
.text_center()
|
||||||
.p_4()
|
.p_4()
|
||||||
.when_some(self.icon, |state, role| {
|
.when_some(self.icon, |state, icon| {
|
||||||
state.child(
|
state.child(
|
||||||
div()
|
div()
|
||||||
.mb_2()
|
.mb_2()
|
||||||
.child(icon(role).size(illustration).color(muted_foreground)),
|
.child(icon.size(illustration).color(muted_foreground)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub enum FieldVariant {
|
|||||||
|
|
||||||
/// A labeled control: the label, description, and error around any element.
|
/// A labeled control: the label, description, and error around any element.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Field {
|
pub struct Field {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
|
|||||||
@@ -570,7 +570,10 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the state entity with initial text, caret at the end.
|
/// Creates the state entity with initial text, caret at the end.
|
||||||
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
pub fn new_with_text<T>(text: T, cx: &mut App) -> Entity<Self>
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
{
|
||||||
let text = sanitize(text.into(), false);
|
let text = sanitize(text.into(), false);
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
let mut state = Self::new_inner(cx);
|
let mut state = Self::new_inner(cx);
|
||||||
@@ -1006,7 +1009,7 @@ impl InputState {
|
|||||||
start = end + '\n'.len_utf8();
|
start = end + '\n'.len_utf8();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => eprintln!("shaping input text failed: {err}"),
|
Err(err) => log::warn!("shaping input text failed: {err}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ShapedText { lines, mask_map }
|
ShapedText { lines, mask_map }
|
||||||
@@ -1186,7 +1189,7 @@ impl InputState {
|
|||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
) {
|
) {
|
||||||
eprintln!("painting input text failed: {err}");
|
log::warn!("painting input text failed: {err}");
|
||||||
}
|
}
|
||||||
line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32;
|
line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32;
|
||||||
}
|
}
|
||||||
@@ -1838,6 +1841,7 @@ impl EntityInputHandler for InputState {
|
|||||||
|
|
||||||
/// A text field bound to an application-owned [`InputState`].
|
/// A text field bound to an application-owned [`InputState`].
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Input {
|
pub struct Input {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
state: Entity<InputState>,
|
state: Entity<InputState>,
|
||||||
@@ -2470,7 +2474,10 @@ fn real_index(mask_map: &Option<Vec<(usize, usize)>>, display: usize) -> usize {
|
|||||||
|
|
||||||
/// Newlines have no meaning in a single-line field; multiline fields keep
|
/// Newlines have no meaning in a single-line field; multiline fields keep
|
||||||
/// them and drop carriage returns.
|
/// them and drop carriage returns.
|
||||||
fn sanitize(text: impl Into<String>, multiline: bool) -> String {
|
fn sanitize<T>(text: T, multiline: bool) -> String
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
{
|
||||||
let text = text.into();
|
let text = text.into();
|
||||||
if multiline {
|
if multiline {
|
||||||
text.replace('\r', "")
|
text.replace('\r', "")
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ pub mod separator;
|
|||||||
#[cfg(feature = "spinner")]
|
#[cfg(feature = "spinner")]
|
||||||
pub mod spinner;
|
pub mod spinner;
|
||||||
|
|
||||||
|
#[cfg(feature = "speech")]
|
||||||
|
pub mod speech;
|
||||||
|
|
||||||
#[cfg(feature = "field")]
|
#[cfg(feature = "field")]
|
||||||
pub mod field;
|
pub mod field;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
|||||||
|
|
||||||
/// Link component.
|
/// Link component.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Link {
|
pub struct Link {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
|
|||||||
+594
-179
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
|
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
|
||||||
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||||
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||||
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point, px,
|
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ type BoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
|
|||||||
|
|
||||||
/// A floating panel anchored below its trigger.
|
/// A floating panel anchored below its trigger.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Popover {
|
pub struct Popover {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
open: bool,
|
open: bool,
|
||||||
@@ -114,7 +115,7 @@ impl RenderOnce for Popover {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||||
|
|
||||||
let mut wrapper = div()
|
div()
|
||||||
.relative()
|
.relative()
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
canvas(
|
||||||
@@ -161,9 +162,8 @@ impl RenderOnce for Popover {
|
|||||||
)
|
)
|
||||||
.when_some(self.trigger, |wrapper, trigger| {
|
.when_some(self.trigger, |wrapper, trigger| {
|
||||||
wrapper.child(trigger(window, cx))
|
wrapper.child(trigger(window, cx))
|
||||||
});
|
})
|
||||||
|
.when(self.open, |wrapper| {
|
||||||
if self.open {
|
|
||||||
let on_change = self.on_change;
|
let on_change = self.on_change;
|
||||||
let label = fallback_label.clone();
|
let label = fallback_label.clone();
|
||||||
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
|
||||||
@@ -224,17 +224,15 @@ impl RenderOnce for Popover {
|
|||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
);
|
||||||
|
|
||||||
wrapper = wrapper.child(deferred(
|
wrapper.child(deferred(
|
||||||
anchored()
|
anchored()
|
||||||
.offset(point(px(0.), anchor_gap))
|
.offset(point(Pixels::ZERO, anchor_gap))
|
||||||
.child(panel.with_animation(
|
.child(panel.with_animation(
|
||||||
"popover-enter",
|
"popover-enter",
|
||||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
||||||
|panel, delta| panel.opacity(delta),
|
|panel, delta| panel.opacity(delta),
|
||||||
)),
|
)),
|
||||||
));
|
))
|
||||||
}
|
})
|
||||||
|
|
||||||
wrapper
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
//! The progress bar: determinate fill or an indeterminate sweep.
|
//! The progress bar: determinate fill or an indeterminate sweep.
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement,
|
Animation, AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement,
|
||||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||||
Styled as _, Window, div, relative,
|
Styled as _, Window, div, relative,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, PROGRESS_ANIMATION_PERIOD, Size};
|
||||||
|
|
||||||
use crate::common::Sizable;
|
use crate::common::Sizable;
|
||||||
|
|
||||||
/// A progress bar. With a `value` in `0..=1` it paints a determinate fill.
|
/// A progress bar. With a `value` in `0..=1` it paints a determinate fill.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Progress {
|
pub struct Progress {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
value: f32,
|
value: f32,
|
||||||
@@ -97,7 +96,7 @@ impl RenderOnce for Progress {
|
|||||||
.bg(ramps.primary.base)
|
.bg(ramps.primary.base)
|
||||||
.with_animation(
|
.with_animation(
|
||||||
"indeterminate",
|
"indeterminate",
|
||||||
Animation::new(Duration::from_millis(1500))
|
Animation::new(PROGRESS_ANIMATION_PERIOD)
|
||||||
.repeat()
|
.repeat()
|
||||||
.with_max_fps(30.),
|
.with_max_fps(30.),
|
||||||
|pill, delta| pill.ml(relative(delta * 0.7)),
|
|pill, delta| pill.ml(relative(delta * 0.7)),
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ use crate::common::{Sizable, h_flex, v_flex};
|
|||||||
type ChangeHandler<V> = std::rc::Rc<dyn Fn(V, &mut Window, &mut App)>;
|
type ChangeHandler<V> = std::rc::Rc<dyn Fn(V, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// Radio button.
|
/// 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> {
|
pub struct Radio<V> {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
value: V,
|
value: V,
|
||||||
@@ -65,6 +69,7 @@ where
|
|||||||
|
|
||||||
/// Group of radio buttons.
|
/// Group of radio buttons.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct RadioGroup<V>
|
pub struct RadioGroup<V>
|
||||||
where
|
where
|
||||||
V: Clone + PartialEq + 'static,
|
V: Clone + PartialEq + 'static,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub enum ScrollAxes {
|
|||||||
|
|
||||||
/// A scrollable container with an overlay scrollbar.
|
/// A scrollable container with an overlay scrollbar.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct ScrollArea {
|
pub struct ScrollArea {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
handle: Option<ScrollHandle>,
|
handle: Option<ScrollHandle>,
|
||||||
@@ -95,7 +96,7 @@ impl RenderOnce for ScrollArea {
|
|||||||
let mut pane_style = self.style.clone();
|
let mut pane_style = self.style.clone();
|
||||||
pane_style.padding = Default::default();
|
pane_style.padding = Default::default();
|
||||||
|
|
||||||
let mut pane = div()
|
let pane = div()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.when(self.axes != ScrollAxes::Horizontal, |area| {
|
.when(self.axes != ScrollAxes::Horizontal, |area| {
|
||||||
@@ -112,34 +113,31 @@ impl RenderOnce for ScrollArea {
|
|||||||
// Keep wheel events from scrolling an outer scroll area too.
|
// Keep wheel events from scrolling an outer scroll area too.
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
})
|
})
|
||||||
.refine_style(&pane_style);
|
.refine_style(&pane_style)
|
||||||
|
.children(self.children);
|
||||||
|
|
||||||
// Extend with children
|
div()
|
||||||
pane.extend(self.children);
|
.relative()
|
||||||
|
.refine_style(&self.style)
|
||||||
let mut wrapper = div().relative().refine_style(&self.style);
|
.child(pane.into_any_element())
|
||||||
wrapper.extend(std::iter::once(pane.into_any_element()));
|
.when_some(self.handle, |wrapper, handle| {
|
||||||
|
|
||||||
if let Some(handle) = self.handle {
|
|
||||||
let id = self.id.clone();
|
let id = self.id.clone();
|
||||||
|
|
||||||
if self.axes != ScrollAxes::Horizontal {
|
wrapper
|
||||||
wrapper.extend(std::iter::once(
|
.when(self.axes != ScrollAxes::Horizontal, |wrapper| {
|
||||||
|
wrapper.child(
|
||||||
Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
|
Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
|
||||||
.visibility(self.visibility)
|
.visibility(self.visibility)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
));
|
)
|
||||||
}
|
})
|
||||||
|
.when(self.axes != ScrollAxes::Vertical, |wrapper| {
|
||||||
if self.axes != ScrollAxes::Vertical {
|
wrapper.child(
|
||||||
wrapper.extend(std::iter::once(
|
|
||||||
Scrollbar::horizontal((id, "h-scrollbar"), handle)
|
Scrollbar::horizontal((id, "h-scrollbar"), handle)
|
||||||
.visibility(self.visibility)
|
.visibility(self.visibility)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
));
|
)
|
||||||
}
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
wrapper
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ use gpui::{
|
|||||||
ScrollHandle, SharedString, Styled as _, Task, UniformListScrollHandle, Window, canvas, fill,
|
ScrollHandle, SharedString, Styled as _, Task, UniformListScrollHandle, Window, canvas, fill,
|
||||||
point,
|
point,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::{ActiveTheme as _, SCROLLBAR_AUTOHIDE_DELAY, SCROLLBAR_FADE_DURATION};
|
||||||
|
use gpui_native_utils::ResultLogErr as _;
|
||||||
|
|
||||||
/// When an overlay scrollbar shows.
|
/// When an overlay scrollbar shows.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
@@ -22,12 +23,8 @@ pub enum ScrollbarVisibility {
|
|||||||
Autohide,
|
Autohide,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long an autohide thumb stays fully visible before its fade begins.
|
/// How often the autohide fade repaints while it runs.
|
||||||
const AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
|
const FADE_STEP: Duration = Duration::from_millis(50);
|
||||||
/// 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.
|
/// The shared autohide timeline for an overlay scrollbar.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -59,21 +56,23 @@ impl Autohide {
|
|||||||
let task = window.spawn(cx, async move |cx| {
|
let task = window.spawn(cx, async move |cx| {
|
||||||
if reduce_motion {
|
if reduce_motion {
|
||||||
cx.background_executor()
|
cx.background_executor()
|
||||||
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
|
.timer(SCROLLBAR_AUTOHIDE_DELAY + SCROLLBAR_FADE_DURATION)
|
||||||
.await;
|
.await;
|
||||||
cx.update(|_, cx| cx.notify(view)).ok();
|
cx.update(|_, cx| cx.notify(view)).log_err();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
|
let steps = (SCROLLBAR_FADE_DURATION.as_millis() / FADE_STEP.as_millis()).max(1);
|
||||||
cx.background_executor().timer(AUTOHIDE_DELAY).await;
|
cx.background_executor()
|
||||||
|
.timer(SCROLLBAR_AUTOHIDE_DELAY)
|
||||||
|
.await;
|
||||||
|
|
||||||
for _ in 0..steps {
|
for _ in 0..steps {
|
||||||
cx.update(|_, cx| cx.notify(view)).ok();
|
cx.update(|_, cx| cx.notify(view)).log_err();
|
||||||
cx.background_executor().timer(AUTOHIDE_FADE_STEP).await;
|
cx.background_executor().timer(FADE_STEP).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.update(|_, cx| cx.notify(view)).ok();
|
cx.update(|_, cx| cx.notify(view)).log_err();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.state.update(cx, |state, _| {
|
self.state.update(cx, |state, _| {
|
||||||
@@ -93,50 +92,19 @@ impl Autohide {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let idle = activity.elapsed();
|
let idle = activity.elapsed();
|
||||||
if idle <= AUTOHIDE_DELAY {
|
if idle <= SCROLLBAR_AUTOHIDE_DELAY {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
|
let fade = idle.saturating_sub(SCROLLBAR_AUTOHIDE_DELAY);
|
||||||
if fade >= AUTOHIDE_FADE {
|
if fade >= SCROLLBAR_FADE_DURATION {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32()
|
1.0 - fade.as_secs_f32() / SCROLLBAR_FADE_DURATION.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.
|
/// The scroll state a scrollbar drives, implemented for gpui's scroll handles.
|
||||||
pub trait ScrollbarHandle: 'static {
|
pub trait ScrollbarHandle: 'static {
|
||||||
/// How far the content can scroll, positive along each axis.
|
/// How far the content can scroll, positive along each axis.
|
||||||
@@ -245,8 +213,9 @@ impl Axis {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An overlay scrollbar, the caller renders it over the scrolled surface.
|
/// An overlay scrollbar, rendered over the scrolled surface.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Scrollbar {
|
pub struct Scrollbar {
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
handle: Rc<dyn ScrollbarHandle>,
|
handle: Rc<dyn ScrollbarHandle>,
|
||||||
@@ -288,6 +257,37 @@ impl Scrollbar {
|
|||||||
self.visibility = visibility;
|
self.visibility = visibility;
|
||||||
self
|
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 {
|
impl RenderOnce for Scrollbar {
|
||||||
@@ -297,7 +297,7 @@ impl RenderOnce for Scrollbar {
|
|||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let visibility = self.visibility;
|
let visibility = self.visibility;
|
||||||
let id = self.id;
|
let id = self.id;
|
||||||
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &id, window, cx)
|
move |_, _, window, cx| Self::paint(&handle, axis, visibility, &id, window, cx)
|
||||||
})
|
})
|
||||||
.absolute()
|
.absolute()
|
||||||
.inset_0()
|
.inset_0()
|
||||||
@@ -307,14 +307,15 @@ impl RenderOnce for Scrollbar {
|
|||||||
/// The drag state shared between the scrollbar's mouse handlers.
|
/// The drag state shared between the scrollbar's mouse handlers.
|
||||||
type Drag = Option<(Pixels, Pixels)>;
|
type Drag = Option<(Pixels, Pixels)>;
|
||||||
|
|
||||||
fn paint_scrollbar(
|
impl Scrollbar {
|
||||||
|
fn paint(
|
||||||
handle: &Rc<dyn ScrollbarHandle>,
|
handle: &Rc<dyn ScrollbarHandle>,
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
visibility: ScrollbarVisibility,
|
visibility: ScrollbarVisibility,
|
||||||
id: &ElementId,
|
id: &ElementId,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
let view = window.current_view();
|
let view = window.current_view();
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
|
||||||
@@ -336,11 +337,12 @@ fn paint_scrollbar(
|
|||||||
ScrollbarVisibility::Always => false,
|
ScrollbarVisibility::Always => false,
|
||||||
ScrollbarVisibility::Autohide => true,
|
ScrollbarVisibility::Autohide => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let autohide_state =
|
let autohide_state =
|
||||||
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", id), window, cx));
|
autohide.then(|| Autohide::new(Self::state_key("autohide", id), window, cx));
|
||||||
|
|
||||||
let track_length = viewport_along - inset * 2.0;
|
let track_length = viewport_along - inset * 2.0;
|
||||||
let (thumb_length, thumb_travel) = thumb_geometry(
|
let (thumb_length, thumb_travel) = Self::thumb_geometry(
|
||||||
viewport_along,
|
viewport_along,
|
||||||
viewport_along + max_offset,
|
viewport_along + max_offset,
|
||||||
axis.along(handle.offset()),
|
axis.along(handle.offset()),
|
||||||
@@ -384,7 +386,7 @@ fn paint_scrollbar(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", id), cx, |_, _| Drag::None);
|
let drag = window.use_keyed_state(Self::state_key("thumb-drag", id), cx, |_, _| Drag::None);
|
||||||
let dragging = drag.read(cx).is_some();
|
let dragging = drag.read(cx).is_some();
|
||||||
|
|
||||||
let alpha = match &autohide_state {
|
let alpha = match &autohide_state {
|
||||||
@@ -392,7 +394,7 @@ fn paint_scrollbar(
|
|||||||
None => 1.0,
|
None => 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(color) = thumb_style(color, alpha) {
|
if let Some(color) = Self::thumb_style(color, alpha) {
|
||||||
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
|
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +429,8 @@ fn paint_scrollbar(
|
|||||||
let start_offset = if in_thumb {
|
let start_offset = if in_thumb {
|
||||||
axis.along(handle.offset())
|
axis.along(handle.offset())
|
||||||
} else if track_length > thumb_length {
|
} 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)
|
- thumb_length / 2.0)
|
||||||
.clamp(Pixels::ZERO, track_length - thumb_length);
|
.clamp(Pixels::ZERO, track_length - thumb_length);
|
||||||
let offset = -(thumb_start / (track_length - thumb_length)) * max_offset;
|
let offset = -(thumb_start / (track_length - thumb_length)) * max_offset;
|
||||||
@@ -513,4 +516,5 @@ fn paint_scrollbar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ use gpui::prelude::FluentBuilder as _;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement,
|
AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||||
KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation,
|
KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation,
|
||||||
SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window,
|
StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, canvas, div,
|
||||||
canvas, div,
|
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::Icon;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, CONTROL_SPRING, Size};
|
||||||
use gpui_native_utils::{focus_handle, focus_ring};
|
use gpui_native_utils::{focus_handle, focus_ring};
|
||||||
|
|
||||||
use crate::common::{Sizable, StyledExt, h_flex};
|
use crate::common::{Sizable, StyledExt, h_flex};
|
||||||
@@ -18,10 +17,11 @@ type ChangeHandler<T> = Rc<dyn Fn(T, &mut Window, &mut App)>;
|
|||||||
|
|
||||||
/// Segment option for a segmented control.
|
/// Segment option for a segmented control.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[must_use]
|
||||||
pub struct SegmentedOption<T> {
|
pub struct SegmentedOption<T> {
|
||||||
value: T,
|
value: T,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> SegmentedOption<T> {
|
impl<T> SegmentedOption<T> {
|
||||||
@@ -38,8 +38,11 @@ impl<T> SegmentedOption<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a leading icon for the segment.
|
/// Sets a leading icon for the segment.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ where
|
|||||||
|
|
||||||
/// A pill strip of mutually exclusive choices.
|
/// A pill strip of mutually exclusive choices.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Segmented<T>
|
pub struct Segmented<T>
|
||||||
where
|
where
|
||||||
T: Clone + PartialEq + 'static,
|
T: Clone + PartialEq + 'static,
|
||||||
@@ -184,7 +188,6 @@ where
|
|||||||
window.use_keyed_state("segmented-track-origin", cx, |_, _| None);
|
window.use_keyed_state("segmented-track-origin", cx, |_, _| None);
|
||||||
|
|
||||||
let focus = focus_handle(&self.id, window, cx);
|
let focus = focus_handle(&self.id, window, cx);
|
||||||
let anim_config = SpringConfig::new(500., 35., 1.);
|
|
||||||
let track_point = *track_origin.read(cx);
|
let track_point = *track_origin.read(cx);
|
||||||
|
|
||||||
let plate_target = geometry
|
let plate_target = geometry
|
||||||
@@ -211,7 +214,7 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(("segment", index))
|
.id((ElementId::from("segment"), segment.label.clone()))
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
@@ -226,8 +229,8 @@ where
|
|||||||
.role(Role::RadioButton)
|
.role(Role::RadioButton)
|
||||||
.aria_label(segment.label.clone())
|
.aria_label(segment.label.clone())
|
||||||
.aria_toggled(Toggled::from(is_selected))
|
.aria_toggled(Toggled::from(is_selected))
|
||||||
.when_some(segment.icon, |segment, role| {
|
.when_some(segment.icon.clone(), |segment, icon| {
|
||||||
segment.child(icon(role).size(icon_size).color(content_color))
|
segment.child(icon.size(icon_size).color(content_color))
|
||||||
})
|
})
|
||||||
.child(segment.label.clone())
|
.child(segment.label.clone())
|
||||||
.child(
|
.child(
|
||||||
@@ -306,12 +309,12 @@ where
|
|||||||
.shadow(plate_shadow.clone())
|
.shadow(plate_shadow.clone())
|
||||||
.with_spring(
|
.with_spring(
|
||||||
"segmented-plate-x",
|
"segmented-plate-x",
|
||||||
SpringAnimation::new(anim_config).to(x),
|
SpringAnimation::new(CONTROL_SPRING).to(x),
|
||||||
|plate, x| plate.left(x),
|
|plate, x| plate.left(x),
|
||||||
)
|
)
|
||||||
.with_spring(
|
.with_spring(
|
||||||
"segmented-plate-w",
|
"segmented-plate-w",
|
||||||
SpringAnimation::new(anim_config).to(width),
|
SpringAnimation::new(CONTROL_SPRING).to(width),
|
||||||
|spring_plate, width| {
|
|spring_plate, width| {
|
||||||
spring_plate.map_element(move |plate| plate.w(width))
|
spring_plate.map_element(move |plate| plate.w(width))
|
||||||
},
|
},
|
||||||
|
|||||||
+82
-16
@@ -2,12 +2,13 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
|
App, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _,
|
||||||
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
|
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
|
||||||
|
Window, canvas,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{Icon, IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||||
use gpui_native_utils::focus_ring;
|
use gpui_native_utils::{focus_handle, focus_ring};
|
||||||
|
|
||||||
use crate::common::{Sizable, StyledExt, h_flex};
|
use crate::common::{Sizable, StyledExt, h_flex};
|
||||||
use crate::menu::{Menu, MenuItem};
|
use crate::menu::{Menu, MenuItem};
|
||||||
@@ -17,10 +18,11 @@ pub type SelectHandler<T> = Rc<dyn Fn(Option<T>, &mut Window, &mut App)>;
|
|||||||
|
|
||||||
/// Select option for a [`Select`] control.
|
/// Select option for a [`Select`] control.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[must_use]
|
||||||
pub struct SelectOption<T> {
|
pub struct SelectOption<T> {
|
||||||
value: T,
|
value: T,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> SelectOption<T> {
|
impl<T> SelectOption<T> {
|
||||||
@@ -37,8 +39,11 @@ impl<T> SelectOption<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a leading icon for the option's row.
|
/// Sets a leading icon for the option's row.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +63,7 @@ where
|
|||||||
|
|
||||||
/// A control that shows the chosen value and opens a menu of the choices.
|
/// A control that shows the chosen value and opens a menu of the choices.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Select<T>
|
pub struct Select<T>
|
||||||
where
|
where
|
||||||
T: Clone + PartialEq + 'static,
|
T: Clone + PartialEq + 'static,
|
||||||
@@ -104,7 +110,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the current value. `None` shows the placeholder.
|
/// Sets the current value. `None` shows the placeholder.
|
||||||
pub fn selected(mut self, selected: impl Into<Option<T>>) -> Self {
|
pub fn selected<S>(mut self, selected: S) -> Self
|
||||||
|
where
|
||||||
|
S: Into<Option<T>>,
|
||||||
|
{
|
||||||
self.selected = selected.into();
|
self.selected = selected.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -184,6 +193,16 @@ where
|
|||||||
.clone()
|
.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 is_open = *open.read(cx);
|
||||||
let open_for_items = open.clone();
|
let open_for_items = open.clone();
|
||||||
|
|
||||||
@@ -217,12 +236,31 @@ where
|
|||||||
let trigger_label = label.clone();
|
let trigger_label = label.clone();
|
||||||
let trigger_style = self.style;
|
let trigger_style = self.style;
|
||||||
|
|
||||||
let trigger = move |_window: &mut Window, _cx: &mut App| {
|
let trigger = {
|
||||||
|
let open = open.clone();
|
||||||
|
let focus = focus.clone();
|
||||||
|
let refocus_for_trigger = refocus_pending.clone();
|
||||||
|
move |_window: &mut Window, _cx: &mut App| {
|
||||||
h_flex()
|
h_flex()
|
||||||
.id((trigger_id.clone(), "select-trigger"))
|
.id((trigger_id.clone(), "select-trigger"))
|
||||||
.debug_selector(|| "select-trigger".to_string())
|
.debug_selector(|| "select-trigger".to_string())
|
||||||
.role(Role::ComboBox)
|
.role(Role::ComboBox)
|
||||||
.aria_label(trigger_label.clone())
|
.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()
|
.cursor_pointer()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -249,41 +287,69 @@ where
|
|||||||
.size(icon_size)
|
.size(icon_size)
|
||||||
.color(muted_foreground),
|
.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)
|
.refine_style(&trigger_style)
|
||||||
.into_any_element()
|
.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| {
|
let items = move |_window: &mut Window, _cx: &mut App| {
|
||||||
self.options
|
self.options
|
||||||
.iter()
|
.iter()
|
||||||
.map(|option| {
|
.enumerate()
|
||||||
|
.map(|(index, option)| {
|
||||||
let is_selected = self.selected.as_ref() == Some(&option.value);
|
let is_selected = self.selected.as_ref() == Some(&option.value);
|
||||||
|
|
||||||
let handler = {
|
let handler = {
|
||||||
let open = open_for_items.clone();
|
let open = open_for_items.clone();
|
||||||
|
let refocus_pending = refocus_for_items.clone();
|
||||||
let value = option.value.clone();
|
let value = option.value.clone();
|
||||||
let on_change = self.on_change.clone();
|
let on_change = self.on_change.clone();
|
||||||
move |window: &mut Window, cx: &mut App| {
|
move |window: &mut Window, cx: &mut App| {
|
||||||
open.update(cx, |open, _| *open = false);
|
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 {
|
if let Some(on_change) = &on_change {
|
||||||
on_change(Some(value.clone()), window, cx);
|
on_change(Some(value.clone()), window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut row = MenuItem::new(option.label.clone());
|
MenuItem::new(option.label.clone())
|
||||||
if let Some(role) = option.icon {
|
.id((select_id.clone(), format!("option-{index}")))
|
||||||
row = row.icon(role);
|
.when_some(option.icon.clone(), |row, icon| row.icon(icon))
|
||||||
}
|
.checked(is_selected)
|
||||||
row.checked(is_selected).handler(handler)
|
.handler(handler)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let menu = Menu::new(self.id)
|
let menu = Menu::new(self.id)
|
||||||
.open(is_open)
|
.open(is_open)
|
||||||
.on_change(move |next, _window, cx| {
|
.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);
|
||||||
|
}
|
||||||
open.update(cx, |open, _| *open = next);
|
open.update(cx, |open, _| *open = next);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.trigger(trigger)
|
.trigger(trigger)
|
||||||
.items(items);
|
.items(items);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type DebugSelector = Rc<dyn Fn() -> String>;
|
|||||||
|
|
||||||
/// A hairline rule that divides sibling content.
|
/// A hairline rule that divides sibling content.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Separator {
|
pub struct Separator {
|
||||||
orientation: SeparatorOrientation,
|
orientation: SeparatorOrientation,
|
||||||
debug_selector: Option<DebugSelector>,
|
debug_selector: Option<DebugSelector>,
|
||||||
|
|||||||
@@ -18,18 +18,24 @@ type ChangeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
|||||||
/// The pointer→value setter shared by the click and drag listeners.
|
/// The pointer→value setter shared by the click and drag listeners.
|
||||||
type ValueSetter = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
|
type ValueSetter = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
|
/// The arrow-key stride as a share of the range when the slider is continuous.
|
||||||
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
|
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 {
|
||||||
let clamped = value.clamp(min, max);
|
let clamped = value.clamp(min, max);
|
||||||
if step > 0.0 {
|
if step > 0.0 {
|
||||||
(min + ((clamped - min) / step).round() * step).clamp(min, max)
|
(min + ((clamped - min) / step).round() * step).clamp(min, max)
|
||||||
} else {
|
} else {
|
||||||
clamped
|
clamped
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A controlled slider over `[min, max]`.
|
/// A controlled slider over `[min, max]`.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Slider {
|
pub struct Slider {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
min: f32,
|
min: f32,
|
||||||
@@ -146,7 +152,7 @@ impl RenderOnce for Slider {
|
|||||||
{
|
{
|
||||||
let fraction = ((x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
|
let fraction = ((x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
|
||||||
on_change(
|
on_change(
|
||||||
constrain(min, max, step, min + fraction * (max - min)),
|
Self::constrain(min, max, step, min + fraction * (max - min)),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -184,20 +190,29 @@ impl RenderOnce for Slider {
|
|||||||
if !focus_handle.contains_focused(window, cx) {
|
if !focus_handle.contains_focused(window, cx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let single_step = if self.step > 0.0 {
|
let single_step = if self.step > 0.0 {
|
||||||
self.step
|
self.step
|
||||||
} else {
|
} else {
|
||||||
(self.max - self.min) / 20.0
|
(self.max - self.min) * FALLBACK_STEP_FRACTION
|
||||||
};
|
};
|
||||||
|
|
||||||
let next = match event.keystroke.key.as_str() {
|
let next = match event.keystroke.key.as_str() {
|
||||||
"left" | "down" => Some(value - single_step),
|
"left" | "down" => Some(value - single_step),
|
||||||
"right" | "up" => Some(value + single_step),
|
"right" | "up" => Some(value + single_step),
|
||||||
|
"home" => Some(self.min),
|
||||||
|
"end" => Some(self.max),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(next) = next
|
if let Some(next) = next
|
||||||
&& let Some(on_change) = self.on_change.as_ref()
|
&& let Some(on_change) = self.on_change.as_ref()
|
||||||
{
|
{
|
||||||
on_change(constrain(self.min, self.max, self.step, next), window, cx);
|
on_change(
|
||||||
|
Self::constrain(self.min, self.max, self.step, next),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::f32::consts::{PI, TAU};
|
use std::f32::consts::{PI, TAU};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -13,12 +12,10 @@ use gpui::{
|
|||||||
Window, canvas, div, percentage, point, px,
|
Window, canvas, div, percentage, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{Icon, IconRole, icon};
|
use gpui_native_icons::{Icon, IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, SPINNER_PERIOD, Size};
|
||||||
|
|
||||||
use crate::common::Sizable;
|
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.
|
/// The share of the circle the spinning arc paints.
|
||||||
const SPIN_SWEEP: f32 = 0.75;
|
const SPIN_SWEEP: f32 = 0.75;
|
||||||
/// The ring stroke as a share of the side.
|
/// The ring stroke as a share of the side.
|
||||||
@@ -36,6 +33,7 @@ pub enum SpinnerVariant {
|
|||||||
|
|
||||||
/// A loading ring.
|
/// A loading ring.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Spinner {
|
pub struct Spinner {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
value: Option<f32>,
|
value: Option<f32>,
|
||||||
@@ -178,7 +176,7 @@ fn spinning_ring(side: Pixels, stroke: Pixels, accent: Hsla) -> AnimationElement
|
|||||||
.size(side)
|
.size(side)
|
||||||
.with_animation(
|
.with_animation(
|
||||||
"spin",
|
"spin",
|
||||||
Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.),
|
Animation::new(SPINNER_PERIOD).repeat().with_max_fps(60.),
|
||||||
move |ring, delta| {
|
move |ring, delta| {
|
||||||
angle.set(delta * TAU);
|
angle.set(delta * TAU);
|
||||||
ring
|
ring
|
||||||
@@ -192,7 +190,7 @@ fn spinning_icon(side: Pixels, accent: Hsla) -> AnimationElement<Icon> {
|
|||||||
.color(accent)
|
.color(accent)
|
||||||
.with_animation(
|
.with_animation(
|
||||||
"spin",
|
"spin",
|
||||||
Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.),
|
Animation::new(SPINNER_PERIOD).repeat().with_max_fps(60.),
|
||||||
|glyph, delta| glyph.with_transformation(Transformation::rotate(percentage(delta))),
|
|glyph, delta| glyph.with_transformation(Transformation::rotate(percentage(delta))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, SharedString,
|
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, Role, SharedString,
|
||||||
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
|
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||||
@@ -34,6 +34,7 @@ impl StatusDotVariant {
|
|||||||
|
|
||||||
/// A small circular bead marking a row's state.
|
/// A small circular bead marking a row's state.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct StatusDot {
|
pub struct StatusDot {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
variant: StatusDotVariant,
|
variant: StatusDotVariant,
|
||||||
@@ -99,7 +100,9 @@ impl RenderOnce for StatusDot {
|
|||||||
.size(side)
|
.size(side)
|
||||||
.rounded(theme.radius.full)
|
.rounded(theme.radius.full)
|
||||||
.bg(color)
|
.bg(color)
|
||||||
.when_some(self.label, |dot, label| dot.aria_label(label))
|
.when_some(self.label, |dot, label| {
|
||||||
|
dot.role(Role::Status).aria_label(label)
|
||||||
|
})
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
|
AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||||
RenderOnce, Role, SharedString, SpringAnimation, SpringConfig, StatefulInteractiveElement as _,
|
RenderOnce, Role, SharedString, SpringAnimation, StatefulInteractiveElement as _, Styled as _,
|
||||||
Styled as _, Toggled, Window, div, px,
|
Toggled, Window, div, px,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, CONTROL_SPRING, Size};
|
||||||
use gpui_native_utils::focus_ring;
|
use gpui_native_utils::focus_ring;
|
||||||
|
|
||||||
use crate::common::{Sizable, h_flex};
|
use crate::common::{Sizable, h_flex};
|
||||||
@@ -16,6 +16,7 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
|
|||||||
|
|
||||||
/// Switch component.
|
/// Switch component.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Switch {
|
pub struct Switch {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
@@ -108,8 +109,8 @@ impl RenderOnce for Switch {
|
|||||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
||||||
let id = self.id.clone();
|
let id = self.id.clone();
|
||||||
|
|
||||||
let anim_config = SpringConfig::new(500., 35., 1.);
|
let anim =
|
||||||
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
|
SpringAnimation::new(CONTROL_SPRING).to(if self.checked { travel } else { px(0.) });
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
|
|||||||
+42
-37
@@ -6,11 +6,12 @@ use std::time::Duration;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
||||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
|
IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
|
||||||
Styled as _, Task, Window, deferred, div, ease_out_quint, px,
|
StatefulInteractiveElement as _, Styled as _, Task, Window, deferred, div, ease_out_quint, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{Icon, IconRole};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::{ActiveTheme as _, TOAST_MAX_WIDTH};
|
||||||
|
use gpui_native_utils::ResultLogErr as _;
|
||||||
|
|
||||||
use crate::button::{Button, ButtonVariant};
|
use crate::button::{Button, ButtonVariant};
|
||||||
use crate::common::{DismissHandler, Sizable as _};
|
use crate::common::{DismissHandler, Sizable as _};
|
||||||
@@ -41,9 +42,10 @@ impl ToastAction {
|
|||||||
|
|
||||||
/// A toast notification.
|
/// A toast notification.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[must_use]
|
||||||
pub struct Toast {
|
pub struct Toast {
|
||||||
message: SharedString,
|
message: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
action: Option<ToastAction>,
|
action: Option<ToastAction>,
|
||||||
duration: Option<Duration>,
|
duration: Option<Duration>,
|
||||||
on_dismiss: Option<DismissHandler>,
|
on_dismiss: Option<DismissHandler>,
|
||||||
@@ -68,8 +70,11 @@ impl Toast {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows this glyph before the content.
|
/// Shows this glyph before the content.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +135,8 @@ impl Toasts {
|
|||||||
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
|
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(duration).await;
|
cx.background_executor().timer(duration).await;
|
||||||
this.update(cx, |toasts, cx| toasts.dismiss(id, cx)).ok();
|
this.update(cx, |toasts, cx| toasts.dismiss(id, cx))
|
||||||
|
.log_err();
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
@@ -144,8 +150,7 @@ impl Toasts {
|
|||||||
self.finish_exit(cx);
|
self.finish_exit(cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.current.as_ref().is_some_and(|entry| entry.id == id) {
|
if let Some(entry) = self.current.take_if(|entry| entry.id == id) {
|
||||||
let entry = self.current.take().expect("current checked above");
|
|
||||||
self.start_exit(entry, cx);
|
self.start_exit(entry, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +179,7 @@ impl Toasts {
|
|||||||
if let Some(previous) = self.exiting.take()
|
if let Some(previous) = self.exiting.take()
|
||||||
&& let Some(on_dismiss) = &previous.toast.on_dismiss
|
&& let Some(on_dismiss) = &previous.toast.on_dismiss
|
||||||
{
|
{
|
||||||
(on_dismiss.clone())(cx);
|
on_dismiss(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.exiting = Some(entry);
|
self.exiting = Some(entry);
|
||||||
@@ -183,7 +188,8 @@ impl Toasts {
|
|||||||
let fade = cx.theme().motion.overlay;
|
let fade = cx.theme().motion.overlay;
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(fade).await;
|
cx.background_executor().timer(fade).await;
|
||||||
this.update(cx, |toasts, cx| toasts.finish_exit(cx)).ok();
|
this.update(cx, |toasts, cx| toasts.finish_exit(cx))
|
||||||
|
.log_err();
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
@@ -192,13 +198,15 @@ impl Toasts {
|
|||||||
if let Some(entry) = self.exiting.take() {
|
if let Some(entry) = self.exiting.take() {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
if let Some(on_dismiss) = &entry.toast.on_dismiss {
|
if let Some(on_dismiss) = &entry.toast.on_dismiss {
|
||||||
(on_dismiss.clone())(cx);
|
on_dismiss(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The floating stack of toasts, bottom center.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct ToastsView {
|
pub struct ToastsView {
|
||||||
toasts: Entity<Toasts>,
|
toasts: Entity<Toasts>,
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
@@ -236,7 +244,7 @@ impl RenderOnce for ToastsView {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.when_some(exiting, |column, entry| {
|
.when_some(exiting, |column, entry| {
|
||||||
let id = entry.id;
|
let id = entry.id;
|
||||||
column.child(pill(&entry, &self.toasts, cx).with_animation(
|
column.child(entry.pill(&self.toasts, cx).with_animation(
|
||||||
("toast-exit", id),
|
("toast-exit", id),
|
||||||
Animation::new(fade),
|
Animation::new(fade),
|
||||||
|pill, delta| pill.opacity(1. - delta),
|
|pill, delta| pill.opacity(1. - delta),
|
||||||
@@ -244,7 +252,7 @@ impl RenderOnce for ToastsView {
|
|||||||
})
|
})
|
||||||
.when_some(current, |column, entry| {
|
.when_some(current, |column, entry| {
|
||||||
let id = entry.id;
|
let id = entry.id;
|
||||||
column.child(pill(&entry, &self.toasts, cx).with_animation(
|
column.child(entry.pill(&self.toasts, cx).with_animation(
|
||||||
("toast-enter", id),
|
("toast-enter", id),
|
||||||
Animation::new(fade).with_easing(ease_out_quint()),
|
Animation::new(fade).with_easing(ease_out_quint()),
|
||||||
|pill, delta| pill.opacity(delta),
|
|pill, delta| pill.opacity(delta),
|
||||||
@@ -254,7 +262,9 @@ impl RenderOnce for ToastsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
impl ToastEntry {
|
||||||
|
/// The rendered pill for this toast.
|
||||||
|
fn pill(&self, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let surface = theme.colors.surface;
|
let surface = theme.colors.surface;
|
||||||
let border = if theme.borders.overlays {
|
let border = if theme.borders.overlays {
|
||||||
@@ -263,20 +273,20 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
|||||||
surface
|
surface
|
||||||
};
|
};
|
||||||
let foreground = theme.colors.foreground;
|
let foreground = theme.colors.foreground;
|
||||||
let muted = theme.colors.muted;
|
|
||||||
let radius = theme.radius.full;
|
let radius = theme.radius.full;
|
||||||
let shadows = theme.elevations.popover.shadows();
|
let shadows = theme.elevations.popover.shadows();
|
||||||
let icon_size = theme.sizes.small.icon_size;
|
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 text_size = theme.sizes.small.text_size;
|
||||||
let toast = &entry.toast;
|
let toast = &self.toast;
|
||||||
let toasts = toasts.clone();
|
let toasts = toasts.clone();
|
||||||
let id = entry.id;
|
let id = self.id;
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id(("toast", id))
|
.id(("toast", id))
|
||||||
.debug_selector(|| "toast-pill".to_string())
|
.debug_selector(|| "toast-pill".to_string())
|
||||||
.max_w(px(480.))
|
.role(Role::Alert)
|
||||||
|
.aria_label(toast.message.clone())
|
||||||
|
.max_w(TOAST_MAX_WIDTH)
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.py_1p5()
|
.py_1p5()
|
||||||
@@ -286,14 +296,14 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
|||||||
.border_color(border)
|
.border_color(border)
|
||||||
.rounded(radius)
|
.rounded(radius)
|
||||||
.shadow(shadows.clone())
|
.shadow(shadows.clone())
|
||||||
.when_some(toast.icon, |pill, icon_role| {
|
.when_some(toast.icon.clone(), |pill, icon| {
|
||||||
pill.child(
|
pill.child(
|
||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.mr_2()
|
.mr_2()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(icon(icon_role).size(icon_size).color(foreground)),
|
.child(icon.size(icon_size).color(foreground)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -313,7 +323,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
|||||||
.variant(ButtonVariant::Primary)
|
.variant(ButtonVariant::Primary)
|
||||||
.small()
|
.small()
|
||||||
.on_click(move |_, _window, cx| {
|
.on_click(move |_, _window, cx| {
|
||||||
(action.handler.clone())(cx);
|
(action.handler)(cx);
|
||||||
toasts.update(cx, |toasts, cx| {
|
toasts.update(cx, |toasts, cx| {
|
||||||
toasts.dismiss(id, cx);
|
toasts.dismiss(id, cx);
|
||||||
});
|
});
|
||||||
@@ -322,23 +332,18 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div().flex_none().ml_4().child(
|
||||||
.id(("toast-close", id))
|
Button::new(("toast-close", id))
|
||||||
.flex_none()
|
.icon(IconRole::Close)
|
||||||
.ml_4()
|
.variant(ButtonVariant::Ghost)
|
||||||
.cursor_pointer()
|
.small()
|
||||||
.p_1()
|
.accessibility_label("Dismiss")
|
||||||
.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| {
|
.on_click(move |_, _window, cx| {
|
||||||
toasts.update(cx, |toasts, cx| {
|
toasts.update(cx, |toasts, cx| {
|
||||||
toasts.dismiss(id, cx);
|
toasts.dismiss(id, cx);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
|
|||||||
|
|
||||||
/// A toggle component.
|
/// A toggle component.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Toggle {
|
pub struct Toggle {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
label: Option<SharedString>,
|
label: Option<SharedString>,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use gpui::AppContext as _;
|
use gpui::AppContext as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
|
||||||
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point,
|
ParentElement as _, Pixels, Render, Role, SharedString, StatefulInteractiveElement as _,
|
||||||
|
Styled as _, Window, canvas, div, point,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, TOOLTIP_MAX_WIDTH, TOOLTIP_OFFSET};
|
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, TOOLTIP_MAX_WIDTH, TOOLTIP_OFFSET};
|
||||||
use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
|
use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
|
||||||
@@ -27,7 +28,12 @@ impl Tooltip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the anchor element ID for the tooltip.
|
/// Sets the anchor element ID for the tooltip.
|
||||||
pub fn anchored<L, I>(text: L, anchor_id: I) -> impl Fn(&mut Window, &mut App) -> AnyView
|
///
|
||||||
|
/// 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
|
||||||
where
|
where
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
I: Into<ElementId>,
|
I: Into<ElementId>,
|
||||||
@@ -73,6 +79,8 @@ impl Render for Tooltip {
|
|||||||
|
|
||||||
let build_panel = || {
|
let build_panel = || {
|
||||||
div()
|
div()
|
||||||
|
.id("tooltip-panel")
|
||||||
|
.role(Role::Tooltip)
|
||||||
.debug_selector(|| "tooltip-panel".to_string())
|
.debug_selector(|| "tooltip-panel".to_string())
|
||||||
.px_2()
|
.px_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||||
@@ -70,19 +69,31 @@ fn real_keystrokes_are_not_pathologically_slow() {
|
|||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let started = Instant::now();
|
// 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"
|
||||||
|
);
|
||||||
|
|
||||||
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
||||||
visual.simulate_keystrokes(&letter.to_string());
|
visual.simulate_keystrokes(&letter.to_string());
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let elapsed = started.elapsed();
|
|
||||||
|
|
||||||
let total_renders = *renders.borrow();
|
let total_renders = *renders.borrow();
|
||||||
println!(
|
println!(
|
||||||
"26 real keystrokes: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
|
"26 real keystrokes: {total_renders} host renders ({:.2} per keystroke)",
|
||||||
total_renders as f32 / 26.
|
total_renders as f32 / 26.
|
||||||
);
|
);
|
||||||
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||||
@@ -78,19 +77,31 @@ fn typing_with_the_palette_open_is_not_pathologically_slow() {
|
|||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let started = Instant::now();
|
// 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"
|
||||||
|
);
|
||||||
|
|
||||||
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
|
||||||
visual.simulate_keystrokes(&letter.to_string());
|
visual.simulate_keystrokes(&letter.to_string());
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let elapsed = started.elapsed();
|
|
||||||
|
|
||||||
let total_renders = *renders.borrow();
|
let total_renders = *renders.borrow();
|
||||||
println!(
|
println!(
|
||||||
"26 real keystrokes with the palette open: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
|
"26 real keystrokes with the palette open: {total_renders} host renders ({:.2} per keystroke)",
|
||||||
total_renders as f32 / 26.
|
total_renders as f32 / 26.
|
||||||
);
|
);
|
||||||
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,3 +158,26 @@ fn clicking_the_panel_does_not_dismiss() {
|
|||||||
click(&mut visual, point(px(960.), px(540.)));
|
click(&mut visual, point(px(960.), px(540.)));
|
||||||
assert!(changes.borrow().is_empty());
|
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());
|
||||||
|
}
|
||||||
|
|||||||
+323
-6
@@ -1,14 +1,15 @@
|
|||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _,
|
Context, DefiniteLength, IntoElement, Modifiers, MouseButton, ParentElement as _, Render,
|
||||||
TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px,
|
Styled as _, TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::menu::{Menu, MenuItem};
|
use gpui_native_core::menu::{Menu, MenuItem};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
open: bool,
|
open: bool,
|
||||||
|
min_width: Option<DefiniteLength>,
|
||||||
changes: Rc<RefCell<Vec<bool>>>,
|
changes: Rc<RefCell<Vec<bool>>>,
|
||||||
activated: Rc<RefCell<Vec<&'static str>>>,
|
activated: Rc<RefCell<Vec<&'static str>>>,
|
||||||
}
|
}
|
||||||
@@ -18,8 +19,7 @@ impl Render for Harness {
|
|||||||
let changes = self.changes.clone();
|
let changes = self.changes.clone();
|
||||||
let view = cx.entity();
|
let view = cx.entity();
|
||||||
let activated = self.activated.clone();
|
let activated = self.activated.clone();
|
||||||
div().size_full().child(
|
let mut menu = Menu::new("test-menu")
|
||||||
Menu::new("test-menu")
|
|
||||||
.open(self.open)
|
.open(self.open)
|
||||||
.on_change(move |next, _, cx| {
|
.on_change(move |next, _, cx| {
|
||||||
changes.borrow_mut().push(next);
|
changes.borrow_mut().push(next);
|
||||||
@@ -46,8 +46,11 @@ impl Render for Harness {
|
|||||||
}),
|
}),
|
||||||
delete_item(activated.clone()),
|
delete_item(activated.clone()),
|
||||||
]
|
]
|
||||||
}),
|
});
|
||||||
)
|
if let Some(width) = self.min_width {
|
||||||
|
menu = menu.min_width(width);
|
||||||
|
}
|
||||||
|
div().size_full().child(menu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +70,61 @@ fn delete_item(activated: Rc<RefCell<Vec<&'static str>>>) -> MenuItem {
|
|||||||
.handler(move |_, _| record(&activated, "delete"))
|
.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) {
|
fn record(sink: &Rc<RefCell<Vec<&'static str>>>, what: &'static str) {
|
||||||
sink.borrow_mut().push(what);
|
sink.borrow_mut().push(what);
|
||||||
}
|
}
|
||||||
@@ -82,6 +140,19 @@ fn menu_window(
|
|||||||
WindowHandle<Harness>,
|
WindowHandle<Harness>,
|
||||||
Changes,
|
Changes,
|
||||||
Activated,
|
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();
|
let mut cx = TestAppContext::single();
|
||||||
cx.update(Theme::init);
|
cx.update(Theme::init);
|
||||||
@@ -91,6 +162,7 @@ fn menu_window(
|
|||||||
let harness_activated = activated.clone();
|
let harness_activated = activated.clone();
|
||||||
let window = cx.add_window(move |_, _| Harness {
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
open,
|
open,
|
||||||
|
min_width,
|
||||||
changes: harness_changes,
|
changes: harness_changes,
|
||||||
activated: harness_activated,
|
activated: harness_activated,
|
||||||
});
|
});
|
||||||
@@ -101,6 +173,25 @@ fn menu_window(
|
|||||||
(cx, visual, window, changes, activated)
|
(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) {
|
fn draw(visual: &mut VisualTestContext) {
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
@@ -112,6 +203,17 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
|||||||
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
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,
|
/// 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
|
/// 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.
|
/// padding. Item 1 of the harness menu is a separator, so it is skipped.
|
||||||
@@ -204,3 +306,218 @@ fn clicking_an_item_activates_and_closes() {
|
|||||||
assert_eq!(*activated.borrow(), vec!["cut"]);
|
assert_eq!(*activated.borrow(), vec!["cut"]);
|
||||||
assert_eq!(*changes.borrow(), vec![false]);
|
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::{
|
use gpui::{
|
||||||
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _,
|
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _,
|
||||||
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
|
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
|
||||||
Window, WindowHandle, div, point, px, rgba,
|
Window, WindowHandle, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::popover::Popover;
|
use gpui_native_core::popover::Popover;
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
@@ -26,7 +26,6 @@ impl Render for Harness {
|
|||||||
div()
|
div()
|
||||||
.id("popover-target")
|
.id("popover-target")
|
||||||
.size(px(40.))
|
.size(px(40.))
|
||||||
.bg(rgba(0x000000ff))
|
|
||||||
.on_click(move |_, _, cx| {
|
.on_click(move |_, _, cx| {
|
||||||
*target_clicks.borrow_mut() += 1;
|
*target_clicks.borrow_mut() += 1;
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
@@ -48,7 +47,6 @@ impl Render for Harness {
|
|||||||
.debug_selector(|| "popover-trigger".to_string())
|
.debug_selector(|| "popover-trigger".to_string())
|
||||||
.w(px(100.))
|
.w(px(100.))
|
||||||
.h(px(32.))
|
.h(px(32.))
|
||||||
.bg(rgba(0x000000ff))
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.content(|_, _| {
|
.content(|_, _| {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use gpui::{
|
|||||||
VisualTestContext, Window, div, px,
|
VisualTestContext, Window, div, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::separator::{Separator, SeparatorOrientation};
|
use gpui_native_core::separator::{Separator, SeparatorOrientation};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::{BORDER_WIDTH, Theme};
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
orientation: SeparatorOrientation,
|
orientation: SeparatorOrientation,
|
||||||
@@ -47,7 +47,7 @@ fn the_horizontal_rule_spans_its_container_at_hairline_height() {
|
|||||||
.debug_bounds("test-separator")
|
.debug_bounds("test-separator")
|
||||||
.expect("horizontal separator rendered");
|
.expect("horizontal separator rendered");
|
||||||
assert_eq!(bounds.size.width, px(200.));
|
assert_eq!(bounds.size.width, px(200.));
|
||||||
assert_eq!(bounds.size.height, px(1.));
|
assert_eq!(bounds.size.height, BORDER_WIDTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -56,6 +56,6 @@ fn the_vertical_rule_spans_its_container_at_hairline_width() {
|
|||||||
let bounds = visual
|
let bounds = visual
|
||||||
.debug_bounds("test-separator")
|
.debug_bounds("test-separator")
|
||||||
.expect("vertical separator rendered");
|
.expect("vertical separator rendered");
|
||||||
assert_eq!(bounds.size.width, px(1.));
|
assert_eq!(bounds.size.width, BORDER_WIDTH);
|
||||||
assert_eq!(bounds.size.height, px(50.));
|
assert_eq!(bounds.size.height, px(50.));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
#![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,8 +155,4 @@ fn dismissal_does_not_run_the_action() {
|
|||||||
*dismissed.borrow(),
|
*dismissed.borrow(),
|
||||||
"on_dismiss still reports the dismissal"
|
"on_dismiss still reports the dismissal"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
*dismissed.borrow(),
|
|
||||||
"on_dismiss still reports the dismissal"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,10 +129,6 @@ fn hovering_the_target_deploys_the_tooltip_after_the_delay() {
|
|||||||
calls.borrow().is_empty(),
|
calls.borrow().is_empty(),
|
||||||
"builder runs only after the delay"
|
"builder runs only after the delay"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
calls.borrow().is_empty(),
|
|
||||||
"builder runs only after the delay"
|
|
||||||
);
|
|
||||||
|
|
||||||
cx.executor().advance_clock(SHOW_DELAY);
|
cx.executor().advance_clock(SHOW_DELAY);
|
||||||
visual.run_until_parked();
|
visual.run_until_parked();
|
||||||
|
|||||||
+29
-24
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||||
Styled as _, Window, div,
|
Styled as _, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::Icon;
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
||||||
|
|
||||||
@@ -15,20 +15,24 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
|
|||||||
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<ListItem>>;
|
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<ListItem>>;
|
||||||
|
|
||||||
/// One row of a [`List`].
|
/// One row of a [`List`].
|
||||||
|
#[must_use]
|
||||||
pub struct ListItem {
|
pub struct ListItem {
|
||||||
|
key: ElementId,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
detail: Option<SharedString>,
|
detail: Option<SharedString>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListItem {
|
impl ListItem {
|
||||||
/// Creates a row with this label.
|
/// Creates a row shown as `label`, identified by `key`.
|
||||||
pub fn new<L>(label: L) -> Self
|
pub fn new<K, L>(key: K, label: L) -> Self
|
||||||
where
|
where
|
||||||
|
K: Into<ElementId>,
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
|
key: key.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
icon: None,
|
icon: None,
|
||||||
detail: None,
|
detail: None,
|
||||||
@@ -36,9 +40,12 @@ impl ListItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon leading the label.
|
/// Shows an icon leading the label, a role or any custom [`gpui_native_icons::IconNamed`].
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ impl ListItem {
|
|||||||
|
|
||||||
/// A single-selection list.
|
/// A single-selection list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct List {
|
pub struct List {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
@@ -98,7 +106,7 @@ impl List {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the rows, built fresh each render.
|
/// Sets the rows, the closure runs during render and must not mutate entities or notify.
|
||||||
pub fn items<F>(mut self, items: F) -> Self
|
pub fn items<F>(mut self, items: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&mut Window, &mut App) -> Vec<ListItem> + 'static,
|
F: Fn(&mut Window, &mut App) -> Vec<ListItem> + 'static,
|
||||||
@@ -143,11 +151,10 @@ impl RenderOnce for List {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, item)| {
|
.map(|(index, item)| {
|
||||||
render_row(
|
item.render_row(
|
||||||
cx,
|
cx,
|
||||||
index,
|
index,
|
||||||
items.len(),
|
items.len(),
|
||||||
item,
|
|
||||||
self.selected == Some(index),
|
self.selected == Some(index),
|
||||||
&self.on_change,
|
&self.on_change,
|
||||||
&focus,
|
&focus,
|
||||||
@@ -184,21 +191,22 @@ impl RenderOnce for List {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_row(
|
impl ListItem {
|
||||||
|
fn render_row(
|
||||||
|
&self,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
index: usize,
|
index: usize,
|
||||||
count: usize,
|
count: usize,
|
||||||
item: &ListItem,
|
|
||||||
selected: bool,
|
selected: bool,
|
||||||
on_change: &Option<ChangeHandler>,
|
on_change: &Option<ChangeHandler>,
|
||||||
focus: &FocusHandle,
|
focus: &FocusHandle,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let label_color = selectable_label_color(cx, selected, item.disabled);
|
let label_color = selectable_label_color(cx, selected, self.disabled);
|
||||||
let disabled = item.disabled;
|
let disabled = self.disabled;
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id(("list-item", index))
|
.id((self.key.clone(), "list-item"))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -222,20 +230,16 @@ fn render_row(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.when_some(item.icon, |row, role| {
|
.when_some(self.icon.clone(), |row, icon| {
|
||||||
row.child(
|
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
|
||||||
icon(role)
|
|
||||||
.size(theme.sizes.medium.icon_size)
|
|
||||||
.color(label_color),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.text_color(label_color)
|
.text_color(label_color)
|
||||||
.child(item.label.clone()),
|
.child(self.label.clone()),
|
||||||
)
|
)
|
||||||
.when_some(item.detail.clone(), |row, detail| {
|
.when_some(self.detail.clone(), |row, detail| {
|
||||||
row.child(
|
row.child(
|
||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
@@ -249,4 +253,5 @@ fn render_row(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-54
@@ -10,7 +10,6 @@ use gpui::{
|
|||||||
Styled as _, Window, div,
|
Styled as _, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_native_core::scroll_area::ScrollArea;
|
use gpui_native_core::scroll_area::ScrollArea;
|
||||||
use gpui_native_icons::{IconRole, icon};
|
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
||||||
|
|
||||||
@@ -19,15 +18,17 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
|
|||||||
type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>;
|
type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>;
|
||||||
|
|
||||||
/// One entry of a [`Sidebar`], a section header or a selectable row.
|
/// One entry of a [`Sidebar`], a section header or a selectable row.
|
||||||
#[derive(Clone)]
|
#[must_use]
|
||||||
pub enum SidebarEntry {
|
pub enum SidebarEntry {
|
||||||
/// A non-selectable section label.
|
/// A non-selectable section label.
|
||||||
Header { label: SharedString },
|
Header { label: SharedString },
|
||||||
/// A selectable row.
|
/// A selectable row.
|
||||||
Row {
|
Row {
|
||||||
|
key: ElementId,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconRole>,
|
|
||||||
detail: Option<SharedString>,
|
detail: Option<SharedString>,
|
||||||
|
prefix: Option<AnyElement>,
|
||||||
|
suffix: Option<AnyElement>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -43,27 +44,22 @@ impl SidebarEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a selectable row.
|
/// Creates a selectable row shown as `label`, identified by `key`.
|
||||||
pub fn row<L>(label: L) -> Self
|
pub fn row<K, L>(key: K, label: L) -> Self
|
||||||
where
|
where
|
||||||
|
K: Into<ElementId>,
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self::Row {
|
Self::Row {
|
||||||
|
key: key.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
icon: None,
|
|
||||||
detail: None,
|
detail: None,
|
||||||
|
prefix: None,
|
||||||
|
suffix: None,
|
||||||
disabled: false,
|
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.
|
/// Shows muted trailing text.
|
||||||
pub fn detail<D>(mut self, detail: D) -> Self
|
pub fn detail<D>(mut self, detail: D) -> Self
|
||||||
where
|
where
|
||||||
@@ -78,6 +74,28 @@ impl SidebarEntry {
|
|||||||
self
|
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.
|
/// Renders the row dimmed; clicks and keyboard skip it.
|
||||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
if let Self::Row {
|
if let Self::Row {
|
||||||
@@ -93,6 +111,7 @@ impl SidebarEntry {
|
|||||||
|
|
||||||
/// A sidebar.
|
/// A sidebar.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
@@ -144,7 +163,7 @@ impl Sidebar {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the entries, built fresh each render.
|
/// Sets the entries, the closure runs during render and must not mutate entities or notify.
|
||||||
pub fn entries<F>(mut self, entries: F) -> Self
|
pub fn entries<F>(mut self, entries: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&mut Window, &mut App) -> Vec<SidebarEntry> + 'static,
|
F: Fn(&mut Window, &mut App) -> Vec<SidebarEntry> + 'static,
|
||||||
@@ -204,21 +223,23 @@ impl RenderOnce for Sidebar {
|
|||||||
|
|
||||||
let mut row_position = 0usize;
|
let mut row_position = 0usize;
|
||||||
let children: Vec<AnyElement> = entries
|
let children: Vec<AnyElement> = entries
|
||||||
.iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, entry)| {
|
.map(|(index, entry)| {
|
||||||
let is_row = matches!(entry, SidebarEntry::Row { .. });
|
let is_row = matches!(entry, SidebarEntry::Row { .. });
|
||||||
|
|
||||||
if is_row {
|
if is_row {
|
||||||
row_position += 1;
|
row_position += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (position_in_set, size_of_set) = if is_row {
|
let (position_in_set, size_of_set) = if is_row {
|
||||||
(row_position, row_count)
|
(row_position, row_count)
|
||||||
} else {
|
} else {
|
||||||
(0, 0)
|
(0, 0)
|
||||||
};
|
};
|
||||||
render_entry(
|
|
||||||
|
entry.render_entry(
|
||||||
cx,
|
cx,
|
||||||
entry,
|
|
||||||
index,
|
index,
|
||||||
position_in_set,
|
position_in_set,
|
||||||
size_of_set,
|
size_of_set,
|
||||||
@@ -268,10 +289,11 @@ impl RenderOnce for Sidebar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
impl SidebarEntry {
|
||||||
fn render_entry(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_entry(
|
||||||
|
self,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
entry: &SidebarEntry,
|
|
||||||
index: usize,
|
index: usize,
|
||||||
position_in_set: usize,
|
position_in_set: usize,
|
||||||
size_of_set: usize,
|
size_of_set: usize,
|
||||||
@@ -279,29 +301,36 @@ fn render_entry(
|
|||||||
material: bool,
|
material: bool,
|
||||||
on_change: &Option<ChangeHandler>,
|
on_change: &Option<ChangeHandler>,
|
||||||
focus: &FocusHandle,
|
focus: &FocusHandle,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
|
||||||
match entry {
|
match self {
|
||||||
SidebarEntry::Header { label } => div()
|
SidebarEntry::Header { label } => {
|
||||||
|
// The id is required for the role to surface in the a11y tree.
|
||||||
|
div()
|
||||||
|
.id(("sidebar-header".into(), label.clone()))
|
||||||
.px_2()
|
.px_2()
|
||||||
.pt_2()
|
.pt_2()
|
||||||
.pb_1()
|
.pb_1()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.font_weight(gpui::FontWeight::MEDIUM)
|
.font_weight(gpui::FontWeight::MEDIUM)
|
||||||
.text_color(theme.colors.muted_foreground)
|
.text_color(theme.colors.muted_foreground)
|
||||||
.child(label.clone())
|
.role(Role::Heading)
|
||||||
.into_any_element(),
|
.child(label)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
SidebarEntry::Row {
|
SidebarEntry::Row {
|
||||||
|
key,
|
||||||
label,
|
label,
|
||||||
icon: row_icon,
|
|
||||||
detail,
|
detail,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
disabled: row_disabled,
|
disabled: row_disabled,
|
||||||
} => {
|
} => {
|
||||||
let label_color = selectable_label_color(cx, selected, *row_disabled);
|
let label_color = selectable_label_color(cx, selected, row_disabled);
|
||||||
|
|
||||||
let mut row = div()
|
div()
|
||||||
.id(("sidebar-row", index))
|
.id((key.clone(), "sidebar-row"))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -313,19 +342,18 @@ fn render_entry(
|
|||||||
.aria_position_in_set(position_in_set)
|
.aria_position_in_set(position_in_set)
|
||||||
.aria_size_of_set(size_of_set)
|
.aria_size_of_set(size_of_set)
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
if material && !*row_disabled {
|
if material && !row_disabled {
|
||||||
if selected {
|
if selected {
|
||||||
row.bg(theme.material.selection)
|
row.bg(theme.material.selection)
|
||||||
} else {
|
} else {
|
||||||
row.hover(|row| row.bg(theme.material.hover))
|
row.hover(|row| row.bg(theme.material.hover))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
paint_selectable_row(cx, row, selected, *row_disabled)
|
paint_selectable_row(cx, row, selected, row_disabled)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.when(!row_disabled, |row| {
|
||||||
if !*row_disabled {
|
row.cursor_pointer().on_click({
|
||||||
row = row.cursor_pointer().on_click({
|
|
||||||
let on_change = on_change.clone();
|
let on_change = on_change.clone();
|
||||||
let focus = focus.clone();
|
let focus = focus.clone();
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
@@ -334,21 +362,12 @@ fn render_entry(
|
|||||||
on_change(Some(index), window, cx);
|
on_change(Some(index), window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
})
|
||||||
|
.when_some(prefix, |row, prefix| row.child(prefix))
|
||||||
row = row.when_some(*row_icon, |row, role| {
|
.child(div().flex_1().text_color(label_color).child(label))
|
||||||
|
.when_some(detail, |row, detail| {
|
||||||
row.child(
|
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()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -357,11 +376,12 @@ fn render_entry(
|
|||||||
} else {
|
} else {
|
||||||
theme.colors.muted_foreground
|
theme.colors.muted_foreground
|
||||||
})
|
})
|
||||||
.child(detail.clone()),
|
.child(detail),
|
||||||
);
|
)
|
||||||
|
})
|
||||||
|
.when_some(suffix, |row, suffix| row.child(suffix))
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
row.into_any_element()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-89
@@ -1,6 +1,6 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
|
AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
|
||||||
ParentElement as _, RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _,
|
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::common::Sizable as _;
|
||||||
use gpui_native_core::spinner::Spinner;
|
use gpui_native_core::spinner::Spinner;
|
||||||
use gpui_native_core::tooltip::Tooltip;
|
use gpui_native_core::tooltip::Tooltip;
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::IconRole;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
use gpui_native_utils::adjacent_selectable;
|
use gpui_native_utils::adjacent_selectable;
|
||||||
|
|
||||||
@@ -31,11 +31,11 @@ pub enum TabsVariant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One tab of a [`Tabs`] strip.
|
/// One tab of a [`Tabs`] strip.
|
||||||
|
#[must_use]
|
||||||
pub struct Tab {
|
pub struct Tab {
|
||||||
/// The stable identity for element state; must survive reordering.
|
/// The stable identity for element state; must survive reordering.
|
||||||
key: ElementId,
|
key: ElementId,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconRole>,
|
|
||||||
lead: Option<AnyElement>,
|
lead: Option<AnyElement>,
|
||||||
busy: bool,
|
busy: bool,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
@@ -53,7 +53,6 @@ impl Tab {
|
|||||||
Self {
|
Self {
|
||||||
key: key.into(),
|
key: key.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
icon: None,
|
|
||||||
lead: None,
|
lead: None,
|
||||||
busy: false,
|
busy: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -62,13 +61,7 @@ impl Tab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon leading the label.
|
/// Sets the leading element, e.g. an icon or an avatar.
|
||||||
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
|
pub fn lead<E>(mut self, lead: E) -> Self
|
||||||
where
|
where
|
||||||
E: IntoElement + 'static,
|
E: IntoElement + 'static,
|
||||||
@@ -105,8 +98,12 @@ impl Tab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab is a data builder, not an element, adopt the combinators explicitly.
|
||||||
|
impl FluentBuilder for Tab {}
|
||||||
|
|
||||||
/// A tab strip.
|
/// A tab strip.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Tabs {
|
pub struct Tabs {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
@@ -134,7 +131,7 @@ impl Tabs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the current selection.
|
/// Sets the current selection, as an index into the `tabs` vec — not a tab key.
|
||||||
pub fn selected(mut self, selected: usize) -> Self {
|
pub fn selected(mut self, selected: usize) -> Self {
|
||||||
self.selected = selected;
|
self.selected = selected;
|
||||||
self
|
self
|
||||||
@@ -146,7 +143,7 @@ impl Tabs {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reports the next selected tab, from clicks and keys alike.
|
/// Reports the next selected tab, as an index into the `tabs` vec — not a tab key; indices shift on reorder.
|
||||||
pub fn on_change<F>(mut self, handler: F) -> Self
|
pub fn on_change<F>(mut self, handler: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(usize, &mut Window, &mut App) + 'static,
|
F: Fn(usize, &mut Window, &mut App) + 'static,
|
||||||
@@ -155,7 +152,7 @@ impl Tabs {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reports a closed tab by index.
|
/// Reports a closed tab, as an index into the `tabs` vec — not a tab key; indices shift on reorder.
|
||||||
pub fn on_close<F>(mut self, handler: F) -> Self
|
pub fn on_close<F>(mut self, handler: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(usize, &mut Window, &mut App) + 'static,
|
F: Fn(usize, &mut Window, &mut App) + 'static,
|
||||||
@@ -164,7 +161,7 @@ impl Tabs {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the tabs, built fresh each render.
|
/// Sets the tabs; the closure runs during render and must not mutate entities or notify.
|
||||||
pub fn tabs<F>(mut self, tabs: F) -> Self
|
pub fn tabs<F>(mut self, tabs: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&mut Window, &mut App) -> Vec<Tab> + 'static,
|
F: Fn(&mut Window, &mut App) -> Vec<Tab> + 'static,
|
||||||
@@ -209,10 +206,9 @@ impl RenderOnce for Tabs {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, tab)| {
|
.map(|(index, tab)| {
|
||||||
render_tab(
|
tab.render_tab(
|
||||||
&self.id,
|
&self.id,
|
||||||
cx,
|
cx,
|
||||||
tab,
|
|
||||||
index,
|
index,
|
||||||
self.selected,
|
self.selected,
|
||||||
self.variant,
|
self.variant,
|
||||||
@@ -223,7 +219,7 @@ impl RenderOnce for Tabs {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut strip_root = div()
|
div()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.flex()
|
.flex()
|
||||||
.text_size(theme.sizes.medium.text_size)
|
.text_size(theme.sizes.medium.text_size)
|
||||||
@@ -251,33 +247,30 @@ impl RenderOnce for Tabs {
|
|||||||
}
|
}
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.map(|root| match self.variant {
|
||||||
strip_root = match self.variant {
|
TabsVariant::Underline => {
|
||||||
TabsVariant::Underline => strip_root
|
root.gap_1().border_b_1().border_color(theme.colors.border)
|
||||||
.gap_1()
|
}
|
||||||
.border_b_1()
|
TabsVariant::Pill => root.items_center().gap_1(),
|
||||||
.border_color(theme.colors.border),
|
})
|
||||||
TabsVariant::Pill => strip_root.items_center().gap_1(),
|
.children(strip)
|
||||||
};
|
|
||||||
|
|
||||||
strip_root.children(strip)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
impl Tab {
|
||||||
fn render_tab(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_tab(
|
||||||
|
mut self,
|
||||||
tabs_id: &ElementId,
|
tabs_id: &ElementId,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
tab: Tab,
|
|
||||||
index: usize,
|
index: usize,
|
||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
variant: TabsVariant,
|
variant: TabsVariant,
|
||||||
on_change: &Option<ChangeHandler>,
|
on_change: &Option<ChangeHandler>,
|
||||||
on_close: &Option<CloseHandler>,
|
on_close: &Option<CloseHandler>,
|
||||||
focus: &gpui::FocusHandle,
|
focus: &gpui::FocusHandle,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let mut tab = tab;
|
|
||||||
let selected = index == selected_index;
|
let selected = index == selected_index;
|
||||||
|
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
@@ -286,7 +279,7 @@ fn render_tab(
|
|||||||
|
|
||||||
let label_color = match variant {
|
let label_color = match variant {
|
||||||
TabsVariant::Underline => {
|
TabsVariant::Underline => {
|
||||||
if tab.disabled {
|
if self.disabled {
|
||||||
theme.colors.muted_foreground
|
theme.colors.muted_foreground
|
||||||
} else if selected {
|
} else if selected {
|
||||||
theme.ramps.primary.base
|
theme.ramps.primary.base
|
||||||
@@ -295,7 +288,7 @@ fn render_tab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabsVariant::Pill => {
|
TabsVariant::Pill => {
|
||||||
if tab.disabled {
|
if self.disabled {
|
||||||
theme.colors.muted_foreground
|
theme.colors.muted_foreground
|
||||||
} else {
|
} else {
|
||||||
theme.colors.foreground
|
theme.colors.foreground
|
||||||
@@ -303,11 +296,14 @@ fn render_tab(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut strip_tab = div()
|
let close = (self.closeable && on_close.is_some() && !self.disabled)
|
||||||
.id((tab.key.clone(), "tab"))
|
.then(|| self.render_tab_close(tabs_id, index, on_close));
|
||||||
|
|
||||||
|
let strip_tab = div()
|
||||||
|
.id((self.key.clone(), "tab"))
|
||||||
.debug_selector({
|
.debug_selector({
|
||||||
let tabs_id = tabs_id.clone();
|
let tabs_id = tabs_id.clone();
|
||||||
let label = tab.label.clone();
|
let label = self.label.clone();
|
||||||
move || format!("{tabs_id}-tab-{}", label)
|
move || format!("{tabs_id}-tab-{}", label)
|
||||||
})
|
})
|
||||||
.flex()
|
.flex()
|
||||||
@@ -316,11 +312,10 @@ fn render_tab(
|
|||||||
.role(Role::Tab)
|
.role(Role::Tab)
|
||||||
.aria_selected(selected)
|
.aria_selected(selected)
|
||||||
.text_color(label_color)
|
.text_color(label_color)
|
||||||
.when(!tab.disabled && !selected, |strip_tab| {
|
.when(!self.disabled && !selected, |strip_tab| {
|
||||||
strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted))
|
strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted))
|
||||||
});
|
})
|
||||||
|
.map(|strip_tab| match variant {
|
||||||
strip_tab = match variant {
|
|
||||||
TabsVariant::Underline => strip_tab
|
TabsVariant::Underline => strip_tab
|
||||||
.px_2()
|
.px_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
@@ -341,45 +336,22 @@ fn render_tab(
|
|||||||
.bg(theme.colors.muted)
|
.bg(theme.colors.muted)
|
||||||
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
|
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
|
||||||
}),
|
}),
|
||||||
};
|
})
|
||||||
|
.when_some(self.lead.take(), |strip_tab, lead| strip_tab.child(lead))
|
||||||
let lead = tab.lead.take();
|
.child(self.label.clone())
|
||||||
|
.when(variant == TabsVariant::Pill, |strip_tab| {
|
||||||
strip_tab = match lead {
|
strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5))
|
||||||
Some(lead) => strip_tab.child(lead),
|
})
|
||||||
None => match variant {
|
.when(self.busy && !self.disabled, |strip_tab| {
|
||||||
TabsVariant::Underline => strip_tab.child(div().flex_none().pb_0p5().when_some(
|
strip_tab.child(
|
||||||
tab.icon,
|
Spinner::new((self.key.clone(), "tab-busy"))
|
||||||
|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()
|
.small()
|
||||||
.accessibility_label("Loading"),
|
.accessibility_label("Loading"),
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
|
.when_some(close, |strip_tab, close| strip_tab.child(close))
|
||||||
if tab.closeable && on_close.is_some() && !tab.disabled {
|
.when(!self.disabled, |strip_tab| {
|
||||||
strip_tab = strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close));
|
strip_tab.cursor_pointer().on_click({
|
||||||
}
|
|
||||||
|
|
||||||
if !tab.disabled {
|
|
||||||
strip_tab = strip_tab.cursor_pointer().on_click({
|
|
||||||
let on_change = on_change.clone();
|
let on_change = on_change.clone();
|
||||||
let focus = focus.clone();
|
let focus = focus.clone();
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
@@ -390,33 +362,33 @@ fn render_tab(
|
|||||||
on_change(index, window, cx);
|
on_change(index, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
match tab.wrap {
|
match self.wrap {
|
||||||
Some(wrap) => wrap(strip_tab),
|
Some(wrap) => wrap(strip_tab),
|
||||||
None => strip_tab.into_any_element(),
|
None => strip_tab.into_any_element(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_tab_close(
|
fn render_tab_close(
|
||||||
|
&self,
|
||||||
tabs_id: &ElementId,
|
tabs_id: &ElementId,
|
||||||
tab: &Tab,
|
|
||||||
index: usize,
|
index: usize,
|
||||||
on_close: &Option<CloseHandler>,
|
on_close: &Option<CloseHandler>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
Button::new((tab.key.clone(), "tab-close"))
|
Button::new((self.key.clone(), "tab-close"))
|
||||||
.icon(IconRole::Close)
|
.icon(IconRole::Close)
|
||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.small()
|
.small()
|
||||||
.tab_stop(false)
|
.tab_stop(false)
|
||||||
.debug_selector({
|
.debug_selector({
|
||||||
let tabs_id = tabs_id.clone();
|
let tabs_id = tabs_id.clone();
|
||||||
let label = tab.label.clone();
|
let label = self.label.clone();
|
||||||
move || format!("{tabs_id}-close-{}", label)
|
move || format!("{tabs_id}-close-{}", label)
|
||||||
})
|
})
|
||||||
.tooltip(Tooltip::text("Close"))
|
.tooltip(Tooltip::text("Close"))
|
||||||
.accessibility_label(SharedString::from(format!("Close {}", tab.label)))
|
.accessibility_label(SharedString::from(format!("Close {}", self.label)))
|
||||||
.on_click({
|
.on_click({
|
||||||
let on_close = on_close.clone();
|
let on_close = on_close.clone();
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
@@ -427,4 +399,5 @@ fn render_tab_close(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-79
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||||
Styled as _, Window, div,
|
Styled as _, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{Icon, IconRole, icon};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
|
||||||
|
|
||||||
@@ -18,9 +18,11 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<TreeItem>>;
|
|||||||
|
|
||||||
/// Tree item.
|
/// Tree item.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
#[must_use]
|
||||||
pub struct TreeItem {
|
pub struct TreeItem {
|
||||||
|
key: ElementId,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
expandable: bool,
|
expandable: bool,
|
||||||
expanded: bool,
|
expanded: bool,
|
||||||
@@ -28,12 +30,14 @@ pub struct TreeItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TreeItem {
|
impl TreeItem {
|
||||||
/// Creates a leaf row with this label at the root depth.
|
/// Creates a leaf row shown as `label` at the root depth, identified by `key`.
|
||||||
pub fn new<L>(label: L) -> Self
|
pub fn new<K, L>(key: K, label: L) -> Self
|
||||||
where
|
where
|
||||||
|
K: Into<ElementId>,
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
|
key: key.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
icon: None,
|
icon: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -44,8 +48,11 @@ impl TreeItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon leading the label.
|
/// Shows an icon leading the label.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +83,7 @@ impl TreeItem {
|
|||||||
|
|
||||||
/// A tree.
|
/// A tree.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Tree {
|
pub struct Tree {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
@@ -125,7 +133,7 @@ impl Tree {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the visible rows, built fresh each render.
|
/// Sets the visible rows; the closure runs during render and must not mutate entities or notify.
|
||||||
pub fn items<F>(mut self, items: F) -> Self
|
pub fn items<F>(mut self, items: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&mut Window, &mut App) -> Vec<TreeItem> + 'static,
|
F: Fn(&mut Window, &mut App) -> Vec<TreeItem> + 'static,
|
||||||
@@ -142,6 +150,45 @@ impl Tree {
|
|||||||
self.a11y_label = Some(label.into());
|
self.a11y_label = Some(label.into());
|
||||||
self
|
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 {
|
impl RenderOnce for Tree {
|
||||||
@@ -166,46 +213,15 @@ impl RenderOnce for Tree {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||||
|
|
||||||
let sibling_sets: Vec<(usize, usize)> = {
|
let sibling_sets = Self::sibling_sets(&items);
|
||||||
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
|
let rows: Vec<AnyElement> = items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, _item)| {
|
.map(|(index, item)| {
|
||||||
let (position_in_set, size_of_set) = sibling_sets[index];
|
let (position_in_set, size_of_set) = sibling_sets[index];
|
||||||
render_row(
|
item.render_row(
|
||||||
cx,
|
cx,
|
||||||
&items[index],
|
|
||||||
index,
|
index,
|
||||||
position_in_set,
|
position_in_set,
|
||||||
size_of_set,
|
size_of_set,
|
||||||
@@ -263,10 +279,11 @@ impl RenderOnce for Tree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
impl TreeItem {
|
||||||
fn render_row(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_row(
|
||||||
|
&self,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
item: &TreeItem,
|
|
||||||
index: usize,
|
index: usize,
|
||||||
position_in_set: usize,
|
position_in_set: usize,
|
||||||
size_of_set: usize,
|
size_of_set: usize,
|
||||||
@@ -274,15 +291,17 @@ fn render_row(
|
|||||||
on_change: &Option<ChangeHandler>,
|
on_change: &Option<ChangeHandler>,
|
||||||
on_disclosure: &Option<DisclosureHandler>,
|
on_disclosure: &Option<DisclosureHandler>,
|
||||||
focus: &FocusHandle,
|
focus: &FocusHandle,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let label_color = selectable_label_color(cx, selected, item.disabled);
|
let label_color = selectable_label_color(cx, selected, self.disabled);
|
||||||
let disabled = item.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;
|
||||||
// One indent step per depth level, on the theme's horizontal padding.
|
// One indent step per depth level, on the theme's horizontal padding.
|
||||||
let indent = theme.sizes.medium.padding_x * (item.depth as f32 + 1.0);
|
let indent = theme.sizes.medium.padding_x * (depth + 1.0);
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id(("tree-item", index))
|
.id((self.key.clone(), "tree-item"))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -292,10 +311,10 @@ fn render_row(
|
|||||||
.rounded(theme.radius.sm)
|
.rounded(theme.radius.sm)
|
||||||
.role(Role::TreeItem)
|
.role(Role::TreeItem)
|
||||||
.aria_selected(selected)
|
.aria_selected(selected)
|
||||||
.aria_level(item.depth + 1)
|
.aria_level(self.depth + 1)
|
||||||
.aria_position_in_set(position_in_set)
|
.aria_position_in_set(position_in_set)
|
||||||
.aria_size_of_set(size_of_set)
|
.aria_size_of_set(size_of_set)
|
||||||
.when(item.expandable, |row| row.aria_expanded(item.expanded))
|
.when(self.expandable, |row| row.aria_expanded(self.expanded))
|
||||||
.map(|row| paint_selectable_row(cx, row, selected, disabled))
|
.map(|row| paint_selectable_row(cx, row, selected, disabled))
|
||||||
.when(!disabled, |row| {
|
.when(!disabled, |row| {
|
||||||
row.cursor_pointer().on_click({
|
row.cursor_pointer().on_click({
|
||||||
@@ -309,46 +328,31 @@ fn render_row(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(render_disclosure(
|
.child(self.render_disclosure(cx, index, selected, on_disclosure))
|
||||||
cx,
|
.when_some(self.icon.clone(), |row, icon| {
|
||||||
item,
|
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
|
||||||
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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.text_color(label_color)
|
.text_color(label_color)
|
||||||
.child(item.label.clone()),
|
.child(self.label.clone()),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The disclosure slot.
|
fn render_disclosure(
|
||||||
fn render_disclosure(
|
&self,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
item: &TreeItem,
|
|
||||||
index: usize,
|
index: usize,
|
||||||
disabled: bool,
|
|
||||||
selected: bool,
|
selected: bool,
|
||||||
on_disclosure: &Option<DisclosureHandler>,
|
on_disclosure: &Option<DisclosureHandler>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let glyph_color = if disabled {
|
let glyph_color = if self.disabled || !selected {
|
||||||
theme.colors.muted_foreground
|
theme.colors.muted_foreground
|
||||||
} else if selected {
|
|
||||||
theme.colors.primary_foreground
|
|
||||||
} else {
|
} else {
|
||||||
theme.colors.muted_foreground
|
theme.colors.primary_foreground
|
||||||
};
|
};
|
||||||
|
|
||||||
let cell = div()
|
let cell = div()
|
||||||
@@ -358,7 +362,7 @@ fn render_disclosure(
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.child(
|
.child(
|
||||||
icon(if item.expanded {
|
icon(if self.expanded {
|
||||||
IconRole::ChevronDown
|
IconRole::ChevronDown
|
||||||
} else {
|
} else {
|
||||||
IconRole::ChevronRight
|
IconRole::ChevronRight
|
||||||
@@ -367,8 +371,8 @@ fn render_disclosure(
|
|||||||
.color(glyph_color),
|
.color(glyph_color),
|
||||||
);
|
);
|
||||||
|
|
||||||
if item.expandable && !disabled {
|
if self.expandable && !self.disabled {
|
||||||
cell.id(("tree-disclosure", index))
|
cell.id((self.key.clone(), "tree-disclosure"))
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.on_click({
|
.on_click({
|
||||||
let on_disclosure = on_disclosure.clone();
|
let on_disclosure = on_disclosure.clone();
|
||||||
@@ -384,4 +388,26 @@ fn render_disclosure(
|
|||||||
} else {
|
} else {
|
||||||
cell.into_any_element()
|
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(|_, _| {
|
.items(|_, _| {
|
||||||
vec![
|
vec![
|
||||||
ListItem::new("One"),
|
ListItem::new("one", "One"),
|
||||||
ListItem::new("Two"),
|
ListItem::new("two", "Two"),
|
||||||
ListItem::new("Unavailable").disabled(true),
|
ListItem::new("unavailable", "Unavailable").disabled(true),
|
||||||
ListItem::new("Three"),
|
ListItem::new("three", "Three"),
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ impl Render for Harness {
|
|||||||
.entries(|_, _| {
|
.entries(|_, _| {
|
||||||
vec![
|
vec![
|
||||||
SidebarEntry::header("Favorites"), // 0: never selectable
|
SidebarEntry::header("Favorites"), // 0: never selectable
|
||||||
SidebarEntry::row("Documents"), // 1
|
SidebarEntry::row("documents", "Documents"), // 1
|
||||||
SidebarEntry::row("Locked").disabled(true), // 2
|
SidebarEntry::row("locked", "Locked").disabled(true), // 2
|
||||||
SidebarEntry::header("Locations"), // 3: never selectable
|
SidebarEntry::header("Locations"), // 3: never selectable
|
||||||
SidebarEntry::row("Trash"), // 4
|
SidebarEntry::row("trash", "Trash"), // 4
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use gpui_native_data::tabs::{Tab, Tabs};
|
|||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
type Changes = Rc<RefCell<Vec<usize>>>;
|
type Changes = Rc<RefCell<Vec<usize>>>;
|
||||||
|
type Closes = Rc<RefCell<Vec<usize>>>;
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
selected: usize,
|
selected: usize,
|
||||||
@@ -64,6 +65,55 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
|||||||
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
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]
|
#[test]
|
||||||
fn clicking_a_tab_selects_it_and_hands_the_strip_the_keyboard() {
|
fn clicking_a_tab_selects_it_and_hands_the_strip_the_keyboard() {
|
||||||
let (_cx, mut visual, changes) = tabs_window();
|
let (_cx, mut visual, changes) = tabs_window();
|
||||||
@@ -100,3 +150,19 @@ fn arrow_keys_skip_disabled_tabs_and_never_wrap() {
|
|||||||
visual.simulate_keystrokes("end"); // End
|
visual.simulate_keystrokes("end"); // End
|
||||||
assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]);
|
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,14 +38,18 @@ impl Render for Harness {
|
|||||||
})
|
})
|
||||||
.items(|_, _| {
|
.items(|_, _| {
|
||||||
vec![
|
vec![
|
||||||
TreeItem::new("Root")
|
TreeItem::new("root", "Root")
|
||||||
.depth(0)
|
.depth(0)
|
||||||
.expandable(true)
|
.expandable(true)
|
||||||
.expanded(true),
|
.expanded(true),
|
||||||
TreeItem::new("Child").depth(1),
|
TreeItem::new("child", "Child").depth(1),
|
||||||
TreeItem::new("Collapsed").depth(0).expandable(true),
|
TreeItem::new("collapsed", "Collapsed")
|
||||||
TreeItem::new("Untouchable").depth(1).disabled(true),
|
.depth(0)
|
||||||
TreeItem::new("Last").depth(0),
|
.expandable(true),
|
||||||
|
TreeItem::new("untouchable", "Untouchable")
|
||||||
|
.depth(1)
|
||||||
|
.disabled(true),
|
||||||
|
TreeItem::new("last", "Last").depth(0),
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ description = "Read-only code surfaces for gpui applications, one cargo feature
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-core = { workspace = true, optional = true }
|
gpui-native-core = { workspace = true, optional = true }
|
||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true, optional = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
gpui-native-utils = { workspace = true }
|
gpui-native-utils = { workspace = true }
|
||||||
tree-sitter = "0.25"
|
log = "0.4"
|
||||||
similar = "2"
|
similar = "2"
|
||||||
pulldown-cmark = { version = "0.13", default-features = false, optional = true }
|
pulldown-cmark = { version = "0.13", default-features = false, optional = true }
|
||||||
tree-sitter-rust = { version = "0.24", optional = true }
|
tree-sitter-rust = { version = "0.24", optional = true }
|
||||||
@@ -18,6 +18,9 @@ tree-sitter-typescript = { version = "0.23", optional = true }
|
|||||||
tree-sitter-javascript = { version = "0.23", optional = true }
|
tree-sitter-javascript = { version = "0.23", optional = true }
|
||||||
tree-sitter-python = { 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]
|
[dev-dependencies]
|
||||||
gpui = { workspace = true, features = ["test-support"] }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
@@ -38,7 +41,13 @@ code_view = [
|
|||||||
"gpui-native-core/scrollbar",
|
"gpui-native-core/scrollbar",
|
||||||
]
|
]
|
||||||
diff_view = ["code_view"]
|
diff_view = ["code_view"]
|
||||||
markdown_view = ["code_view", "dep:pulldown-cmark"]
|
markdown_view = [
|
||||||
|
"dep:gpui-native-core",
|
||||||
|
"gpui-native-core/button",
|
||||||
|
"gpui-native-core/scrollbar",
|
||||||
|
"dep:pulldown-cmark",
|
||||||
|
"dep:gpui-native-icons",
|
||||||
|
]
|
||||||
rust = ["dep:tree-sitter-rust"]
|
rust = ["dep:tree-sitter-rust"]
|
||||||
typescript = ["dep:tree-sitter-typescript"]
|
typescript = ["dep:tree-sitter-typescript"]
|
||||||
javascript = ["dep:tree-sitter-javascript"]
|
javascript = ["dep:tree-sitter-javascript"]
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ use gpui::{
|
|||||||
actions, canvas, div, uniform_list,
|
actions, canvas, div, uniform_list,
|
||||||
};
|
};
|
||||||
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
|
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
|
||||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, MONO_ADVANCE_RATIO, Ramps};
|
use gpui_native_theme::{
|
||||||
|
ActiveTheme as _, ColorTokens, MONO_ADVANCE_RATIO, Ramps, WASH_ALPHA_SOFT,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::highlight::{HighlightedCode, Language, Role, highlight};
|
use crate::highlight::{HighlightedCode, Language, Role, highlight};
|
||||||
use crate::utils::{gutter_cell, run_style};
|
use crate::utils::gutter_cell;
|
||||||
|
|
||||||
actions!(code_view, [Copy]);
|
actions!(code_view, [Copy]);
|
||||||
|
|
||||||
@@ -83,7 +85,7 @@ impl CodeViewState {
|
|||||||
active_line: None,
|
active_line: None,
|
||||||
selection: None,
|
selection: None,
|
||||||
selecting: false,
|
selecting: false,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle().tab_stop(true),
|
||||||
a11y_label: None,
|
a11y_label: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,14 +225,17 @@ impl CodeViewState {
|
|||||||
char_width: Pixels,
|
char_width: Pixels,
|
||||||
text_x: Pixels,
|
text_x: Pixels,
|
||||||
) -> SelectionPoint {
|
) -> 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)
|
let row = (((position.y - bounds.origin.y - scroll_y) / line_height).floor() as isize)
|
||||||
.clamp(0, self.code.lines.len() as isize - 1) as usize;
|
.clamp(0, last_row as isize) as usize;
|
||||||
|
|
||||||
let chars = self.code.lines[row]
|
let chars = self.code.lines.get(row).map_or(0, |line| {
|
||||||
.spans
|
line.spans
|
||||||
.iter()
|
.iter()
|
||||||
.map(|span| span.text.chars().count())
|
.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)
|
let col = (((position.x - text_x) / char_width).round() as isize).clamp(0, chars as isize)
|
||||||
as usize;
|
as usize;
|
||||||
@@ -503,7 +508,7 @@ fn render_row(
|
|||||||
if char_ix > 0 {
|
if char_ix > 0 {
|
||||||
highlights.push((
|
highlights.push((
|
||||||
run_start..run_end,
|
run_start..run_end,
|
||||||
run_style(run_role, run_selected, colors, ramps),
|
run_role.run_style(run_selected, colors, ramps),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
run_start = byte_ix;
|
run_start = byte_ix;
|
||||||
@@ -516,7 +521,7 @@ fn render_row(
|
|||||||
if run_end > run_start || !text.is_empty() {
|
if run_end > run_start || !text.is_empty() {
|
||||||
highlights.push((
|
highlights.push((
|
||||||
run_start..run_end,
|
run_start..run_end,
|
||||||
run_style(run_role, run_selected, colors, ramps),
|
run_role.run_style(run_selected, colors, ramps),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +544,7 @@ fn render_row(
|
|||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.when(active, |row| {
|
.when(active, |row| {
|
||||||
row.bg(Hsla {
|
row.bg(Hsla {
|
||||||
a: colors.muted.a * 0.45,
|
a: colors.muted.a * WASH_ALPHA_SOFT,
|
||||||
..colors.muted
|
..colors.muted
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+188
-88
@@ -3,7 +3,7 @@
|
|||||||
//! bars instead of markers, tinted number gutters, word-level intra-line
|
//! bars instead of markers, tinted number gutters, word-level intra-line
|
||||||
//! highlights and hunk separators. Rows reuse the code view's highlighting;
|
//! highlights and hunk separators. Rows reuse the code view's highlighting;
|
||||||
//! consumers that already hold a git diff can build [`DiffRow`]s themselves
|
//! consumers that already hold a git diff can build [`DiffRow`]s themselves
|
||||||
//! instead of calling [`diff_rows`].
|
//! instead of calling [`DiffViewState::diff_rows`].
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -14,12 +14,12 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
|
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
|
||||||
use gpui_native_theme::{ActiveTheme as _, MONO_ADVANCE_RATIO};
|
use gpui_native_theme::{ActiveTheme as _, MONO_ADVANCE_RATIO};
|
||||||
use similar::{ChangeTag, TextDiff};
|
use similar::{Change, ChangeTag, TextDiff};
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use crate::highlight::{Language, Line, Role, highlight};
|
use crate::highlight::{HighlightedCode, Language, Line, Role, highlight};
|
||||||
use crate::utils::{gutter_cell, line_row, role_color, wash, wash_gutter, wash_strong};
|
use crate::utils::{gutter_cell, wash, wash_gutter, wash_strong};
|
||||||
|
|
||||||
/// What a diff row did to the file.
|
/// What a diff row did to the file.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -96,18 +96,155 @@ impl Default for DiffViewOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DiffRow {
|
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.
|
/// The row's text length, in characters.
|
||||||
fn char_count(&self) -> usize {
|
fn char_count(&self) -> usize {
|
||||||
self.content
|
self.content.text().chars().count()
|
||||||
.spans
|
}
|
||||||
.iter()
|
|
||||||
.map(|span| span.text.chars().count())
|
/// Stable element id: the kind and line numbers packed into one integer.
|
||||||
.sum()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes unified rows between two texts, highlighting every line with `language`.
|
/// Retained state for a diff view.
|
||||||
pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
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
|
||||||
|
.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(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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> {
|
||||||
let old_code = highlight(old, language);
|
let old_code = highlight(old, language);
|
||||||
let new_code = highlight(new, language);
|
let new_code = highlight(new, language);
|
||||||
let diff = TextDiff::from_lines(old, new);
|
let diff = TextDiff::from_lines(old, new);
|
||||||
@@ -116,8 +253,9 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
|||||||
let mut collapsed_before = 0usize;
|
let mut collapsed_before = 0usize;
|
||||||
|
|
||||||
for group in diff.grouped_ops(3) {
|
for group in diff.grouped_ops(3) {
|
||||||
let first = group.first().expect("hunk groups are never empty");
|
let (Some(first), Some(last)) = (group.first(), group.last()) else {
|
||||||
let last = group.last().expect("hunk groups are never empty");
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let old_range = first.old_range().start..last.old_range().end;
|
let old_range = first.old_range().start..last.old_range().end;
|
||||||
let new_range = first.new_range().start..last.new_range().end;
|
let new_range = first.new_range().start..last.new_range().end;
|
||||||
@@ -135,7 +273,7 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
|||||||
new_range.start + 1
|
new_range.start + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
let collapsed = first.old_range().start - collapsed_before;
|
let collapsed = first.old_range().start.saturating_sub(collapsed_before);
|
||||||
if collapsed > 0 {
|
if collapsed > 0 {
|
||||||
rows.push(DiffRow {
|
rows.push(DiffRow {
|
||||||
kind: DiffRowKind::Hunk,
|
kind: DiffRowKind::Hunk,
|
||||||
@@ -158,21 +296,21 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
|||||||
|
|
||||||
for op in &group {
|
for op in &group {
|
||||||
let changes: Vec<_> = diff.iter_changes(op).collect();
|
let changes: Vec<_> = diff.iter_changes(op).collect();
|
||||||
let mut ix = 0;
|
let mut index = 0;
|
||||||
|
|
||||||
while ix < changes.len() {
|
while index < changes.len() {
|
||||||
if changes[ix].tag() == ChangeTag::Equal {
|
if changes[index].tag() == ChangeTag::Equal {
|
||||||
rows.push(line_row(&changes[ix], &new_code, Vec::new()));
|
rows.push(DiffRow::from_change(&changes[index], &new_code, Vec::new()));
|
||||||
ix += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let deletions = changes[ix..]
|
let deletions = changes[index..]
|
||||||
.iter()
|
.iter()
|
||||||
.take_while(|change| change.tag() == ChangeTag::Delete)
|
.take_while(|change| change.tag() == ChangeTag::Delete)
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
let insertions = changes[ix + deletions..]
|
let insertions = changes[index + deletions..]
|
||||||
.iter()
|
.iter()
|
||||||
.take_while(|change| change.tag() == ChangeTag::Insert)
|
.take_while(|change| change.tag() == ChangeTag::Insert)
|
||||||
.count();
|
.count();
|
||||||
@@ -181,8 +319,8 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
|||||||
let mut insertion_ranges = vec![Vec::new(); insertions];
|
let mut insertion_ranges = vec![Vec::new(); insertions];
|
||||||
|
|
||||||
for pair in 0..deletions.min(insertions) {
|
for pair in 0..deletions.min(insertions) {
|
||||||
let old_text = changes[ix + pair].value().trim_end_matches('\n');
|
let old_text = changes[index + pair].value().trim_end_matches('\n');
|
||||||
let new_text = changes[ix + deletions + pair]
|
let new_text = changes[index + deletions + pair]
|
||||||
.value()
|
.value()
|
||||||
.trim_end_matches('\n');
|
.trim_end_matches('\n');
|
||||||
let words = TextDiff::from_words(old_text, new_text);
|
let words = TextDiff::from_words(old_text, new_text);
|
||||||
@@ -210,75 +348,27 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for pair in 0..deletions {
|
for pair in 0..deletions {
|
||||||
rows.push(line_row(
|
rows.push(DiffRow::from_change(
|
||||||
&changes[ix + pair],
|
&changes[index + pair],
|
||||||
&old_code,
|
&old_code,
|
||||||
deletion_ranges[pair].clone(),
|
deletion_ranges[pair].clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
for pair in 0..insertions {
|
for pair in 0..insertions {
|
||||||
rows.push(line_row(
|
rows.push(DiffRow::from_change(
|
||||||
&changes[ix + deletions + pair],
|
&changes[index + deletions + pair],
|
||||||
&new_code,
|
&new_code,
|
||||||
insertion_ranges[pair].clone(),
|
insertion_ranges[pair].clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
ix += deletions + insertions;
|
index += deletions + insertions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows
|
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.
|
/// Sets how changed rows are marked.
|
||||||
@@ -355,7 +445,15 @@ impl Render for DiffViewState {
|
|||||||
move |range, _window, cx| {
|
move |range, _window, cx| {
|
||||||
let diff = state.read(cx);
|
let diff = state.read(cx);
|
||||||
range
|
range
|
||||||
.map(|row_ix| render_row(&diff.rows[row_ix], row_ix, options, cx))
|
.map(|row_index| {
|
||||||
|
render_row(
|
||||||
|
&diff.rows[row_index],
|
||||||
|
&diff.row_layouts[row_index],
|
||||||
|
row_index,
|
||||||
|
options,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -389,7 +487,8 @@ impl Render for DiffViewState {
|
|||||||
|
|
||||||
fn render_row(
|
fn render_row(
|
||||||
row: &DiffRow,
|
row: &DiffRow,
|
||||||
row_ix: usize,
|
layout: &RowLayout,
|
||||||
|
row_index: usize,
|
||||||
options: DiffViewOptions,
|
options: DiffViewOptions,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> gpui::AnyElement {
|
) -> gpui::AnyElement {
|
||||||
@@ -402,8 +501,8 @@ fn render_row(
|
|||||||
let char_width = text_size * MONO_ADVANCE_RATIO;
|
let char_width = text_size * MONO_ADVANCE_RATIO;
|
||||||
|
|
||||||
let frame = div()
|
let frame = div()
|
||||||
.debug_selector(move || format!("diff-row-{row_ix}"))
|
.debug_selector(move || format!("diff-row-{row_index}"))
|
||||||
.id(("diff-row", row_ix))
|
.id(layout.id.clone())
|
||||||
.w_full()
|
.w_full()
|
||||||
.h(line_height)
|
.h(line_height)
|
||||||
.font_family(mono_font)
|
.font_family(mono_font)
|
||||||
@@ -496,7 +595,7 @@ fn render_row(
|
|||||||
&& *role != Role::Text
|
&& *role != Role::Text
|
||||||
{
|
{
|
||||||
segment = segment.highlight(HighlightStyle {
|
segment = segment.highlight(HighlightStyle {
|
||||||
color: Some(role_color(*role, colors, ramps)),
|
color: Some(role.color(colors, ramps)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -546,7 +645,8 @@ fn render_row(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.when(options.line_numbers, |row_element| {
|
.when(options.line_numbers, |row_element| {
|
||||||
let mut number = gutter_cell(
|
row_element.child(
|
||||||
|
gutter_cell(
|
||||||
cx,
|
cx,
|
||||||
colors.muted_foreground,
|
colors.muted_foreground,
|
||||||
row.new_number
|
row.new_number
|
||||||
@@ -559,11 +659,11 @@ fn render_row(
|
|||||||
.bg(match row.kind {
|
.bg(match row.kind {
|
||||||
DiffRowKind::Context => wash_gutter(&colors),
|
DiffRowKind::Context => wash_gutter(&colors),
|
||||||
_ => wash_strong(change_color),
|
_ => wash_strong(change_color),
|
||||||
});
|
})
|
||||||
if options.indicators == DiffIndicators::Bars {
|
.when(options.indicators == DiffIndicators::Bars, |number| {
|
||||||
number = number.border_l_4().border_color(change_color);
|
number.border_l_4().border_color(change_color)
|
||||||
}
|
}),
|
||||||
row_element.child(number)
|
)
|
||||||
})
|
})
|
||||||
.when(
|
.when(
|
||||||
options.indicators == DiffIndicators::Bars && !options.line_numbers,
|
options.indicators == DiffIndicators::Bars && !options.line_numbers,
|
||||||
@@ -581,7 +681,7 @@ fn render_row(
|
|||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.ml_2()
|
.ml_2()
|
||||||
.w(char_width * row.char_count() as f32)
|
.w(char_width * layout.char_count as f32)
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)),
|
.child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,16 +3,20 @@
|
|||||||
//! code without re-parsing it.
|
//! code without re-parsing it.
|
||||||
|
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
|
use gpui_native_utils::ResultLogErr;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use tree_sitter::StreamingIterator as _;
|
use tree_sitter::StreamingIterator as _;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static HIGHLIGHTERS: RefCell<HashMap<Language, Option<Highlighter>>> =
|
static HIGHLIGHTERS: RefCell<HashMap<Language, Option<Highlighter>>> =
|
||||||
RefCell::new(HashMap::new());
|
RefCell::new(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A grammar's parser and compiled highlight query.
|
/// A grammar's parser and compiled highlight query.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
struct Highlighter {
|
struct Highlighter {
|
||||||
parser: tree_sitter::Parser,
|
parser: tree_sitter::Parser,
|
||||||
query: tree_sitter::Query,
|
query: tree_sitter::Query,
|
||||||
@@ -29,7 +33,7 @@ pub enum Language {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The semantic role a span plays.
|
/// The semantic role a span plays.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum Role {
|
pub enum Role {
|
||||||
Text,
|
Text,
|
||||||
Keyword,
|
Keyword,
|
||||||
@@ -47,25 +51,37 @@ pub enum Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A run of text sharing one role.
|
/// A run of text sharing one role.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub text: SharedString,
|
pub text: SharedString,
|
||||||
pub role: Role,
|
pub role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One rendered line of code.
|
/// One rendered line of code.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
pub struct Line {
|
pub struct Line {
|
||||||
pub spans: Vec<Span>,
|
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.
|
/// Source text split into role-styled lines.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
pub struct HighlightedCode {
|
pub struct HighlightedCode {
|
||||||
pub lines: Vec<Line>,
|
pub lines: Vec<Line>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Highlights `source` with `language`'s tree-sitter grammar.
|
/// Highlights `source` with `language`'s tree-sitter grammar.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
||||||
let Some((grammar, query_source)) = grammar_for(language) else {
|
let Some((grammar, query_source)) = grammar_for(language) else {
|
||||||
return plain(source);
|
return plain(source);
|
||||||
@@ -77,10 +93,8 @@ pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
|||||||
.entry(language)
|
.entry(language)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
let mut parser = tree_sitter::Parser::new();
|
let mut parser = tree_sitter::Parser::new();
|
||||||
if parser.set_language(&grammar).is_err() {
|
parser.set_language(&grammar).log_err()?;
|
||||||
return None;
|
let query = tree_sitter::Query::new(&grammar, query_source).log_err()?;
|
||||||
}
|
|
||||||
let query = tree_sitter::Query::new(&grammar, query_source).ok()?;
|
|
||||||
let roles = query
|
let roles = query
|
||||||
.capture_names()
|
.capture_names()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -99,6 +113,7 @@ pub fn highlight(source: &str, language: Language) -> HighlightedCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let Some(tree) = highlighter.parser.parse(source, None) else {
|
let Some(tree) = highlighter.parser.parse(source, None) else {
|
||||||
|
log::warn!("tree-sitter failed to parse {language:?}; rendering plain text");
|
||||||
return plain(source);
|
return plain(source);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,39 +144,94 @@ 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.
|
/// Source rendered without any grammar: plain text roles.
|
||||||
pub(crate) fn plain(source: &str) -> HighlightedCode {
|
pub(crate) fn plain(source: &str) -> HighlightedCode {
|
||||||
build_lines(source, &[])
|
build_lines(source, &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grammar and its highlight query for a language, when compiled in.
|
/// 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)> {
|
fn grammar_for(language: Language) -> Option<(tree_sitter::Language, &'static str)> {
|
||||||
match language {
|
match language {
|
||||||
Language::Rust => cfg!(feature = "rust").then(|| {
|
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((
|
||||||
tree_sitter_rust::LANGUAGE.into(),
|
tree_sitter_rust::LANGUAGE.into(),
|
||||||
tree_sitter_rust::HIGHLIGHTS_QUERY,
|
tree_sitter_rust::HIGHLIGHTS_QUERY,
|
||||||
)
|
))
|
||||||
}),
|
}
|
||||||
Language::TypeScript => cfg!(feature = "typescript").then(|| {
|
|
||||||
(
|
/// 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((
|
||||||
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
||||||
tree_sitter_typescript::HIGHLIGHTS_QUERY,
|
tree_sitter_typescript::HIGHLIGHTS_QUERY,
|
||||||
)
|
))
|
||||||
}),
|
}
|
||||||
Language::JavaScript => cfg!(feature = "javascript").then(|| {
|
|
||||||
(
|
/// 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((
|
||||||
tree_sitter_javascript::LANGUAGE.into(),
|
tree_sitter_javascript::LANGUAGE.into(),
|
||||||
tree_sitter_javascript::HIGHLIGHT_QUERY,
|
tree_sitter_javascript::HIGHLIGHT_QUERY,
|
||||||
)
|
))
|
||||||
}),
|
}
|
||||||
Language::Python => cfg!(feature = "python").then(|| {
|
|
||||||
(
|
/// 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((
|
||||||
tree_sitter_python::LANGUAGE.into(),
|
tree_sitter_python::LANGUAGE.into(),
|
||||||
tree_sitter_python::HIGHLIGHTS_QUERY,
|
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.
|
/// Maps a query capture name to a role; unmapped captures render as text.
|
||||||
|
|||||||
+1103
-136
File diff suppressed because it is too large
Load Diff
+48
-89
@@ -1,27 +1,28 @@
|
|||||||
//! Shared helpers for the editor's views: gutter cells, role colors, code
|
//! Shared helpers for the editor's views: gutter cells, role colors and run
|
||||||
//! line text, language mapping, change washes and diff row building.
|
//! styles, code line text, language mapping and change washes.
|
||||||
|
|
||||||
#[cfg(any(feature = "diff_view", feature = "markdown_view"))]
|
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
|
#[cfg(feature = "markdown_view")]
|
||||||
|
use crate::highlight::{Language, Line};
|
||||||
#[cfg(feature = "markdown_view")]
|
#[cfg(feature = "markdown_view")]
|
||||||
use gpui::StyledText;
|
use gpui::StyledText;
|
||||||
|
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||||
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
|
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};
|
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
|
||||||
#[cfg(feature = "diff_view")]
|
#[cfg(feature = "diff_view")]
|
||||||
use similar::{Change, ChangeTag};
|
use gpui_native_theme::{WASH_ALPHA, WASH_ALPHA_GUTTER, WASH_ALPHA_STRONG};
|
||||||
|
|
||||||
#[cfg(feature = "diff_view")]
|
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||||
use crate::diff_view::{DiffRow, DiffRowKind};
|
|
||||||
#[cfg(feature = "diff_view")]
|
|
||||||
use crate::highlight::HighlightedCode;
|
|
||||||
use crate::highlight::Role;
|
use crate::highlight::Role;
|
||||||
#[cfg(feature = "markdown_view")]
|
|
||||||
use crate::highlight::{Language, Line};
|
|
||||||
|
|
||||||
/// Maps a highlight role to the theme's semantic palette.
|
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||||
pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
impl Role {
|
||||||
match role {
|
/// Maps the role to the theme's semantic palette.
|
||||||
|
pub(crate) fn color(self, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
||||||
|
match self {
|
||||||
Role::Text => colors.foreground,
|
Role::Text => colors.foreground,
|
||||||
Role::Keyword => ramps.primary.base,
|
Role::Keyword => ramps.primary.base,
|
||||||
Role::String | Role::Escape => ramps.success.base,
|
Role::String | Role::Escape => ramps.success.base,
|
||||||
@@ -32,23 +33,39 @@ pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla
|
|||||||
Role::Operator => colors.foreground,
|
Role::Operator => colors.foreground,
|
||||||
Role::Tag => ramps.destructive.base,
|
Role::Tag => ramps.destructive.base,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the highlight style for one run of a code view row.
|
/// Builds the highlight style for one run of a code view row.
|
||||||
pub(crate) fn run_style(
|
pub(crate) fn run_style(
|
||||||
role: Role,
|
self,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
colors: ColorTokens,
|
colors: ColorTokens,
|
||||||
ramps: Ramps,
|
ramps: Ramps,
|
||||||
) -> HighlightStyle {
|
) -> HighlightStyle {
|
||||||
HighlightStyle {
|
HighlightStyle {
|
||||||
color: Some(role_color(role, colors, ramps)),
|
color: Some(self.color(colors, ramps)),
|
||||||
background_color: selected.then_some(colors.selection),
|
background_color: selected.then_some(colors.selection),
|
||||||
..Default::default()
|
..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.
|
/// 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
|
pub(crate) fn gutter_cell<E>(cx: &App, color: Hsla, content: E) -> gpui::Div
|
||||||
where
|
where
|
||||||
E: IntoElement,
|
E: IntoElement,
|
||||||
@@ -68,75 +85,29 @@ where
|
|||||||
.child(content)
|
.child(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// A change wash: the ramp color at the alpha rows tint with.
|
||||||
|
#[cfg(feature = "diff_view")]
|
||||||
pub(crate) fn wash(color: Hsla) -> Hsla {
|
pub(crate) fn wash(color: Hsla) -> Hsla {
|
||||||
Hsla {
|
Hsla {
|
||||||
a: color.a * 0.14,
|
a: color.a * WASH_ALPHA,
|
||||||
..color
|
..color
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stronger wash under number gutters and intra-line word changes.
|
/// The stronger wash under number gutters and intra-line word changes.
|
||||||
|
#[cfg(feature = "diff_view")]
|
||||||
pub(crate) fn wash_strong(color: Hsla) -> Hsla {
|
pub(crate) fn wash_strong(color: Hsla) -> Hsla {
|
||||||
Hsla {
|
Hsla {
|
||||||
a: color.a * 0.34,
|
a: color.a * WASH_ALPHA_STRONG,
|
||||||
..color
|
..color
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The subtle wash that sets the number gutter off from the code.
|
/// The subtle wash that sets the number gutter off from the code.
|
||||||
|
#[cfg(feature = "diff_view")]
|
||||||
pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla {
|
pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla {
|
||||||
Hsla {
|
Hsla {
|
||||||
a: colors.muted.a * 0.18,
|
a: colors.muted.a * WASH_ALPHA_GUTTER,
|
||||||
..colors.muted
|
..colors.muted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,34 +115,22 @@ pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla {
|
|||||||
/// One highlighted code line as styled text, roles resolved from the theme.
|
/// One highlighted code line as styled text, roles resolved from the theme.
|
||||||
#[cfg(feature = "markdown_view")]
|
#[cfg(feature = "markdown_view")]
|
||||||
pub(crate) fn code_line(line: &Line, colors: ColorTokens, ramps: Ramps) -> StyledText {
|
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 highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
|
||||||
|
let mut offset = 0usize;
|
||||||
|
|
||||||
for span in &line.spans {
|
for span in &line.spans {
|
||||||
let start = text.len();
|
let end = offset + span.text.len();
|
||||||
text.push_str(&span.text);
|
|
||||||
if span.role != Role::Text {
|
if span.role != Role::Text {
|
||||||
highlights.push((
|
highlights.push((
|
||||||
start..text.len(),
|
offset..end,
|
||||||
HighlightStyle {
|
HighlightStyle {
|
||||||
color: Some(role_color(span.role, colors, ramps)),
|
color: Some(span.role.color(colors, ramps)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
offset = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText::new(text).with_highlights(highlights)
|
StyledText::new(line.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,4 +1,5 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::hint::black_box;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -101,11 +102,11 @@ fn scrolling_a_large_file_is_not_pathologically_slow() {
|
|||||||
scroll_elapsed / 30
|
scroll_elapsed / 30
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
highlight_elapsed < Duration::from_secs(3),
|
black_box(highlight_elapsed) < Duration::from_secs(10),
|
||||||
"highlighting took {highlight_elapsed:?}"
|
"highlighting took {highlight_elapsed:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
scroll_elapsed < Duration::from_secs(2),
|
black_box(scroll_elapsed) < Duration::from_secs(10),
|
||||||
"scrolling 30 frames took {scroll_elapsed:?}"
|
"scrolling 30 frames took {scroll_elapsed:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gpui::{
|
|||||||
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
|
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
|
||||||
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_editor::diff_view::{DiffRow, DiffRowKind, DiffViewState, diff_rows};
|
use gpui_native_editor::diff_view::{DiffRow, DiffRowKind, DiffViewState};
|
||||||
use gpui_native_editor::highlight::{Language, Role};
|
use gpui_native_editor::highlight::{Language, Role};
|
||||||
|
|
||||||
const OLD: &str = "fn a() {}\nfn b() {}\nfn c() {}\n";
|
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]
|
#[test]
|
||||||
fn rows_mark_removals_additions_and_context_with_line_numbers() {
|
fn rows_mark_removals_additions_and_context_with_line_numbers() {
|
||||||
let rows = diff_rows(OLD, NEW, Language::Rust);
|
let rows = DiffViewState::diff_rows(OLD, NEW, Language::Rust);
|
||||||
|
|
||||||
let kinds: Vec<DiffRowKind> = rows.iter().map(|row| row.kind).collect();
|
let kinds: Vec<DiffRowKind> = rows.iter().map(|row| row.kind).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -72,7 +72,7 @@ fn hunk_separators_carry_the_collapsed_line_count_and_header() {
|
|||||||
new.push_str(&format!("line {line}\n"));
|
new.push_str(&format!("line {line}\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let rows = diff_rows(&old, &new, Language::Rust);
|
let rows = DiffViewState::diff_rows(&old, &new, Language::Rust);
|
||||||
|
|
||||||
let separators: Vec<&DiffRow> = rows
|
let separators: Vec<&DiffRow> = rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -91,7 +91,7 @@ fn hunk_separators_carry_the_collapsed_line_count_and_header() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn word_changes_tint_only_the_changed_words() {
|
fn word_changes_tint_only_the_changed_words() {
|
||||||
let rows = diff_rows(OLD, NEW, Language::Rust);
|
let rows = DiffViewState::diff_rows(OLD, NEW, Language::Rust);
|
||||||
|
|
||||||
let removed = &rows[1];
|
let removed = &rows[1];
|
||||||
let removed_text: String = removed
|
let removed_text: String = removed
|
||||||
@@ -118,7 +118,7 @@ fn word_changes_tint_only_the_changed_words() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_added_line_carries_its_highlight_spans() {
|
fn an_added_line_carries_its_highlight_spans() {
|
||||||
let rows = diff_rows("", "fn zed() {}\n", Language::Rust);
|
let rows = DiffViewState::diff_rows("", "fn zed() {}\n", Language::Rust);
|
||||||
let added = rows
|
let added = rows
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.kind == DiffRowKind::Added)
|
.find(|row| row.kind == DiffRowKind::Added)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::hint::black_box;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -99,11 +100,11 @@ fn scrolling_a_large_diff_is_not_pathologically_slow() {
|
|||||||
scroll_elapsed / 30
|
scroll_elapsed / 30
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
diff_elapsed < Duration::from_secs(3),
|
black_box(diff_elapsed) < Duration::from_secs(10),
|
||||||
"diffing took {diff_elapsed:?}"
|
"diffing took {diff_elapsed:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
scroll_elapsed < Duration::from_secs(2),
|
black_box(scroll_elapsed) < Duration::from_secs(10),
|
||||||
"scrolling 30 frames took {scroll_elapsed:?}"
|
"scrolling 30 frames took {scroll_elapsed:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+478
-34
@@ -2,15 +2,27 @@ use std::cell::RefCell;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent,
|
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _,
|
||||||
px,
|
TestAppContext, VisualTestContext, Window, div, point, px, rems,
|
||||||
};
|
};
|
||||||
use gpui_native_editor::highlight::Role;
|
use gpui_native_editor::highlight::Role;
|
||||||
use gpui_native_editor::markdown::{
|
use gpui_native_editor::markdown::{
|
||||||
InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState,
|
InlineItem, InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading,
|
||||||
|
MarkdownViewState, TextView, bind_text_view_keys,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
fn spans_text(spans: &[InlineSpan]) -> String {
|
||||||
spans
|
spans
|
||||||
.iter()
|
.iter()
|
||||||
@@ -19,13 +31,13 @@ fn spans_text(spans: &[InlineSpan]) -> String {
|
|||||||
.join("")
|
.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineSpan>, usize, bool) {
|
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineItem>, usize, bool) {
|
||||||
match &blocks[index] {
|
match &blocks[index] {
|
||||||
MarkdownBlock::Paragraph {
|
MarkdownBlock::Paragraph {
|
||||||
spans,
|
items,
|
||||||
indent,
|
indent,
|
||||||
quote,
|
quote,
|
||||||
} => (spans, *indent, *quote),
|
} => (items, *indent, *quote),
|
||||||
other => panic!("block {index} is {other:?}, not a paragraph"),
|
other => panic!("block {index} is {other:?}, not a paragraph"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,10 +46,10 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
|
|||||||
match &blocks[index] {
|
match &blocks[index] {
|
||||||
MarkdownBlock::ListItem {
|
MarkdownBlock::ListItem {
|
||||||
marker,
|
marker,
|
||||||
spans,
|
items,
|
||||||
indent,
|
indent,
|
||||||
quote,
|
quote,
|
||||||
} => (*marker, spans_text(spans), *indent, *quote),
|
} => (*marker, items_text(items), *indent, *quote),
|
||||||
other => panic!("block {index} is {other:?}, not a list item"),
|
other => panic!("block {index} is {other:?}, not a list item"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +58,14 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
|
|||||||
fn inline_styles_survive_nesting_and_merge_runs() {
|
fn inline_styles_survive_nesting_and_merge_runs() {
|
||||||
let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done");
|
let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done");
|
||||||
assert_eq!(blocks.len(), 1);
|
assert_eq!(blocks.len(), 1);
|
||||||
let (spans, ..) = paragraph_of(&blocks, 0);
|
let (items, ..) = paragraph_of(&blocks, 0);
|
||||||
|
|
||||||
let styled: Vec<(String, &InlineStyle)> = spans
|
let styled: Vec<(String, &InlineStyle)> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|span| (span.text.to_string(), &span.style))
|
.filter_map(|item| match item {
|
||||||
|
InlineItem::Text(span) => Some((span.text.to_string(), &span.style)),
|
||||||
|
InlineItem::Image { .. } => None,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong)));
|
assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong)));
|
||||||
@@ -133,8 +148,8 @@ fn tight_items_stay_tight_but_block_content_flushes_before_them() {
|
|||||||
assert_eq!(text, "item text");
|
assert_eq!(text, "item text");
|
||||||
assert_eq!(indent, 0);
|
assert_eq!(indent, 0);
|
||||||
|
|
||||||
let (spans, indent, _) = paragraph_of(&blocks, 1);
|
let (items, indent, _) = paragraph_of(&blocks, 1);
|
||||||
assert_eq!(spans_text(spans), "second paragraph");
|
assert_eq!(items_text(items), "second paragraph");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
indent, 1,
|
indent, 1,
|
||||||
"loose item continuations indent under the item's text"
|
"loose item continuations indent under the item's text"
|
||||||
@@ -174,8 +189,8 @@ fn tight_item_content_after_a_nested_block_stays_in_the_item() {
|
|||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|
||||||
let (spans, indent, _) = paragraph_of(&blocks, 2);
|
let (items, indent, _) = paragraph_of(&blocks, 2);
|
||||||
assert_eq!(spans_text(spans), "after");
|
assert_eq!(items_text(items), "after");
|
||||||
assert_eq!(indent, 1, "the continuation stays under the item's text");
|
assert_eq!(indent, 1, "the continuation stays under the item's text");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +265,11 @@ fn quotes_mark_their_paragraphs_and_lists() {
|
|||||||
assert!(!paragraph_of(&blocks, 2).2);
|
assert!(!paragraph_of(&blocks, 2).2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rust")]
|
||||||
#[test]
|
#[test]
|
||||||
fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
||||||
let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```");
|
let blocks = parse_body("```rust\nfn main() {}\n```\n");
|
||||||
assert_eq!(blocks.len(), 2);
|
assert_eq!(blocks.len(), 1);
|
||||||
|
|
||||||
let MarkdownBlock::Code {
|
let MarkdownBlock::Code {
|
||||||
language, lines, ..
|
language, lines, ..
|
||||||
@@ -270,12 +286,18 @@ fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
|||||||
.any(|span| span.role == Role::Keyword),
|
.any(|span| span.role == Role::Keyword),
|
||||||
"rust fences highlight through the grammar"
|
"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 {
|
let MarkdownBlock::Code {
|
||||||
language, lines, ..
|
language, lines, ..
|
||||||
} = &blocks[1]
|
} = &blocks[0]
|
||||||
else {
|
else {
|
||||||
panic!("the second block is code");
|
panic!("the block is code");
|
||||||
};
|
};
|
||||||
assert_eq!(language.as_deref(), Some("text"));
|
assert_eq!(language.as_deref(), Some("text"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -309,11 +331,59 @@ fn tables_parse_header_first_with_column_positions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn images_render_their_alt_text_and_html_renders_nothing() {
|
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.
|
||||||
let blocks = parse_body("see  now\n\n<div>hidden</div>\n");
|
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");
|
assert_eq!(blocks.len(), 1, "the html block renders no block at all");
|
||||||
let (spans, ..) = paragraph_of(&blocks, 0);
|
let (items, ..) = paragraph_of(&blocks, 0);
|
||||||
assert_eq!(spans_text(spans), "see the logo now");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -363,6 +433,19 @@ fn view_window(source: &str) -> (TestAppContext, VisualTestContext, Entity<Markd
|
|||||||
(cx, visual, state)
|
(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]
|
#[test]
|
||||||
fn blocks_render_virtualized_and_scroll_to_a_block() {
|
fn blocks_render_virtualized_and_scroll_to_a_block() {
|
||||||
let mut source = String::new();
|
let mut source = String::new();
|
||||||
@@ -371,12 +454,16 @@ fn blocks_render_virtualized_and_scroll_to_a_block() {
|
|||||||
}
|
}
|
||||||
let (_cx, mut visual, state) = view_window(&source);
|
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!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-0").is_some(),
|
visual.debug_bounds(first_id).is_some(),
|
||||||
"the first block renders"
|
"the first block renders"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-150").is_none(),
|
visual.debug_bounds(target_id).is_none(),
|
||||||
"blocks past the viewport never render"
|
"blocks past the viewport never render"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -388,25 +475,26 @@ fn blocks_render_virtualized_and_scroll_to_a_block() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-150").is_some(),
|
visual.debug_bounds(target_id).is_some(),
|
||||||
"scrolling brings the target block in"
|
"scrolling brings the target block in"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-0").is_none(),
|
visual.debug_bounds(first_id).is_none(),
|
||||||
"the first block scrolled out"
|
"the first block scrolled out"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-199").is_none(),
|
visual.debug_bounds(last_id).is_none(),
|
||||||
"blocks past the target stay unrendered"
|
"blocks past the target stay unrendered"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clicking_a_link_opens_its_destination() {
|
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
|
let block = visual
|
||||||
.debug_bounds("test-md-block-0")
|
.debug_bounds(first_id)
|
||||||
.expect("the paragraph renders");
|
.expect("the paragraph renders");
|
||||||
|
|
||||||
visual.simulate_event(MouseDownEvent {
|
visual.simulate_event(MouseDownEvent {
|
||||||
@@ -461,21 +549,377 @@ fn a_large_document_swaps_and_renders_without_freezing() {
|
|||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let elapsed = started.elapsed();
|
let elapsed = std::hint::black_box(started.elapsed());
|
||||||
assert!(
|
assert!(
|
||||||
elapsed < std::time::Duration::from_secs(15),
|
elapsed < std::time::Duration::from_secs(30),
|
||||||
"swapping to a large document took {elapsed:?}; the frame must not
|
"swapping to a large document took {elapsed:?}; the frame must not
|
||||||
measure every block"
|
measure every block"
|
||||||
);
|
);
|
||||||
|
|
||||||
let count = _cx.update(|cx| state.read(cx).blocks().len());
|
let count = _cx.update(|cx| state.read(cx).blocks().len());
|
||||||
assert_eq!(count, 5000, "five blocks per section");
|
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!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-0").is_some(),
|
visual.debug_bounds(first_id).is_some(),
|
||||||
"the first block renders after the swap"
|
"the first block renders after the swap"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
visual.debug_bounds("test-md-block-4999").is_none(),
|
visual.debug_bounds(last_id).is_none(),
|
||||||
"blocks past the viewport never render"
|
"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"
|
name = "gpui-native-icons"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "System icons for GPUI: semantic roles resolved per platform, with a bundled Lucide fallback"
|
description = "System icons for GPUI: semantic roles resolved per platform, a bundled Lucide fallback, and app-defined icon sets"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 361 B |
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||||
|
|
||||||
|
use gpui::SharedString;
|
||||||
|
|
||||||
use crate::IconRole;
|
use crate::IconRole;
|
||||||
|
|
||||||
@@ -144,14 +146,33 @@ fn icon_score(path: &Path, requested: u32) -> SizeScore {
|
|||||||
.unwrap_or(SizeScore::Any)
|
.unwrap_or(SizeScore::Any)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The discovered icon file for a role at a size, cached for the process.
|
/// A resolved icon file and the asset key its bytes are served under.
|
||||||
type IconCache = Mutex<HashMap<(&'static str, u32), Option<PathBuf>>>;
|
struct CachedIcon {
|
||||||
|
path: PathBuf,
|
||||||
|
asset_key: SharedString,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The discovered icons, cached for the process.
|
||||||
|
type IconCache = Mutex<HashMap<(&'static str, u32), Option<CachedIcon>>>;
|
||||||
|
|
||||||
pub(crate) fn cached_file(role: IconRole, requested: 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();
|
static CACHE: OnceLock<IconCache> = OnceLock::new();
|
||||||
|
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
let mut cache = cache.lock().expect("icon cache poisoned");
|
// A poisoned lock means a lookup panicked; its stale data beats a panic.
|
||||||
|
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
|
||||||
cache
|
cache
|
||||||
.entry((role.slug(), requested))
|
.entry((role.slug(), requested))
|
||||||
@@ -162,8 +183,16 @@ pub(crate) fn cached_file(role: IconRole, requested: u32) -> Option<PathBuf> {
|
|||||||
requested,
|
requested,
|
||||||
&base_dirs(),
|
&base_dirs(),
|
||||||
)
|
)
|
||||||
|
.map(|path| CachedIcon {
|
||||||
|
asset_key: SharedString::from(format!(
|
||||||
|
"icons/freedesktop/{}-{requested}.svg",
|
||||||
|
role.slug()
|
||||||
|
)),
|
||||||
|
path,
|
||||||
})
|
})
|
||||||
.clone()
|
})
|
||||||
|
.as_ref()
|
||||||
|
.map(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -253,7 +282,12 @@ mod tests {
|
|||||||
fn scratch(name: &str) -> PathBuf {
|
fn scratch(name: &str) -> PathBuf {
|
||||||
let root =
|
let root =
|
||||||
std::env::temp_dir().join(format!("gpui-native-icons-{name}-{}", std::process::id()));
|
std::env::temp_dir().join(format!("gpui-native-icons-{name}-{}", std::process::id()));
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
// 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}");
|
||||||
|
}
|
||||||
std::fs::create_dir_all(&root).expect("scratch dir");
|
std::fs::create_dir_all(&root).expect("scratch dir");
|
||||||
root
|
root
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-45
@@ -1,10 +1,11 @@
|
|||||||
//! System icons for GPUI: components and apps reference semantic roles,
|
//! System icons for GPUI: components and apps reference semantic roles,
|
||||||
//! and the crate resolves each role to a glyph for the current platform.
|
//! 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::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
|
AnyElement, AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce,
|
||||||
Styled as _, Transformation, Window, div, px, svg,
|
SharedString, Styled as _, Transformation, Window, div, px, svg,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
@@ -36,6 +37,8 @@ pub enum IconRole {
|
|||||||
WindowMaximize,
|
WindowMaximize,
|
||||||
WindowRestore,
|
WindowRestore,
|
||||||
Loader,
|
Loader,
|
||||||
|
Microphone,
|
||||||
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IconRole {
|
impl IconRole {
|
||||||
@@ -62,6 +65,8 @@ impl IconRole {
|
|||||||
Self::WindowMaximize,
|
Self::WindowMaximize,
|
||||||
Self::WindowRestore,
|
Self::WindowRestore,
|
||||||
Self::Loader,
|
Self::Loader,
|
||||||
|
Self::Microphone,
|
||||||
|
Self::Stop,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +93,13 @@ impl IconRole {
|
|||||||
Self::WindowMaximize => "icons/square.svg",
|
Self::WindowMaximize => "icons/square.svg",
|
||||||
Self::WindowRestore => "icons/copy.svg",
|
Self::WindowRestore => "icons/copy.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
|
Self::Microphone => "icons/mic.svg",
|
||||||
|
Self::Stop => "icons/square.svg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slug(self) -> &'static str {
|
/// The stable identifier used for element and debug ids.
|
||||||
|
pub fn slug(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Check => "check",
|
Self::Check => "check",
|
||||||
Self::Close => "close",
|
Self::Close => "close",
|
||||||
@@ -114,6 +122,8 @@ impl IconRole {
|
|||||||
Self::WindowMaximize => "window-maximize",
|
Self::WindowMaximize => "window-maximize",
|
||||||
Self::WindowRestore => "window-restore",
|
Self::WindowRestore => "window-restore",
|
||||||
Self::Loader => "loader",
|
Self::Loader => "loader",
|
||||||
|
Self::Microphone => "microphone",
|
||||||
|
Self::Stop => "stop",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,30 +200,67 @@ impl IconRole {
|
|||||||
Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"],
|
Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"],
|
||||||
Self::WindowRestore => &["window-restore-symbolic", "window-restore"],
|
Self::WindowRestore => &["window-restore-symbolic", "window-restore"],
|
||||||
Self::Loader => &["process-working-symbolic", "process-working"],
|
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.
|
/// An icon element. Unpainted unless a color is set.
|
||||||
#[derive(IntoElement)]
|
#[derive(Clone, IntoElement)]
|
||||||
pub struct Icon {
|
pub struct Icon {
|
||||||
role: IconRole,
|
glyph: Glyph,
|
||||||
size: Pixels,
|
size: Pixels,
|
||||||
color: Option<Hsla>,
|
color: Option<Hsla>,
|
||||||
transformation: Option<Transformation>,
|
transformation: Option<Transformation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an icon for the role at the default size of 16px.
|
/// 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.
|
||||||
pub fn icon(role: IconRole) -> Icon {
|
pub fn icon(role: IconRole) -> Icon {
|
||||||
Icon {
|
Icon {
|
||||||
role,
|
glyph: Glyph::Role(role),
|
||||||
size: px(16.0),
|
size: DEFAULT_ICON_SIZE,
|
||||||
color: None,
|
color: None,
|
||||||
transformation: None,
|
transformation: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Icon {
|
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 {
|
pub fn size(mut self, size: Pixels) -> Self {
|
||||||
self.size = size;
|
self.size = size;
|
||||||
self
|
self
|
||||||
@@ -232,65 +279,97 @@ impl Icon {
|
|||||||
|
|
||||||
/// The asset key a resolved freedesktop file is served under.
|
/// The asset key a resolved freedesktop file is served under.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn freedesktop_asset_path(&self) -> Option<SharedString> {
|
fn freedesktop_asset_path(size: Pixels, role: &IconRole) -> Option<SharedString> {
|
||||||
let size = self.size.as_f32().max(1.0).round() as u32;
|
let size = size.as_f32().max(1.0).round() as u32;
|
||||||
freedesktop::cached_file(self.role, size).map(|_| {
|
freedesktop::cached_asset_key(*role, size)
|
||||||
SharedString::from(format!("icons/freedesktop/{}-{size}.svg", self.role.slug()))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for Icon {
|
/// Roles resolve per platform.
|
||||||
fn render(self, _window: &mut Window, _cx: &mut gpui::App) -> impl IntoElement {
|
fn render_role(
|
||||||
|
size: Pixels,
|
||||||
|
color: Option<Hsla>,
|
||||||
|
transformation: Option<Transformation>,
|
||||||
|
role: IconRole,
|
||||||
|
) -> AnyElement {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
return self.render_glyph();
|
if let Some(glyph) = segoe::glyph(role) {
|
||||||
#[cfg(not(target_os = "windows"))]
|
return Self::render_glyph(size, color, glyph).into_any_element();
|
||||||
return self.render_svg();
|
}
|
||||||
|
#[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()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Icon {
|
/// Windows: the Segoe Fluent glyph drawn as text in the system font.
|
||||||
/// 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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn render_glyph(self) -> impl IntoElement {
|
fn render_glyph(size: Pixels, color: Option<Hsla>, glyph: &'static str) -> AnyElement {
|
||||||
match segoe::glyph(self.role) {
|
div()
|
||||||
Some(glyph) => div()
|
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.size(self.size)
|
.size(size)
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.font_family(segoe::FONT_FAMILY)
|
.font_family(segoe::FONT_FAMILY)
|
||||||
.text_size(self.size)
|
.text_size(size)
|
||||||
.when_some(self.color, |el, color| el.text_color(color))
|
.when_some(color, |el, color| el.text_color(color))
|
||||||
.child(glyph.to_string()),
|
.child(SharedString::new_static(glyph)),
|
||||||
)
|
)
|
||||||
.into_any_element(),
|
.into_any_element()
|
||||||
None => self.render_svg().into_any_element(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_svg(self) -> impl IntoElement {
|
fn render_svg_path(
|
||||||
#[cfg(target_os = "linux")]
|
size: Pixels,
|
||||||
let path: SharedString = self
|
color: Option<Hsla>,
|
||||||
.freedesktop_asset_path()
|
transformation: Option<Transformation>,
|
||||||
.unwrap_or_else(|| SharedString::from(self.role.asset_path()));
|
path: SharedString,
|
||||||
|
) -> AnyElement {
|
||||||
#[cfg(not(target_os = "linux"))]
|
div()
|
||||||
let path: SharedString = SharedString::from(self.role.asset_path());
|
.flex_none()
|
||||||
|
.size(size)
|
||||||
div().flex_none().size(self.size).child(
|
.child(
|
||||||
svg()
|
svg()
|
||||||
.size_full()
|
.size_full()
|
||||||
.path(path)
|
.path(path)
|
||||||
.when_some(self.color, |el, color| el.text_color(color))
|
.when_some(color, |el, color| el.text_color(color))
|
||||||
.when_some(self.transformation, |el, transformation| {
|
.when_some(transformation, |el, transformation| {
|
||||||
el.with_transformation(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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +397,7 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> {
|
|||||||
"icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")),
|
"icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")),
|
||||||
"icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")),
|
"icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")),
|
||||||
"icons/loader.svg" => Some(include_bytes!("../assets/loader.svg")),
|
"icons/loader.svg" => Some(include_bytes!("../assets/loader.svg")),
|
||||||
|
"icons/mic.svg" => Some(include_bytes!("../assets/mic.svg")),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +438,19 @@ impl AssetSource for IconAssets {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn every_role_resolves_to_a_bundled_asset() {
|
fn every_role_resolves_to_a_bundled_asset() {
|
||||||
for role in IconRole::all() {
|
for role in IconRole::all() {
|
||||||
|
|||||||
+27
-26
@@ -3,34 +3,35 @@
|
|||||||
|
|
||||||
use crate::IconRole;
|
use crate::IconRole;
|
||||||
|
|
||||||
/// The Segoe Fluent Icons codepoint for the role.
|
/// The Segoe Fluent Icons glyph for the role.
|
||||||
pub(crate) fn glyph(role: IconRole) -> Option<char> {
|
pub(crate) fn glyph(role: IconRole) -> Option<&'static str> {
|
||||||
let codepoint = match role {
|
let glyph = match role {
|
||||||
// No Segoe spinner glyph; the icon falls back to the bundled SVG,
|
IconRole::Check => "\u{E73E}", // CheckMark
|
||||||
// which the spinner rotates.
|
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.
|
||||||
IconRole::Loader => return None,
|
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
|
|
||||||
};
|
};
|
||||||
char::from_u32(codepoint)
|
Some(glyph)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The system font the codepoints resolve against (Windows 11+).
|
/// 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 = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
gpui-native-utils = { workspace = true }
|
gpui-native-utils = { workspace = true }
|
||||||
gpui-native-core = { workspace = true, optional = true, features = ["button"] }
|
gpui-native-core = { workspace = true, optional = true }
|
||||||
gpui-native-data = { workspace = true, optional = true, features = ["sidebar"] }
|
gpui-native-data = { workspace = true, optional = true, features = ["sidebar"] }
|
||||||
gpui-native-icons = { workspace = true, optional = true }
|
gpui-native-icons = { workspace = true, optional = true }
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ shell = [
|
|||||||
"resize_handle",
|
"resize_handle",
|
||||||
"window_controls",
|
"window_controls",
|
||||||
"dep:gpui-native-core",
|
"dep:gpui-native-core",
|
||||||
|
"gpui-native-core/button",
|
||||||
"dep:gpui-native-data",
|
"dep:gpui-native-data",
|
||||||
"dep:gpui-native-icons",
|
"dep:gpui-native-icons",
|
||||||
]
|
]
|
||||||
|
|||||||
+55
-56
@@ -15,7 +15,7 @@ use gpui_native_core::common::{h_flex, v_flex};
|
|||||||
use gpui_native_core::context_menu::ContextMenu;
|
use gpui_native_core::context_menu::ContextMenu;
|
||||||
use gpui_native_core::menu::MenuItem;
|
use gpui_native_core::menu::MenuItem;
|
||||||
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
||||||
use gpui_native_icons::IconRole;
|
use gpui_native_icons::{Icon, IconRole};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
|
||||||
use crate::resize_handle::ResizeHandle;
|
use crate::resize_handle::ResizeHandle;
|
||||||
@@ -32,10 +32,11 @@ type PanelBuilder = Box<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
|||||||
type OpenRightSidebarHandler = Rc<dyn Fn(&mut App)>;
|
type OpenRightSidebarHandler = Rc<dyn Fn(&mut App)>;
|
||||||
|
|
||||||
/// A registered panel: its identity, chrome, and how its view is built.
|
/// A registered panel: its identity, chrome, and how its view is built.
|
||||||
|
#[must_use]
|
||||||
pub struct PanelDescriptor {
|
pub struct PanelDescriptor {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
title: SharedString,
|
title: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
build: PanelBuilder,
|
build: PanelBuilder,
|
||||||
right_sidebar: Option<PanelBuilder>,
|
right_sidebar: Option<PanelBuilder>,
|
||||||
}
|
}
|
||||||
@@ -58,8 +59,11 @@ impl PanelDescriptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shows an icon leading the tab label.
|
/// Shows an icon leading the tab label.
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon<I>(mut self, icon: I) -> Self
|
||||||
self.icon = Some(role);
|
where
|
||||||
|
I: Into<Icon>,
|
||||||
|
{
|
||||||
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +134,7 @@ impl Render for DragPreview {
|
|||||||
struct PanelTab {
|
struct PanelTab {
|
||||||
panel: SharedString,
|
panel: SharedString,
|
||||||
title: SharedString,
|
title: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
view: AnyView,
|
view: AnyView,
|
||||||
right_view: Option<AnyView>,
|
right_view: Option<AnyView>,
|
||||||
}
|
}
|
||||||
@@ -215,7 +219,7 @@ impl DockState {
|
|||||||
let tab = PanelTab {
|
let tab = PanelTab {
|
||||||
panel: SharedString::from(id),
|
panel: SharedString::from(id),
|
||||||
title: panel.title.clone(),
|
title: panel.title.clone(),
|
||||||
icon: panel.icon,
|
icon: panel.icon.clone(),
|
||||||
view: (panel.build)(window, cx),
|
view: (panel.build)(window, cx),
|
||||||
right_view,
|
right_view,
|
||||||
};
|
};
|
||||||
@@ -234,7 +238,7 @@ impl DockState {
|
|||||||
|
|
||||||
/// Points the right sidebar at the panel's own view and asks the app to open it.
|
/// 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>) {
|
fn show_right_view_of(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||||
let right_view = tab_in(&self.root, id).and_then(|tab| tab.right_view.clone());
|
let right_view = Self::tab_in(&self.root, id).and_then(|tab| tab.right_view.clone());
|
||||||
let Some(right_view) = right_view else {
|
let Some(right_view) = right_view else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -297,7 +301,7 @@ impl DockState {
|
|||||||
|
|
||||||
// The sidebar showed the closed panel's view.
|
// The sidebar showed the closed panel's view.
|
||||||
if self.right_view.as_ref() == closed_right_view.as_ref() {
|
if self.right_view.as_ref() == closed_right_view.as_ref() {
|
||||||
self.right_view = first_right_view(&self.root);
|
self.right_view = Self::first_right_view(&self.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if emptied {
|
if emptied {
|
||||||
@@ -338,7 +342,7 @@ impl DockState {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.and_then(|tab| tab.right_view.clone())
|
.and_then(|tab| tab.right_view.clone())
|
||||||
.or_else(|| first_right_view(&self.root));
|
.or_else(|| Self::first_right_view(&self.root));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,7 +373,7 @@ impl DockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The closed pane's views are gone; show the first remaining one.
|
// The closed pane's views are gone; show the first remaining one.
|
||||||
self.right_view = first_right_view(&self.root);
|
self.right_view = Self::first_right_view(&self.root);
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -492,7 +496,7 @@ impl DockState {
|
|||||||
|
|
||||||
/// The active tab of the pane new panels open into.
|
/// The active tab of the pane new panels open into.
|
||||||
pub fn active_panel(&self) -> Option<SharedString> {
|
pub fn active_panel(&self) -> Option<SharedString> {
|
||||||
let pane = pane_in(&self.root, self.target_pane_key())?;
|
let pane = Self::pane_in(&self.root, self.target_pane_key())?;
|
||||||
pane.active.clone()
|
pane.active.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +523,7 @@ impl DockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn target_pane_key(&self) -> u64 {
|
fn target_pane_key(&self) -> u64 {
|
||||||
if pane_in(&self.root, self.active_pane).is_some() {
|
if Self::pane_in(&self.root, self.active_pane).is_some() {
|
||||||
self.active_pane
|
self.active_pane
|
||||||
} else {
|
} else {
|
||||||
self.first_pane_key().unwrap_or(0)
|
self.first_pane_key().unwrap_or(0)
|
||||||
@@ -610,7 +614,7 @@ impl DockState {
|
|||||||
) {
|
) {
|
||||||
match node {
|
match node {
|
||||||
Node::Pane(pane) if pane.key == key => {
|
Node::Pane(pane) if pane.key == key => {
|
||||||
let old_pane = take_node(node);
|
let old_pane = Self::take_node(node);
|
||||||
let split = if direction.leads() {
|
let split = if direction.leads() {
|
||||||
Split {
|
Split {
|
||||||
key: split_key,
|
key: split_key,
|
||||||
@@ -632,9 +636,9 @@ impl DockState {
|
|||||||
}
|
}
|
||||||
Node::Pane(_) => {}
|
Node::Pane(_) => {}
|
||||||
Node::Split(split) => {
|
Node::Split(split) => {
|
||||||
if pane_in(&split.a, key).is_some() {
|
if Self::pane_in(&split.a, key).is_some() {
|
||||||
Self::wrap_in(&mut split.a, key, new_pane, direction, split_key);
|
Self::wrap_in(&mut split.a, key, new_pane, direction, split_key);
|
||||||
} else if pane_in(&split.b, key).is_some() {
|
} else if Self::pane_in(&split.b, key).is_some() {
|
||||||
Self::wrap_in(&mut split.b, key, new_pane, direction, split_key);
|
Self::wrap_in(&mut split.b, key, new_pane, direction, split_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,14 +655,14 @@ impl DockState {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let a_direct = direct_pane_key(&split.a) == Some(key);
|
let a_direct = Self::direct_pane_key(&split.a) == Some(key);
|
||||||
let b_direct = direct_pane_key(&split.b) == Some(key);
|
let b_direct = Self::direct_pane_key(&split.b) == Some(key);
|
||||||
|
|
||||||
if a_direct != b_direct {
|
if a_direct != b_direct {
|
||||||
let survivor = if a_direct {
|
let survivor = if a_direct {
|
||||||
take_node(&mut split.b)
|
Self::take_node(&mut split.b)
|
||||||
} else {
|
} else {
|
||||||
take_node(&mut split.a)
|
Self::take_node(&mut split.a)
|
||||||
};
|
};
|
||||||
*node = survivor;
|
*node = survivor;
|
||||||
} else {
|
} else {
|
||||||
@@ -679,7 +683,7 @@ impl DockState {
|
|||||||
.map(|tab| TabView {
|
.map(|tab| TabView {
|
||||||
panel: tab.panel.clone(),
|
panel: tab.panel.clone(),
|
||||||
title: tab.title.clone(),
|
title: tab.title.clone(),
|
||||||
icon: tab.icon,
|
icon: tab.icon.clone(),
|
||||||
view: tab.view.clone(),
|
view: tab.view.clone(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -696,42 +700,35 @@ impl DockState {
|
|||||||
}
|
}
|
||||||
snap(&self.root)
|
snap(&self.root)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds a pane by key anywhere in the tree.
|
/// Finds an open tab by panel id, anywhere in the tree.
|
||||||
fn pane_in(node: &Node, key: u64) -> Option<&Pane> {
|
fn tab_in<'a>(node: &'a Node, id: &str) -> Option<&'a PanelTab> {
|
||||||
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 {
|
match node {
|
||||||
Node::Pane(pane) => pane.tabs.iter().find(|tab| tab.panel.as_ref() == id),
|
Node::Pane(pane) => pane.tabs.iter().find(|tab| tab.panel.as_ref() == id),
|
||||||
Node::Split(split) => tab_in(&split.a, id).or_else(|| tab_in(&split.b, id)),
|
Node::Split(split) => Self::tab_in(&split.a, id).or_else(|| Self::tab_in(&split.b, id)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// The first open tab's right sidebar view, in display order.
|
/// The first open tab's right sidebar view, in display order.
|
||||||
fn first_right_view(node: &Node) -> Option<AnyView> {
|
fn first_right_view(node: &Node) -> Option<AnyView> {
|
||||||
match node {
|
match node {
|
||||||
Node::Pane(pane) => pane.tabs.iter().find_map(|tab| tab.right_view.clone()),
|
Node::Pane(pane) => pane.tabs.iter().find_map(|tab| tab.right_view.clone()),
|
||||||
Node::Split(split) => first_right_view(&split.a).or_else(|| first_right_view(&split.b)),
|
Node::Split(split) => {
|
||||||
|
Self::first_right_view(&split.a).or_else(|| Self::first_right_view(&split.b))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// The pane key of a node that is a pane, for seam-level tree edits.
|
/// The pane key of a node that is a pane, for seam-level tree edits.
|
||||||
fn direct_pane_key(node: &Node) -> Option<u64> {
|
fn direct_pane_key(node: &Node) -> Option<u64> {
|
||||||
match node {
|
match node {
|
||||||
Node::Pane(pane) => Some(pane.key),
|
Node::Pane(pane) => Some(pane.key),
|
||||||
Node::Split(_) => None,
|
Node::Split(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Takes a node out, leaving a placeholder that is never read again.
|
/// Takes a node out, leaving a placeholder that is never read again.
|
||||||
fn take_node(node: &mut Node) -> Node {
|
fn take_node(node: &mut Node) -> Node {
|
||||||
std::mem::replace(
|
std::mem::replace(
|
||||||
node,
|
node,
|
||||||
Node::Pane(Pane {
|
Node::Pane(Pane {
|
||||||
@@ -740,6 +737,7 @@ fn take_node(node: &mut Node) -> Node {
|
|||||||
active: None,
|
active: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One open panel view, per frame.
|
/// One open panel view, per frame.
|
||||||
@@ -747,7 +745,7 @@ fn take_node(node: &mut Node) -> Node {
|
|||||||
struct TabView {
|
struct TabView {
|
||||||
panel: SharedString,
|
panel: SharedString,
|
||||||
title: SharedString,
|
title: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<Icon>,
|
||||||
view: AnyView,
|
view: AnyView,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,6 +771,7 @@ enum TreeView {
|
|||||||
|
|
||||||
/// A dock: renders the pane tree of a [`DockState`].
|
/// A dock: renders the pane tree of a [`DockState`].
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Dock {
|
pub struct Dock {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
state: Entity<DockState>,
|
state: Entity<DockState>,
|
||||||
@@ -1035,14 +1034,18 @@ fn render_pane(
|
|||||||
.selected(selected)
|
.selected(selected)
|
||||||
.tabs({
|
.tabs({
|
||||||
let tabs = pane.tabs.clone();
|
let tabs = pane.tabs.clone();
|
||||||
move |_, _| {
|
move |_, cx| {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let metrics = theme.sizes.resolve(Size::default());
|
||||||
|
|
||||||
tabs.iter()
|
tabs.iter()
|
||||||
.map(|tab| {
|
.map(|tab| {
|
||||||
let mut entry = Tab::new(tab.panel.clone(), tab.title.clone());
|
Tab::new(tab.panel.clone(), tab.title.clone())
|
||||||
if let Some(role) = tab.icon {
|
.when_some(tab.icon.clone(), |entry, icon| {
|
||||||
entry = entry.icon(role);
|
entry.lead(icon.size(metrics.icon_size))
|
||||||
}
|
})
|
||||||
entry.closeable(true).wrap({
|
.closeable(true)
|
||||||
|
.wrap({
|
||||||
let panel = tab.panel.clone();
|
let panel = tab.panel.clone();
|
||||||
let title = tab.title.clone();
|
let title = tab.title.clone();
|
||||||
move |tab| {
|
move |tab| {
|
||||||
@@ -1088,6 +1091,7 @@ fn render_pane(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let bar_id = SharedString::from(format!("{dock_id}-pane-{}-bar", pane.key));
|
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()
|
h_flex()
|
||||||
.id(bar_id.clone())
|
.id(bar_id.clone())
|
||||||
@@ -1104,13 +1108,8 @@ fn render_pane(
|
|||||||
.pr(padding_x)
|
.pr(padding_x)
|
||||||
.child(tabs)
|
.child(tabs)
|
||||||
.child(titlebar_strip(
|
.child(titlebar_strip(
|
||||||
div()
|
div().id(strip_id.clone()).flex_1().self_stretch(),
|
||||||
.id(SharedString::from(format!(
|
strip_id,
|
||||||
"{dock_id}-pane-{}-strip",
|
|
||||||
pane.key
|
|
||||||
)))
|
|
||||||
.flex_1()
|
|
||||||
.self_stretch(),
|
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ use gpui::{
|
|||||||
InteractiveElement as _, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
|
InteractiveElement as _, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
|
||||||
StyleRefinement, Styled, Window, canvas, div, px,
|
StyleRefinement, Styled, Window, canvas, div, px,
|
||||||
};
|
};
|
||||||
|
use gpui_native_theme::{PANE_DEFAULT_SIZE, PANE_MIN_SIZE};
|
||||||
use gpui_native_utils::StyledExt as _;
|
use gpui_native_utils::StyledExt as _;
|
||||||
|
|
||||||
use crate::resize_handle::ResizeHandle;
|
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>;
|
type DebugSelector = std::rc::Rc<dyn Fn() -> String>;
|
||||||
|
|
||||||
/// The retained pane sizes of one resizable group.
|
/// The retained pane sizes of one resizable group.
|
||||||
@@ -56,11 +52,12 @@ impl ResizableState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The equal split of the measured extent.
|
/// The equal split of the measured extent, or the default pane size unmeasured.
|
||||||
fn equal_split(&self, pane_count: usize) -> Pixels {
|
fn equal_split(&self, pane_count: usize) -> Pixels {
|
||||||
match self.measured {
|
match self.measured {
|
||||||
Some(extent) => extent / pane_count as f32,
|
// usize can exceed f32's exact integer range; bound it via u32 before casting.
|
||||||
None => px(DEFAULT_PANE_SIZE),
|
Some(extent) => extent / u32::try_from(pane_count).unwrap_or(u32::MAX) as f32,
|
||||||
|
None => PANE_DEFAULT_SIZE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +90,11 @@ impl ResizableState {
|
|||||||
size = size.min(ceiling);
|
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 {
|
while self.sizes.len() <= ix {
|
||||||
self.sizes.push(px(DEFAULT_PANE_SIZE));
|
self.sizes.push(backfill);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sizes[ix] = 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>) {
|
fn reset_pane(&mut self, ix: usize, pane_count: usize, cx: &mut Context<Self>) {
|
||||||
let split = self.equal_split(pane_count);
|
let split = self.equal_split(pane_count);
|
||||||
while self.sizes.len() <= ix {
|
while self.sizes.len() <= ix {
|
||||||
self.sizes.push(px(DEFAULT_PANE_SIZE));
|
self.sizes.push(split);
|
||||||
}
|
}
|
||||||
self.sizes[ix] = split;
|
self.sizes[ix] = split;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -113,6 +113,7 @@ impl ResizableState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One pane of a resizable group: its content and its drag floor.
|
/// One pane of a resizable group: its content and its drag floor.
|
||||||
|
#[must_use]
|
||||||
pub struct ResizablePane {
|
pub struct ResizablePane {
|
||||||
content: AnyElement,
|
content: AnyElement,
|
||||||
min: Pixels,
|
min: Pixels,
|
||||||
@@ -120,7 +121,7 @@ pub struct ResizablePane {
|
|||||||
|
|
||||||
impl ResizablePane {
|
impl ResizablePane {
|
||||||
/// The floor a pane keeps while dragging unless the caller lowers it.
|
/// The floor a pane keeps while dragging unless the caller lowers it.
|
||||||
pub const DEFAULT_MIN: Pixels = px(DEFAULT_PANE_MIN);
|
pub const DEFAULT_MIN: Pixels = PANE_MIN_SIZE;
|
||||||
|
|
||||||
/// Wraps this element as a pane.
|
/// Wraps this element as a pane.
|
||||||
pub fn new<E>(content: E) -> Self
|
pub fn new<E>(content: E) -> Self
|
||||||
@@ -142,6 +143,7 @@ impl ResizablePane {
|
|||||||
|
|
||||||
/// A group of panes stacked along one axis, separated by drag dividers.
|
/// A group of panes stacked along one axis, separated by drag dividers.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Resizable {
|
pub struct Resizable {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
state: Entity<ResizableState>,
|
state: Entity<ResizableState>,
|
||||||
@@ -220,39 +222,37 @@ impl RenderOnce for Resizable {
|
|||||||
.map(|ix| state.read(cx).pane_size(ix, pane_count))
|
.map(|ix| state.read(cx).pane_size(ix, pane_count))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut group = div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.when(axis == Axis::Vertical, |group| group.flex_col())
|
.when(axis == Axis::Vertical, |group| group.flex_col())
|
||||||
.when(axis == Axis::Horizontal, |group| group.flex_row())
|
.when(axis == Axis::Horizontal, |group| group.flex_row())
|
||||||
.refine_style(&self.style);
|
.refine_style(&self.style)
|
||||||
|
.children(self.panes.into_iter().enumerate().flat_map(|(ix, pane)| {
|
||||||
for (ix, pane) in self.panes.into_iter().enumerate() {
|
|
||||||
let remainder = ix + 1 == pane_count;
|
let remainder = ix + 1 == pane_count;
|
||||||
let content = pane.content;
|
let pane_element = div()
|
||||||
let mut pane_element = if remainder {
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
.when(remainder, |pane_element| {
|
||||||
|
pane_element
|
||||||
|
.flex_1()
|
||||||
.when(axis == Axis::Vertical, |p| p.min_h(pane.min))
|
.when(axis == Axis::Vertical, |p| p.min_h(pane.min))
|
||||||
.when(axis == Axis::Horizontal, |p| p.min_w(pane.min))
|
.when(axis == Axis::Horizontal, |p| p.min_w(pane.min))
|
||||||
} else {
|
})
|
||||||
div()
|
.when(!remainder, |pane_element| {
|
||||||
|
pane_element
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.overflow_hidden()
|
|
||||||
.when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full())
|
.when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full())
|
||||||
.when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full())
|
.when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full())
|
||||||
};
|
})
|
||||||
|
.when_some(debug_selector.clone(), |pane_element, selector| {
|
||||||
if let Some(selector) = &debug_selector {
|
|
||||||
let prefix = selector();
|
let prefix = selector();
|
||||||
pane_element = pane_element.debug_selector(move || format!("{prefix}-{ix}"));
|
pane_element.debug_selector(move || format!("{prefix}-{ix}"))
|
||||||
}
|
})
|
||||||
|
.child(pane.content)
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
group = group.child(pane_element.child(content));
|
let divider = (!remainder).then(|| {
|
||||||
|
// The positional index is a stable id: panes are append-only, never reordered.
|
||||||
if !remainder {
|
|
||||||
let divider =
|
|
||||||
ResizeHandle::new(SharedString::from(format!("{}-divider-{ix}", self.id)))
|
ResizeHandle::new(SharedString::from(format!("{}-divider-{ix}", self.id)))
|
||||||
.axis(axis)
|
.axis(axis)
|
||||||
.on_resize({
|
.on_resize({
|
||||||
@@ -268,14 +268,14 @@ impl RenderOnce for Resizable {
|
|||||||
move |_, cx| {
|
move |_, cx| {
|
||||||
state.update(cx, |state, cx| state.reset_pane(ix, mins.len(), cx));
|
state.update(cx, |state, cx| state.reset_pane(ix, mins.len(), cx));
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
});
|
});
|
||||||
|
|
||||||
group = group.child(divider);
|
std::iter::once(pane_element).chain(divider)
|
||||||
}
|
}))
|
||||||
}
|
.when(pane_count > 0, |group| {
|
||||||
|
group.child(
|
||||||
if pane_count > 0 {
|
|
||||||
group = group.child(
|
|
||||||
canvas(
|
canvas(
|
||||||
{
|
{
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
@@ -291,9 +291,7 @@ impl RenderOnce for Resizable {
|
|||||||
)
|
)
|
||||||
.absolute()
|
.absolute()
|
||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
group
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use gpui::{
|
|||||||
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, deferred, div,
|
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, deferred, div,
|
||||||
fill, point, px,
|
fill, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, RESIZE_KEYBOARD_STEP, Size};
|
use gpui_native_theme::{
|
||||||
|
ActiveTheme as _, BORDER_WIDTH, HAIRLINE_WIDTH, RESIZE_KEYBOARD_STEP, Size,
|
||||||
|
};
|
||||||
use gpui_native_utils::{focus_handle, focus_ring};
|
use gpui_native_utils::{focus_handle, focus_ring};
|
||||||
|
|
||||||
/// How far one keyboard arrow step moves the seam.
|
/// How far one keyboard arrow step moves the seam.
|
||||||
@@ -28,12 +30,13 @@ struct DragState {
|
|||||||
|
|
||||||
/// A thin drag divider that resizes the adjacent region.
|
/// A thin drag divider that resizes the adjacent region.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct ResizeHandle {
|
pub struct ResizeHandle {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
/// The axis the drag moves along: a vertical seam drags horizontally.
|
/// The axis the drag moves along: a vertical seam drags horizontally.
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
a11y_label: Option<SharedString>,
|
accessibility_label: Option<SharedString>,
|
||||||
on_resize: Option<ResizeHandler>,
|
on_resize: Option<ResizeHandler>,
|
||||||
on_reset: Option<ResetHandler>,
|
on_reset: Option<ResetHandler>,
|
||||||
}
|
}
|
||||||
@@ -48,7 +51,7 @@ impl ResizeHandle {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
axis: Axis::Horizontal,
|
axis: Axis::Horizontal,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
a11y_label: None,
|
accessibility_label: None,
|
||||||
on_resize: None,
|
on_resize: None,
|
||||||
on_reset: None,
|
on_reset: None,
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,7 @@ impl ResizeHandle {
|
|||||||
where
|
where
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
{
|
{
|
||||||
self.a11y_label = Some(label.into());
|
self.accessibility_label = Some(label.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@ impl RenderOnce for ResizeHandle {
|
|||||||
let straddle = -thickness / 2.0;
|
let straddle = -thickness / 2.0;
|
||||||
|
|
||||||
let label = self
|
let label = self
|
||||||
.a11y_label
|
.accessibility_label
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| SharedString::from("Resize"));
|
.unwrap_or_else(|| SharedString::from("Resize"));
|
||||||
|
|
||||||
@@ -221,17 +224,17 @@ impl RenderOnce for ResizeHandle {
|
|||||||
|
|
||||||
let line = match axis {
|
let line = match axis {
|
||||||
Axis::Horizontal => {
|
Axis::Horizontal => {
|
||||||
let x = bounds.center().x - px(0.5);
|
let x = bounds.center().x - HAIRLINE_WIDTH;
|
||||||
Bounds::from_corners(
|
Bounds::from_corners(
|
||||||
point(x, bounds.top()),
|
point(x, bounds.top()),
|
||||||
point(x + px(1.), bounds.bottom()),
|
point(x + BORDER_WIDTH, bounds.bottom()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Axis::Vertical => {
|
Axis::Vertical => {
|
||||||
let y = bounds.center().y - px(0.5);
|
let y = bounds.center().y - HAIRLINE_WIDTH;
|
||||||
Bounds::from_corners(
|
Bounds::from_corners(
|
||||||
point(bounds.left(), y),
|
point(bounds.left(), y),
|
||||||
point(bounds.right(), y + px(1.)),
|
point(bounds.right(), y + BORDER_WIDTH),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+54
-41
@@ -14,19 +14,14 @@ use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
|
|||||||
use gpui_native_core::toast::{Toast, Toasts, ToastsView};
|
use gpui_native_core::toast::{Toast, Toasts, ToastsView};
|
||||||
use gpui_native_data::sidebar::Sidebar;
|
use gpui_native_data::sidebar::Sidebar;
|
||||||
use gpui_native_icons::IconRole;
|
use gpui_native_icons::IconRole;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{
|
||||||
|
ActiveTheme as _, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, Size,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::resize_handle::ResizeHandle;
|
use crate::resize_handle::ResizeHandle;
|
||||||
use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, titlebar_strip};
|
use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, titlebar_strip};
|
||||||
use crate::window_frame::frame;
|
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.
|
/// The layout the application owns.
|
||||||
pub struct LayoutState {
|
pub struct LayoutState {
|
||||||
left_visible: bool,
|
left_visible: bool,
|
||||||
@@ -42,15 +37,15 @@ pub struct LayoutState {
|
|||||||
impl LayoutState {
|
impl LayoutState {
|
||||||
/// Creates the state: left sidebar visible, right sidebar hidden.
|
/// Creates the state: left sidebar visible, right sidebar hidden.
|
||||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
cx.new(|cx| Self {
|
cx.new(|_cx| Self {
|
||||||
left_visible: true,
|
left_visible: true,
|
||||||
right_visible: false,
|
right_visible: false,
|
||||||
left_width: px(DEFAULT_SIDEBAR_WIDTH),
|
left_width: SIDEBAR_DEFAULT_WIDTH,
|
||||||
right_width: px(DEFAULT_SIDEBAR_WIDTH),
|
right_width: SIDEBAR_DEFAULT_WIDTH,
|
||||||
#[cfg(feature = "toast")]
|
#[cfg(feature = "toast")]
|
||||||
toasts: cx.new(|_| Toasts::default()),
|
toasts: _cx.new(|_| Toasts::default()),
|
||||||
#[cfg(feature = "alert")]
|
#[cfg(feature = "alert")]
|
||||||
alerts: cx.new(|_| Alerts::default()),
|
alerts: _cx.new(|_| Alerts::default()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,17 +87,21 @@ impl LayoutState {
|
|||||||
|
|
||||||
/// Applies a drag delta to the left sidebar's width, clamped.
|
/// Applies a drag delta to the left sidebar's width, clamped.
|
||||||
fn resize_left(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
fn resize_left(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
||||||
self.left_width =
|
let width = (self.left_width + delta).clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
||||||
(self.left_width + delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
|
if (self.left_width - width).abs() > px(f32::EPSILON) {
|
||||||
|
self.left_width = width;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Applies a drag delta to the right sidebar's width, clamped.
|
/// Applies a drag delta to the right sidebar's width, clamped.
|
||||||
fn resize_right(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
fn resize_right(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
||||||
self.right_width =
|
let width = (self.right_width - delta).clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
||||||
(self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
|
if (self.right_width - width).abs() > px(f32::EPSILON) {
|
||||||
|
self.right_width = width;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Raises a toast in the shell's overlay. Returns its id.
|
/// Raises a toast in the shell's overlay. Returns its id.
|
||||||
#[cfg(feature = "toast")]
|
#[cfg(feature = "toast")]
|
||||||
@@ -159,6 +158,7 @@ impl Side {
|
|||||||
|
|
||||||
/// The application shell.
|
/// The application shell.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[must_use]
|
||||||
pub struct Shell {
|
pub struct Shell {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
state: Entity<LayoutState>,
|
state: Entity<LayoutState>,
|
||||||
@@ -242,11 +242,13 @@ impl RenderOnce for Shell {
|
|||||||
left.take()
|
left.take()
|
||||||
.map(|sidebar| sidebar.into_any_element())
|
.map(|sidebar| sidebar.into_any_element())
|
||||||
.unwrap_or_else(|| div().into_any_element()),
|
.unwrap_or_else(|| div().into_any_element()),
|
||||||
Side::Left,
|
|
||||||
left_material,
|
|
||||||
&state,
|
&state,
|
||||||
titlebar_height,
|
Titlebar {
|
||||||
metrics.dense_padding_x,
|
side: Side::Left,
|
||||||
|
material: left_material,
|
||||||
|
height: titlebar_height,
|
||||||
|
padding_x: metrics.dense_padding_x,
|
||||||
|
},
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
@@ -279,11 +281,13 @@ impl RenderOnce for Shell {
|
|||||||
(id.clone(), "right"),
|
(id.clone(), "right"),
|
||||||
width,
|
width,
|
||||||
right.take().unwrap_or_else(|| div().into_any_element()),
|
right.take().unwrap_or_else(|| div().into_any_element()),
|
||||||
Side::Right,
|
|
||||||
false,
|
|
||||||
&state,
|
&state,
|
||||||
titlebar_height,
|
Titlebar {
|
||||||
metrics.dense_padding_x,
|
side: Side::Right,
|
||||||
|
material: false,
|
||||||
|
height: titlebar_height,
|
||||||
|
padding_x: metrics.dense_padding_x,
|
||||||
|
},
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
@@ -302,48 +306,57 @@ impl RenderOnce for Shell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One sidebar: a header strip with the hide toggle over the app's content.
|
/// Which titlebar duties a sidebar's header strip carries.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[derive(Clone, Copy)]
|
||||||
fn sidebar_region(
|
struct Titlebar {
|
||||||
id: impl Into<ElementId>,
|
|
||||||
width: Pixels,
|
|
||||||
content: AnyElement,
|
|
||||||
side: Side,
|
side: Side,
|
||||||
material: bool,
|
material: bool,
|
||||||
state: &Entity<LayoutState>,
|
height: Pixels,
|
||||||
strip_height: Pixels,
|
|
||||||
padding_x: Pixels,
|
padding_x: Pixels,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sidebar: a header strip with the hide toggle over the app's content.
|
||||||
|
fn sidebar_region<I>(
|
||||||
|
id: I,
|
||||||
|
width: Pixels,
|
||||||
|
content: AnyElement,
|
||||||
|
state: &Entity<LayoutState>,
|
||||||
|
titlebar: Titlebar,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> AnyElement
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
let id = id.into();
|
let id = id.into();
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let background = theme.colors.background;
|
let background = theme.colors.background;
|
||||||
let strip_background = match (side, material) {
|
let strip_background = match (titlebar.side, titlebar.material) {
|
||||||
(Side::Left, true) => theme.material.background,
|
(Side::Left, true) => theme.material.background,
|
||||||
_ => theme.colors.background,
|
_ => theme.colors.background,
|
||||||
};
|
};
|
||||||
|
|
||||||
let strip = h_flex()
|
let strip = h_flex()
|
||||||
.id((id.clone(), "strip"))
|
.id((id.clone(), "strip"))
|
||||||
.debug_selector(move || format!("sidebar-strip-{}", side.name()))
|
.debug_selector(move || format!("sidebar-strip-{}", titlebar.side.name()))
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.h(strip_height)
|
.h(titlebar.height)
|
||||||
.bg(strip_background)
|
.bg(strip_background)
|
||||||
.items_center()
|
.items_center()
|
||||||
.map(|strip| match side {
|
.map(|strip| match titlebar.side {
|
||||||
Side::Left => strip.when(cfg!(target_os = "macos"), |strip| {
|
Side::Left => strip.when(cfg!(target_os = "macos"), |strip| {
|
||||||
strip.pl(TRAFFIC_LIGHT_PAD)
|
strip.pl(TRAFFIC_LIGHT_PAD)
|
||||||
}),
|
}),
|
||||||
Side::Right => strip,
|
Side::Right => strip,
|
||||||
})
|
})
|
||||||
.px(padding_x)
|
.px(titlebar.padding_x)
|
||||||
.child(titlebar_strip(
|
.child(titlebar_strip(
|
||||||
div().id((id.clone(), "strip-drag")).flex_1().self_stretch(),
|
div().id((id.clone(), "strip-drag")).flex_1().self_stretch(),
|
||||||
|
(id.clone(), "strip-drag"),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
.map(|strip| match side {
|
.map(|strip| match titlebar.side {
|
||||||
Side::Left => strip.child(
|
Side::Left => strip.child(
|
||||||
Button::new((id, "sidebar-toggle"))
|
Button::new((id, "sidebar-toggle"))
|
||||||
.icon(IconRole::PanelLeft)
|
.icon(IconRole::PanelLeft)
|
||||||
@@ -370,7 +383,7 @@ fn sidebar_region(
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.when(!material, |content| content.bg(background))
|
.when(!titlebar.material, |content| content.bg(background))
|
||||||
.child(content),
|
.child(content),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use gpui::WindowControlArea;
|
|||||||
use gpui::{App, ClickEvent, Div, ElementId, IntoElement, RenderOnce, Stateful, Window, div};
|
use gpui::{App, ClickEvent, Div, ElementId, IntoElement, RenderOnce, Stateful, Window, div};
|
||||||
use gpui::{InteractiveElement as _, MouseButton, StatefulInteractiveElement as _};
|
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"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
@@ -19,13 +21,18 @@ use gpui_native_theme::ActiveTheme as _;
|
|||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use gpui::Decorations;
|
use gpui::Decorations;
|
||||||
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
|
use gpui::SharedString;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use gpui::{SharedString, px, rgba};
|
use gpui::px;
|
||||||
|
|
||||||
/// macOS traffic-lights zone: the pad a leading titlebar strip reserves.
|
/// macOS traffic-lights zone: the pad a leading titlebar strip reserves.
|
||||||
pub use gpui_native_theme::TRAFFIC_LIGHT_PAD;
|
pub use gpui_native_theme::TRAFFIC_LIGHT_PAD;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use gpui_native_theme::{WINDOWS_CAPTION_WIDTH as CAPTION_WIDTH, WINDOWS_CLOSE_HOVER_HEX};
|
use gpui_native_theme::{
|
||||||
|
WINDOWS_CAPTION_WIDTH as CAPTION_WIDTH, WINDOWS_CLOSE_FOREGROUND, WINDOWS_CLOSE_HOVER,
|
||||||
|
WINDOWS_CLOSE_PRESSED,
|
||||||
|
};
|
||||||
|
|
||||||
/// The window controls for the trailing end of a titlebar strip.
|
/// The window controls for the trailing end of a titlebar strip.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -49,6 +56,7 @@ impl RenderOnce for WindowControls {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let controls = {
|
let controls = {
|
||||||
|
// macOS draws native traffic lights in the shell's reserved pad; nothing to render.
|
||||||
let _ = (id, window, cx);
|
let _ = (id, window, cx);
|
||||||
div()
|
div()
|
||||||
};
|
};
|
||||||
@@ -97,7 +105,6 @@ impl RenderOnce for WindowControls {
|
|||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let controls = {
|
let controls = {
|
||||||
let _ = id;
|
|
||||||
let controls = window.window_controls();
|
let controls = window.window_controls();
|
||||||
let client_decorated =
|
let client_decorated =
|
||||||
matches!(window.window_decorations(), Decorations::Client { .. });
|
matches!(window.window_decorations(), Decorations::Client { .. });
|
||||||
@@ -111,7 +118,7 @@ impl RenderOnce for WindowControls {
|
|||||||
.when(client_decorated && controls.minimize, |row| {
|
.when(client_decorated && controls.minimize, |row| {
|
||||||
row.child(circle_button(
|
row.child(circle_button(
|
||||||
cx,
|
cx,
|
||||||
"window-minimize",
|
SharedString::from(format!("{id}-minimize")),
|
||||||
IconRole::WindowMinimize,
|
IconRole::WindowMinimize,
|
||||||
window.is_minimizable(),
|
window.is_minimizable(),
|
||||||
|window, _cx| window.minimize_window(),
|
|window, _cx| window.minimize_window(),
|
||||||
@@ -120,7 +127,7 @@ impl RenderOnce for WindowControls {
|
|||||||
.when(client_decorated && controls.maximize, |row| {
|
.when(client_decorated && controls.maximize, |row| {
|
||||||
row.child(circle_button(
|
row.child(circle_button(
|
||||||
cx,
|
cx,
|
||||||
"window-maximize",
|
SharedString::from(format!("{id}-maximize")),
|
||||||
if window.is_maximized() {
|
if window.is_maximized() {
|
||||||
IconRole::WindowRestore
|
IconRole::WindowRestore
|
||||||
} else {
|
} else {
|
||||||
@@ -133,7 +140,7 @@ impl RenderOnce for WindowControls {
|
|||||||
.when(client_decorated, |row| {
|
.when(client_decorated, |row| {
|
||||||
row.child(circle_button(
|
row.child(circle_button(
|
||||||
cx,
|
cx,
|
||||||
"window-close",
|
SharedString::from(format!("{id}-close")),
|
||||||
IconRole::Close,
|
IconRole::Close,
|
||||||
true,
|
true,
|
||||||
|window, _cx| window.remove_window(),
|
|window, _cx| window.remove_window(),
|
||||||
@@ -141,6 +148,12 @@ impl RenderOnce for WindowControls {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||||
|
let controls = {
|
||||||
|
let _ = (id, window, cx);
|
||||||
|
div()
|
||||||
|
};
|
||||||
|
|
||||||
controls
|
controls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,16 +168,30 @@ fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) + '
|
|||||||
window.titlebar_double_click();
|
window.titlebar_double_click();
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
window.zoom_window();
|
window.zoom_window();
|
||||||
|
// Windows handles titlebar double-click natively via WindowControlArea::Drag.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let _ = (event, window);
|
let _ = (event, window);
|
||||||
}
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
let _ = (event, window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Makes an element a titlebar strip.
|
/// Makes an element a titlebar strip; `id` keys its drag state, which
|
||||||
pub fn titlebar_strip(this: Stateful<Div>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
|
/// `use_state` would otherwise share among every strip in the window.
|
||||||
let should_move = window.use_state(cx, |_, _| false);
|
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);
|
||||||
this.window_control_area(WindowControlArea::Drag)
|
this.window_control_area(WindowControlArea::Drag)
|
||||||
.on_mouse_down_out(window.listener_for(&should_move, |should_move, _, _, _| {
|
.on_mouse_down_out(window.listener_for(&should_move, |should_move, _, _, _| {
|
||||||
*should_move = false;
|
*should_move = false;
|
||||||
@@ -208,7 +235,7 @@ fn caption_button(
|
|||||||
let glyph_size = theme.sizes.small.caption_size;
|
let glyph_size = theme.sizes.small.caption_size;
|
||||||
|
|
||||||
let hover = if close {
|
let hover = if close {
|
||||||
rgba(WINDOWS_CLOSE_HOVER_HEX)
|
WINDOWS_CLOSE_HOVER
|
||||||
} else {
|
} else {
|
||||||
theme.colors.muted_hover
|
theme.colors.muted_hover
|
||||||
};
|
};
|
||||||
@@ -236,7 +263,7 @@ fn caption_button(
|
|||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.hover(|hovered| {
|
.hover(|hovered| {
|
||||||
hovered.bg(hover).text_color(if close {
|
hovered.bg(hover).text_color(if close {
|
||||||
gpui::white()
|
WINDOWS_CLOSE_FOREGROUND
|
||||||
} else {
|
} else {
|
||||||
theme.colors.foreground
|
theme.colors.foreground
|
||||||
})
|
})
|
||||||
@@ -244,7 +271,7 @@ fn caption_button(
|
|||||||
.active(|pressed| {
|
.active(|pressed| {
|
||||||
pressed
|
pressed
|
||||||
.bg(if close {
|
.bg(if close {
|
||||||
hover.opacity(0.8)
|
WINDOWS_CLOSE_PRESSED
|
||||||
} else {
|
} else {
|
||||||
theme.ramps.secondary.pressed
|
theme.ramps.secondary.pressed
|
||||||
})
|
})
|
||||||
@@ -257,14 +284,9 @@ fn caption_button(
|
|||||||
|
|
||||||
/// Adwaita's headerbar control: a small circle around a symbolic glyph.
|
/// Adwaita's headerbar control: a small circle around a symbolic glyph.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn circle_button<F>(
|
fn circle_button<I, F>(cx: &App, id: I, role: IconRole, enabled: bool, action: F) -> AnyElement
|
||||||
cx: &App,
|
|
||||||
id: &'static str,
|
|
||||||
role: IconRole,
|
|
||||||
enabled: bool,
|
|
||||||
action: F,
|
|
||||||
) -> AnyElement
|
|
||||||
where
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
F: Fn(&mut Window, &mut App) + 'static,
|
F: Fn(&mut Window, &mut App) + 'static,
|
||||||
{
|
{
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ use gpui::{
|
|||||||
IntoElement, MouseButton, ParentElement as _, Pixels, Point, ResizeEdge, Size, Styled as _,
|
IntoElement, MouseButton, ParentElement as _, Pixels, Point, ResizeEdge, Size, Styled as _,
|
||||||
Tiling, Window, canvas, div, point, px, size,
|
Tiling, Window, canvas, div, point, px, size,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, WINDOW_RESIZE_INSET};
|
use gpui_native_theme::{
|
||||||
|
ActiveTheme as _, BORDER_WIDTH, INACTIVE_SHADOW_OPACITY, 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.
|
/// Wraps a window's root in the frame it owes.
|
||||||
pub fn frame(child: impl IntoElement, window: &mut Window, cx: &App) -> Div {
|
pub fn frame<E>(child: E, window: &mut Window, cx: &App) -> Div
|
||||||
|
where
|
||||||
|
E: IntoElement,
|
||||||
|
{
|
||||||
let Decorations::Client { tiling } = window.window_decorations() else {
|
let Decorations::Client { tiling } = window.window_decorations() else {
|
||||||
return div().size_full().child(child);
|
return div().size_full().child(child);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "dock")]
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, MouseButton,
|
AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, MouseButton,
|
||||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _,
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "resizable")]
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent,
|
AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent,
|
||||||
MouseMoveEvent, ParentElement as _, Pixels, Render, Styled as _, Subscription, TestAppContext,
|
MouseMoveEvent, ParentElement as _, Pixels, Render, Styled as _, Subscription, TestAppContext,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "resize_handle")]
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "shell")]
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Entity, InteractiveElement as _, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent,
|
Entity, InteractiveElement as _, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||||
MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, TestAppContext,
|
MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, TestAppContext,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! The titlebar duties: traffic-light pads and window controls, asserted per
|
//! The titlebar duties: traffic-light pads and window controls, asserted per
|
||||||
//! platform — macOS draws only pads, Windows/Linux only draw their controls.
|
//! platform — macOS draws only pads, Windows/Linux only draw their controls.
|
||||||
|
|
||||||
|
#![cfg(feature = "dock")]
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Entity, IntoElement, ParentElement as _, Pixels, Render, Styled as _,
|
AppContext as _, Entity, IntoElement, ParentElement as _, Pixels, Render, Styled as _,
|
||||||
TestAppContext, VisualTestContext, Window, div, px,
|
TestAppContext, VisualTestContext, Window, div, px,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ description = "Native theme engine: reads the OS theme and resolves it into sema
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
|
log = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
||||||
|
|||||||
@@ -9,37 +9,37 @@ use crate::ThemeVariant;
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
|
||||||
pub struct ThemeDefinition {
|
pub struct ThemeDefinition {
|
||||||
pub mode: Option<ThemeVariant>,
|
mode: Option<ThemeVariant>,
|
||||||
pub control_radius: Option<f32>,
|
control_radius: Option<f32>,
|
||||||
pub overlay_radius: Option<f32>,
|
overlay_radius: Option<f32>,
|
||||||
pub sm_radius: Option<f32>,
|
sm_radius: Option<f32>,
|
||||||
pub accent: Option<String>,
|
accent: Option<String>,
|
||||||
pub accent_foreground: Option<String>,
|
accent_foreground: Option<String>,
|
||||||
pub background: Option<String>,
|
background: Option<String>,
|
||||||
pub foreground: Option<String>,
|
foreground: Option<String>,
|
||||||
pub surface: Option<String>,
|
surface: Option<String>,
|
||||||
pub surface_foreground: Option<String>,
|
surface_foreground: Option<String>,
|
||||||
pub primary: Option<String>,
|
primary: Option<String>,
|
||||||
pub primary_foreground: Option<String>,
|
primary_foreground: Option<String>,
|
||||||
pub secondary: Option<String>,
|
secondary: Option<String>,
|
||||||
pub secondary_foreground: Option<String>,
|
secondary_foreground: Option<String>,
|
||||||
pub muted: Option<String>,
|
muted: Option<String>,
|
||||||
pub muted_hover: Option<String>,
|
muted_hover: Option<String>,
|
||||||
pub muted_foreground: Option<String>,
|
muted_foreground: Option<String>,
|
||||||
pub destructive: Option<String>,
|
destructive: Option<String>,
|
||||||
pub destructive_foreground: Option<String>,
|
destructive_foreground: Option<String>,
|
||||||
pub success: Option<String>,
|
success: Option<String>,
|
||||||
pub success_foreground: Option<String>,
|
success_foreground: Option<String>,
|
||||||
pub warning: Option<String>,
|
warning: Option<String>,
|
||||||
pub warning_foreground: Option<String>,
|
warning_foreground: Option<String>,
|
||||||
pub info: Option<String>,
|
info: Option<String>,
|
||||||
pub info_foreground: Option<String>,
|
info_foreground: Option<String>,
|
||||||
pub border: Option<String>,
|
border: Option<String>,
|
||||||
pub input: Option<String>,
|
input: Option<String>,
|
||||||
pub ring: Option<String>,
|
ring: Option<String>,
|
||||||
pub selection: Option<String>,
|
selection: Option<String>,
|
||||||
pub overlay: Option<String>,
|
overlay: Option<String>,
|
||||||
pub scrollbar_thumb: Option<String>,
|
scrollbar_thumb: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThemeDefinition {
|
impl ThemeDefinition {
|
||||||
@@ -47,112 +47,140 @@ impl ThemeDefinition {
|
|||||||
Ok(toml::from_str(source)?)
|
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> {
|
pub fn accent(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.accent.as_deref()?)
|
self.color(&self.accent, "accent")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accent_foreground(&self) -> Option<Hsla> {
|
pub fn accent_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.accent_foreground.as_deref()?)
|
self.color(&self.accent_foreground, "accent_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn background(&self) -> Option<Hsla> {
|
pub fn background(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.background.as_deref()?)
|
self.color(&self.background, "background")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn foreground(&self) -> Option<Hsla> {
|
pub fn foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.foreground.as_deref()?)
|
self.color(&self.foreground, "foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn surface(&self) -> Option<Hsla> {
|
pub fn surface(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.surface.as_deref()?)
|
self.color(&self.surface, "surface")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn surface_foreground(&self) -> Option<Hsla> {
|
pub fn surface_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.surface_foreground.as_deref()?)
|
self.color(&self.surface_foreground, "surface_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn primary(&self) -> Option<Hsla> {
|
pub fn primary(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.primary.as_deref()?)
|
self.color(&self.primary, "primary")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn primary_foreground(&self) -> Option<Hsla> {
|
pub fn primary_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.primary_foreground.as_deref()?)
|
self.color(&self.primary_foreground, "primary_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secondary(&self) -> Option<Hsla> {
|
pub fn secondary(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.secondary.as_deref()?)
|
self.color(&self.secondary, "secondary")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secondary_foreground(&self) -> Option<Hsla> {
|
pub fn secondary_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.secondary_foreground.as_deref()?)
|
self.color(&self.secondary_foreground, "secondary_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn muted(&self) -> Option<Hsla> {
|
pub fn muted(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.muted.as_deref()?)
|
self.color(&self.muted, "muted")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn muted_hover(&self) -> Option<Hsla> {
|
pub fn muted_hover(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.muted_hover.as_deref()?)
|
self.color(&self.muted_hover, "muted_hover")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn muted_foreground(&self) -> Option<Hsla> {
|
pub fn muted_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.muted_foreground.as_deref()?)
|
self.color(&self.muted_foreground, "muted_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn destructive(&self) -> Option<Hsla> {
|
pub fn destructive(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.destructive.as_deref()?)
|
self.color(&self.destructive, "destructive")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn destructive_foreground(&self) -> Option<Hsla> {
|
pub fn destructive_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.destructive_foreground.as_deref()?)
|
self.color(&self.destructive_foreground, "destructive_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn success(&self) -> Option<Hsla> {
|
pub fn success(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.success.as_deref()?)
|
self.color(&self.success, "success")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn success_foreground(&self) -> Option<Hsla> {
|
pub fn success_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.success_foreground.as_deref()?)
|
self.color(&self.success_foreground, "success_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn warning(&self) -> Option<Hsla> {
|
pub fn warning(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.warning.as_deref()?)
|
self.color(&self.warning, "warning")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn warning_foreground(&self) -> Option<Hsla> {
|
pub fn warning_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.warning_foreground.as_deref()?)
|
self.color(&self.warning_foreground, "warning_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn info(&self) -> Option<Hsla> {
|
pub fn info(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.info.as_deref()?)
|
self.color(&self.info, "info")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn info_foreground(&self) -> Option<Hsla> {
|
pub fn info_foreground(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.info_foreground.as_deref()?)
|
self.color(&self.info_foreground, "info_foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn border(&self) -> Option<Hsla> {
|
pub fn border(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.border.as_deref()?)
|
self.color(&self.border, "border")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn input(&self) -> Option<Hsla> {
|
pub fn input(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.input.as_deref()?)
|
self.color(&self.input, "input")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ring(&self) -> Option<Hsla> {
|
pub fn ring(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.ring.as_deref()?)
|
self.color(&self.ring, "ring")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selection(&self) -> Option<Hsla> {
|
pub fn selection(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.selection.as_deref()?)
|
self.color(&self.selection, "selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn overlay(&self) -> Option<Hsla> {
|
pub fn overlay(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.overlay.as_deref()?)
|
self.color(&self.overlay, "overlay")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scrollbar_thumb(&self) -> Option<Hsla> {
|
pub fn scrollbar_thumb(&self) -> Option<Hsla> {
|
||||||
parse_hex(self.scrollbar_thumb.as_deref()?)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,12 +254,12 @@ mod tests {
|
|||||||
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\noverlay_radius = 15.0\nsm_radius = 6.0\n",
|
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\noverlay_radius = 15.0\nsm_radius = 6.0\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(def.mode, Some(crate::ThemeVariant::Dark));
|
assert_eq!(def.mode(), Some(crate::ThemeVariant::Dark));
|
||||||
assert!(def.accent().is_some());
|
assert!(def.accent().is_some());
|
||||||
assert!(def.info().is_some());
|
assert!(def.info().is_some());
|
||||||
assert!(def.foreground().is_none());
|
assert!(def.foreground().is_none());
|
||||||
assert_eq!(def.overlay_radius, Some(15.0));
|
assert_eq!(def.overlay_radius(), Some(15.0));
|
||||||
assert_eq!(def.sm_radius, Some(6.0));
|
assert_eq!(def.sm_radius(), Some(6.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+119
-43
@@ -20,7 +20,9 @@ pub use ramps::{AccentShades, Oklch};
|
|||||||
pub use resolve::{Ramps, ResolvedTheme};
|
pub use resolve::{Ramps, ResolvedTheme};
|
||||||
pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
||||||
|
|
||||||
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, WindowBackgroundAppearance, px};
|
use gpui::{
|
||||||
|
App, Global, Hsla, Pixels, SpringConfig, WindowAppearance, WindowBackgroundAppearance, hsla, px,
|
||||||
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// The hairline width of the control border every button and field paints.
|
/// The hairline width of the control border every button and field paints.
|
||||||
@@ -46,7 +48,51 @@ pub const TRAFFIC_LIGHT_PAD: Pixels = px(78.0);
|
|||||||
/// The width of a Windows caption button.
|
/// The width of a Windows caption button.
|
||||||
pub const WINDOWS_CAPTION_WIDTH: Pixels = px(36.0);
|
pub const WINDOWS_CAPTION_WIDTH: Pixels = px(36.0);
|
||||||
/// Windows paints its caption close-button hover red in every theme.
|
/// Windows paints its caption close-button hover red in every theme.
|
||||||
pub const WINDOWS_CLOSE_HOVER_HEX: u32 = 0xE81120FF;
|
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.);
|
||||||
/// The monospace advance estimate as a share of the text size.
|
/// The monospace advance estimate as a share of the text size.
|
||||||
pub const MONO_ADVANCE_RATIO: f32 = 0.62;
|
pub const MONO_ADVANCE_RATIO: f32 = 0.62;
|
||||||
/// How much a pixel avatar's tint washes over its background.
|
/// How much a pixel avatar's tint washes over its background.
|
||||||
@@ -73,6 +119,15 @@ impl ThemeVariant {
|
|||||||
WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
|
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.
|
/// The resolved theme, installed as the app-wide global.
|
||||||
@@ -424,7 +479,7 @@ impl SizeTable {
|
|||||||
padding: 0.75,
|
padding: 0.75,
|
||||||
text: 0.85,
|
text: 0.85,
|
||||||
icon: 0.875,
|
icon: 0.875,
|
||||||
avatar: 1.5,
|
avatar: 1.125,
|
||||||
strip: 1.5,
|
strip: 1.5,
|
||||||
inset: 0.125,
|
inset: 0.125,
|
||||||
floor_icon: false,
|
floor_icon: false,
|
||||||
@@ -435,7 +490,7 @@ impl SizeTable {
|
|||||||
padding: 1.0,
|
padding: 1.0,
|
||||||
text: 1.0,
|
text: 1.0,
|
||||||
icon: 1.0,
|
icon: 1.0,
|
||||||
avatar: 2.0,
|
avatar: 1.5,
|
||||||
strip: 2.0,
|
strip: 2.0,
|
||||||
inset: 0.25,
|
inset: 0.25,
|
||||||
floor_icon: true,
|
floor_icon: true,
|
||||||
@@ -446,7 +501,7 @@ impl SizeTable {
|
|||||||
padding: 1.25,
|
padding: 1.25,
|
||||||
text: 1.15,
|
text: 1.15,
|
||||||
icon: 1.25,
|
icon: 1.25,
|
||||||
avatar: 3.0,
|
avatar: 2.0,
|
||||||
strip: 3.0,
|
strip: 3.0,
|
||||||
inset: 0.375,
|
inset: 0.375,
|
||||||
floor_icon: true,
|
floor_icon: true,
|
||||||
@@ -587,21 +642,23 @@ impl Theme {
|
|||||||
reapply(cx);
|
reapply(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolves when a window's appearance differs from the resolved variant.
|
/// Re-resolves when no override is set and a window's appearance differs
|
||||||
|
/// from the resolved variant; an override pins the variant instead.
|
||||||
///
|
///
|
||||||
/// Wire to `Window::observe_window_appearance`.
|
/// Wire to `Window::observe_window_appearance`.
|
||||||
pub fn sync_from_window(window: &mut gpui::Window, cx: &mut App) {
|
pub fn sync_from_window(window: &mut gpui::Window, cx: &mut App) {
|
||||||
let variant = ThemeVariant::from_appearance(window.appearance());
|
let window_variant = ThemeVariant::from_appearance(window.appearance());
|
||||||
if cx.global::<Theme>().variant != variant
|
let theme = cx.global::<Theme>();
|
||||||
|| cx.global::<ThemeEngine>().mode_override.is_none()
|
let engine = cx.global::<ThemeEngine>();
|
||||||
{
|
if engine.mode_override.is_none() && theme.variant != window_variant {
|
||||||
reapply(cx);
|
reapply(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sizes the window's rem to the theme's root font size.
|
/// Sizes the window's rem to the theme's root font size.
|
||||||
pub fn sync_window_rem_size(window: &mut gpui::Window, cx: &App) {
|
pub fn sync_window_rem_size(window: &mut gpui::Window, cx: &App) {
|
||||||
window.set_rem_size(cx.global::<Theme>().root_font_size);
|
let theme = cx.global::<Theme>();
|
||||||
|
window.set_rem_size(theme.root_font_size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,20 +674,17 @@ impl Global for ThemeEngine {}
|
|||||||
/// Reads the system, resolves through the current knobs,
|
/// Reads the system, resolves through the current knobs,
|
||||||
/// installs the theme global, and repaints every window.
|
/// installs the theme global, and repaints every window.
|
||||||
pub(crate) fn reapply(cx: &mut App) {
|
pub(crate) fn reapply(cx: &mut App) {
|
||||||
let (system_appearance, preset, mode_override) = {
|
let (preset, mode_override, system) = {
|
||||||
let engine = cx.global::<ThemeEngine>();
|
let engine = cx.global::<ThemeEngine>();
|
||||||
(
|
let system_appearance = ThemeVariant::from_appearance(cx.window_appearance());
|
||||||
ThemeVariant::from_appearance(cx.window_appearance()),
|
let preset = engine.preset.as_ref().map(|(_, def)| def.clone());
|
||||||
engine.preset.as_ref().map(|(_, def)| def.clone()),
|
let mode_override = engine.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))
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
let resolved = resolve::resolve(&system, preset.as_ref(), mode_override);
|
||||||
|
|
||||||
cx.set_reduce_motion(resolved.accessibility.reduce_motion);
|
cx.set_reduce_motion(resolved.accessibility.reduce_motion);
|
||||||
@@ -640,41 +694,39 @@ pub(crate) fn reapply(cx: &mut App) {
|
|||||||
|
|
||||||
/// Forces a variant, independent of the OS. `None` follows the system.
|
/// Forces a variant, independent of the OS. `None` follows the system.
|
||||||
pub fn set_mode_override(variant: Option<ThemeVariant>, cx: &mut App) {
|
pub fn set_mode_override(variant: Option<ThemeVariant>, cx: &mut App) {
|
||||||
cx.global_mut::<ThemeEngine>().mode_override = variant;
|
let engine = cx.global_mut::<ThemeEngine>();
|
||||||
|
engine.mode_override = variant;
|
||||||
reapply(cx);
|
reapply(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs a built-in preset, or `None` to follow the system again.
|
/// Installs a built-in preset, or `None` to follow the system again.
|
||||||
pub fn set_preset(name: Option<&str>, cx: &mut App) -> anyhow::Result<()> {
|
pub fn set_preset(name: Option<&str>, cx: &mut App) -> anyhow::Result<()> {
|
||||||
let preset = match name {
|
let preset = name
|
||||||
Some(name) => {
|
.map(|name| {
|
||||||
let def =
|
presets::builtin(name).ok_or_else(|| {
|
||||||
presets::builtin(name).ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?;
|
anyhow::anyhow!(
|
||||||
let static_name = presets::preset_names()
|
"unknown preset: {name}; available: {}",
|
||||||
.iter()
|
presets::preset_names().collect::<Vec<_>>().join(", ")
|
||||||
.copied()
|
)
|
||||||
.find(|candidate| *candidate == name)
|
})
|
||||||
.ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?;
|
})
|
||||||
Some((static_name, def))
|
.transpose()?;
|
||||||
}
|
let engine = cx.global_mut::<ThemeEngine>();
|
||||||
None => None,
|
engine.preset = preset;
|
||||||
};
|
|
||||||
cx.global_mut::<ThemeEngine>().preset = preset;
|
|
||||||
reapply(cx);
|
reapply(cx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The variant the mode override forces, if any.
|
/// The variant the mode override forces, if any.
|
||||||
pub fn mode_override(cx: &App) -> Option<ThemeVariant> {
|
pub fn mode_override(cx: &App) -> Option<ThemeVariant> {
|
||||||
cx.global::<ThemeEngine>().mode_override
|
let engine = cx.global::<ThemeEngine>();
|
||||||
|
engine.mode_override
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The active preset name, if one is installed.
|
/// The active preset name, if one is installed.
|
||||||
pub fn active_preset(cx: &App) -> Option<&'static str> {
|
pub fn active_preset(cx: &App) -> Option<&'static str> {
|
||||||
cx.global::<ThemeEngine>()
|
let engine = cx.global::<ThemeEngine>();
|
||||||
.preset
|
engine.preset.as_ref().map(|(name, _)| *name)
|
||||||
.as_ref()
|
|
||||||
.map(|(name, _)| *name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the mode override and preset; the theme follows the system again.
|
/// Clears the mode override and preset; the theme follows the system again.
|
||||||
@@ -687,5 +739,29 @@ pub fn follow_system(cx: &mut App) {
|
|||||||
|
|
||||||
/// Which adapter produced the current system snapshot.
|
/// Which adapter produced the current system snapshot.
|
||||||
pub fn detected_source(cx: &App) -> &'static str {
|
pub fn detected_source(cx: &App) -> &'static str {
|
||||||
cx.global::<ThemeEngine>().source.label()
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -2,22 +2,39 @@
|
|||||||
|
|
||||||
use crate::definition::ThemeDefinition;
|
use crate::definition::ThemeDefinition;
|
||||||
|
|
||||||
const TOKYO_NIGHT: &str = include_str!("../presets/tokyo-night.toml");
|
/// 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"))];
|
||||||
|
|
||||||
pub fn preset_names() -> &'static [&'static str] {
|
/// Every bundled preset's name.
|
||||||
&["tokyo-night"]
|
pub fn preset_names() -> impl Iterator<Item = &'static str> {
|
||||||
|
PRESETS.iter().map(|(name, _)| *name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the named preset.
|
/// Parses the named preset, returning its static name and definition.
|
||||||
pub fn builtin(name: &str) -> Option<ThemeDefinition> {
|
pub fn builtin(name: &str) -> Option<(&'static str, ThemeDefinition)> {
|
||||||
match name {
|
let (name, source) = PRESETS
|
||||||
"tokyo-night" => match ThemeDefinition::parse_toml(TOKYO_NIGHT) {
|
.iter()
|
||||||
Ok(def) => Some(def),
|
.find(|(candidate, _)| *candidate == name)
|
||||||
Err(err) => {
|
.copied()?;
|
||||||
eprintln!("bundled preset tokyo-night failed to parse: {err}");
|
match ThemeDefinition::parse_toml(source) {
|
||||||
|
Ok(definition) => Some((name, definition)),
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("bundled preset {name} failed to parse: {error}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
_ => 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ pub struct AccentShades {
|
|||||||
/// Derives hover/pressed/subtle shades for an accent against its background.
|
/// Derives hover/pressed/subtle shades for an accent against its background.
|
||||||
///
|
///
|
||||||
/// Where a platform states ramps (WinUI, KDE) those are used instead,
|
/// 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 {
|
pub fn derive_shades(accent: Hsla, background: Hsla, is_dark: bool) -> AccentShades {
|
||||||
let accent_ok = Oklch::from_hsla(accent);
|
let accent_ok = Oklch::from_hsla(accent);
|
||||||
let step = if is_dark { 0.05 } else { -0.05 };
|
let step = if is_dark { 0.05 } else { -0.05 };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ pub(crate) fn resolve(
|
|||||||
mode_override: Option<ThemeVariant>,
|
mode_override: Option<ThemeVariant>,
|
||||||
) -> ResolvedTheme {
|
) -> ResolvedTheme {
|
||||||
let variant = mode_override
|
let variant = mode_override
|
||||||
.or(preset.and_then(|def| def.mode))
|
.or(preset.and_then(|def| def.mode()))
|
||||||
.unwrap_or(system.variant);
|
.unwrap_or(system.variant);
|
||||||
|
|
||||||
let base = match variant {
|
let base = match variant {
|
||||||
@@ -246,7 +246,7 @@ pub(crate) fn resolve(
|
|||||||
let mut radius = Radius::default();
|
let mut radius = Radius::default();
|
||||||
|
|
||||||
if let Some(control) = preset
|
if let Some(control) = preset
|
||||||
.and_then(|def| def.control_radius)
|
.and_then(|def| def.control_radius())
|
||||||
.map(px)
|
.map(px)
|
||||||
.or(system.control_radius)
|
.or(system.control_radius)
|
||||||
{
|
{
|
||||||
@@ -254,7 +254,7 @@ pub(crate) fn resolve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(overlay) = preset
|
if let Some(overlay) = preset
|
||||||
.and_then(|def| def.overlay_radius)
|
.and_then(|def| def.overlay_radius())
|
||||||
.map(px)
|
.map(px)
|
||||||
.or(system.overlay_radius)
|
.or(system.overlay_radius)
|
||||||
{
|
{
|
||||||
@@ -262,7 +262,7 @@ pub(crate) fn resolve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sm) = preset
|
if let Some(sm) = preset
|
||||||
.and_then(|def| def.sm_radius)
|
.and_then(|def| def.sm_radius())
|
||||||
.map(px)
|
.map(px)
|
||||||
.or(system.sm_radius)
|
.or(system.sm_radius)
|
||||||
{
|
{
|
||||||
@@ -390,7 +390,9 @@ mod tests {
|
|||||||
assert_eq!(resolved.sizes.medium.control_height, px(28.));
|
assert_eq!(resolved.sizes.medium.control_height, px(28.));
|
||||||
assert_eq!(resolved.sizes.medium.input_height, px(30.));
|
assert_eq!(resolved.sizes.medium.input_height, px(30.));
|
||||||
assert_eq!(resolved.sizes.medium.text_size, px(16.));
|
assert_eq!(resolved.sizes.medium.text_size, px(16.));
|
||||||
assert_eq!(resolved.sizes.large.avatar_size, px(48.));
|
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.small.caption_size, px(13.6 * 0.85));
|
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.medium.caption_size, px(16.));
|
||||||
assert_eq!(resolved.sizes.large.caption_size, px(16.));
|
assert_eq!(resolved.sizes.large.caption_size, px(16.));
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ pub(crate) trait ThemeSource: 'static {
|
|||||||
fn label(&self) -> &'static str;
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
||||||
Box::new(macos::MacosSource::default())
|
Box::new(macos::MacosSource::default())
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ impl KdeScheme {
|
|||||||
//
|
//
|
||||||
// Older setups fall back to the scheme's background lightness.
|
// Older setups fall back to the scheme's background lightness.
|
||||||
variant: portal_variant
|
variant: portal_variant
|
||||||
.or(self.background.map(variant_by_lightness))
|
.or(self.background.map(crate::ThemeVariant::by_lightness))
|
||||||
.unwrap_or(crate::ThemeVariant::Light),
|
.unwrap_or(crate::ThemeVariant::Light),
|
||||||
accent: self.accent.or(portal_accent),
|
accent: self.accent.or(portal_accent),
|
||||||
palette: self.palette,
|
palette: self.palette,
|
||||||
@@ -99,14 +99,6 @@ 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.
|
/// The root font size of a `[General]` group's `font=` QFont string.
|
||||||
fn root_font_size(group: Option<&HashMap<&str, &str>>) -> Option<Pixels> {
|
fn root_font_size(group: Option<&HashMap<&str, &str>>) -> Option<Pixels> {
|
||||||
let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect();
|
let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect();
|
||||||
@@ -158,7 +150,10 @@ fn color_list(value: &str) -> Option<Hsla> {
|
|||||||
let green = components.next()?.parse::<f32>().ok()?;
|
let green = components.next()?.parse::<f32>().ok()?;
|
||||||
let blue = components.next()?.parse::<f32>().ok()?;
|
let blue = components.next()?.parse::<f32>().ok()?;
|
||||||
let alpha = components.next().and_then(|a| a.parse::<f32>().ok());
|
let alpha = components.next().and_then(|a| a.parse::<f32>().ok());
|
||||||
if red < 0.0 || green < 0.0 || blue < 0.0 {
|
if !(0.0..=255.0).contains(&red)
|
||||||
|
|| !(0.0..=255.0).contains(&green)
|
||||||
|
|| !(0.0..=255.0).contains(&blue)
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let scale = |component: f32| component / 255.0;
|
let scale = |component: f32| component / 255.0;
|
||||||
@@ -335,19 +330,18 @@ activeBackground=39,44,49
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_scheme_states_breeze_metrics_and_the_default_font() {
|
fn the_default_font_applies_without_a_stated_font() {
|
||||||
let system = KdeScheme::parse(BREEZE_DARK).into_system(Default::default(), None, None);
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(system.control_height, Some(px(33.)));
|
#[test]
|
||||||
assert_eq!(system.input_height, Some(px(29.)));
|
fn color_components_outside_0_to_255_are_rejected() {
|
||||||
assert_eq!(system.menu_item_height, Some(px(29.)));
|
let above = KdeScheme::parse("[Colors:Window]\nBackgroundNormal=300,20,20\n");
|
||||||
assert_eq!(system.control_radius, Some(px(5.)));
|
assert!(above.palette.background.is_none());
|
||||||
assert_eq!(system.overlay_radius, Some(px(5.)));
|
|
||||||
assert_eq!(system.switch_height, Some(px(18.)));
|
let below = KdeScheme::parse("[Colors:Window]\nBackgroundNormal=-5,20,20\n");
|
||||||
assert_eq!(system.switch_width, Some(px(36.)));
|
assert!(below.palette.background.is_none());
|
||||||
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]
|
#[test]
|
||||||
|
|||||||
@@ -13,9 +13,30 @@ use super::kde;
|
|||||||
use super::omarchy;
|
use super::omarchy;
|
||||||
use super::portal;
|
use super::portal;
|
||||||
use super::signal::ReapplySignal;
|
use super::signal::ReapplySignal;
|
||||||
|
|
||||||
use crate::ThemeVariant;
|
use crate::ThemeVariant;
|
||||||
|
|
||||||
use crate::system::{PartialPalette, SourceKind, SystemTheme};
|
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 {
|
pub(crate) struct LinuxSource {
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
portal: Option<portal::io::PortalClient>,
|
portal: Option<portal::io::PortalClient>,
|
||||||
@@ -154,20 +175,20 @@ impl LinuxSource {
|
|||||||
variant,
|
variant,
|
||||||
accent,
|
accent,
|
||||||
palette: PartialPalette::default(),
|
palette: PartialPalette::default(),
|
||||||
control_radius: Some(gpui::px(9.)),
|
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
|
||||||
overlay_radius: Some(gpui::px(15.)),
|
overlay_radius: Some(gpui::px(OVERLAY_RADIUS_PX)),
|
||||||
sm_radius: Some(gpui::px(6.)),
|
sm_radius: Some(gpui::px(SM_RADIUS_PX)),
|
||||||
control_height: Some(gpui::px(34.)),
|
control_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
|
||||||
input_height: Some(gpui::px(34.)),
|
input_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
|
||||||
menu_item_height: Some(gpui::px(32.)),
|
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PX)),
|
||||||
switch_height: Some(gpui::px(26.)),
|
switch_height: Some(gpui::px(SWITCH_HEIGHT_PX)),
|
||||||
switch_width: Some(gpui::px(46.)),
|
switch_width: Some(gpui::px(SWITCH_WIDTH_PX)),
|
||||||
switch_knob: Some(gpui::px(20.)),
|
switch_knob: Some(gpui::px(SWITCH_KNOB_PX)),
|
||||||
titlebar_height: Some(gpui::px(47.)),
|
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
|
||||||
root_font_size: Some(
|
root_font_size: Some(
|
||||||
self.gsettings
|
self.gsettings
|
||||||
.and_then(|gsettings| gsettings.root_font_size)
|
.and_then(|gsettings| gsettings.root_font_size)
|
||||||
.unwrap_or(gpui::px(14.67)),
|
.unwrap_or(gpui::px(ROOT_FONT_SIZE_PX)),
|
||||||
),
|
),
|
||||||
accessibility,
|
accessibility,
|
||||||
window_background: None,
|
window_background: None,
|
||||||
@@ -237,41 +258,44 @@ impl ThemeSource for LinuxSource {
|
|||||||
|
|
||||||
fn install_watchers(&mut self, async_cx: gpui::AsyncApp) {
|
fn install_watchers(&mut self, async_cx: gpui::AsyncApp) {
|
||||||
let signal = ReapplySignal::install(&async_cx);
|
let signal = ReapplySignal::install(&async_cx);
|
||||||
|
let file_signal = signal.sender();
|
||||||
|
|
||||||
if let Some(portal) = &self.portal {
|
if let Some(portal) = &self.portal {
|
||||||
portal.spawn_watch(signal.sender());
|
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 {
|
match self.backend {
|
||||||
Backend::Omarchy => {
|
Backend::Omarchy => {
|
||||||
let Some(current) = omarchy::io::current_dir() else {
|
let Some(current) = omarchy::io::current_dir() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let reapply_signal = signal.sender();
|
|
||||||
|
|
||||||
let watcher = notify::recommended_watcher(
|
let watcher = notify::recommended_watcher(
|
||||||
move |event: std::result::Result<notify::Event, notify::Error>| match event {
|
move |event: std::result::Result<notify::Event, notify::Error>| match event {
|
||||||
Ok(_) => ReapplySignal::poke(&reapply_signal),
|
Ok(_) => ReapplySignal::poke(&file_signal),
|
||||||
Err(error) => eprintln!("theme: omarchy watch error: {error}"),
|
Err(error) => log::warn!("theme: omarchy watch error: {error}"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
match watcher {
|
match watcher {
|
||||||
Ok(mut watcher) => {
|
Ok(mut watcher) => {
|
||||||
if let Err(error) = watcher.watch(¤t, RecursiveMode::NonRecursive) {
|
if let Err(error) = watcher.watch(¤t, RecursiveMode::NonRecursive) {
|
||||||
eprintln!("theme: omarchy watch failed: {error}");
|
log::warn!("theme: omarchy watch failed: {error}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.file_watcher = Some(watcher);
|
self.file_watcher = Some(watcher);
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("theme: omarchy watcher unavailable: {error}"),
|
Err(error) => log::warn!("theme: omarchy watcher unavailable: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Backend::Kde => {
|
Backend::Kde => {
|
||||||
let Some(config) = home_dir().map(|home| home.join(".config")) else {
|
let Some(config) = home_dir().map(|home| home.join(".config")) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let reapply_signal = signal.sender();
|
|
||||||
|
|
||||||
let watcher = notify::recommended_watcher(
|
let watcher = notify::recommended_watcher(
|
||||||
move |event: std::result::Result<notify::Event, notify::Error>| match event {
|
move |event: std::result::Result<notify::Event, notify::Error>| match event {
|
||||||
@@ -279,27 +303,25 @@ impl ThemeSource for LinuxSource {
|
|||||||
if event.paths.iter().any(|path| {
|
if event.paths.iter().any(|path| {
|
||||||
path.file_name().is_some_and(|name| name == "kdeglobals")
|
path.file_name().is_some_and(|name| name == "kdeglobals")
|
||||||
}) {
|
}) {
|
||||||
ReapplySignal::poke(&reapply_signal);
|
ReapplySignal::poke(&file_signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("theme: kde watch error: {error}"),
|
Err(error) => log::warn!("theme: kde watch error: {error}"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
match watcher {
|
match watcher {
|
||||||
Ok(mut watcher) => {
|
Ok(mut watcher) => {
|
||||||
if let Err(error) = watcher.watch(&config, RecursiveMode::NonRecursive) {
|
if let Err(error) = watcher.watch(&config, RecursiveMode::NonRecursive) {
|
||||||
eprintln!("theme: kde watch failed: {error}");
|
log::warn!("theme: kde watch failed: {error}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.file_watcher = Some(watcher);
|
self.file_watcher = Some(watcher);
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("theme: kde watcher unavailable: {error}"),
|
Err(error) => log::warn!("theme: kde watcher unavailable: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Backend::Portal | Backend::Fallback => {}
|
Backend::Portal | Backend::Fallback => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
self._signal = Some(signal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
@@ -354,7 +376,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_gnome_snapshot_states_the_libadwaita_metrics() {
|
fn the_gnome_snapshot_labels_gnome_and_defaults_the_root_font() {
|
||||||
let source = LinuxSource {
|
let source = LinuxSource {
|
||||||
backend: Backend::Portal,
|
backend: Backend::Portal,
|
||||||
portal: None,
|
portal: None,
|
||||||
@@ -369,17 +391,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(system.source, SourceKind::Gnome);
|
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.
|
// The default 11pt font when gsettings states none.
|
||||||
assert_eq!(system.root_font_size, Some(gpui::px(14.67)));
|
assert_eq!(system.root_font_size, Some(gpui::px(ROOT_FONT_SIZE_PX)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use objc2_foundation::{
|
|||||||
};
|
};
|
||||||
use std::ptr::NonNull;
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
use super::narrow_component;
|
||||||
|
|
||||||
use super::ThemeSource;
|
use super::ThemeSource;
|
||||||
use crate::ThemeVariant;
|
use crate::ThemeVariant;
|
||||||
use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
||||||
@@ -46,10 +48,10 @@ impl MacosSource {
|
|||||||
let converted = color.colorUsingColorSpace(&srgb)?;
|
let converted = color.colorUsingColorSpace(&srgb)?;
|
||||||
|
|
||||||
Some(gpui::Hsla::from(gpui::Rgba {
|
Some(gpui::Hsla::from(gpui::Rgba {
|
||||||
r: converted.redComponent() as f32,
|
r: narrow_component(converted.redComponent()),
|
||||||
g: converted.greenComponent() as f32,
|
g: narrow_component(converted.greenComponent()),
|
||||||
b: converted.blueComponent() as f32,
|
b: narrow_component(converted.blueComponent()),
|
||||||
a: converted.alphaComponent() as f32,
|
a: narrow_component(converted.alphaComponent()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ impl OmarchyTheme {
|
|||||||
.get("mode")
|
.get("mode")
|
||||||
.and_then(toml::Value::as_str)
|
.and_then(toml::Value::as_str)
|
||||||
.and_then(mode_variant)
|
.and_then(mode_variant)
|
||||||
.unwrap_or_else(|| variant_by_lightness(background));
|
.unwrap_or_else(|| ThemeVariant::by_lightness(background));
|
||||||
|
|
||||||
let accent = hex_color(&table, "accent").or_else(|| hex_color(&table, "color4"));
|
let accent = hex_color(&table, "accent").or_else(|| hex_color(&table, "color4"));
|
||||||
let selection = hex_color(&table, "selection");
|
let selection = hex_color(&table, "selection");
|
||||||
@@ -86,14 +86,6 @@ 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> {
|
fn hex_color(table: &Table, key: &str) -> Option<Hsla> {
|
||||||
let value = table.get(key)?.as_str()?;
|
let value = table.get(key)?.as_str()?;
|
||||||
let digits = value.trim().strip_prefix('#')?;
|
let digits = value.trim().strip_prefix('#')?;
|
||||||
@@ -224,18 +216,10 @@ color15 = \"#635d6f\"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omarchy_shares_the_gnome_control_metrics() {
|
fn omarchy_states_no_titlebar_or_root_font() {
|
||||||
let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("parses");
|
let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("parses");
|
||||||
let system = theme.into_system(Default::default());
|
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.titlebar_height, None);
|
||||||
assert_eq!(system.root_font_size, None);
|
assert_eq!(system.root_font_size, None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
use gpui::Hsla;
|
use gpui::Hsla;
|
||||||
|
|
||||||
|
use super::narrow_component;
|
||||||
|
|
||||||
use crate::{ThemeVariant, system::AccessibilityPreferences};
|
use crate::{ThemeVariant, system::AccessibilityPreferences};
|
||||||
|
|
||||||
/// The raw portal values, already converted off the DBus wire types.
|
/// The raw portal values, already converted off the DBus wire types.
|
||||||
@@ -26,9 +28,9 @@ impl AppearanceValues {
|
|||||||
color_scheme: self.color_scheme,
|
color_scheme: self.color_scheme,
|
||||||
accent: self.accent_rgb.map(|(r, g, b)| {
|
accent: self.accent_rgb.map(|(r, g, b)| {
|
||||||
Hsla::from(gpui::Rgba {
|
Hsla::from(gpui::Rgba {
|
||||||
r: r as f32,
|
r: narrow_component(r),
|
||||||
g: g as f32,
|
g: narrow_component(g),
|
||||||
b: b as f32,
|
b: narrow_component(b),
|
||||||
a: 1.0,
|
a: 1.0,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -98,7 +100,9 @@ pub(crate) mod io {
|
|||||||
|
|
||||||
/// The most recently read appearance.
|
/// The most recently read appearance.
|
||||||
pub(crate) fn appearance(&self) -> Appearance {
|
pub(crate) fn appearance(&self) -> Appearance {
|
||||||
*self.appearance.lock().expect("portal cache poisoned")
|
// A watch-thread panic must not crash the UI thread.
|
||||||
|
let cache = self.appearance.lock();
|
||||||
|
*cache.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forwards portal `SettingChanged` events to the reapply signal from a dedicated thread.
|
/// Forwards portal `SettingChanged` events to the reapply signal from a dedicated thread.
|
||||||
@@ -107,17 +111,17 @@ pub(crate) mod io {
|
|||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let Some(connection) = Connection::session().ok() else {
|
let Some(connection) = Connection::session().ok() else {
|
||||||
eprintln!("theme: portal watch skipped, no session bus");
|
log::warn!("theme: portal watch skipped, no session bus");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(proxy) = settings_proxy(connection) else {
|
let Some(proxy) = settings_proxy(connection) else {
|
||||||
eprintln!("theme: portal watch skipped, settings proxy unavailable");
|
log::warn!("theme: portal watch skipped, settings proxy unavailable");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(signals) = proxy.receive_signal("SettingChanged") else {
|
let Ok(signals) = proxy.receive_signal("SettingChanged") else {
|
||||||
eprintln!("theme: portal watch skipped, signal subscription failed");
|
log::warn!("theme: portal watch skipped, signal subscription failed");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,10 +138,13 @@ pub(crate) mod io {
|
|||||||
|
|
||||||
match read_appearance(&proxy) {
|
match read_appearance(&proxy) {
|
||||||
Some(latest) => {
|
Some(latest) => {
|
||||||
*appearance.lock().expect("portal cache poisoned") = latest;
|
let mut cache = appearance
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
*cache = latest;
|
||||||
super::super::signal::ReapplySignal::poke(&reapply_signal);
|
super::super::signal::ReapplySignal::poke(&reapply_signal);
|
||||||
}
|
}
|
||||||
None => eprintln!("theme: portal read failed on SettingChanged"),
|
None => log::warn!("theme: portal read failed on SettingChanged"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl ReapplySignal {
|
|||||||
/// Pokes the pipeline from a thread holding a [`Self::sender`] clone.
|
/// Pokes the pipeline from a thread holding a [`Self::sender`] clone.
|
||||||
pub(crate) fn poke(sender: &async_channel::Sender<()>) {
|
pub(crate) fn poke(sender: &async_channel::Sender<()>) {
|
||||||
if let Err(error) = sender.try_send(()) {
|
if let Err(error) = sender.try_send(()) {
|
||||||
eprintln!("theme: reapply signal failed: {error}");
|
log::warn!("theme: reapply signal failed: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,16 +74,19 @@ pub(crate) struct WindowsSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
|
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) };
|
let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||||
|
|
||||||
if initialized.is_err() && initialized != RPC_E_CHANGED_MODE {
|
if initialized.is_err() && initialized != RPC_E_CHANGED_MODE {
|
||||||
eprintln!("theme: COM initialization failed: {initialized:?}");
|
log::error!("theme: COM initialization failed: {initialized:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
match WindowsSource::new() {
|
match WindowsSource::new() {
|
||||||
Some(source) => Box::new(source),
|
Some(source) => Box::new(source),
|
||||||
None => {
|
None => {
|
||||||
eprintln!("theme: Windows system theme unavailable; using fallback");
|
log::warn!("theme: Windows system theme unavailable; using fallback");
|
||||||
Box::new(super::fallback::FallbackSource)
|
Box::new(super::fallback::FallbackSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,7 @@ impl WindowsSource {
|
|||||||
let ui_settings = match UISettings::new() {
|
let ui_settings = match UISettings::new() {
|
||||||
Ok(ui_settings) => ui_settings,
|
Ok(ui_settings) => ui_settings,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("theme: UISettings unavailable: {error}");
|
log::warn!("theme: UISettings unavailable: {error}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -102,7 +105,7 @@ impl WindowsSource {
|
|||||||
let accessibility_settings = match AccessibilitySettings::new() {
|
let accessibility_settings = match AccessibilitySettings::new() {
|
||||||
Ok(accessibility_settings) => accessibility_settings,
|
Ok(accessibility_settings) => accessibility_settings,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("theme: AccessibilitySettings unavailable: {error}");
|
log::warn!("theme: AccessibilitySettings unavailable: {error}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -157,13 +160,7 @@ impl ThemeSource for WindowsSource {
|
|||||||
let foreground = self.color(UIColorType::Foreground);
|
let foreground = self.color(UIColorType::Foreground);
|
||||||
|
|
||||||
let variant = background
|
let variant = background
|
||||||
.map(|background| {
|
.map(ThemeVariant::by_lightness)
|
||||||
if background.l >= 0.5 {
|
|
||||||
ThemeVariant::Light
|
|
||||||
} else {
|
|
||||||
ThemeVariant::Dark
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or(self.registry.apps_use_light_theme.map(|light| {
|
.or(self.registry.apps_use_light_theme.map(|light| {
|
||||||
if light {
|
if light {
|
||||||
ThemeVariant::Light
|
ThemeVariant::Light
|
||||||
@@ -212,7 +209,7 @@ impl ThemeSource for WindowsSource {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Err(error) = self.ui_settings.ColorValuesChanged(&color_handler) {
|
if let Err(error) = self.ui_settings.ColorValuesChanged(&color_handler) {
|
||||||
eprintln!("theme: color change watch failed: {error}");
|
log::warn!("theme: color change watch failed: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let contrast_signal = signal.sender();
|
let contrast_signal = signal.sender();
|
||||||
@@ -226,7 +223,7 @@ impl ThemeSource for WindowsSource {
|
|||||||
.accessibility_settings
|
.accessibility_settings
|
||||||
.HighContrastChanged(&contrast_handler)
|
.HighContrastChanged(&contrast_handler)
|
||||||
{
|
{
|
||||||
eprintln!("theme: high contrast watch failed: {error}");
|
log::warn!("theme: high contrast watch failed: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
self._signal = Some(signal);
|
self._signal = Some(signal);
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ description = "Small helpers shared by the component crates: layout shorthands,
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
|
log = "0.4"
|
||||||
|
|||||||
+25
-2
@@ -4,21 +4,42 @@ use gpui::{
|
|||||||
Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, WindowId, div,
|
Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, WindowId, div,
|
||||||
px,
|
px,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::{ActiveTheme as _, FOCUS_RING_SPREAD};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// A row whose children center on the cross axis.
|
/// A row whose children center on the cross axis.
|
||||||
|
#[must_use]
|
||||||
pub fn h_flex() -> Div {
|
pub fn h_flex() -> Div {
|
||||||
div().flex().flex_row().items_center()
|
div().flex().flex_row().items_center()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A column whose children stack on the main axis.
|
/// A column whose children stack on the main axis.
|
||||||
|
#[must_use]
|
||||||
pub fn v_flex() -> Div {
|
pub fn v_flex() -> Div {
|
||||||
div().flex().flex_col()
|
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.
|
/// Extensions over [`Styled`], shared by the components.
|
||||||
pub trait StyledExt: Styled + Sized {
|
pub trait StyledExt: Styled + Sized {
|
||||||
|
#[must_use]
|
||||||
fn refine_style(mut self, style: &StyleRefinement) -> Self {
|
fn refine_style(mut self, style: &StyleRefinement) -> Self {
|
||||||
self.style().refine(style);
|
self.style().refine(style);
|
||||||
self
|
self
|
||||||
@@ -38,6 +59,7 @@ 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.
|
/// 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)]
|
#[derive(Default)]
|
||||||
struct AnchorBoundsRegistry(HashMap<(WindowId, ElementId), Bounds<Pixels>>);
|
struct AnchorBoundsRegistry(HashMap<(WindowId, ElementId), Bounds<Pixels>>);
|
||||||
|
|
||||||
@@ -72,7 +94,7 @@ pub fn record_anchor_bounds(
|
|||||||
|
|
||||||
/// Returns a focus ring shadow with the given color at half ink.
|
/// Returns a focus ring shadow with the given color at half ink.
|
||||||
pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
|
pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
|
||||||
vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))]
|
vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(FOCUS_RING_SPREAD)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The next selectable row in the direction, or `None` at the ends.
|
/// The next selectable row in the direction, or `None` at the ends.
|
||||||
@@ -128,6 +150,7 @@ pub fn selectable_label_color(cx: &App, selected: bool, disabled: bool) -> Hsla
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A selectable row's background.
|
/// A selectable row's background.
|
||||||
|
#[must_use]
|
||||||
pub fn paint_selectable_row<R>(cx: &App, row: R, selected: bool, disabled: bool) -> R
|
pub fn paint_selectable_row<R>(cx: &App, row: R, selected: bool, disabled: bool) -> R
|
||||||
where
|
where
|
||||||
R: IntoElement + Styled + StatefulInteractiveElement,
|
R: IntoElement + Styled + StatefulInteractiveElement,
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## 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
|
## Queue — to follow
|
||||||
|
|
||||||
Platform metric adoption continues one component at a time; each stop goes
|
Platform metric adoption continues one component at a time; each stop goes
|
||||||
@@ -61,6 +72,9 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
|||||||
|
|
||||||
- [x] `IconRole` + bundled Lucide fallback, Segoe Fluent codepoints (Windows),
|
- [x] `IconRole` + bundled Lucide fallback, Segoe Fluent codepoints (Windows),
|
||||||
freedesktop theme walker (Linux/Omarchy)
|
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`)
|
### core (`crates/core`)
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ reqwest_client = { workspace = true }
|
|||||||
gpui-native-core = { workspace = true, features = ["full"] }
|
gpui-native-core = { workspace = true, features = ["full"] }
|
||||||
gpui-native-data = { workspace = true, features = ["full"] }
|
gpui-native-data = { workspace = true, features = ["full"] }
|
||||||
gpui-native-editor = { workspace = true, features = ["full"] }
|
gpui-native-editor = { workspace = true, features = ["full"] }
|
||||||
gpui-native-layout = { workspace = true }
|
gpui-native-layout = { workspace = true, features = ["full"] }
|
||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
|
gpui-native-utils = { workspace = true }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gallery"
|
name = "gallery"
|
||||||
|
|||||||
+370
-234
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