style: cargo fmt with the workspace tab policy (hard_tabs)

Whitespace only; the fork has no own rustfmt.toml so the Oak root
policy applies.
This commit is contained in:
2026-08-11 23:06:13 +08:00
parent 507d7e6e8d
commit af5482d774
318 changed files with 132564 additions and 132170 deletions
+235 -215
View File
@@ -5,8 +5,8 @@
//! hold decays locally; the pure arithmetic is in [`scopes::math`].
use gpui::{
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
colors::DefaultColors, fill, point, prelude::*, px, size,
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
colors::DefaultColors, fill, point, prelude::*, px, size,
};
use crate::scopes::{decay_peak, meter_lit_segments};
@@ -23,252 +23,272 @@ const PEAK_DECAY: f32 = 0.01;
/// places channels side by side and lights segments bottom to top.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MeterOrientation {
/// Channels stacked vertically, segments lit left to right.
#[default]
Horizontal,
/// Channels side by side, segments lit bottom to top.
Vertical,
/// Channels stacked vertically, segments lit left to right.
#[default]
Horizontal,
/// Channels side by side, segments lit bottom to top.
Vertical,
}
/// Provides per-channel levels in `0..1` (linear or dB-normalized).
pub trait AudioMeterDataSource: 'static {
/// The current level of each channel, `0..1`.
fn levels(&self) -> Vec<f32>;
/// The current level of each channel, `0..1`.
fn levels(&self) -> Vec<f32>;
}
/// An audio level meter.
pub struct AudioLevelMeter<D: AudioMeterDataSource> {
data: Entity<D>,
focus_handle: FocusHandle,
peak: Vec<f32>,
orientation: MeterOrientation,
data: Entity<D>,
focus_handle: FocusHandle,
peak: Vec<f32>,
orientation: MeterOrientation,
}
impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
/// Create a meter over `data`.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
peak: Vec::new(),
orientation: MeterOrientation::Horizontal,
}
}
/// Create a meter over `data`.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
peak: Vec::new(),
orientation: MeterOrientation::Horizontal,
}
}
/// Set the orientation (builder-style, callable after `new`).
pub fn with_orientation(mut self, orientation: MeterOrientation) -> Self {
self.orientation = orientation;
self
}
/// Set the orientation (builder-style, callable after `new`).
pub fn with_orientation(mut self, orientation: MeterOrientation) -> Self {
self.orientation = orientation;
self
}
/// The current orientation.
pub fn orientation(&self) -> MeterOrientation {
self.orientation
}
/// The current orientation.
pub fn orientation(&self) -> MeterOrientation {
self.orientation
}
/// The current per-channel levels.
pub fn levels(&self, cx: &App) -> Vec<f32> {
self.data.read(cx).levels()
}
/// The current per-channel levels.
pub fn levels(&self, cx: &App) -> Vec<f32> {
self.data.read(cx).levels()
}
/// Update the peak-hold state from the current levels (call each frame).
pub fn update(&mut self, cx: &mut Context<Self>) {
let levels = self.data.read(cx).levels();
self.peak.resize(levels.len(), 0.0);
for (peak, level) in self.peak.iter_mut().zip(&levels) {
*peak = decay_peak(*peak, *level, PEAK_DECAY);
}
cx.notify();
}
/// Update the peak-hold state from the current levels (call each frame).
pub fn update(&mut self, cx: &mut Context<Self>) {
let levels = self.data.read(cx).levels();
self.peak.resize(levels.len(), 0.0);
for (peak, level) in self.peak.iter_mut().zip(&levels) {
*peak = decay_peak(*peak, *level, PEAK_DECAY);
}
cx.notify();
}
}
impl<D: AudioMeterDataSource> Focusable for AudioLevelMeter<D> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let levels = self.data.read(cx).levels();
self.peak.resize(levels.len(), 0.0);
let peaks = self.peak.clone();
let lit_counts: Vec<usize> = levels
.iter()
.map(|level| meter_lit_segments(*level, SEGMENTS))
.collect();
let orientation = self.orientation;
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let levels = self.data.read(cx).levels();
self.peak.resize(levels.len(), 0.0);
let peaks = self.peak.clone();
let lit_counts: Vec<usize> = levels
.iter()
.map(|level| meter_lit_segments(*level, SEGMENTS))
.collect();
let orientation = self.orientation;
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let lit_color = Hsla::from(colors.selected);
let dim_color = Hsla::from(colors.border);
let peak_color = Hsla::from(colors.text);
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let lit_color = Hsla::from(colors.selected);
let dim_color = Hsla::from(colors.border);
let peak_color = Hsla::from(colors.text);
match orientation {
MeterOrientation::Horizontal => {
let channel_h = if lit_counts.is_empty() {
height
} else {
height / lit_counts.len() as f32
};
let seg_w = width / SEGMENTS as f32;
for (channel, &lit) in lit_counts.iter().enumerate() {
let y = bounds.top() + px(channel as f32 * channel_h);
for segment in 0..SEGMENTS {
let seg = Bounds::new(
point(bounds.left() + px(segment as f32 * seg_w), y),
size(px((seg_w - 1.0).max(1.0)), px((channel_h - 2.0).max(2.0))),
);
window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color }));
}
// Peak marker.
if let Some(peak) = peaks.get(channel) {
let x = bounds.left() + px((peak.clamp(0.0, 1.0) * width) - 1.0);
let marker = Bounds::new(point(x, y), size(px(2.0), px((channel_h - 2.0).max(2.0))));
window.paint_quad(fill(marker, peak_color));
}
}
}
MeterOrientation::Vertical => {
// Channels side by side; segments stack bottom to top,
// lit from the bottom like an equalizer column.
let channel_w = if lit_counts.is_empty() {
width
} else {
width / lit_counts.len() as f32
};
let seg_h = height / SEGMENTS as f32;
for (channel, &lit) in lit_counts.iter().enumerate() {
let x = bounds.left() + px(channel as f32 * channel_w);
for segment in 0..SEGMENTS {
let y = bounds.bottom() - px((segment + 1) as f32 * seg_h);
let seg = Bounds::new(
point(x, y),
size(px((channel_w - 2.0).max(2.0)), px((seg_h - 1.0).max(1.0))),
);
window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color }));
}
// Peak marker.
if let Some(peak) = peaks.get(channel) {
let y = bounds.bottom() - px(peak.clamp(0.0, 1.0) * height);
let marker = Bounds::new(point(x, y), size(px((channel_w - 2.0).max(2.0)), px(2.0)));
window.paint_quad(fill(marker, peak_color));
}
}
}
}
},
)
.size_full()
}
match orientation {
MeterOrientation::Horizontal => {
let channel_h = if lit_counts.is_empty() {
height
} else {
height / lit_counts.len() as f32
};
let seg_w = width / SEGMENTS as f32;
for (channel, &lit) in lit_counts.iter().enumerate() {
let y = bounds.top() + px(channel as f32 * channel_h);
for segment in 0..SEGMENTS {
let seg = Bounds::new(
point(bounds.left() + px(segment as f32 * seg_w), y),
size(
px((seg_w - 1.0).max(1.0)),
px((channel_h - 2.0).max(2.0)),
),
);
window.paint_quad(fill(
seg,
if segment < lit { lit_color } else { dim_color },
));
}
// Peak marker.
if let Some(peak) = peaks.get(channel) {
let x = bounds.left() + px((peak.clamp(0.0, 1.0) * width) - 1.0);
let marker = Bounds::new(
point(x, y),
size(px(2.0), px((channel_h - 2.0).max(2.0))),
);
window.paint_quad(fill(marker, peak_color));
}
}
}
MeterOrientation::Vertical => {
// Channels side by side; segments stack bottom to top,
// lit from the bottom like an equalizer column.
let channel_w = if lit_counts.is_empty() {
width
} else {
width / lit_counts.len() as f32
};
let seg_h = height / SEGMENTS as f32;
for (channel, &lit) in lit_counts.iter().enumerate() {
let x = bounds.left() + px(channel as f32 * channel_w);
for segment in 0..SEGMENTS {
let y = bounds.bottom() - px((segment + 1) as f32 * seg_h);
let seg = Bounds::new(
point(x, y),
size(
px((channel_w - 2.0).max(2.0)),
px((seg_h - 1.0).max(1.0)),
),
);
window.paint_quad(fill(
seg,
if segment < lit { lit_color } else { dim_color },
));
}
// Peak marker.
if let Some(peak) = peaks.get(channel) {
let y = bounds.bottom() - px(peak.clamp(0.0, 1.0) * height);
let marker = Bounds::new(
point(x, y),
size(px((channel_w - 2.0).max(2.0)), px(2.0)),
);
window.paint_quad(fill(marker, peak_color));
}
}
}
}
},
)
.size_full()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scopes::meter_lit_segments;
use gpui::{Entity, Render, TestAppContext, Window, div, px, size};
use super::*;
use crate::scopes::meter_lit_segments;
use gpui::{Entity, Render, TestAppContext, Window, div, px, size};
#[test]
fn meter_math_matches_scope_core() {
assert_eq!(meter_lit_segments(0.0, SEGMENTS), 0);
assert_eq!(meter_lit_segments(0.5, SEGMENTS), SEGMENTS / 2);
}
#[test]
fn meter_math_matches_scope_core() {
assert_eq!(meter_lit_segments(0.0, SEGMENTS), 0);
assert_eq!(meter_lit_segments(0.5, SEGMENTS), SEGMENTS / 2);
}
struct MockAudio(Vec<f32>);
impl AudioMeterDataSource for MockAudio {
fn levels(&self) -> Vec<f32> {
self.0.clone()
}
}
struct MockAudio(Vec<f32>);
impl AudioMeterDataSource for MockAudio {
fn levels(&self) -> Vec<f32> {
self.0.clone()
}
}
struct Host {
meter: Entity<AudioLevelMeter<MockAudio>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.meter.clone())
}
}
struct Host {
meter: Entity<AudioLevelMeter<MockAudio>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.meter.clone())
}
}
#[gpui::test]
async fn meter_renders_and_decays_peak(cx: &mut TestAppContext) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let audio = cx.new(|_| MockAudio(vec![0.8, 0.2]));
let meter = cx.new(|cx| AudioLevelMeter::new(4, audio, window, cx));
Host { meter }
});
cx.run_until_parked();
#[gpui::test]
async fn meter_renders_and_decays_peak(cx: &mut TestAppContext) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let audio = cx.new(|_| MockAudio(vec![0.8, 0.2]));
let meter = cx.new(|cx| AudioLevelMeter::new(4, audio, window, cx));
Host { meter }
});
cx.run_until_parked();
// update() refreshes peaks from levels.
let (peaks, levels) = window
.update(cx, |host, _, cx| {
host.meter.update(cx, |meter, cx| meter.update(cx));
let levels = host.meter.read(cx).levels(cx);
let peaks = host.meter.read(cx).peak.clone();
(peaks, levels)
})
.unwrap();
assert_eq!(levels, vec![0.8, 0.2]);
// Peaks track the levels on the first update.
assert!((peaks[0] - 0.8).abs() < 0.001);
}
// update() refreshes peaks from levels.
let (peaks, levels) = window
.update(cx, |host, _, cx| {
host.meter.update(cx, |meter, cx| meter.update(cx));
let levels = host.meter.read(cx).levels(cx);
let peaks = host.meter.read(cx).peak.clone();
(peaks, levels)
})
.unwrap();
assert_eq!(levels, vec![0.8, 0.2]);
// Peaks track the levels on the first update.
assert!((peaks[0] - 0.8).abs() < 0.001);
}
#[gpui::test]
async fn vertical_meter_renders_in_a_narrow_strip(cx: &mut TestAppContext) {
// The Oak transport design needs a 26px-wide vertical strip: channels
// side by side, segments lit bottom to top. Render one at that exact
// size and exercise the paint path (the orientation default is
// horizontal, so this also covers the builder).
struct StripHost {
meter: Entity<AudioLevelMeter<MockAudio>>,
}
impl Render for StripHost {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(self.meter.clone())
}
}
#[gpui::test]
async fn vertical_meter_renders_in_a_narrow_strip(cx: &mut TestAppContext) {
// The Oak transport design needs a 26px-wide vertical strip: channels
// side by side, segments lit bottom to top. Render one at that exact
// size and exercise the paint path (the orientation default is
// horizontal, so this also covers the builder).
struct StripHost {
meter: Entity<AudioLevelMeter<MockAudio>>,
}
impl Render for StripHost {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.meter.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(26.0), px(200.0)), |window, cx| {
let audio = cx.new(|_| MockAudio(vec![0.7]));
let meter = cx.new(|cx| {
AudioLevelMeter::new(5, audio, window, cx)
.with_orientation(MeterOrientation::Vertical)
});
assert_eq!(meter.read(cx).orientation(), MeterOrientation::Vertical);
StripHost { meter }
});
cx.run_until_parked();
window
.update(cx, |host, _, cx| {
host.meter.update(cx, |meter, cx| meter.update(cx));
})
.unwrap();
// Still valid after a vertical render + peak update.
assert!(window
.update(cx, |host, _, cx| host.meter.read(cx).orientation())
.unwrap()
== MeterOrientation::Vertical);
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(26.0), px(200.0)), |window, cx| {
let audio = cx.new(|_| MockAudio(vec![0.7]));
let meter = cx.new(|cx| {
AudioLevelMeter::new(5, audio, window, cx)
.with_orientation(MeterOrientation::Vertical)
});
assert_eq!(meter.read(cx).orientation(), MeterOrientation::Vertical);
StripHost { meter }
});
cx.run_until_parked();
window
.update(cx, |host, _, cx| {
host.meter.update(cx, |meter, cx| meter.update(cx));
})
.unwrap();
// Still valid after a vertical render + peak update.
assert!(
window
.update(cx, |host, _, cx| host.meter.read(cx).orientation())
.unwrap() == MeterOrientation::Vertical
);
}
#[test]
fn meter_orientation_defaults_to_horizontal() {
assert_eq!(MeterOrientation::default(), MeterOrientation::Horizontal);
assert_ne!(MeterOrientation::Horizontal, MeterOrientation::Vertical);
}
#[test]
fn meter_orientation_defaults_to_horizontal() {
assert_eq!(MeterOrientation::default(), MeterOrientation::Horizontal);
assert_ne!(MeterOrientation::Horizontal, MeterOrientation::Vertical);
}
}
+261 -238
View File
@@ -5,295 +5,318 @@
//! its model and calls [`CheckBox::set_state`] (which also repaints) when it
//! accepts. The widget never changes its own state on click.
use gpui::{
App, Bounds, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable,
Hsla, KeyDownEvent, Pixels, Render, Window, canvas, colors::DefaultColors, div, fill, point,
prelude::*, px, size,
};
use gpui::PathBuilder;
use gpui::{
App, Bounds, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Hsla,
KeyDownEvent, Pixels, Render, Window, canvas, colors::DefaultColors, div, fill, point,
prelude::*, px, size,
};
/// The display state of a checkbox.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckState {
/// The box is empty.
Unchecked,
/// The box is filled with a check mark.
Checked,
/// The box shows a horizontal bar (partially checked).
Indeterminate,
/// The box is empty.
Unchecked,
/// The box is filled with a check mark.
Checked,
/// The box shows a horizontal bar (partially checked).
Indeterminate,
}
impl CheckState {
/// The next state after a click.
///
/// With tri-state enabled the cycle is
/// `Unchecked -> Checked -> Indeterminate -> Unchecked`; otherwise
/// `Unchecked <-> Checked`.
pub fn toggled(self, tri_state: bool) -> Self {
match (self, tri_state) {
(CheckState::Unchecked, _) => CheckState::Checked,
(CheckState::Checked, true) => CheckState::Indeterminate,
(CheckState::Checked, false) => CheckState::Unchecked,
(CheckState::Indeterminate, _) => CheckState::Unchecked,
}
}
/// The next state after a click.
///
/// With tri-state enabled the cycle is
/// `Unchecked -> Checked -> Indeterminate -> Unchecked`; otherwise
/// `Unchecked <-> Checked`.
pub fn toggled(self, tri_state: bool) -> Self {
match (self, tri_state) {
(CheckState::Unchecked, _) => CheckState::Checked,
(CheckState::Checked, true) => CheckState::Indeterminate,
(CheckState::Checked, false) => CheckState::Unchecked,
(CheckState::Indeterminate, _) => CheckState::Unchecked,
}
}
}
/// A request emitted when a checkbox is toggled.
#[derive(Debug, Clone, PartialEq)]
pub enum CheckBoxEvent {
/// The user clicked (or pressed space/enter on) the box.
Toggled {
/// The control's stable id.
control: usize,
/// The state the control should move to.
state: CheckState,
},
/// The user clicked (or pressed space/enter on) the box.
Toggled {
/// The control's stable id.
control: usize,
/// The state the control should move to.
state: CheckState,
},
}
/// A single checkbox row (box + optional label).
pub struct CheckBox {
control: usize,
state: CheckState,
label: Option<gpui::SharedString>,
enabled: bool,
tri_state: bool,
focus_handle: FocusHandle,
control: usize,
state: CheckState,
label: Option<gpui::SharedString>,
enabled: bool,
tri_state: bool,
focus_handle: FocusHandle,
}
impl CheckBox {
/// Create a checkbox for `control` in `state`.
pub fn new(
control: usize,
state: CheckState,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
state,
label: None,
enabled: true,
tri_state: false,
focus_handle: cx.focus_handle(),
}
}
/// Create a checkbox for `control` in `state`.
pub fn new(
control: usize,
state: CheckState,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
state,
label: None,
enabled: true,
tri_state: false,
focus_handle: cx.focus_handle(),
}
}
/// Attach a label shown to the right of the box.
pub fn with_label(mut self, label: impl Into<gpui::SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Attach a label shown to the right of the box.
pub fn with_label(mut self, label: impl Into<gpui::SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Enable the tri-state cycle.
pub fn with_tri_state(mut self, tri_state: bool) -> Self {
self.tri_state = tri_state;
self
}
/// Enable the tri-state cycle.
pub fn with_tri_state(mut self, tri_state: bool) -> Self {
self.tri_state = tri_state;
self
}
/// Enable or disable the control (disabled boxes ignore clicks).
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Enable or disable the control (disabled boxes ignore clicks).
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// The current display state.
pub fn state(&self) -> CheckState {
self.state
}
/// The current display state.
pub fn state(&self) -> CheckState {
self.state
}
/// Apply a new display state (from the host) and repaint.
pub fn set_state(&mut self, state: CheckState, cx: &mut Context<Self>) {
if self.state != state {
self.state = state;
cx.notify();
}
}
/// Apply a new display state (from the host) and repaint.
pub fn set_state(&mut self, state: CheckState, cx: &mut Context<Self>) {
if self.state != state {
self.state = state;
cx.notify();
}
}
fn emit_toggle(&self, cx: &mut Context<Self>) {
if !self.enabled {
return;
}
cx.emit(CheckBoxEvent::Toggled {
control: self.control,
state: self.state.toggled(self.tri_state),
});
cx.notify();
}
fn emit_toggle(&self, cx: &mut Context<Self>) {
if !self.enabled {
return;
}
cx.emit(CheckBoxEvent::Toggled {
control: self.control,
state: self.state.toggled(self.tri_state),
});
cx.notify();
}
}
impl EventEmitter<CheckBoxEvent> for CheckBox {}
impl Focusable for CheckBox {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CheckBox {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let state = self.state;
let enabled = self.enabled;
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let state = self.state;
let enabled = self.enabled;
let mut box_el = div()
.id(ElementId::named_usize("gpui-widgets-checkbox", self.control))
.size(px(18.0))
.rounded(px(4.0))
.border_1()
.border_color(if enabled { colors.border } else { colors.disabled })
.bg(if state == CheckState::Checked {
colors.selected
} else {
colors.background
})
.track_focus(&self.focus_handle)
.cursor_pointer()
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit_toggle(cx);
cx.stop_propagation();
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
if matches!(event.keystroke.key.as_str(), "space" | "enter") {
this.emit_toggle(cx);
}
}))
.child(canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, cx| {
paint_check(bounds, state, enabled, window, cx);
},
));
let mut box_el = div()
.id(ElementId::named_usize(
"gpui-widgets-checkbox",
self.control,
))
.size(px(18.0))
.rounded(px(4.0))
.border_1()
.border_color(if enabled {
colors.border
} else {
colors.disabled
})
.bg(if state == CheckState::Checked {
colors.selected
} else {
colors.background
})
.track_focus(&self.focus_handle)
.cursor_pointer()
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit_toggle(cx);
cx.stop_propagation();
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
if matches!(event.keystroke.key.as_str(), "space" | "enter") {
this.emit_toggle(cx);
}
}))
.child(canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, cx| {
paint_check(bounds, state, enabled, window, cx);
},
));
if !enabled {
box_el = box_el.opacity(0.45);
}
if !enabled {
box_el = box_el.opacity(0.45);
}
let mut row = div()
.flex()
.items_center()
.gap(px(6.0))
.child(box_el);
if let Some(label) = self.label.clone() {
row = row.child(
div()
.text_color(if enabled { colors.text } else { colors.disabled })
.child(label),
);
}
row
}
let mut row = div().flex().items_center().gap(px(6.0)).child(box_el);
if let Some(label) = self.label.clone() {
row = row.child(
div()
.text_color(if enabled {
colors.text
} else {
colors.disabled
})
.child(label),
);
}
row
}
}
fn paint_check(
bounds: Bounds<Pixels>,
state: CheckState,
enabled: bool,
window: &mut Window,
cx: &mut App,
bounds: Bounds<Pixels>,
state: CheckState,
enabled: bool,
window: &mut Window,
cx: &mut App,
) {
let colors = cx.default_colors().clone();
let stroke_color = if enabled { Hsla::from(colors.selected_text) } else { Hsla::from(colors.disabled) };
let mid_y = bounds.center().y;
let colors = cx.default_colors().clone();
let stroke_color = if enabled {
Hsla::from(colors.selected_text)
} else {
Hsla::from(colors.disabled)
};
let mid_y = bounds.center().y;
match state {
CheckState::Unchecked => {}
CheckState::Indeterminate => {
// A centered horizontal bar.
let bar = Bounds::new(
point(bounds.left() + px(3.0), mid_y - px(1.0)),
size(bounds.size.width - px(6.0), px(2.0)),
);
window.paint_quad(fill(bar, stroke_color));
}
CheckState::Checked => {
let mut check = PathBuilder::stroke(px(2.0));
check.move_to(point(bounds.left() + px(4.0), mid_y));
check.line_to(point(bounds.left() + px(8.0), bounds.bottom() - px(4.0)));
check.line_to(point(bounds.right() - px(3.0), bounds.top() + px(4.0)));
if let Ok(path) = check.build() {
window.paint_path(path, stroke_color);
}
}
}
match state {
CheckState::Unchecked => {}
CheckState::Indeterminate => {
// A centered horizontal bar.
let bar = Bounds::new(
point(bounds.left() + px(3.0), mid_y - px(1.0)),
size(bounds.size.width - px(6.0), px(2.0)),
);
window.paint_quad(fill(bar, stroke_color));
}
CheckState::Checked => {
let mut check = PathBuilder::stroke(px(2.0));
check.move_to(point(bounds.left() + px(4.0), mid_y));
check.line_to(point(bounds.left() + px(8.0), bounds.bottom() - px(4.0)));
check.line_to(point(bounds.right() - px(3.0), bounds.top() + px(4.0)));
if let Ok(path) = check.build() {
window.paint_path(path, stroke_color);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
#[test]
fn binary_toggle_cycles() {
assert_eq!(CheckState::Unchecked.toggled(false), CheckState::Checked);
assert_eq!(CheckState::Checked.toggled(false), CheckState::Unchecked);
}
#[test]
fn binary_toggle_cycles() {
assert_eq!(CheckState::Unchecked.toggled(false), CheckState::Checked);
assert_eq!(CheckState::Checked.toggled(false), CheckState::Unchecked);
}
#[test]
fn tri_state_toggle_cycles() {
assert_eq!(CheckState::Unchecked.toggled(true), CheckState::Checked);
assert_eq!(CheckState::Checked.toggled(true), CheckState::Indeterminate);
assert_eq!(CheckState::Indeterminate.toggled(true), CheckState::Unchecked);
}
#[test]
fn tri_state_toggle_cycles() {
assert_eq!(CheckState::Unchecked.toggled(true), CheckState::Checked);
assert_eq!(CheckState::Checked.toggled(true), CheckState::Indeterminate);
assert_eq!(
CheckState::Indeterminate.toggled(true),
CheckState::Unchecked
);
}
#[test]
fn states_are_distinct() {
assert_ne!(CheckState::Unchecked, CheckState::Checked);
assert_ne!(CheckState::Checked, CheckState::Indeterminate);
}
#[test]
fn states_are_distinct() {
assert_ne!(CheckState::Unchecked, CheckState::Checked);
assert_ne!(CheckState::Checked, CheckState::Indeterminate);
}
#[gpui::test]
async fn click_emits_toggle_request(cx: &mut TestAppContext) {
#[gpui::test]
async fn click_emits_toggle_request(cx: &mut TestAppContext) {
struct Host {
checkbox: Entity<CheckBox>,
events: Vec<CheckBoxEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.checkbox.clone())
}
}
struct Host {
checkbox: Entity<CheckBox>,
events: Vec<CheckBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.checkbox.clone())
}
}
// `default_colors()` requires the global (not initialized in tests).
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let checkbox =
cx.new(|cx| CheckBox::new(1, CheckState::Unchecked, window, cx).with_label("Mute"));
let host = Host {
checkbox,
events: Vec::new(),
};
cx.subscribe(
&host.checkbox,
|host: &mut Host,
_c: Entity<CheckBox>,
event: &CheckBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
// `default_colors()` requires the global (not initialized in tests).
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let checkbox = cx.new(|cx| {
CheckBox::new(1, CheckState::Unchecked, window, cx).with_label("Mute")
}); let host = Host {
checkbox,
events: Vec::new(),
};
cx.subscribe(
&host.checkbox,
|host: &mut Host,
_c: Entity<CheckBox>,
event: &CheckBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.simulate_click(point(px(9.0), px(9.0)), Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.simulate_click(point(px(9.0), px(9.0)), Modifiers::none());
cx.run_until_parked();
let (state, emitted) = cx.read(|app| {
let host = host.read(app);
(
host.checkbox.read(app).state(),
host.events.iter().any(|e| {
matches!(e, CheckBoxEvent::Toggled { control: 1, state: CheckState::Checked })
}),
)
});
// The widget does not mutate itself; the host must apply the request.
assert_eq!(state, CheckState::Unchecked);
assert!(emitted);
}
let (state, emitted) = cx.read(|app| {
let host = host.read(app);
(
host.checkbox.read(app).state(),
host.events.iter().any(|e| {
matches!(
e,
CheckBoxEvent::Toggled {
control: 1,
state: CheckState::Checked
}
)
}),
)
});
// The widget does not mutate itself; the host must apply the request.
assert_eq!(state, CheckState::Unchecked);
assert!(emitted);
}
}
+118 -118
View File
@@ -3,139 +3,139 @@
/// A color in the HSV model: `h` in degrees `0..360`, `s` and `v` in `0..1`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HsvColor {
/// Hue in degrees, `0..360` (wraps).
pub h: f32,
/// Saturation, `0..1`.
pub s: f32,
/// Value (brightness), `0..1`.
pub v: f32,
/// Hue in degrees, `0..360` (wraps).
pub h: f32,
/// Saturation, `0..1`.
pub s: f32,
/// Value (brightness), `0..1`.
pub v: f32,
}
impl HsvColor {
/// Create a color, clamping components into range.
pub fn new(h: f32, s: f32, v: f32) -> Self {
Self {
h: h.rem_euclid(360.0),
s: s.clamp(0.0, 1.0),
v: v.clamp(0.0, 1.0),
}
}
/// Create a color, clamping components into range.
pub fn new(h: f32, s: f32, v: f32) -> Self {
Self {
h: h.rem_euclid(360.0),
s: s.clamp(0.0, 1.0),
v: v.clamp(0.0, 1.0),
}
}
/// Convert from linear RGB components in `0..1`.
pub fn from_rgb(r: f32, g: f32, b: f32) -> Self {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / delta + 2.0)
} else {
60.0 * ((r - g) / delta + 4.0)
};
let s = if max == 0.0 { 0.0 } else { delta / max };
Self::new(h, s, max)
}
/// Convert from linear RGB components in `0..1`.
pub fn from_rgb(r: f32, g: f32, b: f32) -> Self {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / delta + 2.0)
} else {
60.0 * ((r - g) / delta + 4.0)
};
let s = if max == 0.0 { 0.0 } else { delta / max };
Self::new(h, s, max)
}
/// Convert to linear RGB components in `0..1`.
pub fn to_rgb(self) -> (f32, f32, f32) {
let h = (self.h.rem_euclid(360.0)) / 60.0;
let c = self.v * self.s;
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
let m = self.v - c;
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
(r + m, g + m, b + m)
}
/// Convert to linear RGB components in `0..1`.
pub fn to_rgb(self) -> (f32, f32, f32) {
let h = (self.h.rem_euclid(360.0)) / 60.0;
let c = self.v * self.s;
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
let m = self.v - c;
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
(r + m, g + m, b + m)
}
/// Convert to RGBA components in `0..255` (alpha given separately).
pub fn to_rgba_u8(self, alpha: u8) -> [u8; 4] {
let (r, g, b) = self.to_rgb();
[
(r * 255.0).round().clamp(0.0, 255.0) as u8,
(g * 255.0).round().clamp(0.0, 255.0) as u8,
(b * 255.0).round().clamp(0.0, 255.0) as u8,
alpha,
]
}
/// Convert to RGBA components in `0..255` (alpha given separately).
pub fn to_rgba_u8(self, alpha: u8) -> [u8; 4] {
let (r, g, b) = self.to_rgb();
[
(r * 255.0).round().clamp(0.0, 255.0) as u8,
(g * 255.0).round().clamp(0.0, 255.0) as u8,
(b * 255.0).round().clamp(0.0, 255.0) as u8,
alpha,
]
}
/// The fully-saturated hue at maximum value: `hsv(h, 1, 1)`.
pub fn hue_swatch(h: f32) -> Self {
Self::new(h, 1.0, 1.0)
}
/// The fully-saturated hue at maximum value: `hsv(h, 1, 1)`.
pub fn hue_swatch(h: f32) -> Self {
Self::new(h, 1.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 0.001
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 0.001
}
#[test]
fn primary_colors_round_trip() {
// Red.
assert!(approx(HsvColor::from_rgb(1.0, 0.0, 0.0).h, 0.0));
// Green.
assert!(approx(HsvColor::from_rgb(0.0, 1.0, 0.0).h, 120.0));
// Blue.
assert!(approx(HsvColor::from_rgb(0.0, 0.0, 1.0).h, 240.0));
// Yellow.
assert!(approx(HsvColor::from_rgb(1.0, 1.0, 0.0).h, 60.0));
}
#[test]
fn primary_colors_round_trip() {
// Red.
assert!(approx(HsvColor::from_rgb(1.0, 0.0, 0.0).h, 0.0));
// Green.
assert!(approx(HsvColor::from_rgb(0.0, 1.0, 0.0).h, 120.0));
// Blue.
assert!(approx(HsvColor::from_rgb(0.0, 0.0, 1.0).h, 240.0));
// Yellow.
assert!(approx(HsvColor::from_rgb(1.0, 1.0, 0.0).h, 60.0));
}
#[test]
fn rgb_to_hsv_to_rgb_round_trip() {
for (r, g, b) in [
(0.2, 0.4, 0.6),
(1.0, 0.5, 0.25),
(0.9, 0.9, 0.9),
(0.1, 0.1, 0.1),
] {
let hsv = HsvColor::from_rgb(r, g, b);
let (r2, g2, b2) = hsv.to_rgb();
assert!(approx(r, r2) && approx(g, g2) && approx(b, b2), "{hsv:?}");
}
}
#[test]
fn rgb_to_hsv_to_rgb_round_trip() {
for (r, g, b) in [
(0.2, 0.4, 0.6),
(1.0, 0.5, 0.25),
(0.9, 0.9, 0.9),
(0.1, 0.1, 0.1),
] {
let hsv = HsvColor::from_rgb(r, g, b);
let (r2, g2, b2) = hsv.to_rgb();
assert!(approx(r, r2) && approx(g, g2) && approx(b, b2), "{hsv:?}");
}
}
#[test]
fn saturation_and_value_extremes() {
// Gray has zero saturation.
let gray = HsvColor::from_rgb(0.5, 0.5, 0.5);
assert!(approx(gray.s, 0.0));
assert!(approx(gray.v, 0.5));
// Black has zero value.
let black = HsvColor::from_rgb(0.0, 0.0, 0.0);
assert!(approx(black.v, 0.0));
// White is full value, zero saturation.
let white = HsvColor::from_rgb(1.0, 1.0, 1.0);
assert!(approx(white.v, 1.0) && approx(white.s, 0.0));
}
#[test]
fn saturation_and_value_extremes() {
// Gray has zero saturation.
let gray = HsvColor::from_rgb(0.5, 0.5, 0.5);
assert!(approx(gray.s, 0.0));
assert!(approx(gray.v, 0.5));
// Black has zero value.
let black = HsvColor::from_rgb(0.0, 0.0, 0.0);
assert!(approx(black.v, 0.0));
// White is full value, zero saturation.
let white = HsvColor::from_rgb(1.0, 1.0, 1.0);
assert!(approx(white.v, 1.0) && approx(white.s, 0.0));
}
#[test]
fn to_rgba_u8_clamps() {
let red = HsvColor::new(0.0, 1.0, 1.0);
assert_eq!(red.to_rgba_u8(255), [255, 0, 0, 255]);
let black = HsvColor::new(120.0, 1.0, 0.0);
assert_eq!(black.to_rgba_u8(128), [0, 0, 0, 128]);
}
#[test]
fn to_rgba_u8_clamps() {
let red = HsvColor::new(0.0, 1.0, 1.0);
assert_eq!(red.to_rgba_u8(255), [255, 0, 0, 255]);
let black = HsvColor::new(120.0, 1.0, 0.0);
assert_eq!(black.to_rgba_u8(128), [0, 0, 0, 128]);
}
#[test]
fn hue_wraps() {
assert!(approx(HsvColor::new(360.0, 1.0, 1.0).h, 0.0));
assert!(approx(HsvColor::new(-30.0, 1.0, 1.0).h, 330.0));
// Hue 360 == hue 0: same color.
let (r1, g1, b1) = HsvColor::new(0.0, 1.0, 1.0).to_rgb();
let (r2, g2, b2) = HsvColor::new(360.0, 1.0, 1.0).to_rgb();
assert!((r1 - r2).abs() < 0.001 && (g1 - g2).abs() < 0.001 && (b1 - b2).abs() < 0.001);
}
#[test]
fn hue_wraps() {
assert!(approx(HsvColor::new(360.0, 1.0, 1.0).h, 0.0));
assert!(approx(HsvColor::new(-30.0, 1.0, 1.0).h, 330.0));
// Hue 360 == hue 0: same color.
let (r1, g1, b1) = HsvColor::new(0.0, 1.0, 1.0).to_rgb();
let (r2, g2, b2) = HsvColor::new(360.0, 1.0, 1.0).to_rgb();
assert!((r1 - r2).abs() < 0.001 && (g1 - g2).abs() < 0.001 && (b1 - b2).abs() < 0.001);
}
}
File diff suppressed because it is too large Load Diff
+406 -397
View File
@@ -10,469 +10,478 @@
//! or a click outside closes the menu.
use gpui::{
Anchor, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, KeyDownEvent,
Pixels, Point, Render, SharedString, Window, anchored, colors::DefaultColors, deferred, div,
point, prelude::*, px,
Anchor, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable,
KeyDownEvent, Pixels, Point, Render, SharedString, Window, anchored, colors::DefaultColors,
deferred, div, point, prelude::*, px,
};
/// A selectable option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComboBoxOption {
/// The option's value (stable id).
pub value: usize,
/// The label shown in the field and the menu.
pub label: SharedString,
/// The option's value (stable id).
pub value: usize,
/// The label shown in the field and the menu.
pub label: SharedString,
}
impl ComboBoxOption {
/// Create an option.
pub fn new(value: usize, label: impl Into<SharedString>) -> Self {
Self {
value,
label: label.into(),
}
}
/// Create an option.
pub fn new(value: usize, label: impl Into<SharedString>) -> Self {
Self {
value,
label: label.into(),
}
}
}
/// A request emitted by a combo box.
#[derive(Debug, Clone, PartialEq)]
pub enum ComboBoxEvent {
/// The user picked an option (click or arrow key).
Selected {
/// The control's stable id.
control: usize,
/// The chosen option value.
value: usize,
},
/// The menu was opened.
MenuOpened {
/// The control's stable id.
control: usize,
},
/// The menu was closed.
MenuClosed {
/// The control's stable id.
control: usize,
},
/// The user picked an option (click or arrow key).
Selected {
/// The control's stable id.
control: usize,
/// The chosen option value.
value: usize,
},
/// The menu was opened.
MenuOpened {
/// The control's stable id.
control: usize,
},
/// The menu was closed.
MenuClosed {
/// The control's stable id.
control: usize,
},
}
/// Move `selected` by `delta` positions over a list of `len` options,
/// wrapping around. `None` moves to the first (or last) option.
pub fn cycle_selection(selected: Option<usize>, len: usize, delta: i32) -> Option<usize> {
if len == 0 {
return None;
}
let current = match selected {
Some(index) => index,
None if delta > 0 => len - 1,
None => 0,
};
Some(((current as i64 + delta as i64).rem_euclid(len as i64)) as usize)
if len == 0 {
return None;
}
let current = match selected {
Some(index) => index,
None if delta > 0 => len - 1,
None => 0,
};
Some(((current as i64 + delta as i64).rem_euclid(len as i64)) as usize)
}
/// A drop-down selection control.
pub struct ComboBox {
control: usize,
options: Vec<ComboBoxOption>,
selected: Option<usize>,
placeholder: Option<SharedString>,
open: bool,
/// Window position of the menu, recorded when the field is clicked.
popup_position: Point<Pixels>,
/// Whether the menu was open when the field was pressed, so the toggle on
/// mouse-up does not reopen a menu that an outside-click close already
/// dismissed on the same click.
was_open_at_down: bool,
focus_handle: FocusHandle,
control: usize,
options: Vec<ComboBoxOption>,
selected: Option<usize>,
placeholder: Option<SharedString>,
open: bool,
/// Window position of the menu, recorded when the field is clicked.
popup_position: Point<Pixels>,
/// Whether the menu was open when the field was pressed, so the toggle on
/// mouse-up does not reopen a menu that an outside-click close already
/// dismissed on the same click.
was_open_at_down: bool,
focus_handle: FocusHandle,
}
impl ComboBox {
/// Create a combo box for `control` over `options`.
pub fn new(
control: usize,
options: Vec<ComboBoxOption>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
selected: None,
placeholder: None,
open: false,
popup_position: Point::default(),
was_open_at_down: false,
focus_handle: cx.focus_handle(),
}
}
/// Create a combo box for `control` over `options`.
pub fn new(
control: usize,
options: Vec<ComboBoxOption>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
selected: None,
placeholder: None,
open: false,
popup_position: Point::default(),
was_open_at_down: false,
focus_handle: cx.focus_handle(),
}
}
/// Text shown when nothing is selected.
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
/// Text shown when nothing is selected.
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
/// The currently selected option value, if any.
pub fn selected(&self) -> Option<usize> {
self.selected
}
/// The currently selected option value, if any.
pub fn selected(&self) -> Option<usize> {
self.selected
}
/// Whether the menu is open.
pub fn is_open(&self) -> bool {
self.open
}
/// Whether the menu is open.
pub fn is_open(&self) -> bool {
self.open
}
/// Apply the host's selection and repaint.
pub fn set_selected(&mut self, selected: Option<usize>, cx: &mut Context<Self>) {
if self.selected != selected {
self.selected = selected;
cx.notify();
}
}
/// Apply the host's selection and repaint.
pub fn set_selected(&mut self, selected: Option<usize>, cx: &mut Context<Self>) {
if self.selected != selected {
self.selected = selected;
cx.notify();
}
}
fn selected_index(&self) -> Option<usize> {
self.selected
.and_then(|value| self.options.iter().position(|o| o.value == value))
}
fn selected_index(&self) -> Option<usize> {
self.selected
.and_then(|value| self.options.iter().position(|o| o.value == value))
}
fn open_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
if !self.open {
self.open = true;
self.popup_position = position;
cx.emit(ComboBoxEvent::MenuOpened {
control: self.control,
});
cx.notify();
}
}
fn open_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
if !self.open {
self.open = true;
self.popup_position = position;
cx.emit(ComboBoxEvent::MenuOpened {
control: self.control,
});
cx.notify();
}
}
fn close_menu(&mut self, cx: &mut Context<Self>) {
if self.open {
self.open = false;
cx.emit(ComboBoxEvent::MenuClosed {
control: self.control,
});
cx.notify();
}
}
fn close_menu(&mut self, cx: &mut Context<Self>) {
if self.open {
self.open = false;
cx.emit(ComboBoxEvent::MenuClosed {
control: self.control,
});
cx.notify();
}
}
fn toggle_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
if self.open {
self.close_menu(cx);
} else {
self.open_menu(position, cx);
}
}
fn toggle_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
if self.open {
self.close_menu(cx);
} else {
self.open_menu(position, cx);
}
}
fn pick(&mut self, value: usize, cx: &mut Context<Self>) {
self.selected = Some(value);
self.open = false;
cx.emit(ComboBoxEvent::Selected {
control: self.control,
value,
});
cx.emit(ComboBoxEvent::MenuClosed {
control: self.control,
});
cx.notify();
}
fn pick(&mut self, value: usize, cx: &mut Context<Self>) {
self.selected = Some(value);
self.open = false;
cx.emit(ComboBoxEvent::Selected {
control: self.control,
value,
});
cx.emit(ComboBoxEvent::MenuClosed {
control: self.control,
});
cx.notify();
}
fn cycle(&mut self, delta: i32, cx: &mut Context<Self>) {
if let Some(index) = cycle_selection(self.selected_index(), self.options.len(), delta) {
let value = self.options[index].value;
self.pick(value, cx);
}
}
fn cycle(&mut self, delta: i32, cx: &mut Context<Self>) {
if let Some(index) = cycle_selection(self.selected_index(), self.options.len(), delta) {
let value = self.options[index].value;
self.pick(value, cx);
}
}
}
impl EventEmitter<ComboBoxEvent> for ComboBox {}
impl Focusable for ComboBox {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ComboBox {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let label = self
.selected
.and_then(|value| self.options.iter().find(|o| o.value == value))
.map(|o| o.label.clone())
.or_else(|| self.placeholder.clone())
.unwrap_or_else(|| SharedString::from(""));
let open = self.open;
let control = self.control;
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let label = self
.selected
.and_then(|value| self.options.iter().find(|o| o.value == value))
.map(|o| o.label.clone())
.or_else(|| self.placeholder.clone())
.unwrap_or_else(|| SharedString::from(""));
let open = self.open;
let control = self.control;
let field = div()
.id(ElementId::named_usize("gpui-widgets-combo-field", control))
.min_w(px(140.0))
.h(px(24.0))
.rounded_md()
.border_1()
.border_color(if open { colors.selected } else { colors.border })
.bg(colors.background)
.px_2()
.flex()
.items_center()
.justify_between()
.gap(px(6.0))
.cursor_pointer()
.track_focus(&self.focus_handle)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _event: &gpui::MouseDownEvent, _window, _cx| {
this.was_open_at_down = this.open;
}),
)
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
if this.was_open_at_down {
this.close_menu(cx);
} else {
this.open_menu(event.position(), cx);
}
cx.stop_propagation();
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"up" => this.cycle(-1, cx),
"down" => this.cycle(1, cx),
"escape" => this.close_menu(cx),
"enter" | "space" => {
this.toggle_menu(this.popup_position, cx);
}
_ => {}
}
}))
.child(div().text_color(colors.text).child(label))
.child(div().child(chevron(colors.border)));
let field = div()
.id(ElementId::named_usize("gpui-widgets-combo-field", control))
.min_w(px(140.0))
.h(px(24.0))
.rounded_md()
.border_1()
.border_color(if open { colors.selected } else { colors.border })
.bg(colors.background)
.px_2()
.flex()
.items_center()
.justify_between()
.gap(px(6.0))
.cursor_pointer()
.track_focus(&self.focus_handle)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _event: &gpui::MouseDownEvent, _window, _cx| {
this.was_open_at_down = this.open;
}),
)
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
if this.was_open_at_down {
this.close_menu(cx);
} else {
this.open_menu(event.position(), cx);
}
cx.stop_propagation();
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"up" => this.cycle(-1, cx),
"down" => this.cycle(1, cx),
"escape" => this.close_menu(cx),
"enter" | "space" => {
this.toggle_menu(this.popup_position, cx);
}
_ => {}
}
}))
.child(div().text_color(colors.text).child(label))
.child(div().child(chevron(colors.border)));
let popup = if open {
// Build the option list inline so each option can carry its own
// view listener.
let mut menu = div()
.id(ElementId::named_usize("gpui-widgets-combo-menu", control))
.debug_selector(|| "combo-menu".into())
.min_w(px(140.0))
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.container)
.py_1()
.flex()
.flex_col()
.overflow_y_scroll()
.max_h(px(220.0))
.on_mouse_up_out(
gpui::MouseButton::Left,
cx.listener(|this, _event: &gpui::MouseUpEvent, _window, cx| {
this.close_menu(cx);
}),
);
let popup = if open {
// Build the option list inline so each option can carry its own
// view listener.
let mut menu = div()
.id(ElementId::named_usize("gpui-widgets-combo-menu", control))
.debug_selector(|| "combo-menu".into())
.min_w(px(140.0))
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.container)
.py_1()
.flex()
.flex_col()
.overflow_y_scroll()
.max_h(px(220.0))
.on_mouse_up_out(
gpui::MouseButton::Left,
cx.listener(|this, _event: &gpui::MouseUpEvent, _window, cx| {
this.close_menu(cx);
}),
);
for option in &self.options {
let is_selected = self.selected == Some(option.value);
let value = option.value;
let label = option.label.clone();
menu = menu.child(
div()
.id(ElementId::named_usize(
format!("gpui-widgets-combo-option-{control}"),
value,
))
.px_2()
.py_1()
.text_color(colors.text)
.hover(|style| style.bg(colors.selected))
.when(is_selected, |el| el.text_color(colors.selected_text))
.on_click(
cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.pick(value, cx);
cx.stop_propagation();
}),
)
.child(label),
);
}
for option in &self.options {
let is_selected = self.selected == Some(option.value);
let value = option.value;
let label = option.label.clone();
menu = menu.child(
div()
.id(ElementId::named_usize(
format!("gpui-widgets-combo-option-{control}"),
value,
))
.px_2()
.py_1()
.text_color(colors.text)
.hover(|style| style.bg(colors.selected))
.when(is_selected, |el| el.text_color(colors.selected_text))
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.pick(value, cx);
cx.stop_propagation();
}))
.child(label),
);
}
deferred(
anchored()
.position(self.popup_position)
.anchor(Anchor::TopLeft)
.offset(point(px(0.0), px(26.0)))
.snap_to_window_with_margin(px(8.0))
.child(menu),
)
.with_priority(1)
} else {
deferred(div())
};
deferred(
anchored()
.position(self.popup_position)
.anchor(Anchor::TopLeft)
.offset(point(px(0.0), px(26.0)))
.snap_to_window_with_margin(px(8.0))
.child(menu),
)
.with_priority(1)
} else {
deferred(div())
};
div()
.relative()
.child(field)
.child(popup)
}
div().relative().child(field).child(popup)
}
}
/// A small down-chevron painted with a canvas.
fn chevron(color: gpui::Rgba) -> impl IntoElement {
use gpui::{canvas, point, px, Bounds, PathBuilder, Pixels};
use gpui::{Bounds, PathBuilder, Pixels, canvas, point, px};
canvas(
move |_bounds, _window, _cx| (),
move |bounds: Bounds<Pixels>, (), window, cx| {
let _ = cx;
let mut path = PathBuilder::fill();
path.move_to(point(bounds.left() + px(3.0), bounds.top() + px(2.0)));
path.line_to(point(bounds.center().x, bounds.bottom() - px(2.0)));
path.line_to(point(bounds.right() - px(3.0), bounds.top() + px(2.0)));
path.close();
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
canvas(
move |_bounds, _window, _cx| (),
move |bounds: Bounds<Pixels>, (), window, cx| {
let _ = cx;
let mut path = PathBuilder::fill();
path.move_to(point(bounds.left() + px(3.0), bounds.top() + px(2.0)));
path.line_to(point(bounds.center().x, bounds.bottom() - px(2.0)));
path.line_to(point(bounds.right() - px(3.0), bounds.top() + px(2.0)));
path.close();
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
#[test]
fn cycle_selection_wraps() {
assert_eq!(cycle_selection(None, 3, 1), Some(0));
assert_eq!(cycle_selection(None, 3, -1), Some(2));
assert_eq!(cycle_selection(Some(0), 3, -1), Some(2));
assert_eq!(cycle_selection(Some(2), 3, 1), Some(0));
assert_eq!(cycle_selection(Some(1), 3, 1), Some(2));
assert_eq!(cycle_selection(None, 0, 1), None);
}
#[test]
fn cycle_selection_wraps() {
assert_eq!(cycle_selection(None, 3, 1), Some(0));
assert_eq!(cycle_selection(None, 3, -1), Some(2));
assert_eq!(cycle_selection(Some(0), 3, -1), Some(2));
assert_eq!(cycle_selection(Some(2), 3, 1), Some(0));
assert_eq!(cycle_selection(Some(1), 3, 1), Some(2));
assert_eq!(cycle_selection(None, 0, 1), None);
}
fn options() -> Vec<ComboBoxOption> {
vec![
ComboBoxOption::new(1, "Frame"),
ComboBoxOption::new(2, "Timecode"),
ComboBoxOption::new(3, "Frames"),
]
}
fn options() -> Vec<ComboBoxOption> {
vec![
ComboBoxOption::new(1, "Frame"),
ComboBoxOption::new(2, "Timecode"),
ComboBoxOption::new(3, "Frames"),
]
}
#[gpui::test]
async fn click_opens_menu_and_selects(cx: &mut TestAppContext) {
struct Host {
combo: Entity<ComboBox>,
events: Vec<ComboBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.combo.clone())
}
}
#[gpui::test]
async fn click_opens_menu_and_selects(cx: &mut TestAppContext) {
struct Host {
combo: Entity<ComboBox>,
events: Vec<ComboBoxEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.combo.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let combo = cx.new(|cx| ComboBox::new(1, options(), window, cx));
let host = Host {
combo,
events: Vec::new(),
};
cx.subscribe(
&host.combo,
|host: &mut Host,
_c: Entity<ComboBox>,
event: &ComboBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let combo = cx.new(|cx| ComboBox::new(1, options(), window, cx));
let host = Host {
combo,
events: Vec::new(),
};
cx.subscribe(
&host.combo,
|host: &mut Host,
_c: Entity<ComboBox>,
event: &ComboBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Open the menu, then force a redraw so the popup lands in the frame.
cx.simulate_click(point(px(70.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Open the menu, then force a redraw so the popup lands in the frame.
cx.simulate_click(point(px(70.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let menu_visible = cx.debug_bounds("combo-menu").is_some();
eprintln!("menu bounds: {:?}", cx.debug_bounds("combo-menu"));
assert!(menu_visible, "menu should be open after clicking the field");
let menu_visible = cx.debug_bounds("combo-menu").is_some();
eprintln!("menu bounds: {:?}", cx.debug_bounds("combo-menu"));
assert!(menu_visible, "menu should be open after clicking the field");
// Pick the second option (roughly below the field).
// Pick the second option (menu at x=70, options from x=71; use a
// well-inside point).
cx.simulate_click(point(px(90.0), px(94.0)), Modifiers::none());
cx.run_until_parked();
let (selected, emitted, menu_closed) = cx.read(|app| {
let host = host.read(app);
(
host.combo.read(app).selected(),
host.events.iter().any(|e| {
matches!(e, ComboBoxEvent::Selected { control: 1, value: 2 })
}),
host.events
.iter()
.any(|e| matches!(e, ComboBoxEvent::MenuClosed { .. })),
)
});
assert_eq!(selected, Some(2));
assert!(emitted);
assert!(menu_closed);
}
// Pick the second option (roughly below the field).
// Pick the second option (menu at x=70, options from x=71; use a
// well-inside point).
cx.simulate_click(point(px(90.0), px(94.0)), Modifiers::none());
cx.run_until_parked();
let (selected, emitted, menu_closed) = cx.read(|app| {
let host = host.read(app);
(
host.combo.read(app).selected(),
host.events.iter().any(|e| {
matches!(
e,
ComboBoxEvent::Selected {
control: 1,
value: 2
}
)
}),
host.events
.iter()
.any(|e| matches!(e, ComboBoxEvent::MenuClosed { .. })),
)
});
assert_eq!(selected, Some(2));
assert!(emitted);
assert!(menu_closed);
}
#[gpui::test]
async fn arrow_keys_cycle_selection(cx: &mut TestAppContext) {
struct Host {
combo: Entity<ComboBox>,
events: Vec<ComboBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.combo.clone())
}
}
#[gpui::test]
async fn arrow_keys_cycle_selection(cx: &mut TestAppContext) {
struct Host {
combo: Entity<ComboBox>,
events: Vec<ComboBoxEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.combo.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let combo = cx.new(|cx| ComboBox::new(1, options(), window, cx));
let host = Host {
combo,
events: Vec::new(),
};
cx.subscribe(
&host.combo,
|host: &mut Host,
_c: Entity<ComboBox>,
event: &ComboBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let combo = cx.new(|cx| ComboBox::new(1, options(), window, cx));
let host = Host {
combo,
events: Vec::new(),
};
cx.subscribe(
&host.combo,
|host: &mut Host,
_c: Entity<ComboBox>,
event: &ComboBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Focus the field, then press down twice: none -> first -> second.
cx.simulate_click(point(px(70.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
cx.simulate_keystrokes("escape");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Focus the field, then press down twice: none -> first -> second.
cx.simulate_click(point(px(70.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
cx.simulate_keystrokes("escape");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
let selected = cx.read(|app| host.read(app).combo.read(app).selected());
assert_eq!(selected, Some(2));
}
let selected = cx.read(|app| host.read(app).combo.read(app).selected());
assert_eq!(selected, Some(2));
}
}
+248 -227
View File
@@ -4,297 +4,318 @@
/// A 2D point in normalized curve space (`x` and `y` in `0..1`).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct CurveVec2 {
/// Horizontal position.
pub x: f64,
/// Vertical position.
pub y: f64,
/// Horizontal position.
pub x: f64,
/// Vertical position.
pub y: f64,
}
impl CurveVec2 {
/// Create a vector.
pub const fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
/// Create a vector.
pub const fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
/// Which control handle of a point is being edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandleSide {
/// The handle leading into the point (from the previous point).
In,
/// The handle leaving the point (toward the next point).
Out,
/// The handle leading into the point (from the previous point).
In,
/// The handle leaving the point (toward the next point).
Out,
}
/// A keyframe point with optional bezier control handles (offsets from the
/// point, in normalized units).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CurvePoint {
/// Input position, `0..1`.
pub x: f64,
/// Output value, `0..1`.
pub y: f64,
/// Control point offset for the incoming segment.
pub handle_in: Option<CurveVec2>,
/// Control point offset for the outgoing segment.
pub handle_out: Option<CurveVec2>,
/// Input position, `0..1`.
pub x: f64,
/// Output value, `0..1`.
pub y: f64,
/// Control point offset for the incoming segment.
pub handle_in: Option<CurveVec2>,
/// Control point offset for the outgoing segment.
pub handle_out: Option<CurveVec2>,
}
impl CurvePoint {
/// Create a point with no handles (linear segments).
pub const fn new(x: f64, y: f64) -> Self {
Self {
x,
y,
handle_in: None,
handle_out: None,
}
}
/// Create a point with no handles (linear segments).
pub const fn new(x: f64, y: f64) -> Self {
Self {
x,
y,
handle_in: None,
handle_out: None,
}
}
/// Create a point with both handles set to `offset`.
pub const fn with_handles(x: f64, y: f64, offset: CurveVec2) -> Self {
Self {
x,
y,
handle_in: Some(offset),
handle_out: Some(offset),
}
}
/// Create a point with both handles set to `offset`.
pub const fn with_handles(x: f64, y: f64, offset: CurveVec2) -> Self {
Self {
x,
y,
handle_in: Some(offset),
handle_out: Some(offset),
}
}
}
/// Evaluate a cubic bezier at `t` in `0..1`.
pub fn cubic_bezier(p0: CurveVec2, c1: CurveVec2, c2: CurveVec2, p1: CurveVec2, t: f64) -> CurveVec2 {
let u = 1.0 - t;
CurveVec2::new(
u * u * u * p0.x + 3.0 * u * u * t * c1.x + 3.0 * u * t * t * c2.x + t * t * t * p1.x,
u * u * u * p0.y + 3.0 * u * u * t * c1.y + 3.0 * u * t * t * c2.y + t * t * t * p1.y,
)
pub fn cubic_bezier(
p0: CurveVec2,
c1: CurveVec2,
c2: CurveVec2,
p1: CurveVec2,
t: f64,
) -> CurveVec2 {
let u = 1.0 - t;
CurveVec2::new(
u * u * u * p0.x + 3.0 * u * u * t * c1.x + 3.0 * u * t * t * c2.x + t * t * t * p1.x,
u * u * u * p0.y + 3.0 * u * u * t * c1.y + 3.0 * u * t * t * c2.y + t * t * t * p1.y,
)
}
/// The control points of the segment from `p0` to `p1` (linear when handles
/// are absent).
pub fn segment_controls(p0: &CurvePoint, p1: &CurvePoint) -> (CurveVec2, CurveVec2) {
let c1 = match p0.handle_out {
Some(h) => CurveVec2::new(p0.x + h.x, p0.y + h.y),
None => CurveVec2::new((p0.x + p1.x) / 2.0, p0.y),
};
let c2 = match p1.handle_in {
Some(h) => CurveVec2::new(p1.x + h.x, p1.y + h.y),
None => CurveVec2::new((p0.x + p1.x) / 2.0, p1.y),
};
(c1, c2)
let c1 = match p0.handle_out {
Some(h) => CurveVec2::new(p0.x + h.x, p0.y + h.y),
None => CurveVec2::new((p0.x + p1.x) / 2.0, p0.y),
};
let c2 = match p1.handle_in {
Some(h) => CurveVec2::new(p1.x + h.x, p1.y + h.y),
None => CurveVec2::new((p0.x + p1.x) / 2.0, p1.y),
};
(c1, c2)
}
/// Sample the curve at `x`, returning the output value. `x` is clamped to
/// the point range; values before the first (after the last) point clamp to
/// the first (last) point's output.
pub fn sample_curve(points: &[CurvePoint], x: f64) -> f64 {
if points.is_empty() {
return 0.0;
}
if points.len() == 1 {
return points[0].y;
}
let x = x.clamp(points[0].x, points[points.len() - 1].x);
let index = points
.windows(2)
.position(|w| x >= w[0].x && x <= w[1].x)
.unwrap_or(points.len() - 2);
let p0 = &points[index];
let p1 = &points[index + 1];
let (c1, c2) = segment_controls(p0, p1);
sample_segment(
CurveVec2::new(p0.x, p0.y),
c1,
c2,
CurveVec2::new(p1.x, p1.y),
x,
)
if points.is_empty() {
return 0.0;
}
if points.len() == 1 {
return points[0].y;
}
let x = x.clamp(points[0].x, points[points.len() - 1].x);
let index = points
.windows(2)
.position(|w| x >= w[0].x && x <= w[1].x)
.unwrap_or(points.len() - 2);
let p0 = &points[index];
let p1 = &points[index + 1];
let (c1, c2) = segment_controls(p0, p1);
sample_segment(
CurveVec2::new(p0.x, p0.y),
c1,
c2,
CurveVec2::new(p1.x, p1.y),
x,
)
}
/// Sample one segment for the `y` at a given `x`, by finding the `t` whose
/// bezier x coordinate matches (binary search, since bezier x is monotonic
/// for well-formed curves).
fn sample_segment(p0: CurveVec2, c1: CurveVec2, c2: CurveVec2, p1: CurveVec2, x: f64) -> f64 {
let mut lo = 0.0;
let mut hi = 1.0;
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
let px = cubic_bezier(p0, c1, c2, p1, mid).x;
if px < x {
lo = mid;
} else {
hi = mid;
}
}
let t = (lo + hi) / 2.0;
cubic_bezier(p0, c1, c2, p1, t).y
let mut lo = 0.0;
let mut hi = 1.0;
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
let px = cubic_bezier(p0, c1, c2, p1, mid).x;
if px < x {
lo = mid;
} else {
hi = mid;
}
}
let t = (lo + hi) / 2.0;
cubic_bezier(p0, c1, c2, p1, t).y
}
/// Approximate the whole curve as a polyline (for painting). Each segment is
/// sampled `samples` times.
pub fn polyline(points: &[CurvePoint], samples: usize) -> Vec<CurveVec2> {
let mut out = Vec::new();
if points.is_empty() {
return out;
}
out.push(CurveVec2::new(points[0].x, points[0].y));
for window in points.windows(2) {
let (p0, p1) = (&window[0], &window[1]);
let (c1, c2) = segment_controls(p0, p1);
let p0v = CurveVec2::new(p0.x, p0.y);
let p1v = CurveVec2::new(p1.x, p1.y);
for i in 1..=samples {
let t = i as f64 / samples as f64;
out.push(cubic_bezier(p0v, c1, c2, p1v, t));
}
}
out
let mut out = Vec::new();
if points.is_empty() {
return out;
}
out.push(CurveVec2::new(points[0].x, points[0].y));
for window in points.windows(2) {
let (p0, p1) = (&window[0], &window[1]);
let (c1, c2) = segment_controls(p0, p1);
let p0v = CurveVec2::new(p0.x, p0.y);
let p1v = CurveVec2::new(p1.x, p1.y);
for i in 1..=samples {
let t = i as f64 / samples as f64;
out.push(cubic_bezier(p0v, c1, c2, p1v, t));
}
}
out
}
/// Find the point closest to `pos` within `threshold` (normalized units).
pub fn hit_test_point(points: &[CurvePoint], pos: CurveVec2, threshold: f64) -> Option<usize> {
let mut best = None;
let mut best_dist = threshold;
for (index, point) in points.iter().enumerate() {
let dx = point.x - pos.x;
let dy = point.y - pos.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= best_dist {
best_dist = dist;
best = Some(index);
}
}
best
let mut best = None;
let mut best_dist = threshold;
for (index, point) in points.iter().enumerate() {
let dx = point.x - pos.x;
let dy = point.y - pos.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= best_dist {
best_dist = dist;
best = Some(index);
}
}
best
}
/// Find the handle closest to `pos` within `threshold`, preferring handles
/// over points when both are within reach.
pub fn hit_test_handle(
points: &[CurvePoint],
pos: CurveVec2,
threshold: f64,
points: &[CurvePoint],
pos: CurveVec2,
threshold: f64,
) -> Option<(usize, HandleSide)> {
let mut best = None;
let mut best_dist = threshold;
for (index, point) in points.iter().enumerate() {
for (side, handle) in [
(HandleSide::In, point.handle_in),
(HandleSide::Out, point.handle_out),
] {
if let Some(h) = handle {
let hp = CurveVec2::new(point.x + h.x, point.y + h.y);
let dx = hp.x - pos.x;
let dy = hp.y - pos.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= best_dist {
best_dist = dist;
best = Some((index, side));
}
}
}
}
best
let mut best = None;
let mut best_dist = threshold;
for (index, point) in points.iter().enumerate() {
for (side, handle) in [
(HandleSide::In, point.handle_in),
(HandleSide::Out, point.handle_out),
] {
if let Some(h) = handle {
let hp = CurveVec2::new(point.x + h.x, point.y + h.y);
let dx = hp.x - pos.x;
let dy = hp.y - pos.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= best_dist {
best_dist = dist;
best = Some((index, side));
}
}
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn bezier_endpoints() {
let p0 = CurveVec2::new(0.0, 0.0);
let p1 = CurveVec2::new(1.0, 1.0);
assert_eq!(cubic_bezier(p0, p0, p1, p1, 0.0), p0);
assert_eq!(cubic_bezier(p0, p0, p1, p1, 1.0), p1);
// A straight-line bezier at t=0.5 is the midpoint.
let mid = cubic_bezier(p0, p0, p1, p1, 0.5);
assert!(approx(mid.x, 0.5) && approx(mid.y, 0.5));
}
#[test]
fn bezier_endpoints() {
let p0 = CurveVec2::new(0.0, 0.0);
let p1 = CurveVec2::new(1.0, 1.0);
assert_eq!(cubic_bezier(p0, p0, p1, p1, 0.0), p0);
assert_eq!(cubic_bezier(p0, p0, p1, p1, 1.0), p1);
// A straight-line bezier at t=0.5 is the midpoint.
let mid = cubic_bezier(p0, p0, p1, p1, 0.5);
assert!(approx(mid.x, 0.5) && approx(mid.y, 0.5));
}
#[test]
fn linear_curve_samples_exactly() {
let points = vec![CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)];
assert!(approx(sample_curve(&points, 0.0), 0.0));
assert!(approx(sample_curve(&points, 0.5), 0.5));
assert!(approx(sample_curve(&points, 1.0), 1.0));
// Clamps outside the range.
assert!(approx(sample_curve(&points, 2.0), 1.0));
assert!(approx(sample_curve(&points, -1.0), 0.0));
}
#[test]
fn linear_curve_samples_exactly() {
let points = vec![CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)];
assert!(approx(sample_curve(&points, 0.0), 0.0));
assert!(approx(sample_curve(&points, 0.5), 0.5));
assert!(approx(sample_curve(&points, 1.0), 1.0));
// Clamps outside the range.
assert!(approx(sample_curve(&points, 2.0), 1.0));
assert!(approx(sample_curve(&points, -1.0), 0.0));
}
#[test]
fn stepped_curve_clamps_to_segments() {
let points = vec![
CurvePoint::new(0.0, 0.0),
CurvePoint::new(0.5, 0.0),
CurvePoint::new(1.0, 1.0),
];
assert!(approx(sample_curve(&points, 0.25), 0.0));
assert!(approx(sample_curve(&points, 0.75), 0.5));
}
#[test]
fn stepped_curve_clamps_to_segments() {
let points = vec![
CurvePoint::new(0.0, 0.0),
CurvePoint::new(0.5, 0.0),
CurvePoint::new(1.0, 1.0),
];
assert!(approx(sample_curve(&points, 0.25), 0.0));
assert!(approx(sample_curve(&points, 0.75), 0.5));
}
#[test]
fn single_point_is_constant() {
let points = vec![CurvePoint::new(0.5, 0.7)];
assert!(approx(sample_curve(&points, 0.0), 0.7));
assert!(approx(sample_curve(&points, 0.9), 0.7));
}
#[test]
fn single_point_is_constant() {
let points = vec![CurvePoint::new(0.5, 0.7)];
assert!(approx(sample_curve(&points, 0.0), 0.7));
assert!(approx(sample_curve(&points, 0.9), 0.7));
}
#[test]
fn bezier_handles_bend_the_curve() {
// A curve whose outgoing handle pushes straight up at the start must
// start with output above the linear interpolation.
let points = vec![
CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 1.0)),
CurvePoint::new(1.0, 1.0),
];
let linear = sample_curve(&[CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)], 0.25);
let bent = sample_curve(&points, 0.25);
assert!(bent > linear, "bent={bent} linear={linear}");
}
#[test]
fn bezier_handles_bend_the_curve() {
// A curve whose outgoing handle pushes straight up at the start must
// start with output above the linear interpolation.
let points = vec![
CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 1.0)),
CurvePoint::new(1.0, 1.0),
];
let linear = sample_curve(
&[CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)],
0.25,
);
let bent = sample_curve(&points, 0.25);
assert!(bent > linear, "bent={bent} linear={linear}");
}
#[test]
fn polyline_has_expected_length() {
let points = vec![CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)];
let line = polyline(&points, 8);
assert_eq!(line.len(), 9);
assert_eq!(line.first().unwrap(), &CurveVec2::new(0.0, 0.0));
assert_eq!(line.last().unwrap(), &CurveVec2::new(1.0, 1.0));
assert_eq!(polyline(&[], 8).len(), 0);
}
#[test]
fn polyline_has_expected_length() {
let points = vec![CurvePoint::new(0.0, 0.0), CurvePoint::new(1.0, 1.0)];
let line = polyline(&points, 8);
assert_eq!(line.len(), 9);
assert_eq!(line.first().unwrap(), &CurveVec2::new(0.0, 0.0));
assert_eq!(line.last().unwrap(), &CurveVec2::new(1.0, 1.0));
assert_eq!(polyline(&[], 8).len(), 0);
}
#[test]
fn hit_test_finds_nearest_point() {
let points = vec![
CurvePoint::new(0.1, 0.1),
CurvePoint::new(0.5, 0.5),
CurvePoint::new(0.9, 0.9),
];
assert_eq!(hit_test_point(&points, CurveVec2::new(0.52, 0.52), 0.1), Some(1));
assert_eq!(hit_test_point(&points, CurveVec2::new(0.1, 0.1), 0.1), Some(0));
// Beyond the threshold.
assert_eq!(hit_test_point(&points, CurveVec2::new(0.3, 0.3), 0.05), None);
}
#[test]
fn hit_test_finds_nearest_point() {
let points = vec![
CurvePoint::new(0.1, 0.1),
CurvePoint::new(0.5, 0.5),
CurvePoint::new(0.9, 0.9),
];
assert_eq!(
hit_test_point(&points, CurveVec2::new(0.52, 0.52), 0.1),
Some(1)
);
assert_eq!(
hit_test_point(&points, CurveVec2::new(0.1, 0.1), 0.1),
Some(0)
);
// Beyond the threshold.
assert_eq!(
hit_test_point(&points, CurveVec2::new(0.3, 0.3), 0.05),
None
);
}
#[test]
fn hit_test_finds_handle() {
let points = vec![CurvePoint {
x: 0.2,
y: 0.5,
handle_in: Some(CurveVec2::new(-0.1, -0.1)),
handle_out: Some(CurveVec2::new(0.1, 0.1)),
}];
// The outgoing handle endpoint sits at (0.3, 0.6).
let hit = hit_test_handle(&points, CurveVec2::new(0.31, 0.61), 0.05);
assert_eq!(hit, Some((0, HandleSide::Out)));
// The incoming handle endpoint sits at (0.1, 0.4).
let hit = hit_test_handle(&points, CurveVec2::new(0.09, 0.39), 0.05);
assert_eq!(hit, Some((0, HandleSide::In)));
assert_eq!(hit_test_handle(&points, CurveVec2::new(0.9, 0.9), 0.05), None);
}
#[test]
fn hit_test_finds_handle() {
let points = vec![CurvePoint {
x: 0.2,
y: 0.5,
handle_in: Some(CurveVec2::new(-0.1, -0.1)),
handle_out: Some(CurveVec2::new(0.1, 0.1)),
}];
// The outgoing handle endpoint sits at (0.3, 0.6).
let hit = hit_test_handle(&points, CurveVec2::new(0.31, 0.61), 0.05);
assert_eq!(hit, Some((0, HandleSide::Out)));
// The incoming handle endpoint sits at (0.1, 0.4).
let hit = hit_test_handle(&points, CurveVec2::new(0.09, 0.39), 0.05);
assert_eq!(hit, Some((0, HandleSide::In)));
assert_eq!(
hit_test_handle(&points, CurveVec2::new(0.9, 0.9), 0.05),
None
);
}
}
+465 -448
View File
@@ -12,9 +12,9 @@ mod curve;
use std::sync::{Arc, RwLock};
use gpui::{
App, Bounds, ClickEvent, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle,
Focusable, Hsla, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Render, Window,
canvas, colors::DefaultColors, div, fill, point, prelude::*, px, quad, size,
App, Bounds, ClickEvent, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle,
Focusable, Hsla, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Render, Window,
canvas, colors::DefaultColors, div, fill, point, prelude::*, px, quad, size,
};
use gpui::{BorderStyle, Corners, Edges, PathBuilder};
@@ -28,49 +28,49 @@ const HIT_THRESHOLD: f64 = 0.06;
/// A request emitted by a curve editor.
#[derive(Debug, Clone, PartialEq)]
pub enum CurveEditorEvent {
/// A keyframe point was dragged to a new position.
PointMoved {
/// The control's stable id.
control: usize,
/// The point's index in the (sorted) point list.
index: usize,
/// The point's new position/handles.
point: CurvePoint,
},
/// A bezier control handle was dragged.
HandleMoved {
/// The control's stable id.
control: usize,
/// The owning point's index.
index: usize,
/// Which handle was moved.
side: HandleSide,
/// The handle's new offset from the point (normalized).
handle: CurveVec2,
},
/// A new keyframe point was added (double-click on the canvas).
PointAdded {
/// The control's stable id.
control: usize,
/// The index the point was inserted at.
index: usize,
/// The new point.
point: CurvePoint,
},
/// A keyframe point was dragged to a new position.
PointMoved {
/// The control's stable id.
control: usize,
/// The point's index in the (sorted) point list.
index: usize,
/// The point's new position/handles.
point: CurvePoint,
},
/// A bezier control handle was dragged.
HandleMoved {
/// The control's stable id.
control: usize,
/// The owning point's index.
index: usize,
/// Which handle was moved.
side: HandleSide,
/// The handle's new offset from the point (normalized).
handle: CurveVec2,
},
/// A new keyframe point was added (double-click on the canvas).
PointAdded {
/// The control's stable id.
control: usize,
/// The index the point was inserted at.
index: usize,
/// The new point.
point: CurvePoint,
},
}
/// What a drag gesture is editing.
#[derive(Clone, Copy, Debug, PartialEq)]
enum DragTarget {
None,
Point(usize),
Handle { index: usize, side: HandleSide },
None,
Point(usize),
Handle { index: usize, side: HandleSide },
}
/// Transient payload carried by an in-flight drag.
#[derive(Clone, Copy, Debug)]
struct CurveDrag {
target: DragTarget,
target: DragTarget,
}
/// Invisible ghost view for drags.
@@ -78,464 +78,481 @@ struct CurveDrag {
struct CurveGhost;
impl Render for CurveGhost {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(0.0)).h(px(0.0))
}
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(0.0)).h(px(0.0))
}
}
/// A keyframe curve editor.
pub struct CurveEditor {
control: usize,
points: Vec<CurvePoint>,
focus_handle: FocusHandle,
/// Canvas bounds, refreshed each frame for position conversion.
bounds: Bounds<Pixels>,
/// Hit target recorded on mouse-down, consumed when the drag starts.
pending_drag: Option<DragTarget>,
control: usize,
points: Vec<CurvePoint>,
focus_handle: FocusHandle,
/// Canvas bounds, refreshed each frame for position conversion.
bounds: Bounds<Pixels>,
/// Hit target recorded on mouse-down, consumed when the drag starts.
pending_drag: Option<DragTarget>,
}
impl CurveEditor {
/// Create an editor for `control` over `points` (already sorted by x).
pub fn new(
control: usize,
points: Vec<CurvePoint>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
points,
focus_handle: cx.focus_handle(),
bounds: Bounds::default(),
pending_drag: None,
}
}
/// Create an editor for `control` over `points` (already sorted by x).
pub fn new(
control: usize,
points: Vec<CurvePoint>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
points,
focus_handle: cx.focus_handle(),
bounds: Bounds::default(),
pending_drag: None,
}
}
/// The current working copy of the curve.
pub fn points(&self) -> &[CurvePoint] {
&self.points
}
/// The current working copy of the curve.
pub fn points(&self) -> &[CurvePoint] {
&self.points
}
/// Apply the host's reconciled curve and repaint.
pub fn set_points(&mut self, points: Vec<CurvePoint>, cx: &mut Context<Self>) {
self.points = points;
cx.notify();
}
/// Apply the host's reconciled curve and repaint.
pub fn set_points(&mut self, points: Vec<CurvePoint>, cx: &mut Context<Self>) {
self.points = points;
cx.notify();
}
/// Convert a window position into normalized curve coordinates.
fn normalize(&self, position: Point<Pixels>) -> CurveVec2 {
let w = f32::from(self.bounds.size.width);
let h = f32::from(self.bounds.size.height);
if w <= 0.0 || h <= 0.0 {
return CurveVec2::new(0.5, 0.5);
}
CurveVec2::new(
((f32::from(position.x) - f32::from(self.bounds.left())) / w).clamp(0.0, 1.0) as f64,
1.0 - ((f32::from(position.y) - f32::from(self.bounds.top())) / h).clamp(0.0, 1.0)
as f64,
)
}
/// Convert a window position into normalized curve coordinates.
fn normalize(&self, position: Point<Pixels>) -> CurveVec2 {
let w = f32::from(self.bounds.size.width);
let h = f32::from(self.bounds.size.height);
if w <= 0.0 || h <= 0.0 {
return CurveVec2::new(0.5, 0.5);
}
CurveVec2::new(
((f32::from(position.x) - f32::from(self.bounds.left())) / w).clamp(0.0, 1.0) as f64,
1.0 - ((f32::from(position.y) - f32::from(self.bounds.top())) / h).clamp(0.0, 1.0)
as f64,
)
}
fn hit_test(&self, position: Point<Pixels>) -> DragTarget {
let pos = self.normalize(position);
if let Some((index, side)) = hit_test_handle(&self.points, pos, HIT_THRESHOLD) {
return DragTarget::Handle { index, side };
}
if let Some(index) = hit_test_point(&self.points, pos, HIT_THRESHOLD) {
return DragTarget::Point(index);
}
DragTarget::None
}
fn hit_test(&self, position: Point<Pixels>) -> DragTarget {
let pos = self.normalize(position);
if let Some((index, side)) = hit_test_handle(&self.points, pos, HIT_THRESHOLD) {
return DragTarget::Handle { index, side };
}
if let Some(index) = hit_test_point(&self.points, pos, HIT_THRESHOLD) {
return DragTarget::Point(index);
}
DragTarget::None
}
/// Clamp a point's x so the list stays sorted (points cannot pass each
/// other), while y is clamped to the unit range.
fn move_point(&mut self, index: usize, pos: CurveVec2, cx: &mut Context<Self>) {
let (prev_x, next_x) = if self.points.len() == 1 {
(0.0, 1.0)
} else if index == 0 {
(0.0, self.points[1].x)
} else if index == self.points.len() - 1 {
(self.points[index - 1].x, 1.0)
} else {
(self.points[index - 1].x, self.points[index + 1].x)
};
let min_x = (prev_x + 0.001).min(1.0);
let max_x = (next_x - 0.001).max(0.0);
let point = self.points.get_mut(index).expect("point index in range");
point.x = pos.x.clamp(min_x, max_x);
point.y = pos.y.clamp(0.0, 1.0);
let moved = *point;
cx.emit(CurveEditorEvent::PointMoved {
control: self.control,
index,
point: moved,
});
cx.notify();
}
/// Clamp a point's x so the list stays sorted (points cannot pass each
/// other), while y is clamped to the unit range.
fn move_point(&mut self, index: usize, pos: CurveVec2, cx: &mut Context<Self>) {
let (prev_x, next_x) = if self.points.len() == 1 {
(0.0, 1.0)
} else if index == 0 {
(0.0, self.points[1].x)
} else if index == self.points.len() - 1 {
(self.points[index - 1].x, 1.0)
} else {
(self.points[index - 1].x, self.points[index + 1].x)
};
let min_x = (prev_x + 0.001).min(1.0);
let max_x = (next_x - 0.001).max(0.0);
let point = self.points.get_mut(index).expect("point index in range");
point.x = pos.x.clamp(min_x, max_x);
point.y = pos.y.clamp(0.0, 1.0);
let moved = *point;
cx.emit(CurveEditorEvent::PointMoved {
control: self.control,
index,
point: moved,
});
cx.notify();
}
fn move_handle(
&mut self,
index: usize,
side: HandleSide,
pos: CurveVec2,
cx: &mut Context<Self>,
) {
let Some(point) = self.points.get_mut(index) else {
return;
};
let offset = CurveVec2::new(pos.x - point.x, pos.y - point.y);
match side {
HandleSide::In => point.handle_in = Some(offset),
HandleSide::Out => point.handle_out = Some(offset),
}
cx.emit(CurveEditorEvent::HandleMoved {
control: self.control,
index,
side,
handle: offset,
});
cx.notify();
}
fn move_handle(
&mut self,
index: usize,
side: HandleSide,
pos: CurveVec2,
cx: &mut Context<Self>,
) {
let Some(point) = self.points.get_mut(index) else {
return;
};
let offset = CurveVec2::new(pos.x - point.x, pos.y - point.y);
match side {
HandleSide::In => point.handle_in = Some(offset),
HandleSide::Out => point.handle_out = Some(offset),
}
cx.emit(CurveEditorEvent::HandleMoved {
control: self.control,
index,
side,
handle: offset,
});
cx.notify();
}
/// Insert a new point at `pos` (double-click), keeping the list sorted.
fn add_point(&mut self, pos: CurveVec2, cx: &mut Context<Self>) {
let x = pos.x.clamp(0.0, 1.0);
let y = pos.y.clamp(0.0, 1.0);
let insert_at = self.points.partition_point(|p| p.x < x);
self.points.insert(insert_at, CurvePoint::new(x, y));
cx.emit(CurveEditorEvent::PointAdded {
control: self.control,
index: insert_at,
point: CurvePoint::new(x, y),
});
cx.notify();
}
/// Insert a new point at `pos` (double-click), keeping the list sorted.
fn add_point(&mut self, pos: CurveVec2, cx: &mut Context<Self>) {
let x = pos.x.clamp(0.0, 1.0);
let y = pos.y.clamp(0.0, 1.0);
let insert_at = self.points.partition_point(|p| p.x < x);
self.points.insert(insert_at, CurvePoint::new(x, y));
cx.emit(CurveEditorEvent::PointAdded {
control: self.control,
index: insert_at,
point: CurvePoint::new(x, y),
});
cx.notify();
}
}
impl EventEmitter<CurveEditorEvent> for CurveEditor {}
impl Focusable for CurveEditor {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CurveEditor {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let entity = cx.entity();
let canvas_entity = entity.clone();
let points = self.points.clone();
let control = self.control;
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let entity = cx.entity();
let canvas_entity = entity.clone();
let points = self.points.clone();
let control = self.control;
div()
.id(ElementId::named_usize("gpui-widgets-curve", control))
.h(px(EDITOR_HEIGHT))
.rounded_md()
.bg(colors.background)
.border_1()
.border_color(colors.border)
.overflow_hidden()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, event: &MouseDownEvent, _window, _cx| {
this.pending_drag = Some(this.hit_test(event.position));
}),
)
.on_drag(
Arc::new(RwLock::new(CurveDrag {
target: DragTarget::None,
})),
move |drag, offset, window, cx| curve_ghost(drag, offset, window, cx, entity.clone()),
)
.on_drag_move(
cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<CurveDrag>>>, _window, cx| {
let drag = event.drag(cx).clone();
let target = drag.read().unwrap().target;
let pos = this.normalize(event.event.position);
match target {
DragTarget::Point(index) => this.move_point(index, pos, cx),
DragTarget::Handle { index, side } => {
this.move_handle(index, side, pos, cx);
}
DragTarget::None => {}
}
},
),
)
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
if event.click_count() >= 2 {
this.add_point(this.normalize(event.position()), cx);
}
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
if matches!(event.keystroke.key.as_str(), "escape") {
this.pending_drag = None;
}
cx.notify();
}))
.child(
canvas(
move |bounds, _window, cx| {
canvas_entity.update(cx, |this, _| this.bounds = bounds);
bounds
},
move |bounds, content, window, cx| {
paint_curve(bounds, content, &points, &colors, window, cx);
},
)
.size_full(),
)
}
div()
.id(ElementId::named_usize("gpui-widgets-curve", control))
.h(px(EDITOR_HEIGHT))
.rounded_md()
.bg(colors.background)
.border_1()
.border_color(colors.border)
.overflow_hidden()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, event: &MouseDownEvent, _window, _cx| {
this.pending_drag = Some(this.hit_test(event.position));
}),
)
.on_drag(
Arc::new(RwLock::new(CurveDrag {
target: DragTarget::None,
})),
move |drag, offset, window, cx| {
curve_ghost(drag, offset, window, cx, entity.clone())
},
)
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<Arc<RwLock<CurveDrag>>>, _window, cx| {
let drag = event.drag(cx).clone();
let target = drag.read().unwrap().target;
let pos = this.normalize(event.event.position);
match target {
DragTarget::Point(index) => this.move_point(index, pos, cx),
DragTarget::Handle { index, side } => {
this.move_handle(index, side, pos, cx);
}
DragTarget::None => {}
}
},
))
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
if event.click_count() >= 2 {
this.add_point(this.normalize(event.position()), cx);
}
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
if matches!(event.keystroke.key.as_str(), "escape") {
this.pending_drag = None;
}
cx.notify();
}))
.child(
canvas(
move |bounds, _window, cx| {
canvas_entity.update(cx, |this, _| this.bounds = bounds);
bounds
},
move |bounds, content, window, cx| {
paint_curve(bounds, content, &points, &colors, window, cx);
},
)
.size_full(),
)
}
}
/// Initialize an in-flight drag with the hit target recorded at mouse-down.
fn curve_ghost(
drag: &Arc<RwLock<CurveDrag>>,
_offset: Point<Pixels>,
_window: &mut Window,
cx: &mut App,
entity: Entity<CurveEditor>,
drag: &Arc<RwLock<CurveDrag>>,
_offset: Point<Pixels>,
_window: &mut Window,
cx: &mut App,
entity: Entity<CurveEditor>,
) -> Entity<CurveGhost> {
entity.update(cx, |this, _| {
let target = this.pending_drag.take().unwrap_or(DragTarget::None);
if let Ok(mut drag) = drag.write() {
drag.target = target;
}
});
cx.new(|_| CurveGhost)
entity.update(cx, |this, _| {
let target = this.pending_drag.take().unwrap_or(DragTarget::None);
if let Ok(mut drag) = drag.write() {
drag.target = target;
}
});
cx.new(|_| CurveGhost)
}
fn paint_curve(
bounds: Bounds<Pixels>,
_content: Bounds<Pixels>,
points: &[CurvePoint],
colors: &gpui::colors::Colors,
window: &mut Window,
_cx: &mut App,
bounds: Bounds<Pixels>,
_content: Bounds<Pixels>,
points: &[CurvePoint],
colors: &gpui::colors::Colors,
window: &mut Window,
_cx: &mut App,
) {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
if width <= 0.0 || height <= 0.0 {
return;
}
let to_px = |v: CurveVec2| {
point(
bounds.left() + px((v.x as f32) * width),
bounds.top() + px((1.0 - v.y as f32) * height),
)
};
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
if width <= 0.0 || height <= 0.0 {
return;
}
let to_px = |v: CurveVec2| {
point(
bounds.left() + px((v.x as f32) * width),
bounds.top() + px((1.0 - v.y as f32) * height),
)
};
// Subtle grid lines.
let grid = Hsla::from(colors.border).opacity(0.35);
for i in 1..4 {
let fx = i as f32 / 4.0;
window.paint_quad(fill(
Bounds::new(
point(bounds.left() + px(fx * width), bounds.top()),
size(px(1.0), bounds.size.height),
),
grid,
));
window.paint_quad(fill(
Bounds::new(
point(bounds.left(), bounds.top() + px(fx * height)),
size(bounds.size.width, px(1.0)),
),
grid,
));
}
// Subtle grid lines.
let grid = Hsla::from(colors.border).opacity(0.35);
for i in 1..4 {
let fx = i as f32 / 4.0;
window.paint_quad(fill(
Bounds::new(
point(bounds.left() + px(fx * width), bounds.top()),
size(px(1.0), bounds.size.height),
),
grid,
));
window.paint_quad(fill(
Bounds::new(
point(bounds.left(), bounds.top() + px(fx * height)),
size(bounds.size.width, px(1.0)),
),
grid,
));
}
// The curve polyline.
let line = curve::polyline(points, 16);
if line.len() >= 2 {
let mut path = PathBuilder::stroke(px(2.0));
let mut iter = line.iter();
if let Some(first) = iter.next() {
path.move_to(to_px(*first));
}
for v in iter {
path.line_to(to_px(*v));
}
if let Ok(path) = path.build() {
window.paint_path(path, Hsla::from(colors.selected));
}
}
// The curve polyline.
let line = curve::polyline(points, 16);
if line.len() >= 2 {
let mut path = PathBuilder::stroke(px(2.0));
let mut iter = line.iter();
if let Some(first) = iter.next() {
path.move_to(to_px(*first));
}
for v in iter {
path.line_to(to_px(*v));
}
if let Ok(path) = path.build() {
window.paint_path(path, Hsla::from(colors.selected));
}
}
// Points and handles.
let point_color = Hsla::from(colors.text);
let handle_color = Hsla::from(colors.disabled);
for pt in points {
let p = to_px(CurveVec2::new(pt.x, pt.y));
for handle in [pt.handle_in, pt.handle_out] {
if let Some(h) = handle {
let hp = to_px(CurveVec2::new(pt.x + h.x, pt.y + h.y));
let mut line = PathBuilder::stroke(px(1.0));
line.move_to(p);
line.line_to(hp);
if let Ok(path) = line.build() {
window.paint_path(path, handle_color);
}
let dot = Bounds::new(
point(hp.x - px(3.0), hp.y - px(3.0)),
size(px(6.0), px(6.0)),
);
window.paint_quad(quad(
dot,
Corners::all(px(3.0)),
handle_color,
Edges::all(px(0.0)),
handle_color,
BorderStyle::Solid,
));
}
}
let r = px(5.0);
let circle = Bounds::new(point(p.x - r, p.y - r), size(r * 2.0, r * 2.0));
window.paint_quad(quad(
circle,
Corners::all(r),
point_color,
Edges::all(px(1.5)),
Hsla::from(colors.background),
BorderStyle::Solid,
));
}
// Points and handles.
let point_color = Hsla::from(colors.text);
let handle_color = Hsla::from(colors.disabled);
for pt in points {
let p = to_px(CurveVec2::new(pt.x, pt.y));
for handle in [pt.handle_in, pt.handle_out] {
if let Some(h) = handle {
let hp = to_px(CurveVec2::new(pt.x + h.x, pt.y + h.y));
let mut line = PathBuilder::stroke(px(1.0));
line.move_to(p);
line.line_to(hp);
if let Ok(path) = line.build() {
window.paint_path(path, handle_color);
}
let dot = Bounds::new(
point(hp.x - px(3.0), hp.y - px(3.0)),
size(px(6.0), px(6.0)),
);
window.paint_quad(quad(
dot,
Corners::all(px(3.0)),
handle_color,
Edges::all(px(0.0)),
handle_color,
BorderStyle::Solid,
));
}
}
let r = px(5.0);
let circle = Bounds::new(point(p.x - r, p.y - r), size(r * 2.0, r * 2.0));
window.paint_quad(quad(
circle,
Corners::all(r),
point_color,
Edges::all(px(1.5)),
Hsla::from(colors.background),
BorderStyle::Solid,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Modifiers, TestAppContext, VisualTestContext};
use super::*;
use gpui::{Modifiers, TestAppContext, VisualTestContext};
fn demo_points() -> Vec<CurvePoint> {
vec![
CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 0.5)),
CurvePoint::with_handles(1.0, 1.0, CurveVec2::new(0.0, -0.5)),
]
}
fn demo_points() -> Vec<CurvePoint> {
vec![
CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 0.5)),
CurvePoint::with_handles(1.0, 1.0, CurveVec2::new(0.0, -0.5)),
]
}
#[gpui::test]
async fn dragging_a_point_emits_point_moved(cx: &mut TestAppContext) {
struct Host {
editor: Entity<CurveEditor>,
events: Vec<CurveEditorEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.editor.clone())
}
}
#[gpui::test]
async fn dragging_a_point_emits_point_moved(cx: &mut TestAppContext) {
struct Host {
editor: Entity<CurveEditor>,
events: Vec<CurveEditorEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.editor.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(200.0)), |window, cx| {
let editor = cx.new(|cx| CurveEditor::new(1, demo_points(), window, cx));
let host = Host {
editor,
events: Vec::new(),
};
cx.subscribe(
&host.editor,
|host: &mut Host,
_e: Entity<CurveEditor>,
event: &CurveEditorEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(200.0)), |window, cx| {
let editor = cx.new(|cx| CurveEditor::new(1, demo_points(), window, cx));
let host = Host {
editor,
events: Vec::new(),
};
cx.subscribe(
&host.editor,
|host: &mut Host,
_e: Entity<CurveEditor>,
event: &CurveEditorEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// The curve editor spans the full window width, 120px tall. The
// first point is at normalized (0,0) -> bottom-left of the canvas.
let start = point(px(5.0), px(115.0));
let drag_to = point(px(5.0), px(60.0));
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(point(px(5.0), px(105.0)), MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(drag_to, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(drag_to, MouseButton::Left, Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// The curve editor spans the full window width, 120px tall. The
// first point is at normalized (0,0) -> bottom-left of the canvas.
let start = point(px(5.0), px(115.0));
let drag_to = point(px(5.0), px(60.0));
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(
point(px(5.0), px(105.0)),
MouseButton::Left,
Modifiers::none(),
);
cx.simulate_mouse_move(drag_to, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(drag_to, MouseButton::Left, Modifiers::none());
cx.run_until_parked();
let (points, moved) = cx.read(|app| {
let host = host.read(app);
let points = host.editor.read(app).points().to_vec();
let moved = host.events.iter().any(|e| {
matches!(e, CurveEditorEvent::PointMoved { index: 0, .. })
});
(points, moved)
});
assert!(moved, "expected a PointMoved event for point 0");
assert!(points[0].y > 0.1, "point should have moved up: {:?}", points[0]);
}
let (points, moved) = cx.read(|app| {
let host = host.read(app);
let points = host.editor.read(app).points().to_vec();
let moved = host
.events
.iter()
.any(|e| matches!(e, CurveEditorEvent::PointMoved { index: 0, .. }));
(points, moved)
});
assert!(moved, "expected a PointMoved event for point 0");
assert!(
points[0].y > 0.1,
"point should have moved up: {:?}",
points[0]
);
}
#[gpui::test]
async fn double_click_adds_point(cx: &mut TestAppContext) {
struct Host {
editor: Entity<CurveEditor>,
events: Vec<CurveEditorEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.editor.clone())
}
}
#[gpui::test]
async fn double_click_adds_point(cx: &mut TestAppContext) {
struct Host {
editor: Entity<CurveEditor>,
events: Vec<CurveEditorEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.editor.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(200.0)), |window, cx| {
let editor = cx.new(|cx| CurveEditor::new(1, demo_points(), window, cx));
let host = Host {
editor,
events: Vec::new(),
};
cx.subscribe(
&host.editor,
|host: &mut Host,
_e: Entity<CurveEditor>,
event: &CurveEditorEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(200.0)), |window, cx| {
let editor = cx.new(|cx| CurveEditor::new(1, demo_points(), window, cx));
let host = Host {
editor,
events: Vec::new(),
};
cx.subscribe(
&host.editor,
|host: &mut Host,
_e: Entity<CurveEditor>,
event: &CurveEditorEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Double-click in the middle of the canvas (x=200, y=100).
let pos = point(px(200.0), px(100.0));
let modifiers = Modifiers::none();
cx.simulate_event(MouseDownEvent {
position: pos,
modifiers,
button: MouseButton::Left,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(gpui::MouseUpEvent {
position: pos,
modifiers,
button: MouseButton::Left,
click_count: 2,
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Double-click in the middle of the canvas (x=200, y=100).
let pos = point(px(200.0), px(100.0));
let modifiers = Modifiers::none();
cx.simulate_event(MouseDownEvent {
position: pos,
modifiers,
button: MouseButton::Left,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(gpui::MouseUpEvent {
position: pos,
modifiers,
button: MouseButton::Left,
click_count: 2,
});
cx.run_until_parked();
let (count, added) = cx.read(|app| {
let host = host.read(app);
(
host.editor.read(app).points().len(),
host.events
.iter()
.any(|e| matches!(e, CurveEditorEvent::PointAdded { .. })),
)
});
assert_eq!(count, 3);
assert!(added);
}
let (count, added) = cx.read(|app| {
let host = host.read(app);
(
host.editor.read(app).points().len(),
host.events
.iter()
.any(|e| matches!(e, CurveEditorEvent::PointAdded { .. })),
)
});
assert_eq!(count, 3);
assert!(added);
}
}
+101 -94
View File
@@ -8,8 +8,7 @@
//! picker later.
use gpui::{
App, Context, Entity, Render, SharedString, Window, colors::DefaultColors, div, prelude::*,
px,
App, Context, Entity, Render, SharedString, Window, colors::DefaultColors, div, prelude::*, px,
};
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
@@ -17,116 +16,124 @@ use super::{DialogButton, Modal, ModalOptions};
/// The content view of a file dialog: a path text field.
pub struct FileDialogContent {
editor: Entity<EditableTextState>,
editor: Entity<EditableTextState>,
}
impl FileDialogContent {
/// The path currently entered.
pub fn path(&self, app: &gpui::App) -> SharedString {
self.editor.read(app).as_str().into()
}
/// The path currently entered.
pub fn path(&self, app: &gpui::App) -> SharedString {
self.editor.read(app).as_str().into()
}
/// Set the path shown in the field.
pub fn set_path(&mut self, path: impl Into<SharedString>, cx: &mut Context<Self>) {
let path = path.into();
self.editor.update(cx, |editor, cx| {
editor.emplace(path.as_ref(), cx);
});
cx.notify();
}
/// Set the path shown in the field.
pub fn set_path(&mut self, path: impl Into<SharedString>, cx: &mut Context<Self>) {
let path = path.into();
self.editor.update(cx, |editor, cx| {
editor.emplace(path.as_ref(), cx);
});
cx.notify();
}
}
impl Render for FileDialogContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let weak = self.editor.downgrade();
div()
.flex()
.flex_col()
.gap_2()
.child(div().text_color(colors.text).child("Path"))
.child(
div()
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_2()
.py_1()
.child(text_input("gpui-widgets-file-path").state(weak).accepts_input(true)),
)
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let weak = self.editor.downgrade();
div()
.flex()
.flex_col()
.gap_2()
.child(div().text_color(colors.text).child("Path"))
.child(
div()
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_2()
.py_1()
.child(
text_input("gpui-widgets-file-path")
.state(weak)
.accepts_input(true),
),
)
}
}
/// Build a file dialog. OK is button index `0`, Cancel is index `1`. The host
/// reads the chosen path from [`FileDialogContent::path`] when OK is clicked.
pub fn file_dialog(
control: usize,
title: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
control: usize,
title: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
) -> (Entity<Modal>, Entity<FileDialogContent>) {
let content = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
let modal = cx.new(|cx| {
Modal::new(
control,
ModalOptions::new(title, px(420.0))
.with_button(DialogButton::primary("Open"))
.with_button(DialogButton::cancel("Cancel")),
window,
cx,
)
.with_content(content.clone())
});
(modal, content)
let content = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
let modal = cx.new(|cx| {
Modal::new(
control,
ModalOptions::new(title, px(420.0))
.with_button(DialogButton::primary("Open"))
.with_button(DialogButton::cancel("Cancel")),
window,
cx,
)
.with_content(content.clone())
});
(modal, content)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Render, TestAppContext, VisualTestContext, Window, div, px, size};
use super::*;
use gpui::{Entity, Render, TestAppContext, VisualTestContext, Window, div, px, size};
#[gpui::test]
async fn path_round_trips(cx: &mut TestAppContext) {
cx.update(|app| {
let content = app.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
content.update(app, |content, cx| {
content.set_path("/tmp/movie.mov", cx);
assert_eq!(content.path(cx), "/tmp/movie.mov");
});
});
}
#[gpui::test]
async fn path_round_trips(cx: &mut TestAppContext) {
cx.update(|app| {
let content = app.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
content.update(app, |content, cx| {
content.set_path("/tmp/movie.mov", cx);
assert_eq!(content.path(cx), "/tmp/movie.mov");
});
});
}
#[gpui::test]
async fn file_dialog_content_renders(cx: &mut TestAppContext) {
struct Host {
content: Entity<FileDialogContent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.content.clone())
}
}
#[gpui::test]
async fn file_dialog_content_renders(cx: &mut TestAppContext) {
struct Host {
content: Entity<FileDialogContent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.content.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(120.0)), |_window, cx| {
let content = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
content.update(cx, |content, cx| content.set_path("/tmp/movie.mov", cx));
Host { content }
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(120.0)), |_window, cx| {
let content = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
FileDialogContent { editor }
});
content.update(cx, |content, cx| content.set_path("/tmp/movie.mov", cx));
Host { content }
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
}
+149 -134
View File
@@ -7,70 +7,75 @@ use super::{DialogButton, Modal, ModalOptions};
/// The severity of a message box.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageBoxLevel {
/// Informational.
Info,
/// A warning.
Warning,
/// An error.
Error,
/// Informational.
Info,
/// A warning.
Warning,
/// An error.
Error,
}
impl MessageBoxLevel {
/// The icon color for this level.
fn color(self) -> gpui::Hsla {
match self {
MessageBoxLevel::Info => gpui::Hsla {
h: 0.6,
s: 0.8,
l: 0.5,
a: 1.0,
},
MessageBoxLevel::Warning => gpui::Hsla {
h: 0.1,
s: 0.9,
l: 0.5,
a: 1.0,
},
MessageBoxLevel::Error => gpui::Hsla {
h: 0.0,
s: 0.8,
l: 0.5,
a: 1.0,
},
}
}
/// The icon color for this level.
fn color(self) -> gpui::Hsla {
match self {
MessageBoxLevel::Info => gpui::Hsla {
h: 0.6,
s: 0.8,
l: 0.5,
a: 1.0,
},
MessageBoxLevel::Warning => gpui::Hsla {
h: 0.1,
s: 0.9,
l: 0.5,
a: 1.0,
},
MessageBoxLevel::Error => gpui::Hsla {
h: 0.0,
s: 0.8,
l: 0.5,
a: 1.0,
},
}
}
/// The glyph shown next to the message.
fn glyph(self) -> &'static str {
match self {
MessageBoxLevel::Info => "ℹ",
MessageBoxLevel::Warning => "⚠",
MessageBoxLevel::Error => "✖",
}
}
/// The glyph shown next to the message.
fn glyph(self) -> &'static str {
match self {
MessageBoxLevel::Info => "ℹ",
MessageBoxLevel::Warning => "⚠",
MessageBoxLevel::Error => "✖",
}
}
}
/// The content view of a message box.
struct MessageContent {
level: MessageBoxLevel,
message: gpui::SharedString,
level: MessageBoxLevel,
message: gpui::SharedString,
}
impl Render for MessageContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.flex()
.items_start()
.gap_3()
.child(
div()
.w(px(24.0))
.text_color(self.level.color())
.child(self.level.glyph()),
)
.child(div().flex_1().text_color(colors.text).child(self.message.clone()))
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.flex()
.items_start()
.gap_3()
.child(
div()
.w(px(24.0))
.text_color(self.level.color())
.child(self.level.glyph()),
)
.child(
div()
.flex_1()
.text_color(colors.text)
.child(self.message.clone()),
)
}
}
/// Build a message box modal.
@@ -79,95 +84,105 @@ impl Render for MessageContent {
/// subscribes to its [`ModalEvent`](super::ModalEvent)s (the OK button is
/// button index `0`).
pub fn message_box(
control: usize,
level: MessageBoxLevel,
title: impl Into<gpui::SharedString>,
message: impl Into<gpui::SharedString>,
window: &mut Window,
cx: &mut App,
control: usize,
level: MessageBoxLevel,
title: impl Into<gpui::SharedString>,
message: impl Into<gpui::SharedString>,
window: &mut Window,
cx: &mut App,
) -> Entity<Modal> {
let content = cx.new(|_| MessageContent {
level,
message: message.into(),
});
let modal = cx.new(|cx| {
Modal::new(
control,
ModalOptions::new(title, px(380.0)).with_button(DialogButton::primary("OK")),
window,
cx,
)
.with_content(content)
});
modal
let content = cx.new(|_| MessageContent {
level,
message: message.into(),
});
let modal = cx.new(|cx| {
Modal::new(
control,
ModalOptions::new(title, px(380.0)).with_button(DialogButton::primary("OK")),
window,
cx,
)
.with_content(content)
});
modal
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Modifiers, TestAppContext, VisualTestContext, px, size};
use super::*;
use gpui::{Modifiers, TestAppContext, VisualTestContext, px, size};
#[test]
fn levels_have_distinct_glyphs() {
assert_ne!(MessageBoxLevel::Info.glyph(), MessageBoxLevel::Error.glyph());
assert_ne!(MessageBoxLevel::Info.color(), MessageBoxLevel::Warning.color());
}
#[test]
fn levels_have_distinct_glyphs() {
assert_ne!(
MessageBoxLevel::Info.glyph(),
MessageBoxLevel::Error.glyph()
);
assert_ne!(
MessageBoxLevel::Info.color(),
MessageBoxLevel::Warning.color()
);
}
#[gpui::test]
async fn ok_button_emits_index_zero(cx: &mut TestAppContext) {
struct Host {
modal: Entity<Modal>,
events: Vec<super::super::ModalEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.modal.clone())
}
}
#[gpui::test]
async fn ok_button_emits_index_zero(cx: &mut TestAppContext) {
struct Host {
modal: Entity<Modal>,
events: Vec<super::super::ModalEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.modal.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(500.0), px(300.0)), |window, cx| {
let modal = message_box(
1,
MessageBoxLevel::Error,
"Render failed",
"The export could not be completed.",
window,
cx,
);
let host = Host {
modal,
events: Vec::new(),
};
cx.subscribe(
&host.modal,
|host: &mut Host,
_m: Entity<Modal>,
event: &super::super::ModalEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(500.0), px(300.0)), |window, cx| {
let modal = message_box(
1,
MessageBoxLevel::Error,
"Render failed",
"The export could not be completed.",
window,
cx,
);
let host = Host {
modal,
events: Vec::new(),
};
cx.subscribe(
&host.modal,
|host: &mut Host,
_m: Entity<Modal>,
event: &super::super::ModalEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Click the OK button (index 0).
let ok = cx.debug_bounds("dialog-button-0").expect("OK button rendered");
cx.simulate_click(ok.center(), Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Click the OK button (index 0).
let ok = cx
.debug_bounds("dialog-button-0")
.expect("OK button rendered");
cx.simulate_click(ok.center(), Modifiers::none());
cx.run_until_parked();
let routed = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(
e,
super::super::ModalEvent::ButtonClicked { button: 0, .. }
)
})
});
assert!(routed);
}
let routed = cx.read(|app| {
host.read(app)
.events
.iter()
.any(|e| matches!(e, super::super::ModalEvent::ButtonClicked { button: 0, .. }))
});
assert!(routed);
}
}
+281 -282
View File
@@ -11,343 +11,342 @@ pub mod message_box;
pub mod progress;
use gpui::{
AnyView, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Hsla,
KeyDownEvent, Render, SharedString, Window, colors::DefaultColors, div, prelude::*,
AnyView, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Hsla,
KeyDownEvent, Render, SharedString, Window, colors::DefaultColors, div, prelude::*,
};
/// How a button behaves in the dialog.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogButtonRole {
/// The default action (`enter` triggers it).
Primary,
/// A secondary action.
Secondary,
/// Cancels the dialog (`escape` also triggers it).
Cancel,
/// The default action (`enter` triggers it).
Primary,
/// A secondary action.
Secondary,
/// Cancels the dialog (`escape` also triggers it).
Cancel,
}
/// A button in a dialog's button row.
#[derive(Debug, Clone, PartialEq)]
pub struct DialogButton {
/// The button label.
pub label: SharedString,
/// The button's role.
pub role: DialogButtonRole,
/// The button label.
pub label: SharedString,
/// The button's role.
pub role: DialogButtonRole,
}
impl DialogButton {
/// Create a button.
pub fn new(label: impl Into<SharedString>, role: DialogButtonRole) -> Self {
Self {
label: label.into(),
role,
}
}
/// Create a button.
pub fn new(label: impl Into<SharedString>, role: DialogButtonRole) -> Self {
Self {
label: label.into(),
role,
}
}
/// A primary button.
pub fn primary(label: impl Into<SharedString>) -> Self {
Self::new(label, DialogButtonRole::Primary)
}
/// A primary button.
pub fn primary(label: impl Into<SharedString>) -> Self {
Self::new(label, DialogButtonRole::Primary)
}
/// A cancel button.
pub fn cancel(label: impl Into<SharedString>) -> Self {
Self::new(label, DialogButtonRole::Cancel)
}
/// A cancel button.
pub fn cancel(label: impl Into<SharedString>) -> Self {
Self::new(label, DialogButtonRole::Cancel)
}
}
/// Configuration for a [`Modal`].
#[derive(Debug, Clone)]
pub struct ModalOptions {
/// The title shown in the card's header.
pub title: SharedString,
/// The width of the card.
pub width: gpui::Pixels,
/// The buttons in the footer row.
pub buttons: Vec<DialogButton>,
/// The title shown in the card's header.
pub title: SharedString,
/// The width of the card.
pub width: gpui::Pixels,
/// The buttons in the footer row.
pub buttons: Vec<DialogButton>,
}
impl ModalOptions {
/// Create options.
pub fn new(title: impl Into<SharedString>, width: gpui::Pixels) -> Self {
Self {
title: title.into(),
width,
buttons: Vec::new(),
}
}
/// Create options.
pub fn new(title: impl Into<SharedString>, width: gpui::Pixels) -> Self {
Self {
title: title.into(),
width,
buttons: Vec::new(),
}
}
/// Add a button.
pub fn with_button(mut self, button: DialogButton) -> Self {
self.buttons.push(button);
self
}
/// Add a button.
pub fn with_button(mut self, button: DialogButton) -> Self {
self.buttons.push(button);
self
}
}
/// A request emitted by a modal dialog.
#[derive(Debug, Clone, PartialEq)]
pub enum ModalEvent {
/// A button was clicked.
ButtonClicked {
/// The modal's stable id.
control: usize,
/// The index of the clicked button in [`ModalOptions::buttons`].
button: usize,
},
/// The dialog was dismissed (escape or backdrop).
Dismissed {
/// The modal's stable id.
control: usize,
},
/// A button was clicked.
ButtonClicked {
/// The modal's stable id.
control: usize,
/// The index of the clicked button in [`ModalOptions::buttons`].
button: usize,
},
/// The dialog was dismissed (escape or backdrop).
Dismissed {
/// The modal's stable id.
control: usize,
},
}
/// A modal dialog frame: mask, title bar, content slot and button row.
pub struct Modal {
control: usize,
options: ModalOptions,
content: Option<AnyView>,
focus_handle: FocusHandle,
control: usize,
options: ModalOptions,
content: Option<AnyView>,
focus_handle: FocusHandle,
}
impl Modal {
/// Create a modal with `options` (no content yet).
pub fn new(
control: usize,
options: ModalOptions,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
content: None,
focus_handle: cx.focus_handle(),
}
}
/// Create a modal with `options` (no content yet).
pub fn new(
control: usize,
options: ModalOptions,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
content: None,
focus_handle: cx.focus_handle(),
}
}
/// Attach the dialog's content view.
pub fn with_content(mut self, content: impl Into<AnyView>) -> Self {
self.content = Some(content.into());
self
}
/// Attach the dialog's content view.
pub fn with_content(mut self, content: impl Into<AnyView>) -> Self {
self.content = Some(content.into());
self
}
/// Replace the content view.
pub fn set_content(&mut self, content: impl Into<AnyView>, cx: &mut Context<Self>) {
self.content = Some(content.into());
cx.notify();
}
/// Replace the content view.
pub fn set_content(&mut self, content: impl Into<AnyView>, cx: &mut Context<Self>) {
self.content = Some(content.into());
cx.notify();
}
fn emit_button(&mut self, index: usize, cx: &mut Context<Self>) {
cx.emit(ModalEvent::ButtonClicked {
control: self.control,
button: index,
});
cx.notify();
}
fn emit_button(&mut self, index: usize, cx: &mut Context<Self>) {
cx.emit(ModalEvent::ButtonClicked {
control: self.control,
button: index,
});
cx.notify();
}
}
impl EventEmitter<ModalEvent> for Modal {}
impl Focusable for Modal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Modal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let control = self.control;
let buttons = self.options.buttons.clone();
let primary = buttons
.iter()
.position(|b| b.role == DialogButtonRole::Primary);
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let control = self.control;
let buttons = self.options.buttons.clone();
let primary = buttons
.iter()
.position(|b| b.role == DialogButtonRole::Primary);
// Mask + card, centered.
div()
.id(ElementId::named_usize("gpui-widgets-modal", control))
.absolute()
.size_full()
.bg(Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 0.4,
})
.occlude()
.block_mouse_except_scroll()
.flex()
.items_center()
.justify_center()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(move |this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"escape" => {
cx.emit(ModalEvent::Dismissed {
control: this.control,
});
cx.notify();
}
"enter" => {
if let Some(index) = primary {
this.emit_button(index, cx);
}
}
_ => {}
}
}))
.child(
div()
.w(self.options.width)
.rounded_lg()
.border_1()
.border_color(colors.border)
.bg(colors.container)
.debug_selector(|| "dialog-card".into())
.shadow_lg()
.flex()
.flex_col()
.child(
div()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.text_color(colors.text)
.child(self.options.title.clone()),
)
.child(
div()
.p_4()
.flex_1()
.child(if let Some(content) = &self.content {
content.clone().into_any_element()
} else {
div().into_any_element()
}),
)
.child(
div()
.px_4()
.py_3()
.flex()
.justify_end()
.gap_2()
.children(
buttons
.into_iter()
.enumerate()
.map(|(index, button)| {
let role = button.role;
let label = button.label;
let bg = match role {
DialogButtonRole::Primary => colors.selected,
_ => colors.background,
};
let text = match role {
DialogButtonRole::Primary => colors.selected_text,
_ => colors.text,
};
div()
.id(ElementId::named_usize(
format!("gpui-widgets-modal-button-{control}"),
index,
))
.debug_selector(|| {
format!("dialog-button-{index}").into()
})
.px_3()
.py_1()
.rounded_md()
.bg(bg)
.text_color(text)
.cursor_pointer()
.on_click(cx.listener(
move |this, _event: &ClickEvent, _window, cx| {
this.emit_button(index, cx);
cx.stop_propagation();
},
))
.child(label)
}),
),
),
)
}
// Mask + card, centered.
div()
.id(ElementId::named_usize("gpui-widgets-modal", control))
.absolute()
.size_full()
.bg(Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 0.4,
})
.occlude()
.block_mouse_except_scroll()
.flex()
.items_center()
.justify_center()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(move |this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"escape" => {
cx.emit(ModalEvent::Dismissed {
control: this.control,
});
cx.notify();
}
"enter" => {
if let Some(index) = primary {
this.emit_button(index, cx);
}
}
_ => {}
}
}))
.child(
div()
.w(self.options.width)
.rounded_lg()
.border_1()
.border_color(colors.border)
.bg(colors.container)
.debug_selector(|| "dialog-card".into())
.shadow_lg()
.flex()
.flex_col()
.child(
div()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.text_color(colors.text)
.child(self.options.title.clone()),
)
.child(
div()
.p_4()
.flex_1()
.child(if let Some(content) = &self.content {
content.clone().into_any_element()
} else {
div().into_any_element()
}),
)
.child(div().px_4().py_3().flex().justify_end().gap_2().children(
buttons.into_iter().enumerate().map(|(index, button)| {
let role = button.role;
let label = button.label;
let bg = match role {
DialogButtonRole::Primary => colors.selected,
_ => colors.background,
};
let text = match role {
DialogButtonRole::Primary => colors.selected_text,
_ => colors.text,
};
div()
.id(ElementId::named_usize(
format!("gpui-widgets-modal-button-{control}"),
index,
))
.debug_selector(|| format!("dialog-button-{index}").into())
.px_3()
.py_1()
.rounded_md()
.bg(bg)
.text_color(text)
.cursor_pointer()
.on_click(cx.listener(
move |this, _event: &ClickEvent, _window, cx| {
this.emit_button(index, cx);
cx.stop_propagation();
},
))
.child(label)
}),
)),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, px, size};
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, px, size};
#[test]
fn button_roles_and_primary_index() {
let options = ModalOptions::new("Save", px(400.0))
.with_button(DialogButton::cancel("Cancel"))
.with_button(DialogButton::primary("Save"));
assert_eq!(options.buttons.len(), 2);
assert_eq!(options.buttons[0].role, DialogButtonRole::Cancel);
assert_eq!(options.buttons[1].role, DialogButtonRole::Primary);
}
#[test]
fn button_roles_and_primary_index() {
let options = ModalOptions::new("Save", px(400.0))
.with_button(DialogButton::cancel("Cancel"))
.with_button(DialogButton::primary("Save"));
assert_eq!(options.buttons.len(), 2);
assert_eq!(options.buttons[0].role, DialogButtonRole::Cancel);
assert_eq!(options.buttons[1].role, DialogButtonRole::Primary);
}
#[gpui::test]
async fn button_click_emits_routed_event(cx: &mut TestAppContext) {
struct Host {
modal: Entity<Modal>,
events: Vec<ModalEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.modal.clone())
}
}
#[gpui::test]
async fn button_click_emits_routed_event(cx: &mut TestAppContext) {
struct Host {
modal: Entity<Modal>,
events: Vec<ModalEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.modal.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(500.0), px(300.0)), |window, cx| {
let modal = cx.new(|cx| {
Modal::new(
1,
ModalOptions::new("Prompt", px(360.0))
.with_button(DialogButton::cancel("Cancel"))
.with_button(DialogButton::primary("Apply")),
window,
cx,
)
});
let host = Host {
modal,
events: Vec::new(),
};
cx.subscribe(
&host.modal,
|host: &mut Host,
_m: Entity<Modal>,
event: &ModalEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(500.0), px(300.0)), |window, cx| {
let modal = cx.new(|cx| {
Modal::new(
1,
ModalOptions::new("Prompt", px(360.0))
.with_button(DialogButton::cancel("Cancel"))
.with_button(DialogButton::primary("Apply")),
window,
cx,
)
});
let host = Host {
modal,
events: Vec::new(),
};
cx.subscribe(
&host.modal,
|host: &mut Host,
_m: Entity<Modal>,
event: &ModalEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Click the last button in the card's bottom-right corner.
let card = cx.debug_bounds("dialog-card").expect("dialog card rendered");
eprintln!("card={card:?} btn0={:?} btn1={:?}",
cx.debug_bounds("dialog-button-0"),
cx.debug_bounds("dialog-button-1"));
let btn = cx.debug_bounds("dialog-button-1").expect("button rendered");
cx.simulate_click(btn.center(), Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Click the last button in the card's bottom-right corner.
let card = cx
.debug_bounds("dialog-card")
.expect("dialog card rendered");
eprintln!(
"card={card:?} btn0={:?} btn1={:?}",
cx.debug_bounds("dialog-button-0"),
cx.debug_bounds("dialog-button-1")
);
let btn = cx.debug_bounds("dialog-button-1").expect("button rendered");
cx.simulate_click(btn.center(), Modifiers::none());
cx.run_until_parked();
let routed = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, ModalEvent::ButtonClicked { button: 1, .. })
})
});
assert!(routed, "expected ButtonClicked for the Apply (index 1) button");
}
let routed = cx.read(|app| {
host.read(app)
.events
.iter()
.any(|e| matches!(e, ModalEvent::ButtonClicked { button: 1, .. }))
});
assert!(
routed,
"expected ButtonClicked for the Apply (index 1) button"
);
}
}
+120 -116
View File
@@ -9,144 +9,148 @@ use super::{DialogButton, Modal, ModalOptions};
/// The content view of a progress dialog: a labeled progress bar.
pub struct ProgressContent {
label: gpui::SharedString,
/// Progress in `0..=1`.
fraction: f32,
label: gpui::SharedString,
/// Progress in `0..=1`.
fraction: f32,
}
impl ProgressContent {
/// Create a progress content view.
pub fn new(label: impl Into<gpui::SharedString>, fraction: f32) -> Self {
Self {
label: label.into(),
fraction: fraction.clamp(0.0, 1.0),
}
}
/// Create a progress content view.
pub fn new(label: impl Into<gpui::SharedString>, fraction: f32) -> Self {
Self {
label: label.into(),
fraction: fraction.clamp(0.0, 1.0),
}
}
/// Update the progress and repaint.
pub fn set_progress(&mut self, fraction: f32, cx: &mut Context<Self>) {
self.fraction = fraction.clamp(0.0, 1.0);
cx.notify();
}
/// Update the progress and repaint.
pub fn set_progress(&mut self, fraction: f32, cx: &mut Context<Self>) {
self.fraction = fraction.clamp(0.0, 1.0);
cx.notify();
}
/// The current progress.
pub fn fraction(&self) -> f32 {
self.fraction
}
/// The current progress.
pub fn fraction(&self) -> f32 {
self.fraction
}
}
impl Render for ProgressContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let fraction = self.fraction;
div()
.flex()
.flex_col()
.gap_2()
.child(div().text_color(colors.text).child(self.label.clone()))
.child(
div()
.h(px(10.0))
.rounded_full()
.bg(colors.background)
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div()
.h_full()
.w(px((fraction * 100.0).clamp(0.0, 100.0)))
.bg(colors.selected),
),
)
.child(
div()
.text_color(colors.disabled)
.child(format!("{:.0}%", fraction * 100.0)),
)
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let fraction = self.fraction;
div()
.flex()
.flex_col()
.gap_2()
.child(div().text_color(colors.text).child(self.label.clone()))
.child(
div()
.h(px(10.0))
.rounded_full()
.bg(colors.background)
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div()
.h_full()
.w(px((fraction * 100.0).clamp(0.0, 100.0)))
.bg(colors.selected),
),
)
.child(
div()
.text_color(colors.disabled)
.child(format!("{:.0}%", fraction * 100.0)),
)
}
}
/// Build a progress dialog with a Cancel button (index `1`) and an implicit
/// primary "Run" button (index `0`). Returns the modal and its content so the
/// host can drive the bar.
pub fn progress_dialog(
control: usize,
title: impl Into<gpui::SharedString>,
label: impl Into<gpui::SharedString>,
window: &mut Window,
cx: &mut App,
control: usize,
title: impl Into<gpui::SharedString>,
label: impl Into<gpui::SharedString>,
window: &mut Window,
cx: &mut App,
) -> (Entity<Modal>, Entity<ProgressContent>) {
let content = cx.new(|_| ProgressContent::new(label, 0.0));
let modal = cx.new(|cx| {
Modal::new(
control,
ModalOptions::new(title, px(360.0))
.with_button(DialogButton::primary("Run"))
.with_button(DialogButton::cancel("Cancel")),
window,
cx,
)
.with_content(content.clone())
});
(modal, content)
let content = cx.new(|_| ProgressContent::new(label, 0.0));
let modal = cx.new(|cx| {
Modal::new(
control,
ModalOptions::new(title, px(360.0))
.with_button(DialogButton::primary("Run"))
.with_button(DialogButton::cancel("Cancel")),
window,
cx,
)
.with_content(content.clone())
});
(modal, content)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use super::*;
use gpui::TestAppContext;
#[test]
fn progress_clamps_at_construction() {
assert_eq!(ProgressContent::new("Encoding", 2.0).fraction(), 1.0);
assert_eq!(ProgressContent::new("Encoding", -0.5).fraction(), 0.0);
}
#[test]
fn progress_clamps_at_construction() {
assert_eq!(ProgressContent::new("Encoding", 2.0).fraction(), 1.0);
assert_eq!(ProgressContent::new("Encoding", -0.5).fraction(), 0.0);
}
#[gpui::test]
async fn set_progress_clamps(cx: &mut TestAppContext) {
cx.update(|app| {
let content = app.new(|_| ProgressContent::new("Encoding", 0.0));
content.update(app, |content, cx| {
content.set_progress(1.5, cx);
assert_eq!(content.fraction(), 1.0);
content.set_progress(-1.0, cx);
assert_eq!(content.fraction(), 0.0);
});
});
}
#[gpui::test]
async fn set_progress_clamps(cx: &mut TestAppContext) {
cx.update(|app| {
let content = app.new(|_| ProgressContent::new("Encoding", 0.0));
content.update(app, |content, cx| {
content.set_progress(1.5, cx);
assert_eq!(content.fraction(), 1.0);
content.set_progress(-1.0, cx);
assert_eq!(content.fraction(), 0.0);
});
});
}
#[gpui::test]
async fn progress_content_renders(cx: &mut TestAppContext) {
use gpui::{Entity, Render, VisualTestContext, Window, div, prelude::*, px, size};
#[gpui::test]
async fn progress_content_renders(cx: &mut TestAppContext) {
use gpui::{Entity, Render, VisualTestContext, Window, div, prelude::*, px, size};
struct Host {
content: Entity<ProgressContent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.content.clone())
}
}
struct Host {
content: Entity<ProgressContent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.content.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(320.0), px(80.0)), |_window, cx| {
let content = cx.new(|_| ProgressContent::new("Encoding", 0.5));
Host { content }
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
window
.update(cx, |host, _, cx| {
host.content
.update(cx, |content, cx| content.set_progress(0.75, cx));
})
.unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let fraction = cx.read(|app| host.read(app).content.read(app).fraction());
assert_eq!(fraction, 0.75);
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(320.0), px(80.0)), |_window, cx| {
let content = cx.new(|_| ProgressContent::new("Encoding", 0.5));
Host { content }
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
window
.update(cx, |host, _, cx| {
host.content
.update(cx, |content, cx| content.set_progress(0.75, cx));
})
.unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let fraction = cx.read(|app| host.read(app).content.read(app).fraction());
assert_eq!(fraction, 0.75);
}
}
+16 -13
View File
@@ -12,20 +12,23 @@ pub use gpui::i18n::*;
#[cfg(test)]
mod tests {
use super::*;
use super::*;
/// The hook is a thin re-export: installing a table through the widget
/// path is visible to `gpui::i18n::tr` and vice versa.
#[test]
fn widget_path_shares_the_gpui_table() {
clear_table();
assert_eq!(tr("viewer.safe_frames", "安全框"), "安全框");
/// The hook is a thin re-export: installing a table through the widget
/// path is visible to `gpui::i18n::tr` and vice versa.
#[test]
fn widget_path_shares_the_gpui_table() {
clear_table();
assert_eq!(tr("viewer.safe_frames", "安全框"), "安全框");
let mut table = StringTable::new();
table.insert("viewer.safe_frames".into(), "Safe Frames".into());
set_table(table);
let mut table = StringTable::new();
table.insert("viewer.safe_frames".into(), "Safe Frames".into());
set_table(table);
assert_eq!(gpui::i18n::tr("viewer.safe_frames", "安全框"), "Safe Frames");
clear_table();
}
assert_eq!(
gpui::i18n::tr("viewer.safe_frames", "安全框"),
"Safe Frames"
);
clear_table();
}
}
+3 -3
View File
@@ -24,11 +24,11 @@ impl Global for IconResolverGlobal {}
/// Registers the host's icon resolver. Call once at startup, before any
/// window renders.
pub fn set_resolver(resolver: IconResolver, cx: &mut App) {
cx.set_global(IconResolverGlobal(resolver));
cx.set_global(IconResolverGlobal(resolver));
}
/// The file path of the named icon in the current theme, if resolvable.
pub fn path(name: &str, cx: &App) -> Option<PathBuf> {
cx.try_global::<IconResolverGlobal>()
.and_then(|global| (global.0)(name, cx))
cx.try_global::<IconResolverGlobal>()
.and_then(|global| (global.0)(name, cx))
}
+127 -129
View File
@@ -6,19 +6,19 @@
//! control; the widget itself never touches the engine.
use gpui::{
App, Bounds, Div, ElementId, Hsla, Pixels, Stateful, Window, canvas, colors::DefaultColors,
div, fill, point, prelude::*, px, size,
App, Bounds, Div, ElementId, Hsla, Pixels, Stateful, Window, canvas, colors::DefaultColors,
div, fill, point, prelude::*, px, size,
};
/// The keying state of a control at the current playhead frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyingState {
/// The control has no keyframe.
NoKey,
/// The control has a keyframe somewhere on the timeline.
HasKey,
/// The control has a keyframe exactly at the current frame.
AtCurrentFrame,
/// The control has no keyframe.
NoKey,
/// The control has a keyframe somewhere on the timeline.
HasKey,
/// The control has a keyframe exactly at the current frame.
AtCurrentFrame,
}
/// A request emitted when a keying diamond is clicked.
@@ -27,32 +27,32 @@ pub enum KeyingState {
/// `SliderEvent::Keying(KeyingRequest)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyingRequest {
/// The stable id of the control that owns the diamond.
pub control: usize,
/// The keying state the control reported when the diamond was painted.
pub state: KeyingState,
/// The stable id of the control that owns the diamond.
pub control: usize,
/// The keying state the control reported when the diamond was painted.
pub state: KeyingState,
}
impl KeyingRequest {
/// Create a keying request for `control`.
pub fn new(control: usize, state: KeyingState) -> Self {
Self { control, state }
}
/// Create a keying request for `control`.
pub fn new(control: usize, state: KeyingState) -> Self {
Self { control, state }
}
}
/// Accent used for "has a key" diamonds.
const KEY_COLOR: Hsla = Hsla {
h: 0.09,
s: 0.75,
l: 0.55,
a: 1.0,
h: 0.09,
s: 0.75,
l: 0.55,
a: 1.0,
};
/// Brighter accent for "key at current frame".
const KEY_ACTIVE_COLOR: Hsla = Hsla {
h: 0.09,
s: 0.9,
l: 0.68,
a: 1.0,
h: 0.09,
s: 0.9,
l: 0.68,
a: 1.0,
};
/// Base opacity of an empty diamond, so missing keys read dimmer.
const EMPTY_OPACITY: f32 = 0.45;
@@ -64,128 +64,126 @@ const DIAMOND_SIZE: f32 = 14.0;
/// The returned element is interactive: chain `.on_click(...)` (or
/// `cx.listener(...)`) to receive clicks, then emit a [`KeyingRequest`].
pub fn keying_diamond(control: usize, state: KeyingState) -> Stateful<Div> {
div()
.size(px(20.0))
.flex()
.items_center()
.justify_center()
.opacity(if state == KeyingState::NoKey {
EMPTY_OPACITY
} else {
1.0
})
.hover(|style| style.opacity(1.0))
.child(canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, cx| paint_diamond(bounds, state, window, cx),
))
.id(ElementId::named_usize("gpui-widgets-keying", control))
div()
.size(px(20.0))
.flex()
.items_center()
.justify_center()
.opacity(if state == KeyingState::NoKey {
EMPTY_OPACITY
} else {
1.0
})
.hover(|style| style.opacity(1.0))
.child(canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, cx| paint_diamond(bounds, state, window, cx),
))
.id(ElementId::named_usize("gpui-widgets-keying", control))
}
/// A ready-made [`KeyingRequest`] for this diamond's state.
pub fn keying_request(control: usize, state: KeyingState) -> KeyingRequest {
KeyingRequest::new(control, state)
KeyingRequest::new(control, state)
}
fn paint_diamond(bounds: Bounds<Pixels>, state: KeyingState, window: &mut Window, cx: &mut App) {
use gpui::PathBuilder;
let colors = cx.default_colors().clone();
let center = bounds.center();
let half = px(DIAMOND_SIZE / 2.0 - 2.0);
let (fill_color, border_color) = match state {
KeyingState::NoKey => (
Hsla::from(colors.disabled),
Hsla::from(colors.disabled),
),
KeyingState::HasKey => (KEY_COLOR, KEY_COLOR),
KeyingState::AtCurrentFrame => (KEY_ACTIVE_COLOR, KEY_ACTIVE_COLOR),
};
use gpui::PathBuilder;
let colors = cx.default_colors().clone();
let center = bounds.center();
let half = px(DIAMOND_SIZE / 2.0 - 2.0);
let (fill_color, border_color) = match state {
KeyingState::NoKey => (Hsla::from(colors.disabled), Hsla::from(colors.disabled)),
KeyingState::HasKey => (KEY_COLOR, KEY_COLOR),
KeyingState::AtCurrentFrame => (KEY_ACTIVE_COLOR, KEY_ACTIVE_COLOR),
};
// The diamond body is filled when a key exists, hollow otherwise.
if state != KeyingState::NoKey {
let mut body = PathBuilder::fill();
body.move_to(point(center.x, center.y - half));
body.line_to(point(center.x + half, center.y));
body.line_to(point(center.x, center.y + half));
body.line_to(point(center.x - half, center.y));
body.close();
if let Ok(path) = body.build() {
window.paint_path(path, fill_color);
}
}
// The diamond body is filled when a key exists, hollow otherwise.
if state != KeyingState::NoKey {
let mut body = PathBuilder::fill();
body.move_to(point(center.x, center.y - half));
body.line_to(point(center.x + half, center.y));
body.line_to(point(center.x, center.y + half));
body.line_to(point(center.x - half, center.y));
body.close();
if let Ok(path) = body.build() {
window.paint_path(path, fill_color);
}
}
// The outline is always painted so the diamond is visible when empty.
let mut outline = PathBuilder::stroke(px(1.0));
outline.move_to(point(center.x, center.y - half));
outline.line_to(point(center.x + half, center.y));
outline.line_to(point(center.x, center.y + half));
outline.line_to(point(center.x - half, center.y));
outline.close();
if let Ok(path) = outline.build() {
window.paint_path(path, border_color);
}
// The outline is always painted so the diamond is visible when empty.
let mut outline = PathBuilder::stroke(px(1.0));
outline.move_to(point(center.x, center.y - half));
outline.line_to(point(center.x + half, center.y));
outline.line_to(point(center.x, center.y + half));
outline.line_to(point(center.x - half, center.y));
outline.close();
if let Ok(path) = outline.build() {
window.paint_path(path, border_color);
}
// A center dot marks a key exactly at the current frame.
if state == KeyingState::AtCurrentFrame {
let origin = point(center.x - px(2.0), center.y - px(2.0));
window.paint_quad(fill(
Bounds::new(origin, size(px(4.0), px(4.0))),
Hsla::from(colors.background),
));
}
// A center dot marks a key exactly at the current frame.
if state == KeyingState::AtCurrentFrame {
let origin = point(center.x - px(2.0), center.y - px(2.0));
window.paint_quad(fill(
Bounds::new(origin, size(px(4.0), px(4.0))),
Hsla::from(colors.background),
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Context, Render, TestAppContext, Window, div, px, size};
use super::*;
use gpui::{Context, Render, TestAppContext, Window, div, px, size};
struct Host {
control: usize,
state: KeyingState,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(keying_diamond(self.control, self.state))
}
}
struct Host {
control: usize,
state: KeyingState,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(keying_diamond(self.control, self.state))
}
}
#[gpui::test]
async fn diamond_renders_in_a_window(cx: &mut TestAppContext) {
use gpui::VisualTestContext;
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(60.0), px(60.0)), |_window, _cx| Host {
control: 3,
state: KeyingState::AtCurrentFrame,
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
#[gpui::test]
async fn diamond_renders_in_a_window(cx: &mut TestAppContext) {
use gpui::VisualTestContext;
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(60.0), px(60.0)), |_window, _cx| Host {
control: 3,
state: KeyingState::AtCurrentFrame,
});
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
}
#[test]
fn request_round_trips() {
let request = KeyingRequest::new(7, KeyingState::AtCurrentFrame);
assert_eq!(request.control, 7);
assert_eq!(request.state, KeyingState::AtCurrentFrame);
assert_ne!(request, KeyingRequest::new(7, KeyingState::HasKey));
}
#[test]
fn request_round_trips() {
let request = KeyingRequest::new(7, KeyingState::AtCurrentFrame);
assert_eq!(request.control, 7);
assert_eq!(request.state, KeyingState::AtCurrentFrame);
assert_ne!(request, KeyingRequest::new(7, KeyingState::HasKey));
}
#[test]
fn states_are_distinct() {
assert_ne!(KeyingState::NoKey, KeyingState::HasKey);
assert_ne!(KeyingState::HasKey, KeyingState::AtCurrentFrame);
assert_ne!(KeyingState::NoKey, KeyingState::AtCurrentFrame);
}
#[test]
fn states_are_distinct() {
assert_ne!(KeyingState::NoKey, KeyingState::HasKey);
assert_ne!(KeyingState::HasKey, KeyingState::AtCurrentFrame);
assert_ne!(KeyingState::NoKey, KeyingState::AtCurrentFrame);
}
#[test]
fn keying_request_helper_matches() {
assert_eq!(
keying_request(3, KeyingState::NoKey),
KeyingRequest::new(3, KeyingState::NoKey)
);
}
#[test]
fn keying_request_helper_matches() {
assert_eq!(
keying_request(3, KeyingState::NoKey),
KeyingRequest::new(3, KeyingState::NoKey)
);
}
}
+39 -39
View File
@@ -39,45 +39,45 @@ pub mod viewer;
#[cfg(test)]
mod tests {
use gpui::prelude::*;
use gpui::{AbsoluteLength, DefiniteLength, div, rems};
use gpui::prelude::*;
use gpui::{AbsoluteLength, DefiniteLength, div, rems};
/// Fractional spacing helpers exist on the [`Styled`](gpui::Styled) trait
/// (via `gpui_macros::padding_style_methods!` / `margin_style_methods!`),
/// named after Tailwind's fractional scale: `0p5`, `1p5`, `2p5`, `3p5`
/// (i.e. 0.5/1.5/2.5/3.5 units of 4px = 2/6/10/14px).
///
/// We deliberately do **not** add `_0_5`-style aliases (`py_0_5`,
/// `px_1_5`, ...): every fractional value they would name already exists
/// under the established `0p5`/`1p5`/`2p5`/`3p5` convention used
/// throughout gpui and gpui_widgets, and a second naming scheme for the
/// same helpers would only fragment the API surface. This test pins the
/// helpers (and their values) so a future refactor of the style macros
/// cannot silently drop them.
#[test]
fn fractional_spacing_helpers_exist() {
let mut padding = div().py_0p5().px_1p5();
assert_eq!(
padding.style().padding.top,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.125))))
);
assert_eq!(
padding.style().padding.left,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375))))
);
assert_eq!(
padding.style().padding.right,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375))))
);
/// Fractional spacing helpers exist on the [`Styled`](gpui::Styled) trait
/// (via `gpui_macros::padding_style_methods!` / `margin_style_methods!`),
/// named after Tailwind's fractional scale: `0p5`, `1p5`, `2p5`, `3p5`
/// (i.e. 0.5/1.5/2.5/3.5 units of 4px = 2/6/10/14px).
///
/// We deliberately do **not** add `_0_5`-style aliases (`py_0_5`,
/// `px_1_5`, ...): every fractional value they would name already exists
/// under the established `0p5`/`1p5`/`2p5`/`3p5` convention used
/// throughout gpui and gpui_widgets, and a second naming scheme for the
/// same helpers would only fragment the API surface. This test pins the
/// helpers (and their values) so a future refactor of the style macros
/// cannot silently drop them.
#[test]
fn fractional_spacing_helpers_exist() {
let mut padding = div().py_0p5().px_1p5();
assert_eq!(
padding.style().padding.top,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.125))))
);
assert_eq!(
padding.style().padding.left,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375))))
);
assert_eq!(
padding.style().padding.right,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375))))
);
let mut margin = div().my_2p5().pt_3p5();
assert_eq!(
margin.style().margin.top,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.625))).into())
);
assert_eq!(
margin.style().padding.top,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.875))).into())
);
}
let mut margin = div().my_2p5().pt_3p5();
assert_eq!(
margin.style().margin.top,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.625))).into())
);
assert_eq!(
margin.style().padding.top,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.875))).into())
);
}
}
File diff suppressed because it is too large Load Diff
+206 -203
View File
@@ -6,240 +6,243 @@ use gpui::SharedString;
/// A menu item.
#[derive(Debug, Clone, PartialEq)]
pub struct MenuItem {
/// The item's stable id (used in [`MenuEvent`](super::MenuEvent)).
pub id: usize,
/// The label shown in the menu.
pub label: SharedString,
/// A shortcut to display on the right (e.g. `"⌘S"`).
pub shortcut: Option<SharedString>,
/// Whether the item can be activated.
pub enabled: bool,
/// `None` = no checkmark; `Some(checked)` = a check/tick state.
pub checked: Option<bool>,
/// A nested submenu, opened on hover/click.
pub submenu: Option<Box<Menu>>,
/// Whether a separator line follows this item.
pub separator_after: bool,
/// The item's stable id (used in [`MenuEvent`](super::MenuEvent)).
pub id: usize,
/// The label shown in the menu.
pub label: SharedString,
/// A shortcut to display on the right (e.g. `"⌘S"`).
pub shortcut: Option<SharedString>,
/// Whether the item can be activated.
pub enabled: bool,
/// `None` = no checkmark; `Some(checked)` = a check/tick state.
pub checked: Option<bool>,
/// A nested submenu, opened on hover/click.
pub submenu: Option<Box<Menu>>,
/// Whether a separator line follows this item.
pub separator_after: bool,
}
impl MenuItem {
/// Create a plain enabled item.
pub fn new(id: usize, label: impl Into<SharedString>) -> Self {
Self {
id,
label: label.into(),
shortcut: None,
enabled: true,
checked: None,
submenu: None,
separator_after: false,
}
}
/// Create a plain enabled item.
pub fn new(id: usize, label: impl Into<SharedString>) -> Self {
Self {
id,
label: label.into(),
shortcut: None,
enabled: true,
checked: None,
submenu: None,
separator_after: false,
}
}
/// Mark the item disabled.
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
/// Mark the item disabled.
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
/// Attach a shortcut label.
pub fn with_shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
/// Attach a shortcut label.
pub fn with_shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
/// Set a checked state.
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = Some(checked);
self
}
/// Set a checked state.
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = Some(checked);
self
}
/// Set the checked state of an already-built item (runtime mutation).
///
/// Unlike the construction-only [`with_checked`](Self::with_checked), this
/// lets a host flip a menu checkmark after the [`Menu`] has been handed
/// to a view — e.g. through [`Menu::set_item_checked`] on the menu held
/// by a [`MenuBar`](super::MenuBar) — without rebuilding the menu. The
/// renderers read `checked` on every frame, so the change shows up on the
/// next repaint.
pub fn set_checked(&mut self, checked: bool) -> &mut Self {
self.checked = Some(checked);
self
}
/// Set the checked state of an already-built item (runtime mutation).
///
/// Unlike the construction-only [`with_checked`](Self::with_checked), this
/// lets a host flip a menu checkmark after the [`Menu`] has been handed
/// to a view — e.g. through [`Menu::set_item_checked`] on the menu held
/// by a [`MenuBar`](super::MenuBar) — without rebuilding the menu. The
/// renderers read `checked` on every frame, so the change shows up on the
/// next repaint.
pub fn set_checked(&mut self, checked: bool) -> &mut Self {
self.checked = Some(checked);
self
}
/// Remove the checkmark from an already-built item (runtime mutation).
pub fn clear_checked(&mut self) -> &mut Self {
self.checked = None;
self
}
/// Remove the checkmark from an already-built item (runtime mutation).
pub fn clear_checked(&mut self) -> &mut Self {
self.checked = None;
self
}
/// Attach a submenu.
pub fn with_submenu(mut self, submenu: Menu) -> Self {
self.submenu = Some(Box::new(submenu));
self
}
/// Attach a submenu.
pub fn with_submenu(mut self, submenu: Menu) -> Self {
self.submenu = Some(Box::new(submenu));
self
}
/// Draw a separator line after this item.
pub fn separated(mut self) -> Self {
self.separator_after = true;
self
}
/// Draw a separator line after this item.
pub fn separated(mut self) -> Self {
self.separator_after = true;
self
}
}
/// A menu: an ordered list of items.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Menu {
/// The items of this menu.
pub items: Vec<MenuItem>,
/// The items of this menu.
pub items: Vec<MenuItem>,
}
impl Menu {
/// Create a menu.
pub fn new(items: Vec<MenuItem>) -> Self {
Self { items }
}
/// Create a menu.
pub fn new(items: Vec<MenuItem>) -> Self {
Self { items }
}
/// Whether the item at `index` is a visual separator (an empty item).
pub fn is_separator(item: &MenuItem) -> bool {
item.label.is_empty()
}
/// Whether the item at `index` is a visual separator (an empty item).
pub fn is_separator(item: &MenuItem) -> bool {
item.label.is_empty()
}
/// Sets the checked state of the item with `id`, searching top-level
/// items and their submenus recursively. Returns whether an item with
/// that id was found and updated.
///
/// Runtime counterpart to [`MenuItem::with_checked`]: hosts that hold a
/// live [`Menu`] (e.g. in a [`MenuBar`](super::MenuBar)) can toggle a
/// checkmark without rebuilding the menu.
pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool {
for item in &mut self.items {
if item.id == id {
item.set_checked(checked);
return true;
}
if let Some(submenu) = item.submenu.as_mut() {
if submenu.set_item_checked(id, checked) {
return true;
}
}
}
false
}
/// Sets the checked state of the item with `id`, searching top-level
/// items and their submenus recursively. Returns whether an item with
/// that id was found and updated.
///
/// Runtime counterpart to [`MenuItem::with_checked`]: hosts that hold a
/// live [`Menu`] (e.g. in a [`MenuBar`](super::MenuBar)) can toggle a
/// checkmark without rebuilding the menu.
pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool {
for item in &mut self.items {
if item.id == id {
item.set_checked(checked);
return true;
}
if let Some(submenu) = item.submenu.as_mut() {
if submenu.set_item_checked(id, checked) {
return true;
}
}
}
false
}
/// The next selectable index from `current`, moving `delta` steps
/// (skipping separators and disabled items). `None` returns the first
/// (or last) selectable item. Returns `None` if nothing is selectable.
pub fn navigate(&self, current: Option<usize>, delta: i32) -> Option<usize> {
let selectable: Vec<usize> = self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.enabled && !Self::is_separator(item))
.map(|(index, _)| index)
.collect();
if selectable.is_empty() {
return None;
}
let position = current.and_then(|c| selectable.iter().position(|&i| i == c));
let next = match position {
Some(pos) => {
((pos as i64 + delta as i64).rem_euclid(selectable.len() as i64)) as usize
}
None if delta > 0 => 0,
None => selectable.len() - 1,
};
Some(selectable[next])
}
/// The next selectable index from `current`, moving `delta` steps
/// (skipping separators and disabled items). `None` returns the first
/// (or last) selectable item. Returns `None` if nothing is selectable.
pub fn navigate(&self, current: Option<usize>, delta: i32) -> Option<usize> {
let selectable: Vec<usize> = self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.enabled && !Self::is_separator(item))
.map(|(index, _)| index)
.collect();
if selectable.is_empty() {
return None;
}
let position = current.and_then(|c| selectable.iter().position(|&i| i == c));
let next = match position {
Some(pos) => ((pos as i64 + delta as i64).rem_euclid(selectable.len() as i64)) as usize,
None if delta > 0 => 0,
None => selectable.len() - 1,
};
Some(selectable[next])
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
fn sample_menu() -> Menu {
Menu::new(vec![
MenuItem::new(1, "Open…").with_shortcut("⌘O"),
MenuItem::new(2, "Save").with_shortcut("⌘S"),
MenuItem::new(3, "Save As…").with_shortcut("⇧⌘S").separated(),
MenuItem::new(4, "Export").disabled(),
MenuItem::new(5, "Export Again").with_checked(true),
])
}
fn sample_menu() -> Menu {
Menu::new(vec![
MenuItem::new(1, "Open…").with_shortcut("⌘O"),
MenuItem::new(2, "Save").with_shortcut("⌘S"),
MenuItem::new(3, "Save As…")
.with_shortcut("⇧⌘S")
.separated(),
MenuItem::new(4, "Export").disabled(),
MenuItem::new(5, "Export Again").with_checked(true),
])
}
#[test]
fn navigate_skips_disabled_and_separators() {
let menu = sample_menu();
// From none, down lands on the first selectable (Open).
assert_eq!(menu.navigate(None, 1), Some(0));
// From none, up lands on the last selectable (Export Again, idx 4).
assert_eq!(menu.navigate(None, -1), Some(4));
// From Open (0), down skips nothing until Save (1).
assert_eq!(menu.navigate(Some(0), 1), Some(1));
// From Save As (2), down skips the disabled Export (3) to Export Again (4).
assert_eq!(menu.navigate(Some(2), 1), Some(4));
// Wrap around.
assert_eq!(menu.navigate(Some(4), 1), Some(0));
assert_eq!(menu.navigate(Some(0), -1), Some(4));
}
#[test]
fn navigate_skips_disabled_and_separators() {
let menu = sample_menu();
// From none, down lands on the first selectable (Open).
assert_eq!(menu.navigate(None, 1), Some(0));
// From none, up lands on the last selectable (Export Again, idx 4).
assert_eq!(menu.navigate(None, -1), Some(4));
// From Open (0), down skips nothing until Save (1).
assert_eq!(menu.navigate(Some(0), 1), Some(1));
// From Save As (2), down skips the disabled Export (3) to Export Again (4).
assert_eq!(menu.navigate(Some(2), 1), Some(4));
// Wrap around.
assert_eq!(menu.navigate(Some(4), 1), Some(0));
assert_eq!(menu.navigate(Some(0), -1), Some(4));
}
#[test]
fn navigate_returns_none_when_nothing_selectable() {
let menu = Menu::new(vec![
MenuItem::new(1, "Only").disabled(),
MenuItem::new(2, ""),
]);
assert_eq!(menu.navigate(None, 1), None);
assert_eq!(menu.navigate(Some(1), 1), None);
}
#[test]
fn navigate_returns_none_when_nothing_selectable() {
let menu = Menu::new(vec![
MenuItem::new(1, "Only").disabled(),
MenuItem::new(2, ""),
]);
assert_eq!(menu.navigate(None, 1), None);
assert_eq!(menu.navigate(Some(1), 1), None);
}
#[test]
fn checked_state_is_optional() {
let menu = sample_menu();
assert_eq!(menu.items[0].checked, None);
assert_eq!(menu.items[4].checked, Some(true));
}
#[test]
fn checked_state_is_optional() {
let menu = sample_menu();
assert_eq!(menu.items[0].checked, None);
assert_eq!(menu.items[4].checked, Some(true));
}
#[test]
fn set_item_checked_mutates_in_place() {
let mut menu = sample_menu();
// Runtime toggle on a built item, found by id.
assert!(menu.set_item_checked(5, false));
assert_eq!(menu.items[4].checked, Some(false));
assert!(menu.set_item_checked(5, true));
assert_eq!(menu.items[4].checked, Some(true));
// Clear the checkmark entirely.
assert!(menu.items[4].clear_checked().checked.is_none());
// Unknown ids report failure and change nothing.
assert!(!menu.set_item_checked(999, true));
assert_eq!(menu.items[0].checked, None);
}
#[test]
fn set_item_checked_mutates_in_place() {
let mut menu = sample_menu();
// Runtime toggle on a built item, found by id.
assert!(menu.set_item_checked(5, false));
assert_eq!(menu.items[4].checked, Some(false));
assert!(menu.set_item_checked(5, true));
assert_eq!(menu.items[4].checked, Some(true));
// Clear the checkmark entirely.
assert!(menu.items[4].clear_checked().checked.is_none());
// Unknown ids report failure and change nothing.
assert!(!menu.set_item_checked(999, true));
assert_eq!(menu.items[0].checked, None);
}
#[test]
fn set_item_checked_reaches_nested_submenus() {
let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]);
let mut menu = Menu::new(vec![
MenuItem::new(5, "Nested").with_submenu(sub),
MenuItem::new(6, "Top"),
]);
assert!(menu.set_item_checked(11, true));
assert_eq!(menu.items[0].submenu.as_ref().unwrap().items[1].checked, Some(true));
// The top-level item is untouched.
assert_eq!(menu.items[1].checked, None);
}
#[test]
fn set_item_checked_reaches_nested_submenus() {
let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]);
let mut menu = Menu::new(vec![
MenuItem::new(5, "Nested").with_submenu(sub),
MenuItem::new(6, "Top"),
]);
assert!(menu.set_item_checked(11, true));
assert_eq!(
menu.items[0].submenu.as_ref().unwrap().items[1].checked,
Some(true)
);
// The top-level item is untouched.
assert_eq!(menu.items[1].checked, None);
}
#[test]
fn cascade_nesting() {
let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]);
let item = MenuItem::new(5, "Nested").with_submenu(sub.clone());
assert_eq!(item.submenu.as_ref().unwrap().items.len(), 2);
assert_eq!(item.submenu.as_ref().unwrap().items[1].id, 11);
// The nested menu itself navigates independently.
assert_eq!(item.submenu.as_ref().unwrap().navigate(None, -1), Some(1));
}
#[test]
fn cascade_nesting() {
let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]);
let item = MenuItem::new(5, "Nested").with_submenu(sub.clone());
assert_eq!(item.submenu.as_ref().unwrap().items.len(), 2);
assert_eq!(item.submenu.as_ref().unwrap().items[1].id, 11);
// The nested menu itself navigates independently.
assert_eq!(item.submenu.as_ref().unwrap().navigate(None, -1), Some(1));
}
#[test]
fn separator_detection() {
assert!(Menu::is_separator(&MenuItem::new(0, "")));
assert!(!Menu::is_separator(&MenuItem::new(0, "Save")));
}
#[test]
fn separator_detection() {
assert!(Menu::is_separator(&MenuItem::new(0, "")));
assert!(!Menu::is_separator(&MenuItem::new(0, "Save")));
}
}
File diff suppressed because it is too large Load Diff
+202 -192
View File
@@ -5,239 +5,249 @@
//! the widget via [`RadioGroup::set_selected`].
use gpui::{
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window, colors::DefaultColors, div, prelude::*, px,
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window, colors::DefaultColors, div, prelude::*, px,
};
/// A single selectable option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RadioOption {
/// The option's value (stable id).
pub value: usize,
/// The label shown next to the radio circle.
pub label: SharedString,
/// The option's value (stable id).
pub value: usize,
/// The label shown next to the radio circle.
pub label: SharedString,
}
impl RadioOption {
/// Create an option.
pub fn new(value: usize, label: impl Into<SharedString>) -> Self {
Self {
value,
label: label.into(),
}
}
/// Create an option.
pub fn new(value: usize, label: impl Into<SharedString>) -> Self {
Self {
value,
label: label.into(),
}
}
}
/// A request emitted when an option is selected.
#[derive(Debug, Clone, PartialEq)]
pub enum RadioGroupEvent {
/// The user clicked an option.
Selected {
/// The group's stable id.
control: usize,
/// The chosen option value.
value: usize,
},
/// The user clicked an option.
Selected {
/// The group's stable id.
control: usize,
/// The chosen option value.
value: usize,
},
}
/// A group of mutually-exclusive radio options.
pub struct RadioGroup {
control: usize,
options: Vec<RadioOption>,
selected: Option<usize>,
enabled: bool,
focus_handle: FocusHandle,
control: usize,
options: Vec<RadioOption>,
selected: Option<usize>,
enabled: bool,
focus_handle: FocusHandle,
}
impl RadioGroup {
/// Create a radio group for `control` over `options`.
pub fn new(
control: usize,
options: Vec<RadioOption>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
selected: None,
enabled: true,
focus_handle: cx.focus_handle(),
}
}
/// Create a radio group for `control` over `options`.
pub fn new(
control: usize,
options: Vec<RadioOption>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
selected: None,
enabled: true,
focus_handle: cx.focus_handle(),
}
}
/// The currently selected value, if any.
pub fn selected(&self) -> Option<usize> {
self.selected
}
/// The currently selected value, if any.
pub fn selected(&self) -> Option<usize> {
self.selected
}
/// Apply the host's selection and repaint.
pub fn set_selected(&mut self, selected: Option<usize>, cx: &mut Context<Self>) {
if self.selected != selected {
self.selected = selected;
cx.notify();
}
}
/// Apply the host's selection and repaint.
pub fn set_selected(&mut self, selected: Option<usize>, cx: &mut Context<Self>) {
if self.selected != selected {
self.selected = selected;
cx.notify();
}
}
/// Enable or disable the whole group.
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Enable or disable the whole group.
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
fn emit_select(&self, value: usize, cx: &mut Context<Self>) {
if !self.enabled {
return;
}
cx.emit(RadioGroupEvent::Selected {
control: self.control,
value,
});
cx.notify();
}
fn emit_select(&self, value: usize, cx: &mut Context<Self>) {
if !self.enabled {
return;
}
cx.emit(RadioGroupEvent::Selected {
control: self.control,
value,
});
cx.notify();
}
}
impl EventEmitter<RadioGroupEvent> for RadioGroup {}
impl Focusable for RadioGroup {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RadioGroup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let selected = self.selected;
let enabled = self.enabled;
let control = self.control;
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let selected = self.selected;
let enabled = self.enabled;
let control = self.control;
let mut column = div().flex().flex_col().gap(px(4.0));
for option in self.options.clone() {
let is_selected = selected == Some(option.value);
let accent = if is_selected {
colors.selected
} else {
colors.background
};
let border = if enabled {
colors.border
} else {
colors.disabled
};
let text_color = if enabled {
colors.text
} else {
colors.disabled
};
let value = option.value;
let label = option.label;
let mut column = div().flex().flex_col().gap(px(4.0));
for option in self.options.clone() {
let is_selected = selected == Some(option.value);
let accent = if is_selected {
colors.selected
} else {
colors.background
};
let border = if enabled {
colors.border
} else {
colors.disabled
};
let text_color = if enabled {
colors.text
} else {
colors.disabled
};
let value = option.value;
let label = option.label;
column = column.child(
div()
.id(ElementId::named_usize(
format!("gpui-widgets-radio-{control}"),
value,
))
.flex()
.items_center()
.gap(px(6.0))
.cursor_pointer()
.track_focus(&self.focus_handle)
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.emit_select(value, cx);
cx.stop_propagation();
}))
.child(
div()
.size(px(16.0))
.rounded_full()
.border_1()
.border_color(border)
.bg(accent)
.flex()
.items_center()
.justify_center()
.child(if is_selected {
div().size(px(6.0)).rounded_full().bg(colors.selected_text)
} else {
div().size(px(0.0))
}),
)
.child(div().text_color(text_color).child(label)),
);
}
column
}
column = column.child(
div()
.id(ElementId::named_usize(
format!("gpui-widgets-radio-{control}"),
value,
))
.flex()
.items_center()
.gap(px(6.0))
.cursor_pointer()
.track_focus(&self.focus_handle)
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.emit_select(value, cx);
cx.stop_propagation();
}))
.child(
div()
.size(px(16.0))
.rounded_full()
.border_1()
.border_color(border)
.bg(accent)
.flex()
.items_center()
.justify_center()
.child(if is_selected {
div().size(px(6.0)).rounded_full().bg(colors.selected_text)
} else {
div().size(px(0.0))
}),
)
.child(div().text_color(text_color).child(label)),
);
}
column
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
#[test]
fn option_construction() {
let option = RadioOption::new(3, "1080p");
assert_eq!(option.value, 3);
assert_eq!(option.label, "1080p");
}
#[test]
fn option_construction() {
let option = RadioOption::new(3, "1080p");
assert_eq!(option.value, 3);
assert_eq!(option.label, "1080p");
}
#[gpui::test]
async fn click_emits_selection(cx: &mut TestAppContext) {
struct Host {
group: Entity<RadioGroup>,
events: Vec<RadioGroupEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.group.clone())
}
}
#[gpui::test]
async fn click_emits_selection(cx: &mut TestAppContext) {
struct Host {
group: Entity<RadioGroup>,
events: Vec<RadioGroupEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.group.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(120.0)), |window, cx| {
let group = cx.new(|cx| {
RadioGroup::new(
1,
vec![
RadioOption::new(1, "1080p"),
RadioOption::new(2, "4K"),
RadioOption::new(3, "8K"),
],
window,
cx,
)
});
let host = Host {
group,
events: Vec::new(),
};
cx.subscribe(
&host.group,
|host: &mut Host,
_g: Entity<RadioGroup>,
event: &RadioGroupEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(120.0)), |window, cx| {
let group = cx.new(|cx| {
RadioGroup::new(
1,
vec![
RadioOption::new(1, "1080p"),
RadioOption::new(2, "4K"),
RadioOption::new(3, "8K"),
],
window,
cx,
)
});
let host = Host {
group,
events: Vec::new(),
};
cx.subscribe(
&host.group,
|host: &mut Host,
_g: Entity<RadioGroup>,
event: &RadioGroupEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Second option (4K) is around y = 30 + 4 + 16/2.
cx.simulate_click(point(px(30.0), px(42.0)), Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Second option (4K) is around y = 30 + 4 + 16/2.
cx.simulate_click(point(px(30.0), px(42.0)), Modifiers::none());
cx.run_until_parked();
let emitted = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, RadioGroupEvent::Selected { control: 1, value: 2 })
})
});
assert!(emitted);
}
let emitted = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(
e,
RadioGroupEvent::Selected {
control: 1,
value: 2
}
)
})
});
assert!(emitted);
}
}
+95 -98
View File
@@ -4,43 +4,45 @@
/// Bin luma samples (`0..1`) into `bins` histogram buckets, returning the
/// count per bucket. Samples outside `0..1` clamp to the edges.
pub fn histogram_bins(samples: &[f32], bins: usize) -> Vec<u32> {
if bins == 0 {
return Vec::new();
}
let mut out = vec![0u32; bins];
if samples.is_empty() {
return out;
}
for &sample in samples {
let clamped = sample.clamp(0.0, 1.0);
let index = ((clamped * bins as f32) as usize).min(bins - 1);
out[index] += 1;
}
out
if bins == 0 {
return Vec::new();
}
let mut out = vec![0u32; bins];
if samples.is_empty() {
return out;
}
for &sample in samples {
let clamped = sample.clamp(0.0, 1.0);
let index = ((clamped * bins as f32) as usize).min(bins - 1);
out[index] += 1;
}
out
}
/// Compute the min/max envelope of `samples` over `columns` vertical slices.
/// Each column covers a contiguous slice of the input; empty columns report
/// `(0.0, 0.0)`.
pub fn waveform_envelope(samples: &[f32], columns: usize) -> Vec<(f32, f32)> {
let mut out = vec![(0.0f32, 0.0f32); columns.max(1)];
if columns == 0 || samples.is_empty() {
return out;
}
for column in 0..columns {
let start = column * samples.len() / columns;
let end = ((column + 1) * samples.len() / columns).max(start + 1).min(samples.len());
let mut min = f32::MAX;
let mut max = f32::MIN;
for &sample in &samples[start..end] {
min = min.min(sample);
max = max.max(sample);
}
if end > start {
out[column] = (min, max);
}
}
out
let mut out = vec![(0.0f32, 0.0f32); columns.max(1)];
if columns == 0 || samples.is_empty() {
return out;
}
for column in 0..columns {
let start = column * samples.len() / columns;
let end = ((column + 1) * samples.len() / columns)
.max(start + 1)
.min(samples.len());
let mut min = f32::MAX;
let mut max = f32::MIN;
for &sample in &samples[start..end] {
min = min.min(sample);
max = max.max(sample);
}
if end > start {
out[column] = (min, max);
}
}
out
}
/// A chroma sample pair (e.g. `u`, `v` centered on `0.5`).
@@ -50,91 +52,86 @@ pub type ChromaSample = (f32, f32);
/// (`u - 0.5`, `v - 0.5`, normalized to `-0.5..0.5`). Out-of-range values
/// clamp.
pub fn vectorscope_points(samples: &[ChromaSample]) -> Vec<(f32, f32)> {
samples
.iter()
.map(|&(u, v)| {
(
(u - 0.5).clamp(-0.5, 0.5),
(v - 0.5).clamp(-0.5, 0.5),
)
})
.collect()
samples
.iter()
.map(|&(u, v)| ((u - 0.5).clamp(-0.5, 0.5), (v - 0.5).clamp(-0.5, 0.5)))
.collect()
}
/// Map a normalized `0..1` level to a meter segment's lit count: `segments`
/// segments, the lit portion is proportional to the level.
pub fn meter_lit_segments(level: f32, segments: usize) -> usize {
let lit = (level.clamp(0.0, 1.0) * segments as f32).round() as usize;
lit.min(segments)
let lit = (level.clamp(0.0, 1.0) * segments as f32).round() as usize;
lit.min(segments)
}
/// Peak-hold decay: `peak` decays toward `level` at `decay_per_frame`.
pub fn decay_peak(peak: f32, level: f32, decay_per_frame: f32) -> f32 {
if level >= peak {
level
} else {
(peak - decay_per_frame).max(level)
}
if level >= peak {
level
} else {
(peak - decay_per_frame).max(level)
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
#[test]
fn histogram_bins_correctly() {
let samples = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0];
let bins = histogram_bins(&samples, 4);
// Buckets: [0,0.25): 0.0, 0.1; [0.25,0.5): 0.25; [0.5,0.75): 0.5;
// [0.75,1]: 0.75, 0.9, 1.0 (1.0 clamps into the last bucket).
assert_eq!(bins, vec![2, 1, 1, 3]);
}
#[test]
fn histogram_bins_correctly() {
let samples = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0];
let bins = histogram_bins(&samples, 4);
// Buckets: [0,0.25): 0.0, 0.1; [0.25,0.5): 0.25; [0.5,0.75): 0.5;
// [0.75,1]: 0.75, 0.9, 1.0 (1.0 clamps into the last bucket).
assert_eq!(bins, vec![2, 1, 1, 3]);
}
#[test]
fn histogram_clamps_out_of_range() {
let bins = histogram_bins(&[-1.0, 0.5, 2.0], 2);
assert_eq!(bins, vec![1, 2]);
}
#[test]
fn histogram_clamps_out_of_range() {
let bins = histogram_bins(&[-1.0, 0.5, 2.0], 2);
assert_eq!(bins, vec![1, 2]);
}
#[test]
fn histogram_handles_empty_and_zero_bins() {
assert_eq!(histogram_bins(&[], 4), vec![0u32; 4]);
assert_eq!(histogram_bins(&[0.5], 0), Vec::<u32>::new());
}
#[test]
fn histogram_handles_empty_and_zero_bins() {
assert_eq!(histogram_bins(&[], 4), vec![0u32; 4]);
assert_eq!(histogram_bins(&[0.5], 0), Vec::<u32>::new());
}
#[test]
fn waveform_envelope_slices() {
let samples: Vec<f32> = (0..100).map(|i| i as f32 / 100.0).collect();
let env = waveform_envelope(&samples, 10);
assert_eq!(env.len(), 10);
// First column covers [0, 10): min 0.0, max 0.09.
assert!((env[0].0 - 0.0).abs() < 0.001);
assert!((env[0].1 - 0.09).abs() < 0.001);
// Last column covers [90, 100): min 0.9, max 0.99.
assert!((env[9].0 - 0.9).abs() < 0.001);
assert!((env[9].1 - 0.99).abs() < 0.001);
}
#[test]
fn waveform_envelope_slices() {
let samples: Vec<f32> = (0..100).map(|i| i as f32 / 100.0).collect();
let env = waveform_envelope(&samples, 10);
assert_eq!(env.len(), 10);
// First column covers [0, 10): min 0.0, max 0.09.
assert!((env[0].0 - 0.0).abs() < 0.001);
assert!((env[0].1 - 0.09).abs() < 0.001);
// Last column covers [90, 100): min 0.9, max 0.99.
assert!((env[9].0 - 0.9).abs() < 0.001);
assert!((env[9].1 - 0.99).abs() < 0.001);
}
#[test]
fn vectorscope_projection_centers() {
let points = vectorscope_points(&[(0.5, 0.5), (1.0, 0.0), (0.0, 1.0)]);
assert_eq!(points[0], (0.0, 0.0));
assert_eq!(points[1], (0.5, -0.5));
assert_eq!(points[2], (-0.5, 0.5));
// Out-of-range clamps.
let clamped = vectorscope_points(&[(2.0, -1.0)]);
assert_eq!(clamped[0], (0.5, -0.5));
}
#[test]
fn vectorscope_projection_centers() {
let points = vectorscope_points(&[(0.5, 0.5), (1.0, 0.0), (0.0, 1.0)]);
assert_eq!(points[0], (0.0, 0.0));
assert_eq!(points[1], (0.5, -0.5));
assert_eq!(points[2], (-0.5, 0.5));
// Out-of-range clamps.
let clamped = vectorscope_points(&[(2.0, -1.0)]);
assert_eq!(clamped[0], (0.5, -0.5));
}
#[test]
fn meter_lit_and_peak_decay() {
assert_eq!(meter_lit_segments(0.0, 8), 0);
assert_eq!(meter_lit_segments(0.5, 8), 4);
assert_eq!(meter_lit_segments(1.0, 8), 8);
assert_eq!(meter_lit_segments(1.5, 8), 8);
#[test]
fn meter_lit_and_peak_decay() {
assert_eq!(meter_lit_segments(0.0, 8), 0);
assert_eq!(meter_lit_segments(0.5, 8), 4);
assert_eq!(meter_lit_segments(1.0, 8), 8);
assert_eq!(meter_lit_segments(1.5, 8), 8);
assert_eq!(decay_peak(0.8, 0.5, 0.1), 0.7);
assert_eq!(decay_peak(0.8, 0.9, 0.1), 0.9);
assert_eq!(decay_peak(0.8, 0.78, 0.1), 0.78);
}
assert_eq!(decay_peak(0.8, 0.5, 0.1), 0.7);
assert_eq!(decay_peak(0.8, 0.9, 0.1), 0.9);
assert_eq!(decay_peak(0.8, 0.78, 0.1), 0.78);
}
}
+226 -218
View File
@@ -10,280 +10,288 @@ mod math;
pub use math::*;
use gpui::{
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
colors::DefaultColors, fill, point, prelude::*, px, size,
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
colors::DefaultColors, fill, point, prelude::*, px, size,
};
/// Provides luma samples (`0..1`) for the histogram and waveform scopes.
pub trait LumaDataSource: 'static {
/// Luma samples for the current frame.
fn luma_samples(&self) -> Vec<f32>;
/// Luma samples for the current frame.
fn luma_samples(&self) -> Vec<f32>;
}
/// Provides chroma samples for the vectorscope.
pub trait ChromaDataSource: 'static {
/// `(u, v)` samples in `0..1` (centered on `0.5`).
fn chroma_samples(&self) -> Vec<(f32, f32)>;
/// `(u, v)` samples in `0..1` (centered on `0.5`).
fn chroma_samples(&self) -> Vec<(f32, f32)>;
}
/// A luminance histogram.
pub struct Histogram<D: LumaDataSource> {
data: Entity<D>,
focus_handle: FocusHandle,
data: Entity<D>,
focus_handle: FocusHandle,
}
impl<D: LumaDataSource> Histogram<D> {
/// Create a histogram over `data`.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
}
}
/// Create a histogram over `data`.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
}
}
/// The current histogram bins (for tests and hosts).
pub fn bins(&self, cx: &App) -> Vec<u32> {
histogram_bins(&self.data.read(cx).luma_samples(), 64)
}
/// The current histogram bins (for tests and hosts).
pub fn bins(&self, cx: &App) -> Vec<u32> {
histogram_bins(&self.data.read(cx).luma_samples(), 64)
}
}
impl<D: LumaDataSource> Focusable for Histogram<D> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<D: LumaDataSource> Render for Histogram<D> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let bins = self.bins(cx);
let height = bins.iter().copied().max().unwrap_or(1).max(1) as f32;
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let bar_w = width / bins.len() as f32;
let bar_color = Hsla::from(colors.selected);
for (index, &count) in bins.iter().enumerate() {
let h = (count as f32 / height) * f32::from(bounds.size.height);
let bar = Bounds::new(
point(bounds.left() + px(index as f32 * bar_w), bounds.bottom() - px(h)),
size(px((bar_w - 1.0).max(1.0)), px(h)),
);
window.paint_quad(fill(bar, bar_color));
}
},
)
.size_full()
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let bins = self.bins(cx);
let height = bins.iter().copied().max().unwrap_or(1).max(1) as f32;
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let bar_w = width / bins.len() as f32;
let bar_color = Hsla::from(colors.selected);
for (index, &count) in bins.iter().enumerate() {
let h = (count as f32 / height) * f32::from(bounds.size.height);
let bar = Bounds::new(
point(
bounds.left() + px(index as f32 * bar_w),
bounds.bottom() - px(h),
),
size(px((bar_w - 1.0).max(1.0)), px(h)),
);
window.paint_quad(fill(bar, bar_color));
}
},
)
.size_full()
}
}
/// A waveform scope (min/max envelope over the frame's luma).
pub struct Waveform<D: LumaDataSource> {
data: Entity<D>,
focus_handle: FocusHandle,
data: Entity<D>,
focus_handle: FocusHandle,
}
impl<D: LumaDataSource> Waveform<D> {
/// Create a waveform scope.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
}
}
/// Create a waveform scope.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
}
}
/// The current envelope columns.
pub fn envelope(&self, cx: &App) -> Vec<(f32, f32)> {
waveform_envelope(&self.data.read(cx).luma_samples(), 128)
}
/// The current envelope columns.
pub fn envelope(&self, cx: &App) -> Vec<(f32, f32)> {
waveform_envelope(&self.data.read(cx).luma_samples(), 128)
}
}
impl<D: LumaDataSource> Focusable for Waveform<D> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<D: LumaDataSource> Render for Waveform<D> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let envelope = self.envelope(cx);
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let col_w = width / envelope.len() as f32;
let line = Hsla::from(colors.selected);
for (column, &(min, max)) in envelope.iter().enumerate() {
let y_min = (1.0 - min.clamp(0.0, 1.0)) * height;
let y_max = (1.0 - max.clamp(0.0, 1.0)) * height;
let band = Bounds::new(
point(bounds.left() + px(column as f32 * col_w), bounds.top() + px(y_max)),
size(px((col_w - 0.5).max(0.5)), px((y_min - y_max).max(1.0))),
);
window.paint_quad(fill(band, line));
}
},
)
.size_full()
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let envelope = self.envelope(cx);
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let col_w = width / envelope.len() as f32;
let line = Hsla::from(colors.selected);
for (column, &(min, max)) in envelope.iter().enumerate() {
let y_min = (1.0 - min.clamp(0.0, 1.0)) * height;
let y_max = (1.0 - max.clamp(0.0, 1.0)) * height;
let band = Bounds::new(
point(
bounds.left() + px(column as f32 * col_w),
bounds.top() + px(y_max),
),
size(px((col_w - 0.5).max(0.5)), px((y_min - y_max).max(1.0))),
);
window.paint_quad(fill(band, line));
}
},
)
.size_full()
}
}
/// A vectorscope (chroma projection with a graticule).
pub struct Vectorscope<D: ChromaDataSource> {
data: Entity<D>,
focus_handle: FocusHandle,
data: Entity<D>,
focus_handle: FocusHandle,
}
impl<D: ChromaDataSource> Vectorscope<D> {
/// Create a vectorscope.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
}
}
/// Create a vectorscope.
pub fn new(
_control: usize,
data: Entity<D>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
data,
focus_handle: cx.focus_handle(),
}
}
/// The projected chroma points.
pub fn points(&self, cx: &App) -> Vec<(f32, f32)> {
vectorscope_points(&self.data.read(cx).chroma_samples())
}
/// The projected chroma points.
pub fn points(&self, cx: &App) -> Vec<(f32, f32)> {
vectorscope_points(&self.data.read(cx).chroma_samples())
}
}
impl<D: ChromaDataSource> Focusable for Vectorscope<D> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<D: ChromaDataSource> Render for Vectorscope<D> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let points = self.points(cx);
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let center = point(bounds.left() + px(width / 2.0), bounds.top() + px(height / 2.0));
let grid = Hsla::from(colors.border);
// Graticule: crosshair + box.
window.paint_quad(fill(
Bounds::new(
point(bounds.left(), center.y - px(0.5)),
size(bounds.size.width, px(1.0)),
),
grid,
));
window.paint_quad(fill(
Bounds::new(
point(center.x - px(0.5), bounds.top()),
size(px(1.0), bounds.size.height),
),
grid,
));
// Points: u -> x, v -> y (inverted).
let point_color = Hsla::from(colors.selected);
for &(u, v) in points.iter().take(4096) {
let x = center.x + px(u / 0.5 * width / 2.0);
let y = center.y - px(v / 0.5 * height / 2.0);
let dot = Bounds::new(point(x - px(1.0), y - px(1.0)), size(px(2.0), px(2.0)));
window.paint_quad(fill(dot, point_color));
}
},
)
.size_full()
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let points = self.points(cx);
canvas(
move |_bounds, _window, _cx| (),
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let center = point(
bounds.left() + px(width / 2.0),
bounds.top() + px(height / 2.0),
);
let grid = Hsla::from(colors.border);
// Graticule: crosshair + box.
window.paint_quad(fill(
Bounds::new(
point(bounds.left(), center.y - px(0.5)),
size(bounds.size.width, px(1.0)),
),
grid,
));
window.paint_quad(fill(
Bounds::new(
point(center.x - px(0.5), bounds.top()),
size(px(1.0), bounds.size.height),
),
grid,
));
// Points: u -> x, v -> y (inverted).
let point_color = Hsla::from(colors.selected);
for &(u, v) in points.iter().take(4096) {
let x = center.x + px(u / 0.5 * width / 2.0);
let y = center.y - px(v / 0.5 * height / 2.0);
let dot = Bounds::new(point(x - px(1.0), y - px(1.0)), size(px(2.0), px(2.0)));
window.paint_quad(fill(dot, point_color));
}
},
)
.size_full()
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Render, TestAppContext, Window, div, px, size};
use super::*;
use gpui::{Entity, Render, TestAppContext, Window, div, px, size};
struct MockLuma(Vec<f32>);
impl LumaDataSource for MockLuma {
fn luma_samples(&self) -> Vec<f32> {
self.0.clone()
}
}
struct MockChroma(Vec<(f32, f32)>);
impl ChromaDataSource for MockChroma {
fn chroma_samples(&self) -> Vec<(f32, f32)> {
self.0.clone()
}
}
struct MockLuma(Vec<f32>);
impl LumaDataSource for MockLuma {
fn luma_samples(&self) -> Vec<f32> {
self.0.clone()
}
}
struct MockChroma(Vec<(f32, f32)>);
impl ChromaDataSource for MockChroma {
fn chroma_samples(&self) -> Vec<(f32, f32)> {
self.0.clone()
}
}
struct Host {
histogram: Entity<Histogram<MockLuma>>,
waveform: Entity<Waveform<MockLuma>>,
vectorscope: Entity<Vectorscope<MockChroma>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(self.histogram.clone())
.child(self.waveform.clone())
.child(self.vectorscope.clone())
}
}
struct Host {
histogram: Entity<Histogram<MockLuma>>,
waveform: Entity<Waveform<MockLuma>>,
vectorscope: Entity<Vectorscope<MockChroma>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(self.histogram.clone())
.child(self.waveform.clone())
.child(self.vectorscope.clone())
}
}
#[gpui::test]
async fn scopes_render_from_mock_data(cx: &mut TestAppContext) {
use gpui::VisualTestContext;
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let luma = cx.new(|_| MockLuma((0..100).map(|i| i as f32 / 100.0).collect()));
let chroma = cx.new(|_| MockChroma(vec![(0.5, 0.5), (0.75, 0.25), (0.25, 0.75)]));
let histogram = cx.new(|cx| Histogram::new(1, luma.clone(), window, cx));
let waveform = cx.new(|cx| Waveform::new(2, luma.clone(), window, cx));
let vectorscope = cx.new(|cx| Vectorscope::new(3, chroma.clone(), window, cx));
Host {
histogram,
waveform,
vectorscope,
}
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Force a draw so the canvas paint closures run (no double-lease:
// VisualTestContext::update goes through App::update_window).
cx.update(|window, cx| {
window.draw(cx).clear();
});
#[gpui::test]
async fn scopes_render_from_mock_data(cx: &mut TestAppContext) {
use gpui::VisualTestContext;
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let luma = cx.new(|_| MockLuma((0..100).map(|i| i as f32 / 100.0).collect()));
let chroma = cx.new(|_| MockChroma(vec![(0.5, 0.5), (0.75, 0.25), (0.25, 0.75)]));
let histogram = cx.new(|cx| Histogram::new(1, luma.clone(), window, cx));
let waveform = cx.new(|cx| Waveform::new(2, luma.clone(), window, cx));
let vectorscope = cx.new(|cx| Vectorscope::new(3, chroma.clone(), window, cx));
Host {
histogram,
waveform,
vectorscope,
}
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Force a draw so the canvas paint closures run (no double-lease:
// VisualTestContext::update goes through App::update_window).
cx.update(|window, cx| {
window.draw(cx).clear();
});
let (bins, envelope, points) = cx.read(|app| {
let host = host.read(app);
(
host.histogram.read(app).bins(app),
host.waveform.read(app).envelope(app),
host.vectorscope.read(app).points(app),
)
});
assert_eq!(bins.len(), 64);
let total: u32 = bins.iter().sum();
assert_eq!(total, 100);
assert_eq!(envelope.len(), 128);
assert_eq!(points.len(), 3);
}
let (bins, envelope, points) = cx.read(|app| {
let host = host.read(app);
(
host.histogram.read(app).bins(app),
host.waveform.read(app).envelope(app),
host.vectorscope.read(app).points(app),
)
});
assert_eq!(bins.len(), 64);
let total: u32 = bins.iter().sum();
assert_eq!(total, 100);
assert_eq!(envelope.len(), 128);
assert_eq!(points.len(), 3);
}
}
File diff suppressed because it is too large Load Diff
+342 -329
View File
@@ -14,374 +14,387 @@ use crate::value::{RationalValue, SliderValue, ValueKind};
/// floating point.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SliderModel {
/// The family of values this slider edits.
pub kind: ValueKind,
/// Canonical numeric position (for `Rational`: the numerator).
pub raw: f64,
/// Inclusive lower bound of `raw`.
pub min: f64,
/// Inclusive upper bound of `raw`.
pub max: f64,
/// Coarse increment (wheel notch / arrow key).
pub step: f64,
/// Fine increment used with a fine-adjust modifier.
pub fine_step: f64,
/// Value restored by a middle-click reset.
pub default_raw: f64,
/// Denominator for `Rational` values (unused otherwise).
pub rational_den: i64,
/// The family of values this slider edits.
pub kind: ValueKind,
/// Canonical numeric position (for `Rational`: the numerator).
pub raw: f64,
/// Inclusive lower bound of `raw`.
pub min: f64,
/// Inclusive upper bound of `raw`.
pub max: f64,
/// Coarse increment (wheel notch / arrow key).
pub step: f64,
/// Fine increment used with a fine-adjust modifier.
pub fine_step: f64,
/// Value restored by a middle-click reset.
pub default_raw: f64,
/// Denominator for `Rational` values (unused otherwise).
pub rational_den: i64,
}
impl SliderModel {
/// Create a slider over `[min, max]` with `step` increments.
///
/// For `Rational` sliders `min`, `max`, `step` and `default_raw` are in
/// numerator units; pair with [`Self::with_rational_den`].
pub fn new(kind: ValueKind, min: f64, max: f64, step: f64, default_raw: f64) -> Self {
let raw = default_raw.clamp(min, max);
Self {
kind,
raw,
min,
max,
step,
fine_step: step / 10.0,
default_raw: default_raw.clamp(min, max),
rational_den: 1,
}
}
/// Create a slider over `[min, max]` with `step` increments.
///
/// For `Rational` sliders `min`, `max`, `step` and `default_raw` are in
/// numerator units; pair with [`Self::with_rational_den`].
pub fn new(kind: ValueKind, min: f64, max: f64, step: f64, default_raw: f64) -> Self {
let raw = default_raw.clamp(min, max);
Self {
kind,
raw,
min,
max,
step,
fine_step: step / 10.0,
default_raw: default_raw.clamp(min, max),
rational_den: 1,
}
}
/// Set the denominator of a `Rational` slider (a no-op otherwise).
pub fn with_rational_den(mut self, den: i64) -> Self {
if self.kind == ValueKind::Rational {
self.rational_den = den.max(1);
}
self
}
/// Set the denominator of a `Rational` slider (a no-op otherwise).
pub fn with_rational_den(mut self, den: i64) -> Self {
if self.kind == ValueKind::Rational {
self.rational_den = den.max(1);
}
self
}
/// Override the fine-adjust increment.
pub fn with_fine_step(mut self, fine_step: f64) -> Self {
self.fine_step = fine_step;
self
}
/// Override the fine-adjust increment.
pub fn with_fine_step(mut self, fine_step: f64) -> Self {
self.fine_step = fine_step;
self
}
/// The current value.
pub fn value(&self) -> SliderValue {
match self.kind {
ValueKind::Float => SliderValue::Float(self.raw),
ValueKind::Integer => SliderValue::Integer(self.raw as i64),
ValueKind::Rational => {
SliderValue::Rational(RationalValue::new_or_one(self.raw as i64, self.rational_den))
}
ValueKind::Angle => SliderValue::Angle(self.raw),
}
}
/// The current value.
pub fn value(&self) -> SliderValue {
match self.kind {
ValueKind::Float => SliderValue::Float(self.raw),
ValueKind::Integer => SliderValue::Integer(self.raw as i64),
ValueKind::Rational => SliderValue::Rational(RationalValue::new_or_one(
self.raw as i64,
self.rational_den,
)),
ValueKind::Angle => SliderValue::Angle(self.raw),
}
}
/// True if `v` is within `[min, max]` (and finite).
pub fn in_range(&self, v: f64) -> bool {
v.is_finite() && v >= self.min && v <= self.max
}
/// True if `v` is within `[min, max]` (and finite).
pub fn in_range(&self, v: f64) -> bool {
v.is_finite() && v >= self.min && v <= self.max
}
/// Clamp a raw position to `[min, max]`.
pub fn clamp(&self, raw: f64) -> f64 {
raw.clamp(self.min, self.max)
}
/// Clamp a raw position to `[min, max]`.
pub fn clamp(&self, raw: f64) -> f64 {
raw.clamp(self.min, self.max)
}
/// Snap a raw position to the nearest multiple of `step`, measured from
/// `min`. Positions outside the range snap back to the bounds.
fn snap_with_step(&self, raw: f64, step: f64) -> f64 {
let raw = self.clamp(raw);
if step <= 0.0 || self.spanned() == 0.0 {
return raw;
}
let steps = ((raw - self.min) / step).round();
self.clamp(self.min + steps * step)
}
/// Snap a raw position to the nearest multiple of `step`, measured from
/// `min`. Positions outside the range snap back to the bounds.
fn snap_with_step(&self, raw: f64, step: f64) -> f64 {
let raw = self.clamp(raw);
if step <= 0.0 || self.spanned() == 0.0 {
return raw;
}
let steps = ((raw - self.min) / step).round();
self.clamp(self.min + steps * step)
}
/// Snap a raw position to the nearest coarse step, measured from `min`.
pub fn snap(&self, raw: f64) -> f64 {
self.snap_with_step(raw, self.step)
}
/// Snap a raw position to the nearest coarse step, measured from `min`.
pub fn snap(&self, raw: f64) -> f64 {
self.snap_with_step(raw, self.step)
}
/// The width of the range (`max - min`, never negative).
pub fn spanned(&self) -> f64 {
(self.max - self.min).max(0.0)
}
/// The width of the range (`max - min`, never negative).
pub fn spanned(&self) -> f64 {
(self.max - self.min).max(0.0)
}
/// Set the position from a raw value: clamps and snaps to `step`,
/// returns whether the position changed.
pub fn apply_with_step(&mut self, raw: f64, step: f64) -> bool {
if !raw.is_finite() {
return false;
}
let snapped = self.snap_with_step(raw, step);
if (snapped - self.raw).abs() > f64::EPSILON {
self.raw = snapped;
true
} else {
false
}
}
/// Set the position from a raw value: clamps and snaps to `step`,
/// returns whether the position changed.
pub fn apply_with_step(&mut self, raw: f64, step: f64) -> bool {
if !raw.is_finite() {
return false;
}
let snapped = self.snap_with_step(raw, step);
if (snapped - self.raw).abs() > f64::EPSILON {
self.raw = snapped;
true
} else {
false
}
}
/// Set the position from a raw value: clamps and snaps to the coarse
/// step, returns whether the position changed.
pub fn apply_raw(&mut self, raw: f64) -> bool {
self.apply_with_step(raw, self.step)
}
/// Set the position from a raw value: clamps and snaps to the coarse
/// step, returns whether the position changed.
pub fn apply_raw(&mut self, raw: f64) -> bool {
self.apply_with_step(raw, self.step)
}
/// Set the value, converting between representations where possible.
/// Returns whether the position changed. Non-finite input is rejected.
pub fn set_value(&mut self, value: SliderValue) -> bool {
let finite = match value {
SliderValue::Float(v) | SliderValue::Angle(v) => v.is_finite(),
SliderValue::Integer(_) | SliderValue::Rational(_) => true,
};
if !finite {
return false;
}
let raw = match (self.kind, value) {
(ValueKind::Float, SliderValue::Float(v))
| (ValueKind::Angle, SliderValue::Angle(v)) => v,
(ValueKind::Integer, SliderValue::Integer(v)) => v as f64,
(ValueKind::Rational, SliderValue::Rational(v)) => {
(v.num() as f64) * (self.rational_den as f64) / (v.den() as f64)
}
// Cross-kind conversions go through the numeric projection.
(kind, value) => {
let v = value.to_f64();
match kind {
ValueKind::Integer => v.trunc(),
ValueKind::Rational => {
(v * self.rational_den as f64).round() / self.rational_den as f64
}
_ => v,
}
}
};
self.apply_raw(raw)
}
/// Set the value, converting between representations where possible.
/// Returns whether the position changed. Non-finite input is rejected.
pub fn set_value(&mut self, value: SliderValue) -> bool {
let finite = match value {
SliderValue::Float(v) | SliderValue::Angle(v) => v.is_finite(),
SliderValue::Integer(_) | SliderValue::Rational(_) => true,
};
if !finite {
return false;
}
let raw = match (self.kind, value) {
(ValueKind::Float, SliderValue::Float(v))
| (ValueKind::Angle, SliderValue::Angle(v)) => v,
(ValueKind::Integer, SliderValue::Integer(v)) => v as f64,
(ValueKind::Rational, SliderValue::Rational(v)) => {
(v.num() as f64) * (self.rational_den as f64) / (v.den() as f64)
}
// Cross-kind conversions go through the numeric projection.
(kind, value) => {
let v = value.to_f64();
match kind {
ValueKind::Integer => v.trunc(),
ValueKind::Rational => {
(v * self.rational_den as f64).round() / self.rational_den as f64
}
_ => v,
}
}
};
self.apply_raw(raw)
}
/// The next position `delta` coarse (or fine) steps away from `from`,
/// clamped to the range.
pub fn step_from(&self, from: f64, delta: i32, fine: bool) -> f64 {
let step = if fine { self.fine_step } else { self.step };
if step <= 0.0 {
return self.clamp(from);
}
self.clamp(from + delta as f64 * step)
}
/// The next position `delta` coarse (or fine) steps away from `from`,
/// clamped to the range.
pub fn step_from(&self, from: f64, delta: i32, fine: bool) -> f64 {
let step = if fine { self.fine_step } else { self.step };
if step <= 0.0 {
return self.clamp(from);
}
self.clamp(from + delta as f64 * step)
}
/// Step the current position by `delta` steps, snapping to the effective
/// (coarse or fine) step.
pub fn apply_step(&mut self, delta: i32, fine: bool) -> bool {
let step = if fine { self.fine_step } else { self.step };
self.apply_with_step(self.step_from(self.raw, delta, fine), step)
}
/// Step the current position by `delta` steps, snapping to the effective
/// (coarse or fine) step.
pub fn apply_step(&mut self, delta: i32, fine: bool) -> bool {
let step = if fine { self.fine_step } else { self.step };
self.apply_with_step(self.step_from(self.raw, delta, fine), step)
}
/// Reset to the default position; returns whether it changed.
pub fn reset(&mut self) -> bool {
let snapped = self.snap(self.default_raw);
if (snapped - self.raw).abs() > f64::EPSILON {
self.raw = snapped;
true
} else {
false
}
}
/// Reset to the default position; returns whether it changed.
pub fn reset(&mut self) -> bool {
let snapped = self.snap(self.default_raw);
if (snapped - self.raw).abs() > f64::EPSILON {
self.raw = snapped;
true
} else {
false
}
}
/// Whether the current position equals the (snapped) default.
pub fn is_at_default(&self) -> bool {
(self.snap(self.default_raw) - self.raw).abs() <= f64::EPSILON
}
/// Whether the current position equals the (snapped) default.
pub fn is_at_default(&self) -> bool {
(self.snap(self.default_raw) - self.raw).abs() <= f64::EPSILON
}
/// Normalized position in `0..=1` for painting. Returns `0.5` when the
/// range is empty so the handle never leaves the track.
pub fn fraction(&self) -> f64 {
if self.spanned() == 0.0 {
return 0.5;
}
((self.raw - self.min) / self.spanned()).clamp(0.0, 1.0)
}
/// Normalized position in `0..=1` for painting. Returns `0.5` when the
/// range is empty so the handle never leaves the track.
pub fn fraction(&self) -> f64 {
if self.spanned() == 0.0 {
return 0.5;
}
((self.raw - self.min) / self.spanned()).clamp(0.0, 1.0)
}
/// Set the position from a normalized `0..=1` fraction of the range.
pub fn set_fraction(&mut self, t: f64) -> bool {
if !t.is_finite() {
return false;
}
let raw = self.min + t.clamp(0.0, 1.0) * self.spanned();
self.apply_raw(raw)
}
/// Set the position from a normalized `0..=1` fraction of the range.
pub fn set_fraction(&mut self, t: f64) -> bool {
if !t.is_finite() {
return false;
}
let raw = self.min + t.clamp(0.0, 1.0) * self.spanned();
self.apply_raw(raw)
}
/// Apply a vertical drag of `dy_px` pixels over a track that spans
/// `range_px` pixels. Dragging up (`dy_px < 0`) increases the value.
/// `fine` scales the motion by `1/10` and snaps to the fine step.
pub fn drag_delta(&mut self, dy_px: f32, range_px: f32, fine: bool) -> bool {
if range_px <= 0.0 {
return false;
}
let scale = if fine { 0.1 } else { 1.0 };
let step = if fine { self.fine_step } else { self.step };
let delta = -(dy_px as f64) / range_px as f64 * self.spanned() * scale;
self.apply_with_step(self.raw + delta, step)
}
/// Apply a vertical drag of `dy_px` pixels over a track that spans
/// `range_px` pixels. Dragging up (`dy_px < 0`) increases the value.
/// `fine` scales the motion by `1/10` and snaps to the fine step.
pub fn drag_delta(&mut self, dy_px: f32, range_px: f32, fine: bool) -> bool {
if range_px <= 0.0 {
return false;
}
let scale = if fine { 0.1 } else { 1.0 };
let step = if fine { self.fine_step } else { self.step };
let delta = -(dy_px as f64) / range_px as f64 * self.spanned() * scale;
self.apply_with_step(self.raw + delta, step)
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
fn float_slider() -> SliderModel {
SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 0.5)
}
fn float_slider() -> SliderModel {
SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 0.5)
}
#[test]
fn new_clamps_default() {
let model = SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 5.0);
assert_eq!(model.raw, 1.0);
assert!(model.is_at_default());
}
#[test]
fn new_clamps_default() {
let model = SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.1, 5.0);
assert_eq!(model.raw, 1.0);
assert!(model.is_at_default());
}
#[test]
fn clamps_to_range() {
let model = float_slider();
assert_eq!(model.clamp(-2.0), 0.0);
assert_eq!(model.clamp(2.0), 1.0);
assert!(model.in_range(0.5));
assert!(!model.in_range(1.5));
assert!(!model.in_range(f64::NAN));
}
#[test]
fn clamps_to_range() {
let model = float_slider();
assert_eq!(model.clamp(-2.0), 0.0);
assert_eq!(model.clamp(2.0), 1.0);
assert!(model.in_range(0.5));
assert!(!model.in_range(1.5));
assert!(!model.in_range(f64::NAN));
}
#[test]
fn snaps_to_step_from_min() {
let model = float_slider();
assert!((model.snap(0.27) - 0.3).abs() < 1e-9);
assert!((model.snap(0.24) - 0.2).abs() < 1e-9);
// Off-range values snap back to the bounds.
assert!((model.snap(-5.0) - 0.0).abs() < 1e-9);
assert!((model.snap(5.0) - 1.0).abs() < 1e-9);
}
#[test]
fn snaps_to_step_from_min() {
let model = float_slider();
assert!((model.snap(0.27) - 0.3).abs() < 1e-9);
assert!((model.snap(0.24) - 0.2).abs() < 1e-9);
// Off-range values snap back to the bounds.
assert!((model.snap(-5.0) - 0.0).abs() < 1e-9);
assert!((model.snap(5.0) - 1.0).abs() < 1e-9);
}
#[test]
fn snaps_relative_to_nonzero_min() {
let model = SliderModel::new(ValueKind::Float, 0.5, 2.5, 0.5, 1.0);
// (1.15 - 0.5) / 0.5 = 1.3 -> round to 1 -> 0.5 + 0.5 = 1.0
assert!((model.snap(1.15) - 1.0).abs() < 1e-9);
// (1.35 - 0.5) / 0.5 = 1.7 -> round to 2 -> 1.5
assert!((model.snap(1.35) - 1.5).abs() < 1e-9);
}
#[test]
fn snaps_relative_to_nonzero_min() {
let model = SliderModel::new(ValueKind::Float, 0.5, 2.5, 0.5, 1.0);
// (1.15 - 0.5) / 0.5 = 1.3 -> round to 1 -> 0.5 + 0.5 = 1.0
assert!((model.snap(1.15) - 1.0).abs() < 1e-9);
// (1.35 - 0.5) / 0.5 = 1.7 -> round to 2 -> 1.5
assert!((model.snap(1.35) - 1.5).abs() < 1e-9);
}
#[test]
fn apply_raw_rejects_nan() {
let mut model = float_slider();
assert!(!model.apply_raw(f64::NAN));
assert_eq!(model.raw, 0.5);
}
#[test]
fn apply_raw_rejects_nan() {
let mut model = float_slider();
assert!(!model.apply_raw(f64::NAN));
assert_eq!(model.raw, 0.5);
}
#[test]
fn step_from_clamps() {
let model = float_slider();
assert!((model.step_from(0.95, 1, false) - 1.0).abs() < 1e-9);
assert!((model.step_from(0.05, -1, false) - 0.0).abs() < 1e-9);
assert!((model.step_from(0.5, 1, false) - 0.6).abs() < 1e-9);
// Fine step is 1/10 of the coarse step.
assert!((model.step_from(0.5, 1, true) - 0.51).abs() < 1e-9);
}
#[test]
fn step_from_clamps() {
let model = float_slider();
assert!((model.step_from(0.95, 1, false) - 1.0).abs() < 1e-9);
assert!((model.step_from(0.05, -1, false) - 0.0).abs() < 1e-9);
assert!((model.step_from(0.5, 1, false) - 0.6).abs() < 1e-9);
// Fine step is 1/10 of the coarse step.
assert!((model.step_from(0.5, 1, true) - 0.51).abs() < 1e-9);
}
#[test]
fn reset_restores_default() {
let mut model = float_slider();
model.apply_raw(0.9);
assert!(!model.is_at_default());
assert!(model.reset());
assert_eq!(model.raw, 0.5);
assert!(model.is_at_default());
}
#[test]
fn reset_restores_default() {
let mut model = float_slider();
model.apply_raw(0.9);
assert!(!model.is_at_default());
assert!(model.reset());
assert_eq!(model.raw, 0.5);
assert!(model.is_at_default());
}
#[test]
fn fraction_and_set_fraction_are_inverse() {
let mut model = float_slider();
assert_eq!(model.fraction(), 0.5);
model.set_fraction(0.0);
assert_eq!(model.raw, 0.0);
model.set_fraction(1.0);
assert_eq!(model.raw, 1.0);
// Positions are quantized to the step: 0.25 lands on the nearest
// representable value (0.3 with a 0.1 step).
model.set_fraction(0.25);
assert!((model.raw - 0.3).abs() < 1e-9);
assert!((model.fraction() - 0.3).abs() < 1e-9);
}
#[test]
fn fraction_and_set_fraction_are_inverse() {
let mut model = float_slider();
assert_eq!(model.fraction(), 0.5);
model.set_fraction(0.0);
assert_eq!(model.raw, 0.0);
model.set_fraction(1.0);
assert_eq!(model.raw, 1.0);
// Positions are quantized to the step: 0.25 lands on the nearest
// representable value (0.3 with a 0.1 step).
model.set_fraction(0.25);
assert!((model.raw - 0.3).abs() < 1e-9);
assert!((model.fraction() - 0.3).abs() < 1e-9);
}
#[test]
fn fraction_when_range_is_empty() {
let model = SliderModel::new(ValueKind::Float, 1.0, 1.0, 0.1, 1.0);
assert_eq!(model.fraction(), 0.5);
}
#[test]
fn fraction_when_range_is_empty() {
let model = SliderModel::new(ValueKind::Float, 1.0, 1.0, 0.1, 1.0);
assert_eq!(model.fraction(), 0.5);
}
#[test]
fn drag_up_increases_value() {
let mut model = float_slider();
// 10px up over a 100px track spans 10% of the range.
assert!(model.drag_delta(-10.0, 100.0, false));
assert!((model.raw - 0.6).abs() < 1e-9);
// Fine drag moves 10x less.
let mut fine = float_slider();
fine.drag_delta(-10.0, 100.0, true);
assert!((fine.raw - 0.51).abs() < 1e-9);
// Dragging up past the max clamps.
let mut model = float_slider();
model.drag_delta(-1000.0, 100.0, false);
assert_eq!(model.raw, 1.0);
}
#[test]
fn drag_up_increases_value() {
let mut model = float_slider();
// 10px up over a 100px track spans 10% of the range.
assert!(model.drag_delta(-10.0, 100.0, false));
assert!((model.raw - 0.6).abs() < 1e-9);
// Fine drag moves 10x less.
let mut fine = float_slider();
fine.drag_delta(-10.0, 100.0, true);
assert!((fine.raw - 0.51).abs() < 1e-9);
// Dragging up past the max clamps.
let mut model = float_slider();
model.drag_delta(-1000.0, 100.0, false);
assert_eq!(model.raw, 1.0);
}
#[test]
fn integer_kind_round_trips_exactly() {
let mut model = SliderModel::new(ValueKind::Integer, -10.0, 10.0, 1.0, 0.0);
assert_eq!(model.value(), SliderValue::Integer(0));
model.set_value(SliderValue::Integer(5));
assert_eq!(model.value(), SliderValue::Integer(5));
model.apply_step(1, false);
assert_eq!(model.value(), SliderValue::Integer(6));
// A float value converts by truncation.
model.set_value(SliderValue::Float(3.9));
assert_eq!(model.value(), SliderValue::Integer(3));
}
#[test]
fn integer_kind_round_trips_exactly() {
let mut model = SliderModel::new(ValueKind::Integer, -10.0, 10.0, 1.0, 0.0);
assert_eq!(model.value(), SliderValue::Integer(0));
model.set_value(SliderValue::Integer(5));
assert_eq!(model.value(), SliderValue::Integer(5));
model.apply_step(1, false);
assert_eq!(model.value(), SliderValue::Integer(6));
// A float value converts by truncation.
model.set_value(SliderValue::Float(3.9));
assert_eq!(model.value(), SliderValue::Integer(3));
}
#[test]
fn rational_kind_stays_exact() {
let mut model = SliderModel::new(ValueKind::Rational, 0.0, 24.0, 1.0, 0.0)
.with_rational_den(24);
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(0, 1).unwrap()));
model.apply_raw(1.0);
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(1, 24).unwrap()));
model.apply_raw(24.0);
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(1, 1).unwrap()));
// Setting a rational with a different denominator rescales.
model.set_value(SliderValue::Rational(RationalValue::new(1, 2).unwrap()));
assert_eq!(model.value(), SliderValue::Rational(RationalValue::new(1, 2).unwrap()));
}
#[test]
fn rational_kind_stays_exact() {
let mut model =
SliderModel::new(ValueKind::Rational, 0.0, 24.0, 1.0, 0.0).with_rational_den(24);
assert_eq!(
model.value(),
SliderValue::Rational(RationalValue::new(0, 1).unwrap())
);
model.apply_raw(1.0);
assert_eq!(
model.value(),
SliderValue::Rational(RationalValue::new(1, 24).unwrap())
);
model.apply_raw(24.0);
assert_eq!(
model.value(),
SliderValue::Rational(RationalValue::new(1, 1).unwrap())
);
// Setting a rational with a different denominator rescales.
model.set_value(SliderValue::Rational(RationalValue::new(1, 2).unwrap()));
assert_eq!(
model.value(),
SliderValue::Rational(RationalValue::new(1, 2).unwrap())
);
}
#[test]
fn angle_kind_round_trips() {
let mut model = SliderModel::new(ValueKind::Angle, 0.0, 360.0, 0.5, 45.0);
assert_eq!(model.value(), SliderValue::Angle(45.0));
model.set_value(SliderValue::Angle(270.5));
assert_eq!(model.value(), SliderValue::Angle(270.5));
}
#[test]
fn angle_kind_round_trips() {
let mut model = SliderModel::new(ValueKind::Angle, 0.0, 360.0, 0.5, 45.0);
assert_eq!(model.value(), SliderValue::Angle(45.0));
model.set_value(SliderValue::Angle(270.5));
assert_eq!(model.value(), SliderValue::Angle(270.5));
}
#[test]
fn set_value_rejects_non_finite() {
let mut model = float_slider();
assert!(!model.set_value(SliderValue::Float(f64::NAN)));
assert!(!model.set_value(SliderValue::Angle(f64::INFINITY)));
assert_eq!(model.raw, 0.5);
}
#[test]
fn set_value_rejects_non_finite() {
let mut model = float_slider();
assert!(!model.set_value(SliderValue::Float(f64::NAN)));
assert!(!model.set_value(SliderValue::Angle(f64::INFINITY)));
assert_eq!(model.raw, 0.5);
}
#[test]
fn integer_fine_step_is_whole() {
let model = SliderModel::new(ValueKind::Integer, 0.0, 10.0, 1.0, 5.0);
// Fine step for integers is 1/10 by default; snapping keeps it whole.
let snapped = model.snap(model.step_from(5.0, 1, true));
assert_eq!(snapped, 5.0);
}
#[test]
fn integer_fine_step_is_whole() {
let model = SliderModel::new(ValueKind::Integer, 0.0, 10.0, 1.0, 5.0);
// Fine step for integers is 1/10 by default; snapping keeps it whole.
let snapped = model.snap(model.step_from(5.0, 1, true));
assert_eq!(snapped, 5.0);
}
}
+324 -307
View File
@@ -6,8 +6,8 @@
//! the value. Every change is emitted as [`SpinBoxEvent::ValueChanged`].
use gpui::{
App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent,
Render, Window, colors::DefaultColors, div, prelude::*, px,
App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
KeyDownEvent, Render, Window, colors::DefaultColors, div, prelude::*, px,
};
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
@@ -17,295 +17,302 @@ use crate::value::{DefaultFormatter, SliderValue, ValueFormatter};
/// A request emitted by a spinbox.
#[derive(Debug, Clone, PartialEq)]
pub enum SpinBoxEvent {
/// The value changed (commit, button, wheel or arrow key).
ValueChanged {
/// The control's stable id.
control: usize,
/// The new value.
value: SliderValue,
},
/// Direct text entry was committed.
EditCommitted {
/// The control's stable id.
control: usize,
/// The accepted value.
value: SliderValue,
},
/// Direct text entry was cancelled (invalid input kept).
EditCancelled {
/// The control's stable id.
control: usize,
},
/// The value changed (commit, button, wheel or arrow key).
ValueChanged {
/// The control's stable id.
control: usize,
/// The new value.
value: SliderValue,
},
/// Direct text entry was committed.
EditCommitted {
/// The control's stable id.
control: usize,
/// The accepted value.
value: SliderValue,
},
/// Direct text entry was cancelled (invalid input kept).
EditCancelled {
/// The control's stable id.
control: usize,
},
}
/// A numeric spinbox control.
pub struct SpinBox {
control: usize,
model: SliderModel,
formatter: Box<dyn ValueFormatter>,
editor: Entity<EditableTextState>,
focus_handle: FocusHandle,
/// Keeps the commit-on-blur listener alive for the widget's lifetime.
_commit_subscription: gpui::Subscription,
control: usize,
model: SliderModel,
formatter: Box<dyn ValueFormatter>,
editor: Entity<EditableTextState>,
focus_handle: FocusHandle,
/// Keeps the commit-on-blur listener alive for the widget's lifetime.
_commit_subscription: gpui::Subscription,
}
impl SpinBox {
/// Create a spinbox for `control` over `model`.
pub fn new(
control: usize,
model: SliderModel,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let formatter = Box::new(DefaultFormatter);
let text = formatter.format(model.value());
let editor = cx.new(|cx| EditableTextState::new(StringStorage::from(text.to_string()), cx));
let focus_handle = editor.read(cx).focus_handle(cx);
let _commit_subscription =
cx.on_focus_out(&focus_handle, window, |this, _event, _window, cx| {
this.commit_edit(cx);
});
Self {
control,
model,
formatter,
editor,
focus_handle,
_commit_subscription,
}
}
/// Create a spinbox for `control` over `model`.
pub fn new(
control: usize,
model: SliderModel,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let formatter = Box::new(DefaultFormatter);
let text = formatter.format(model.value());
let editor = cx.new(|cx| EditableTextState::new(StringStorage::from(text.to_string()), cx));
let focus_handle = editor.read(cx).focus_handle(cx);
let _commit_subscription =
cx.on_focus_out(&focus_handle, window, |this, _event, _window, cx| {
this.commit_edit(cx);
});
Self {
control,
model,
formatter,
editor,
focus_handle,
_commit_subscription,
}
}
/// Inject a custom formatter/parser.
pub fn with_formatter(mut self, formatter: impl ValueFormatter) -> Self {
self.formatter = Box::new(formatter);
self
}
/// Inject a custom formatter/parser.
pub fn with_formatter(mut self, formatter: impl ValueFormatter) -> Self {
self.formatter = Box::new(formatter);
self
}
/// The current value.
pub fn value(&self) -> SliderValue {
self.model.value()
}
/// The current value.
pub fn value(&self) -> SliderValue {
self.model.value()
}
/// Apply a value from the host and refresh the displayed text.
pub fn set_value(&mut self, value: SliderValue, cx: &mut Context<Self>) {
self.model.set_value(value);
self.sync_text(cx);
cx.notify();
}
/// Apply a value from the host and refresh the displayed text.
pub fn set_value(&mut self, value: SliderValue, cx: &mut Context<Self>) {
self.model.set_value(value);
self.sync_text(cx);
cx.notify();
}
/// Repaint the editor with the formatted value (only when the field is
/// not being edited, so typing is never clobbered).
fn sync_text(&self, cx: &mut Context<Self>) {
let text = self.formatter.format(self.model.value());
self.editor.update(cx, |editor, cx| {
editor.emplace(text.as_ref(), cx);
});
}
/// Repaint the editor with the formatted value (only when the field is
/// not being edited, so typing is never clobbered).
fn sync_text(&self, cx: &mut Context<Self>) {
let text = self.formatter.format(self.model.value());
self.editor.update(cx, |editor, cx| {
editor.emplace(text.as_ref(), cx);
});
}
fn apply_and_notify(&mut self, changed: bool, cx: &mut Context<Self>) {
if changed {
self.sync_text(cx);
cx.emit(SpinBoxEvent::ValueChanged {
control: self.control,
value: self.model.value(),
});
cx.notify();
}
}
fn apply_and_notify(&mut self, changed: bool, cx: &mut Context<Self>) {
if changed {
self.sync_text(cx);
cx.emit(SpinBoxEvent::ValueChanged {
control: self.control,
value: self.model.value(),
});
cx.notify();
}
}
fn step(&mut self, delta: i32, fine: bool, cx: &mut Context<Self>) {
let changed = self.model.apply_step(delta, fine);
self.apply_and_notify(changed, cx);
}
fn step(&mut self, delta: i32, fine: bool, cx: &mut Context<Self>) {
let changed = self.model.apply_step(delta, fine);
self.apply_and_notify(changed, cx);
}
fn commit_edit(&mut self, cx: &mut Context<Self>) {
let text = self.editor.read(cx).as_str().to_string();
match self.formatter.parse(&text) {
Ok(value) => {
let changed = self.model.set_value(value);
if changed {
self.apply_and_notify(true, cx);
}
cx.emit(SpinBoxEvent::EditCommitted {
control: self.control,
value: self.model.value(),
});
self.sync_text(cx);
}
Err(_) => {
cx.emit(SpinBoxEvent::EditCancelled {
control: self.control,
});
self.sync_text(cx);
}
}
cx.notify();
}
fn commit_edit(&mut self, cx: &mut Context<Self>) {
let text = self.editor.read(cx).as_str().to_string();
match self.formatter.parse(&text) {
Ok(value) => {
let changed = self.model.set_value(value);
if changed {
self.apply_and_notify(true, cx);
}
cx.emit(SpinBoxEvent::EditCommitted {
control: self.control,
value: self.model.value(),
});
self.sync_text(cx);
}
Err(_) => {
cx.emit(SpinBoxEvent::EditCancelled {
control: self.control,
});
self.sync_text(cx);
}
}
cx.notify();
}
}
impl EventEmitter<SpinBoxEvent> for SpinBox {}
impl Focusable for SpinBox {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SpinBox {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let weak = self.editor.downgrade();
let control = self.control;
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let weak = self.editor.downgrade();
let control = self.control;
div()
.flex()
.items_center()
.gap(px(2.0))
.child(
div()
.id(ElementId::named_usize("gpui-widgets-spinbox-field", control))
.min_w(px(64.0))
.h(px(24.0))
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_1()
.flex()
.items_center()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"up" => this.step(1, event.keystroke.modifiers.shift, cx),
"down" => this.step(-1, event.keystroke.modifiers.shift, cx),
"enter" => this.commit_edit(cx),
"escape" => {
// Revert the displayed text without committing.
this.sync_text(cx);
cx.notify();
}
_ => {}
}
}))
.child(
text_input(ElementId::named_usize(
"gpui-widgets-spinbox-input",
control,
))
.state(weak)
.accepts_input(true),
),
)
.child(
div()
.flex()
.flex_col()
.child(
div()
.id(ElementId::named_usize("gpui-widgets-spinbox-up", control))
.size(px(12.0))
.cursor_pointer()
.child(arrow_element(true, colors.border))
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.step(1, false, cx);
cx.stop_propagation();
})),
)
.child(
div()
.id(ElementId::named_usize("gpui-widgets-spinbox-down", control))
.size(px(12.0))
.cursor_pointer()
.child(arrow_element(false, colors.border))
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.step(-1, false, cx);
cx.stop_propagation();
})),
),
)
}
div()
.flex()
.items_center()
.gap(px(2.0))
.child(
div()
.id(ElementId::named_usize(
"gpui-widgets-spinbox-field",
control,
))
.min_w(px(64.0))
.h(px(24.0))
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_1()
.flex()
.items_center()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"up" => this.step(1, event.keystroke.modifiers.shift, cx),
"down" => this.step(-1, event.keystroke.modifiers.shift, cx),
"enter" => this.commit_edit(cx),
"escape" => {
// Revert the displayed text without committing.
this.sync_text(cx);
cx.notify();
}
_ => {}
}
}))
.child(
text_input(ElementId::named_usize(
"gpui-widgets-spinbox-input",
control,
))
.state(weak)
.accepts_input(true),
),
)
.child(
div()
.flex()
.flex_col()
.child(
div()
.id(ElementId::named_usize("gpui-widgets-spinbox-up", control))
.size(px(12.0))
.cursor_pointer()
.child(arrow_element(true, colors.border))
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.step(1, false, cx);
cx.stop_propagation();
})),
)
.child(
div()
.id(ElementId::named_usize("gpui-widgets-spinbox-down", control))
.size(px(12.0))
.cursor_pointer()
.child(arrow_element(false, colors.border))
.on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.step(-1, false, cx);
cx.stop_propagation();
})),
),
)
}
}
/// A tiny up (▲) or down (▼) arrow painted with a canvas.
fn arrow_element(up: bool, color: gpui::Rgba) -> impl IntoElement {
use gpui::{canvas, point, px, Bounds, PathBuilder, Pixels};
use gpui::{Bounds, PathBuilder, Pixels, canvas, point, px};
canvas(
move |_bounds, _window, _cx| (),
move |bounds: Bounds<Pixels>, (), window, cx| {
let _ = cx;
let tip = if up {
point(bounds.center().x, bounds.top() + px(3.0))
} else {
point(bounds.center().x, bounds.bottom() - px(3.0))
};
let base = if up {
(bounds.left() + px(3.0), bounds.bottom() - px(3.0))
} else {
(bounds.left() + px(3.0), bounds.top() + px(3.0))
};
let mut path = PathBuilder::fill();
path.move_to(tip);
path.line_to(point(base.0, base.1));
path.line_to(point(bounds.right() - px(3.0), base.1));
path.close();
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
canvas(
move |_bounds, _window, _cx| (),
move |bounds: Bounds<Pixels>, (), window, cx| {
let _ = cx;
let tip = if up {
point(bounds.center().x, bounds.top() + px(3.0))
} else {
point(bounds.center().x, bounds.bottom() - px(3.0))
};
let base = if up {
(bounds.left() + px(3.0), bounds.bottom() - px(3.0))
} else {
(bounds.left() + px(3.0), bounds.top() + px(3.0))
};
let mut path = PathBuilder::fill();
path.move_to(tip);
path.line_to(point(base.0, base.1));
path.line_to(point(bounds.right() - px(3.0), base.1));
path.close();
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::ValueKind;
use gpui::{Modifiers, TestAppContext, VisualTestContext, point, px, size};
use super::*;
use crate::value::ValueKind;
use gpui::{Modifiers, TestAppContext, VisualTestContext, point, px, size};
fn float_model() -> SliderModel {
SliderModel::new(ValueKind::Float, 0.0, 10.0, 1.0, 5.0)
}
fn float_model() -> SliderModel {
SliderModel::new(ValueKind::Float, 0.0, 10.0, 1.0, 5.0)
}
#[gpui::test]
async fn up_button_steps_value(cx: &mut TestAppContext) {
struct Host {
spinbox: Entity<SpinBox>,
events: Vec<SpinBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.spinbox.clone())
}
}
#[gpui::test]
async fn up_button_steps_value(cx: &mut TestAppContext) {
struct Host {
spinbox: Entity<SpinBox>,
events: Vec<SpinBoxEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.spinbox.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let spinbox = cx.new(|cx| SpinBox::new(1, float_model(), window, cx));
let host = Host {
spinbox,
events: Vec::new(),
};
cx.subscribe(
&host.spinbox,
|host: &mut Host,
_s: Entity<SpinBox>,
event: &SpinBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let spinbox = cx.new(|cx| SpinBox::new(1, float_model(), window, cx));
let host = Host {
spinbox,
events: Vec::new(),
};
cx.subscribe(
&host.spinbox,
|host: &mut Host,
_s: Entity<SpinBox>,
event: &SpinBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// The up button sits directly right of the field.
cx.simulate_click(point(px(70.0), px(6.0)), Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// The up button sits directly right of the field.
cx.simulate_click(point(px(70.0), px(6.0)), Modifiers::none());
cx.run_until_parked();
let (value, changed) = cx.read(|app| {
let (value, changed) = cx.read(|app| {
let host = host.read(app);
(
host.spinbox.read(app).value(),
@@ -314,60 +321,67 @@ mod tests {
}),
)
});
assert!((value.to_f64() - 6.0).abs() < 1e-9, "expected 6, got {value:?}");
assert!(changed);
}
assert!(
(value.to_f64() - 6.0).abs() < 1e-9,
"expected 6, got {value:?}"
);
assert!(changed);
}
#[gpui::test]
async fn enter_commits_typed_value(cx: &mut TestAppContext) {
struct Host {
spinbox: Entity<SpinBox>,
events: Vec<SpinBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.spinbox.clone())
}
}
#[gpui::test]
async fn enter_commits_typed_value(cx: &mut TestAppContext) {
struct Host {
spinbox: Entity<SpinBox>,
events: Vec<SpinBoxEvent>,
}
impl Render for Host {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
div().size_full().child(self.spinbox.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let spinbox = cx.new(|cx| SpinBox::new(1, float_model(), window, cx));
let host = Host {
spinbox,
events: Vec::new(),
};
cx.subscribe(
&host.spinbox,
|host: &mut Host,
_s: Entity<SpinBox>,
event: &SpinBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(200.0), px(60.0)), |window, cx| {
let spinbox = cx.new(|cx| SpinBox::new(1, float_model(), window, cx));
let host = Host {
spinbox,
events: Vec::new(),
};
cx.subscribe(
&host.spinbox,
|host: &mut Host,
_s: Entity<SpinBox>,
event: &SpinBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Click the field to focus it, seed text programmatically (the test
// platform cannot deliver IME characters), then commit with enter.
cx.simulate_click(point(px(30.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Click the field to focus it, seed text programmatically (the test
// platform cannot deliver IME characters), then commit with enter.
cx.simulate_click(point(px(30.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
let editor = cx
.read(|app| host.read(app).spinbox.read(app).editor.clone())
.clone();
cx.cx.update(|app| {
editor.update(app, |editor, cx| editor.emplace("7.5", cx));
});
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let editor = cx
.read(|app| host.read(app).spinbox.read(app).editor.clone())
.clone();
cx.cx.update(|app| {
editor.update(app, |editor, cx| editor.emplace("7.5", cx));
});
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let (value, committed) = cx.read(|app| {
let (value, committed) = cx.read(|app| {
let host = host.read(app);
(
host.spinbox.read(app).value(),
@@ -376,8 +390,11 @@ mod tests {
}),
)
});
// 7.5 snaps to the integer step... no: step is 1.0 so 7.5 -> 8.
assert!((value.to_f64() - 8.0).abs() < 1e-9, "expected 8, got {value:?}");
assert!(committed);
}
// 7.5 snaps to the integer step... no: step is 1.0 so 7.5 -> 8.
assert!(
(value.to_f64() - 8.0).abs() < 1e-9,
"expected 8, got {value:?}"
);
assert!(committed);
}
}
+138 -138
View File
@@ -14,93 +14,93 @@ use std::sync::Arc;
/// Oak's olive theme palette.
#[derive(Debug, Clone, PartialEq)]
pub struct OakTheme {
/// The theme's display name.
pub name: gpui::SharedString,
/// Window background (panels, bars).
pub window: Rgba,
/// Base background (content areas, inputs).
pub base: Rgba,
/// Alternate base (stripes, separators).
pub alternate_base: Rgba,
/// Primary text.
pub text: Rgba,
/// Accent (selection, highlight).
pub accent: Rgba,
/// Text on the accent.
pub accent_text: Rgba,
/// Link color.
pub link: Rgba,
/// Disabled text.
pub disabled_text: Rgba,
/// Disabled button text.
pub disabled_button_text: Rgba,
/// The theme's display name.
pub name: gpui::SharedString,
/// Window background (panels, bars).
pub window: Rgba,
/// Base background (content areas, inputs).
pub base: Rgba,
/// Alternate base (stripes, separators).
pub alternate_base: Rgba,
/// Primary text.
pub text: Rgba,
/// Accent (selection, highlight).
pub accent: Rgba,
/// Text on the accent.
pub accent_text: Rgba,
/// Link color.
pub link: Rgba,
/// Disabled text.
pub disabled_text: Rgba,
/// Disabled button text.
pub disabled_button_text: Rgba,
}
fn rgba(hex: u32) -> Rgba {
let r = ((hex >> 16) & 0xff) as f32 / 255.0;
let g = ((hex >> 8) & 0xff) as f32 / 255.0;
let b = (hex & 0xff) as f32 / 255.0;
Rgba { r, g, b, a: 1.0 }
let r = ((hex >> 16) & 0xff) as f32 / 255.0;
let g = ((hex >> 8) & 0xff) as f32 / 255.0;
let b = (hex & 0xff) as f32 / 255.0;
Rgba { r, g, b, a: 1.0 }
}
impl OakTheme {
/// The olive-dark palette (oak's default).
pub fn olive_dark() -> Self {
Self {
name: "Olive Dark".into(),
window: rgba(0x353535),
base: rgba(0x191919),
alternate_base: rgba(0x353535),
text: rgb(0xffffff),
accent: rgba(0x2A82DA),
accent_text: rgb(0xffffff),
link: rgba(0xE0B040),
disabled_text: rgba(0xA0A0A0),
disabled_button_text: rgba(0x808080),
}
}
/// The olive-dark palette (oak's default).
pub fn olive_dark() -> Self {
Self {
name: "Olive Dark".into(),
window: rgba(0x353535),
base: rgba(0x191919),
alternate_base: rgba(0x353535),
text: rgb(0xffffff),
accent: rgba(0x2A82DA),
accent_text: rgb(0xffffff),
link: rgba(0xE0B040),
disabled_text: rgba(0xA0A0A0),
disabled_button_text: rgba(0x808080),
}
}
/// The olive-light palette.
pub fn olive_light() -> Self {
Self {
name: "Olive Light".into(),
window: rgba(0xD0D0D0),
base: rgba(0xF0F0F0),
alternate_base: rgba(0xD0D0D0),
text: rgb(0x000000),
accent: rgba(0x2A82DA),
accent_text: rgb(0xffffff),
link: rgba(0x2A82DA),
disabled_text: rgba(0x808080),
disabled_button_text: rgba(0x808080),
}
}
/// The olive-light palette.
pub fn olive_light() -> Self {
Self {
name: "Olive Light".into(),
window: rgba(0xD0D0D0),
base: rgba(0xF0F0F0),
alternate_base: rgba(0xD0D0D0),
text: rgb(0x000000),
accent: rgba(0x2A82DA),
accent_text: rgb(0xffffff),
link: rgba(0x2A82DA),
disabled_text: rgba(0x808080),
disabled_button_text: rgba(0x808080),
}
}
/// The border color derived from this theme (alternate base darkened for
/// dark themes, lightened for light themes).
pub fn border(&self) -> Rgba {
let factor = if relative_luminance(self.text) > relative_luminance(self.base) {
0.7
} else {
1.25
};
scale_luma(self.alternate_base, factor)
}
/// The border color derived from this theme (alternate base darkened for
/// dark themes, lightened for light themes).
pub fn border(&self) -> Rgba {
let factor = if relative_luminance(self.text) > relative_luminance(self.base) {
0.7
} else {
1.25
};
scale_luma(self.alternate_base, factor)
}
/// Map this theme onto gpui's [`Colors`](gpui::colors::Colors) struct so
/// `cx.default_colors()` picks it up.
pub fn colors(&self) -> gpui::colors::Colors {
gpui::colors::Colors {
text: self.text,
selected_text: self.accent_text,
background: self.base,
disabled: self.disabled_text,
selected: self.accent,
border: self.border(),
separator: self.alternate_base,
container: self.window,
}
}
/// Map this theme onto gpui's [`Colors`](gpui::colors::Colors) struct so
/// `cx.default_colors()` picks it up.
pub fn colors(&self) -> gpui::colors::Colors {
gpui::colors::Colors {
text: self.text,
selected_text: self.accent_text,
background: self.base,
disabled: self.disabled_text,
selected: self.accent,
border: self.border(),
separator: self.alternate_base,
container: self.window,
}
}
}
/// The current full theme, set by [`apply_theme`].
@@ -111,83 +111,83 @@ impl Global for ThemeGlobal {}
/// Apply a theme: swaps the global [`Colors`](gpui::colors::Colors) (re-theming
/// every widget that reads `cx.default_colors()`) and stores the full theme.
pub fn apply_theme(cx: &mut App, theme: &OakTheme) {
cx.set_global(GlobalColors(Arc::new(theme.colors())));
cx.set_global(ThemeGlobal(Arc::new(theme.clone())));
cx.set_global(GlobalColors(Arc::new(theme.colors())));
cx.set_global(ThemeGlobal(Arc::new(theme.clone())));
}
/// The current theme, or olive-dark if none was applied.
pub fn current_theme(cx: &App) -> Arc<OakTheme> {
cx.try_global::<ThemeGlobal>()
.map(|global| global.0.clone())
.unwrap_or_else(|| Arc::new(OakTheme::olive_dark()))
cx.try_global::<ThemeGlobal>()
.map(|global| global.0.clone())
.unwrap_or_else(|| Arc::new(OakTheme::olive_dark()))
}
fn relative_luminance(color: Rgba) -> f32 {
// Simple perceptual approximation (sRGB -> luma).
0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
// Simple perceptual approximation (sRGB -> luma).
0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
}
fn scale_luma(color: Rgba, factor: f32) -> Rgba {
let scale = |channel: f32| (channel * factor).clamp(0.0, 1.0);
Rgba {
r: scale(color.r),
g: scale(color.g),
b: scale(color.b),
a: color.a,
}
let scale = |channel: f32| (channel * factor).clamp(0.0, 1.0);
Rgba {
r: scale(color.r),
g: scale(color.g),
b: scale(color.b),
a: color.a,
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{TestAppContext, colors::DefaultColors};
use super::*;
use gpui::{TestAppContext, colors::DefaultColors};
#[test]
fn dark_theme_text_contrasts_with_base() {
let theme = OakTheme::olive_dark();
let text_luma = relative_luminance(theme.text);
let base_luma = relative_luminance(theme.base);
// Text must be clearly brighter than the base.
assert!(text_luma - base_luma > 0.4, "dark theme lacks contrast");
// The accent must be visible against both.
let accent_luma = relative_luminance(theme.accent);
assert!((accent_luma - base_luma).abs() > 0.1);
}
#[test]
fn dark_theme_text_contrasts_with_base() {
let theme = OakTheme::olive_dark();
let text_luma = relative_luminance(theme.text);
let base_luma = relative_luminance(theme.base);
// Text must be clearly brighter than the base.
assert!(text_luma - base_luma > 0.4, "dark theme lacks contrast");
// The accent must be visible against both.
let accent_luma = relative_luminance(theme.accent);
assert!((accent_luma - base_luma).abs() > 0.1);
}
#[test]
fn light_theme_text_contrasts_with_base() {
let theme = OakTheme::olive_light();
let text_luma = relative_luminance(theme.text);
let base_luma = relative_luminance(theme.base);
assert!(base_luma - text_luma > 0.5, "light theme lacks contrast");
}
#[test]
fn light_theme_text_contrasts_with_base() {
let theme = OakTheme::olive_light();
let text_luma = relative_luminance(theme.text);
let base_luma = relative_luminance(theme.base);
assert!(base_luma - text_luma > 0.5, "light theme lacks contrast");
}
#[test]
fn colors_map_keeps_text_and_selected() {
let colors = OakTheme::olive_dark().colors();
assert_eq!(colors.selected, rgb(0x2A82DA));
assert_eq!(colors.selected_text, rgb(0xffffff));
}
#[test]
fn colors_map_keeps_text_and_selected() {
let colors = OakTheme::olive_dark().colors();
assert_eq!(colors.selected, rgb(0x2A82DA));
assert_eq!(colors.selected_text, rgb(0xffffff));
}
#[test]
fn border_derives_from_alternate_base() {
let dark = OakTheme::olive_dark();
let border = dark.border();
// Dark theme: border is darker than the alternate base.
assert!(relative_luminance(border) < relative_luminance(dark.alternate_base));
let light = OakTheme::olive_light();
assert!(relative_luminance(light.border()) > relative_luminance(light.alternate_base));
}
#[test]
fn border_derives_from_alternate_base() {
let dark = OakTheme::olive_dark();
let border = dark.border();
// Dark theme: border is darker than the alternate base.
assert!(relative_luminance(border) < relative_luminance(dark.alternate_base));
let light = OakTheme::olive_light();
assert!(relative_luminance(light.border()) > relative_luminance(light.alternate_base));
}
#[gpui::test]
async fn apply_theme_switches_default_colors(cx: &mut TestAppContext) {
cx.update(|app| {
apply_theme(app, &OakTheme::olive_light());
let colors = app.default_colors().clone();
assert_eq!(colors.background, rgb(0xF0F0F0));
assert_eq!(colors.text, rgb(0x000000));
// And the extended theme is queryable.
assert_eq!(current_theme(app).name, "Olive Light");
});
}
#[gpui::test]
async fn apply_theme_switches_default_colors(cx: &mut TestAppContext) {
cx.update(|app| {
apply_theme(app, &OakTheme::olive_light());
let colors = app.default_colors().clone();
assert_eq!(colors.background, rgb(0xF0F0F0));
assert_eq!(colors.text, rgb(0x000000));
// And the extended theme is queryable.
assert_eq!(current_theme(app).name, "Olive Light");
});
}
}
+14 -14
View File
@@ -6,30 +6,30 @@
//! `el.tooltip(move |window, cx| tooltip_view(label.clone(), window, cx))`.
use gpui::{
AnyView, App, Context, Render, SharedString, Window, colors::DefaultColors, div, prelude::*,
AnyView, App, Context, Render, SharedString, Window, colors::DefaultColors, div, prelude::*,
};
/// The tooltip view: a small rounded label in the theme's accent.
pub struct TooltipView {
label: SharedString,
label: SharedString,
}
impl Render for TooltipView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.px_2()
.py_1()
.rounded_md()
.bg(colors.selected)
.text_color(colors.selected_text)
.text_xs()
.child(self.label.clone())
}
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.px_2()
.py_1()
.rounded_md()
.bg(colors.selected)
.text_color(colors.selected_text)
.text_xs()
.child(self.label.clone())
}
}
/// Builds a tooltip view for `label`, for use in a
/// [`Div::tooltip`](gpui::Div::tooltip) builder.
pub fn tooltip_view(label: SharedString, _window: &mut Window, cx: &mut App) -> AnyView {
cx.new(|_cx| TooltipView { label }).into()
cx.new(|_cx| TooltipView { label }).into()
}
+344 -308
View File
@@ -11,174 +11,174 @@ use gpui::SharedString;
/// The family of values a control can edit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ValueKind {
/// A real number.
Float,
/// A whole number.
Integer,
/// An exact fraction `num / den`.
Rational,
/// An angle in degrees.
Angle,
/// A real number.
Float,
/// A whole number.
Integer,
/// An exact fraction `num / den`.
Rational,
/// An angle in degrees.
Angle,
}
/// The value edited by a form control.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SliderValue {
/// A real number.
Float(f64),
/// A whole number.
Integer(i64),
/// An exact fraction.
Rational(RationalValue),
/// An angle in degrees.
Angle(f64),
/// A real number.
Float(f64),
/// A whole number.
Integer(i64),
/// An exact fraction.
Rational(RationalValue),
/// An angle in degrees.
Angle(f64),
}
impl SliderValue {
/// The [`ValueKind`] of this value.
pub fn kind(self) -> ValueKind {
match self {
SliderValue::Float(_) => ValueKind::Float,
SliderValue::Integer(_) => ValueKind::Integer,
SliderValue::Rational(_) => ValueKind::Rational,
SliderValue::Angle(_) => ValueKind::Angle,
}
}
/// The [`ValueKind`] of this value.
pub fn kind(self) -> ValueKind {
match self {
SliderValue::Float(_) => ValueKind::Float,
SliderValue::Integer(_) => ValueKind::Integer,
SliderValue::Rational(_) => ValueKind::Rational,
SliderValue::Angle(_) => ValueKind::Angle,
}
}
/// The numeric projection used for painting and interpolation.
///
/// For [`SliderValue::Rational`] this is `num / den` as `f64`; the exact
/// pair is preserved by [`SliderValue::Rational`] itself.
pub fn to_f64(self) -> f64 {
match self {
SliderValue::Float(v) => v,
SliderValue::Integer(v) => v as f64,
SliderValue::Rational(v) => v.to_f64(),
SliderValue::Angle(v) => v,
}
}
/// The numeric projection used for painting and interpolation.
///
/// For [`SliderValue::Rational`] this is `num / den` as `f64`; the exact
/// pair is preserved by [`SliderValue::Rational`] itself.
pub fn to_f64(self) -> f64 {
match self {
SliderValue::Float(v) => v,
SliderValue::Integer(v) => v as f64,
SliderValue::Rational(v) => v.to_f64(),
SliderValue::Angle(v) => v,
}
}
/// Rebuild a value from its numeric projection.
///
/// [`SliderValue::Integer`] truncates toward zero and
/// [`SliderValue::Rational`] reconstructs the closest fraction with a
/// bounded denominator, so this is only lossless when the source value
/// was exact to begin with.
pub fn from_f64(kind: ValueKind, value: f64) -> Self {
match kind {
ValueKind::Float => SliderValue::Float(value),
ValueKind::Integer => SliderValue::Integer(value.trunc() as i64),
ValueKind::Rational => SliderValue::Rational(RationalValue::from_f64(value, 1_000_000)),
ValueKind::Angle => SliderValue::Angle(value),
}
}
/// Rebuild a value from its numeric projection.
///
/// [`SliderValue::Integer`] truncates toward zero and
/// [`SliderValue::Rational`] reconstructs the closest fraction with a
/// bounded denominator, so this is only lossless when the source value
/// was exact to begin with.
pub fn from_f64(kind: ValueKind, value: f64) -> Self {
match kind {
ValueKind::Float => SliderValue::Float(value),
ValueKind::Integer => SliderValue::Integer(value.trunc() as i64),
ValueKind::Rational => SliderValue::Rational(RationalValue::from_f64(value, 1_000_000)),
ValueKind::Angle => SliderValue::Angle(value),
}
}
}
/// An exact rational number, kept normalized (`den > 0`, `gcd(|num|, den) == 1`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RationalValue {
num: i64,
den: i64,
num: i64,
den: i64,
}
/// Error returned when a rational cannot be constructed from raw parts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum RationalError {
/// The denominator must not be zero.
#[error("rational denominator must not be zero")]
ZeroDenominator,
/// The denominator must not be zero.
#[error("rational denominator must not be zero")]
ZeroDenominator,
}
impl RationalValue {
/// The zero rational `0/1`.
pub const ZERO: Self = Self { num: 0, den: 1 };
/// The one rational `1/1`.
pub const ONE: Self = Self { num: 1, den: 1 };
/// The zero rational `0/1`.
pub const ZERO: Self = Self { num: 0, den: 1 };
/// The one rational `1/1`.
pub const ONE: Self = Self { num: 1, den: 1 };
/// Construct a normalized rational, rejecting a zero denominator.
pub fn new(num: i64, den: i64) -> Result<Self, RationalError> {
if den == 0 {
return Err(RationalError::ZeroDenominator);
}
Ok(Self::new_unchecked(num, den))
}
/// Construct a normalized rational, rejecting a zero denominator.
pub fn new(num: i64, den: i64) -> Result<Self, RationalError> {
if den == 0 {
return Err(RationalError::ZeroDenominator);
}
Ok(Self::new_unchecked(num, den))
}
/// Construct a normalized rational, clamping a zero denominator to one.
pub fn new_or_one(num: i64, den: i64) -> Self {
if den == 0 {
return Self { num, den: 1 };
}
Self::new_unchecked(num, den)
}
/// Construct a normalized rational, clamping a zero denominator to one.
pub fn new_or_one(num: i64, den: i64) -> Self {
if den == 0 {
return Self { num, den: 1 };
}
Self::new_unchecked(num, den)
}
fn new_unchecked(num: i64, den: i64) -> Self {
let (num, den) = if den < 0 { (-num, -den) } else { (num, den) };
let gcd = num.unsigned_abs().gcd(den as u64) as i64;
Self {
num: num / gcd,
den: den / gcd,
}
}
fn new_unchecked(num: i64, den: i64) -> Self {
let (num, den) = if den < 0 { (-num, -den) } else { (num, den) };
let gcd = num.unsigned_abs().gcd(den as u64) as i64;
Self {
num: num / gcd,
den: den / gcd,
}
}
/// The numerator of the normalized fraction.
pub fn num(self) -> i64 {
self.num
}
/// The numerator of the normalized fraction.
pub fn num(self) -> i64 {
self.num
}
/// The denominator of the normalized fraction (always positive).
pub fn den(self) -> i64 {
self.den
}
/// The denominator of the normalized fraction (always positive).
pub fn den(self) -> i64 {
self.den
}
/// The value as `f64`.
pub fn to_f64(self) -> f64 {
self.num as f64 / self.den as f64
}
/// The value as `f64`.
pub fn to_f64(self) -> f64 {
self.num as f64 / self.den as f64
}
/// The closest rational with `1 <= den <= max_den`, via continued
/// fractions. `NaN` and infinities map to the zero rational; negative
/// values keep their sign.
pub fn from_f64(value: f64, max_den: i64) -> Self {
if !value.is_finite() {
return Self::ZERO;
}
let max_den = max_den.max(1);
let negative = value < 0.0;
let value = value.abs();
// Continued fraction convergents with bounded denominator.
let mut n0 = 0i64;
let mut d0 = 1i64;
let mut n1 = 1i64;
let mut d1 = 0i64;
let mut remainder = value;
for _ in 0..64 {
let a = remainder.floor() as i64;
let n2 = a.saturating_mul(n1).saturating_add(n0);
let d2 = a.saturating_mul(d1).saturating_add(d0);
if d2 > max_den {
break;
}
n0 = n1;
d0 = d1;
n1 = n2;
d1 = d2;
let frac = remainder - remainder.floor();
if frac.abs() < 1e-12 {
break;
}
remainder = 1.0 / frac;
}
let (num, den) = if negative { (-n1, d1) } else { (n1, d1) };
Self::new_unchecked(num, den)
}
/// The closest rational with `1 <= den <= max_den`, via continued
/// fractions. `NaN` and infinities map to the zero rational; negative
/// values keep their sign.
pub fn from_f64(value: f64, max_den: i64) -> Self {
if !value.is_finite() {
return Self::ZERO;
}
let max_den = max_den.max(1);
let negative = value < 0.0;
let value = value.abs();
// Continued fraction convergents with bounded denominator.
let mut n0 = 0i64;
let mut d0 = 1i64;
let mut n1 = 1i64;
let mut d1 = 0i64;
let mut remainder = value;
for _ in 0..64 {
let a = remainder.floor() as i64;
let n2 = a.saturating_mul(n1).saturating_add(n0);
let d2 = a.saturating_mul(d1).saturating_add(d0);
if d2 > max_den {
break;
}
n0 = n1;
d0 = d1;
n1 = n2;
d1 = d2;
let frac = remainder - remainder.floor();
if frac.abs() < 1e-12 {
break;
}
remainder = 1.0 / frac;
}
let (num, den) = if negative { (-n1, d1) } else { (n1, d1) };
Self::new_unchecked(num, den)
}
}
/// A failure to parse a value from text.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseValueError {
/// The text did not match the expected format for this value kind.
#[error("could not parse {0:?} as {1:?}")]
InvalidFormat(SharedString, ValueKind),
/// The text did not match the expected format for this value kind.
#[error("could not parse {0:?} as {1:?}")]
InvalidFormat(SharedString, ValueKind),
}
/// Formats and parses a [`SliderValue`] for display and direct entry.
@@ -187,11 +187,11 @@ pub enum ParseValueError {
/// specialize the representation (e.g. timecode instead of frames, or
/// fractions with a fixed denominator).
pub trait ValueFormatter: 'static {
/// Render a value as text.
fn format(&self, value: SliderValue) -> SharedString;
/// Parse text into a value. Must reject malformed input with
/// [`ParseValueError`]; controls refuse to apply rejected input.
fn parse(&self, text: &str) -> Result<SliderValue, ParseValueError>;
/// Render a value as text.
fn format(&self, value: SliderValue) -> SharedString;
/// Parse text into a value. Must reject malformed input with
/// [`ParseValueError`]; controls refuse to apply rejected input.
fn parse(&self, text: &str) -> Result<SliderValue, ParseValueError>;
}
/// The default formatter:
@@ -204,195 +204,231 @@ pub trait ValueFormatter: 'static {
pub struct DefaultFormatter;
impl ValueFormatter for DefaultFormatter {
fn format(&self, value: SliderValue) -> SharedString {
match value {
SliderValue::Float(v) => {
if !v.is_finite() {
return "0".into();
}
let rounded = (v * 1000.0).round() / 1000.0;
let mut text = format!("{rounded}");
if text.contains('.') {
while text.ends_with('0') {
text.pop();
}
if text.ends_with('.') {
text.pop();
}
}
text.into()
}
SliderValue::Integer(v) => format!("{v}").into(),
SliderValue::Rational(v) => format!("{}/{}", v.num(), v.den()).into(),
SliderValue::Angle(v) => {
let rounded = (v * 1000.0).round() / 1000.0;
format!("{rounded}°").into()
}
}
}
fn format(&self, value: SliderValue) -> SharedString {
match value {
SliderValue::Float(v) => {
if !v.is_finite() {
return "0".into();
}
let rounded = (v * 1000.0).round() / 1000.0;
let mut text = format!("{rounded}");
if text.contains('.') {
while text.ends_with('0') {
text.pop();
}
if text.ends_with('.') {
text.pop();
}
}
text.into()
}
SliderValue::Integer(v) => format!("{v}").into(),
SliderValue::Rational(v) => format!("{}/{}", v.num(), v.den()).into(),
SliderValue::Angle(v) => {
let rounded = (v * 1000.0).round() / 1000.0;
format!("{rounded}°").into()
}
}
}
fn parse(&self, text: &str) -> Result<SliderValue, ParseValueError> {
let text = text.trim();
let kind = |reason: SharedString| ParseValueError::InvalidFormat(reason, ValueKind::Float);
if let Some((num, den)) = text.split_once('/') {
let num: i64 = num
.trim()
.parse()
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational))?;
let den: i64 = den
.trim()
.parse()
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational))?;
return RationalValue::new(num, den)
.map(SliderValue::Rational)
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational));
}
let (body, is_angle) = text
.strip_suffix('°')
.map(|body| (body, true))
.or_else(|| {
text.strip_suffix("deg")
.or_else(|| text.strip_suffix("DEG"))
.map(|body| (body, true))
})
.unwrap_or((text, false));
if is_angle {
let value: f64 = body
.trim()
.parse()
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Angle))?;
return Ok(SliderValue::Angle(value));
}
// An exact decimal with no fractional part parses as an integer when
// there are no digits after the point; otherwise as a float.
let value: f64 = body
.trim()
.parse()
.map_err(|_| kind(text.into()))?;
if value.fract() == 0.0 && value.abs() < i64::MAX as f64 {
Ok(SliderValue::Integer(value as i64))
} else {
Ok(SliderValue::Float(value))
}
}
fn parse(&self, text: &str) -> Result<SliderValue, ParseValueError> {
let text = text.trim();
let kind = |reason: SharedString| ParseValueError::InvalidFormat(reason, ValueKind::Float);
if let Some((num, den)) = text.split_once('/') {
let num: i64 = num
.trim()
.parse()
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational))?;
let den: i64 = den
.trim()
.parse()
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational))?;
return RationalValue::new(num, den)
.map(SliderValue::Rational)
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational));
}
let (body, is_angle) = text
.strip_suffix('°')
.map(|body| (body, true))
.or_else(|| {
text.strip_suffix("deg")
.or_else(|| text.strip_suffix("DEG"))
.map(|body| (body, true))
})
.unwrap_or((text, false));
if is_angle {
let value: f64 = body
.trim()
.parse()
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Angle))?;
return Ok(SliderValue::Angle(value));
}
// An exact decimal with no fractional part parses as an integer when
// there are no digits after the point; otherwise as a float.
let value: f64 = body.trim().parse().map_err(|_| kind(text.into()))?;
if value.fract() == 0.0 && value.abs() < i64::MAX as f64 {
Ok(SliderValue::Integer(value as i64))
} else {
Ok(SliderValue::Float(value))
}
}
}
/// Extension: `u64::gcd` used by [`RationalValue`].
trait Gcd {
fn gcd(self, other: Self) -> Self;
fn gcd(self, other: Self) -> Self;
}
impl Gcd for u64 {
fn gcd(mut self, mut other: Self) -> Self {
while other != 0 {
(self, other) = (other, self % other);
}
self
}
fn gcd(mut self, mut other: Self) -> Self {
while other != 0 {
(self, other) = (other, self % other);
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
#[test]
fn rational_normalizes() {
assert_eq!(
RationalValue::new(4, 6).unwrap(),
RationalValue::new(2, 3).unwrap()
);
assert_eq!(RationalValue::new(2, 3).unwrap().num(), 2);
assert_eq!(RationalValue::new(2, 3).unwrap().den(), 3);
assert_eq!(RationalValue::new(-2, 4).unwrap(), RationalValue::new(-1, 2).unwrap());
assert_eq!(RationalValue::new(1, -2).unwrap(), RationalValue::new(-1, 2).unwrap());
}
#[test]
fn rational_normalizes() {
assert_eq!(
RationalValue::new(4, 6).unwrap(),
RationalValue::new(2, 3).unwrap()
);
assert_eq!(RationalValue::new(2, 3).unwrap().num(), 2);
assert_eq!(RationalValue::new(2, 3).unwrap().den(), 3);
assert_eq!(
RationalValue::new(-2, 4).unwrap(),
RationalValue::new(-1, 2).unwrap()
);
assert_eq!(
RationalValue::new(1, -2).unwrap(),
RationalValue::new(-1, 2).unwrap()
);
}
#[test]
fn rational_rejects_zero_denominator() {
assert_eq!(
RationalValue::new(1, 0),
Err(RationalError::ZeroDenominator)
);
assert_eq!(RationalValue::new_or_one(1, 0), RationalValue::new(1, 1).unwrap());
}
#[test]
fn rational_rejects_zero_denominator() {
assert_eq!(
RationalValue::new(1, 0),
Err(RationalError::ZeroDenominator)
);
assert_eq!(
RationalValue::new_or_one(1, 0),
RationalValue::new(1, 1).unwrap()
);
}
#[test]
fn rational_from_f64_exact() {
assert_eq!(RationalValue::from_f64(0.5, 1000), RationalValue::new(1, 2).unwrap());
assert_eq!(RationalValue::from_f64(0.25, 1000), RationalValue::new(1, 4).unwrap());
assert_eq!(RationalValue::from_f64(2.0, 1000), RationalValue::new(2, 1).unwrap());
assert_eq!(RationalValue::from_f64(-1.5, 1000), RationalValue::new(-3, 2).unwrap());
assert_eq!(RationalValue::from_f64(0.3333333333, 1000), RationalValue::new(1, 3).unwrap());
assert_eq!(RationalValue::from_f64(f64::NAN, 1000), RationalValue::ZERO);
assert_eq!(RationalValue::from_f64(f64::INFINITY, 1000), RationalValue::ZERO);
}
#[test]
fn rational_from_f64_exact() {
assert_eq!(
RationalValue::from_f64(0.5, 1000),
RationalValue::new(1, 2).unwrap()
);
assert_eq!(
RationalValue::from_f64(0.25, 1000),
RationalValue::new(1, 4).unwrap()
);
assert_eq!(
RationalValue::from_f64(2.0, 1000),
RationalValue::new(2, 1).unwrap()
);
assert_eq!(
RationalValue::from_f64(-1.5, 1000),
RationalValue::new(-3, 2).unwrap()
);
assert_eq!(
RationalValue::from_f64(0.3333333333, 1000),
RationalValue::new(1, 3).unwrap()
);
assert_eq!(RationalValue::from_f64(f64::NAN, 1000), RationalValue::ZERO);
assert_eq!(
RationalValue::from_f64(f64::INFINITY, 1000),
RationalValue::ZERO
);
}
#[test]
fn rational_from_f64_bounded_denominator() {
// 1/999983 needs a huge denominator; the bounded approximation may be
// 0 but must never exceed the bound or produce a negative denominator.
let value = RationalValue::from_f64(1.0 / 999983.0, 1000);
assert!(value.den() <= 1000);
assert!(value.den() > 0);
// A friendlier irrational keeps a nonzero approximation.
let approx = RationalValue::from_f64(std::f64::consts::FRAC_1_PI, 1000);
assert!(approx.num() > 0);
assert!(approx.den() <= 1000);
assert!((approx.to_f64() - std::f64::consts::FRAC_1_PI).abs() < 1e-3);
}
#[test]
fn rational_from_f64_bounded_denominator() {
// 1/999983 needs a huge denominator; the bounded approximation may be
// 0 but must never exceed the bound or produce a negative denominator.
let value = RationalValue::from_f64(1.0 / 999983.0, 1000);
assert!(value.den() <= 1000);
assert!(value.den() > 0);
// A friendlier irrational keeps a nonzero approximation.
let approx = RationalValue::from_f64(std::f64::consts::FRAC_1_PI, 1000);
assert!(approx.num() > 0);
assert!(approx.den() <= 1000);
assert!((approx.to_f64() - std::f64::consts::FRAC_1_PI).abs() < 1e-3);
}
#[test]
fn value_kind_projection() {
assert_eq!(SliderValue::Integer(-3).to_f64(), -3.0);
assert_eq!(SliderValue::Rational(RationalValue::new(1, 3).unwrap()).to_f64(), 1.0 / 3.0);
assert_eq!(SliderValue::from_f64(ValueKind::Integer, 3.9), SliderValue::Integer(3));
assert_eq!(
SliderValue::from_f64(ValueKind::Rational, 0.5),
SliderValue::Rational(RationalValue::new(1, 2).unwrap())
);
assert_eq!(SliderValue::from_f64(ValueKind::Angle, 90.0), SliderValue::Angle(90.0));
}
#[test]
fn value_kind_projection() {
assert_eq!(SliderValue::Integer(-3).to_f64(), -3.0);
assert_eq!(
SliderValue::Rational(RationalValue::new(1, 3).unwrap()).to_f64(),
1.0 / 3.0
);
assert_eq!(
SliderValue::from_f64(ValueKind::Integer, 3.9),
SliderValue::Integer(3)
);
assert_eq!(
SliderValue::from_f64(ValueKind::Rational, 0.5),
SliderValue::Rational(RationalValue::new(1, 2).unwrap())
);
assert_eq!(
SliderValue::from_f64(ValueKind::Angle, 90.0),
SliderValue::Angle(90.0)
);
}
#[test]
fn default_formatter_round_trips() {
let formatter = DefaultFormatter;
for value in [
SliderValue::Float(1.5),
SliderValue::Integer(-42),
SliderValue::Rational(RationalValue::new(1, 3).unwrap()),
SliderValue::Angle(90.0),
] {
let text = formatter.format(value);
let parsed = formatter.parse(text.as_ref()).unwrap();
assert_eq!(parsed.kind(), value.kind());
}
}
#[test]
fn default_formatter_round_trips() {
let formatter = DefaultFormatter;
for value in [
SliderValue::Float(1.5),
SliderValue::Integer(-42),
SliderValue::Rational(RationalValue::new(1, 3).unwrap()),
SliderValue::Angle(90.0),
] {
let text = formatter.format(value);
let parsed = formatter.parse(text.as_ref()).unwrap();
assert_eq!(parsed.kind(), value.kind());
}
}
#[test]
fn default_formatter_trims_and_truncates() {
let formatter = DefaultFormatter;
assert_eq!(formatter.format(SliderValue::Float(1.5)), "1.5");
assert_eq!(formatter.format(SliderValue::Float(0.123456)), "0.123");
assert_eq!(formatter.format(SliderValue::Float(2.0)), "2");
assert_eq!(formatter.format(SliderValue::Angle(45.5)), "45.5°");
assert_eq!(formatter.format(SliderValue::Integer(7)), "7");
}
#[test]
fn default_formatter_trims_and_truncates() {
let formatter = DefaultFormatter;
assert_eq!(formatter.format(SliderValue::Float(1.5)), "1.5");
assert_eq!(formatter.format(SliderValue::Float(0.123456)), "0.123");
assert_eq!(formatter.format(SliderValue::Float(2.0)), "2");
assert_eq!(formatter.format(SliderValue::Angle(45.5)), "45.5°");
assert_eq!(formatter.format(SliderValue::Integer(7)), "7");
}
#[test]
fn default_formatter_rejects_invalid_input() {
let formatter = DefaultFormatter;
assert!(formatter.parse("abc").is_err());
assert!(formatter.parse("1/0").is_err());
assert!(formatter.parse("12/ab").is_err());
assert!(formatter.parse("").is_err());
assert!(formatter.parse("90°abc").is_err());
}
#[test]
fn default_formatter_rejects_invalid_input() {
let formatter = DefaultFormatter;
assert!(formatter.parse("abc").is_err());
assert!(formatter.parse("1/0").is_err());
assert!(formatter.parse("12/ab").is_err());
assert!(formatter.parse("").is_err());
assert!(formatter.parse("90°abc").is_err());
}
#[test]
fn default_formatter_parses_angle_suffix() {
let formatter = DefaultFormatter;
assert_eq!(formatter.parse("90°").unwrap(), SliderValue::Angle(90.0));
assert_eq!(formatter.parse("45 deg").unwrap(), SliderValue::Angle(45.0));
assert_eq!(formatter.parse("180 DEG").unwrap(), SliderValue::Angle(180.0));
}
#[test]
fn default_formatter_parses_angle_suffix() {
let formatter = DefaultFormatter;
assert_eq!(formatter.parse("90°").unwrap(), SliderValue::Angle(90.0));
assert_eq!(formatter.parse("45 deg").unwrap(), SliderValue::Angle(45.0));
assert_eq!(
formatter.parse("180 DEG").unwrap(),
SliderValue::Angle(180.0)
);
}
}
+6 -6
View File
@@ -9,10 +9,10 @@ use gpui::timeline::{Frame, FrameRate};
/// A read-only view of the engine's playback clock.
pub trait PlaybackClock: 'static {
/// The current playhead frame.
fn current_frame(&self) -> Frame;
/// Whether playback is running.
fn is_playing(&self) -> bool;
/// The sequence's frame rate.
fn frame_rate(&self) -> FrameRate;
/// The current playhead frame.
fn current_frame(&self) -> Frame;
/// Whether playback is running.
fn is_playing(&self) -> bool;
/// The sequence's frame rate.
fn frame_rate(&self) -> FrameRate;
}
File diff suppressed because it is too large Load Diff
+124 -124
View File
@@ -7,150 +7,150 @@ use gpui::timeline::Frame;
/// The playback state of a viewer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransportState {
/// The current playhead frame.
pub frame: Frame,
/// Whether playback is running (the engine drives the position; the
/// view only reflects it).
pub playing: bool,
/// The loop-in point, if set.
pub in_point: Option<Frame>,
/// The loop-out point, if set.
pub out_point: Option<Frame>,
/// Whether playback loops between the in/out points.
pub loop_range: bool,
/// The current playhead frame.
pub frame: Frame,
/// Whether playback is running (the engine drives the position; the
/// view only reflects it).
pub playing: bool,
/// The loop-in point, if set.
pub in_point: Option<Frame>,
/// The loop-out point, if set.
pub out_point: Option<Frame>,
/// Whether playback loops between the in/out points.
pub loop_range: bool,
}
impl Default for TransportState {
fn default() -> Self {
Self::new()
}
fn default() -> Self {
Self::new()
}
}
impl TransportState {
/// Create a stopped transport at frame 0.
pub fn new() -> Self {
Self {
frame: Frame(0),
playing: false,
in_point: None,
out_point: None,
loop_range: false,
}
}
/// Create a stopped transport at frame 0.
pub fn new() -> Self {
Self {
frame: Frame(0),
playing: false,
in_point: None,
out_point: None,
loop_range: false,
}
}
/// Toggle play/pause; returns the new state.
pub fn toggle_play(&mut self) -> bool {
self.playing = !self.playing;
self.playing
}
/// Toggle play/pause; returns the new state.
pub fn toggle_play(&mut self) -> bool {
self.playing = !self.playing;
self.playing
}
/// Advance one frame. Clamps at `length` (exclusive); when a loop range
/// is active the position wraps to `in_point` instead of stopping at
/// `out_point`.
pub fn advance(&mut self, length: Frame) -> Frame {
let next = self.frame.0 + 1;
self.frame = if let Some(out) = self.out_point {
if self.loop_range && next >= out.0 {
Frame(self.in_point.unwrap_or(Frame(0)).0)
} else {
Frame(next.min(length.0 - 1))
}
} else {
Frame(next.min(length.0 - 1))
};
self.frame
}
/// Advance one frame. Clamps at `length` (exclusive); when a loop range
/// is active the position wraps to `in_point` instead of stopping at
/// `out_point`.
pub fn advance(&mut self, length: Frame) -> Frame {
let next = self.frame.0 + 1;
self.frame = if let Some(out) = self.out_point {
if self.loop_range && next >= out.0 {
Frame(self.in_point.unwrap_or(Frame(0)).0)
} else {
Frame(next.min(length.0 - 1))
}
} else {
Frame(next.min(length.0 - 1))
};
self.frame
}
/// Step the playhead by `delta` frames, clamped to `[0, length)`.
pub fn step(&mut self, delta: i64, length: Frame) -> Frame {
self.frame = Frame((self.frame.0 + delta).clamp(0, length.0 - 1));
self.frame
}
/// Step the playhead by `delta` frames, clamped to `[0, length)`.
pub fn step(&mut self, delta: i64, length: Frame) -> Frame {
self.frame = Frame((self.frame.0 + delta).clamp(0, length.0 - 1));
self.frame
}
/// Set the loop-in point at the current frame.
pub fn set_in_point(&mut self, length: Frame) {
self.in_point = Some(Frame(self.frame.0.min(length.0 - 1)));
self.loop_range = true;
}
/// Set the loop-in point at the current frame.
pub fn set_in_point(&mut self, length: Frame) {
self.in_point = Some(Frame(self.frame.0.min(length.0 - 1)));
self.loop_range = true;
}
/// Set the loop-out point at the current frame.
pub fn set_out_point(&mut self, length: Frame) {
self.out_point = Some(Frame(self.frame.0.min(length.0 - 1)));
self.loop_range = true;
}
/// Set the loop-out point at the current frame.
pub fn set_out_point(&mut self, length: Frame) {
self.out_point = Some(Frame(self.frame.0.min(length.0 - 1)));
self.loop_range = true;
}
/// Clear the loop range.
pub fn clear_range(&mut self) {
self.in_point = None;
self.out_point = None;
self.loop_range = false;
}
/// Clear the loop range.
pub fn clear_range(&mut self) {
self.in_point = None;
self.out_point = None;
self.loop_range = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
#[test]
fn toggle_play_flips() {
let mut t = TransportState::new();
assert!(!t.playing);
assert!(t.toggle_play());
assert!(!t.toggle_play());
}
#[test]
fn toggle_play_flips() {
let mut t = TransportState::new();
assert!(!t.playing);
assert!(t.toggle_play());
assert!(!t.toggle_play());
}
#[test]
fn advance_clamps_at_length() {
let mut t = TransportState::new();
t.frame = Frame(9);
assert_eq!(t.advance(Frame(10)), Frame(9));
assert_eq!(t.advance(Frame(10)), Frame(9));
}
#[test]
fn advance_clamps_at_length() {
let mut t = TransportState::new();
t.frame = Frame(9);
assert_eq!(t.advance(Frame(10)), Frame(9));
assert_eq!(t.advance(Frame(10)), Frame(9));
}
#[test]
fn advance_loops_within_range() {
let mut t = TransportState::new();
t.frame = Frame(5);
t.in_point = Some(Frame(4));
t.out_point = Some(Frame(8));
t.loop_range = true;
assert_eq!(t.advance(Frame(100)), Frame(6));
t.frame = Frame(7);
assert_eq!(t.advance(Frame(100)), Frame(4));
}
#[test]
fn advance_loops_within_range() {
let mut t = TransportState::new();
t.frame = Frame(5);
t.in_point = Some(Frame(4));
t.out_point = Some(Frame(8));
t.loop_range = true;
assert_eq!(t.advance(Frame(100)), Frame(6));
t.frame = Frame(7);
assert_eq!(t.advance(Frame(100)), Frame(4));
}
#[test]
fn advance_ignores_out_point_without_loop() {
let mut t = TransportState::new();
t.out_point = Some(Frame(8));
t.loop_range = false;
t.frame = Frame(7);
assert_eq!(t.advance(Frame(100)), Frame(8));
}
#[test]
fn advance_ignores_out_point_without_loop() {
let mut t = TransportState::new();
t.out_point = Some(Frame(8));
t.loop_range = false;
t.frame = Frame(7);
assert_eq!(t.advance(Frame(100)), Frame(8));
}
#[test]
fn step_clamps_to_range() {
let mut t = TransportState::new();
t.frame = Frame(2);
assert_eq!(t.step(-5, Frame(10)), Frame(0));
assert_eq!(t.step(100, Frame(10)), Frame(9));
// 9 + 1 -> 10, clamped to the last index 9.
assert_eq!(t.step(1, Frame(10)), Frame(9));
}
#[test]
fn step_clamps_to_range() {
let mut t = TransportState::new();
t.frame = Frame(2);
assert_eq!(t.step(-5, Frame(10)), Frame(0));
assert_eq!(t.step(100, Frame(10)), Frame(9));
// 9 + 1 -> 10, clamped to the last index 9.
assert_eq!(t.step(1, Frame(10)), Frame(9));
}
#[test]
fn in_out_points_respect_length() {
let mut t = TransportState::new();
t.frame = Frame(50);
t.set_in_point(Frame(10));
assert_eq!(t.in_point, Some(Frame(9)));
t.frame = Frame(50);
t.set_out_point(Frame(10));
assert_eq!(t.out_point, Some(Frame(9)));
assert!(t.loop_range);
t.clear_range();
assert_eq!(t.in_point, None);
assert_eq!(t.out_point, None);
assert!(!t.loop_range);
}
#[test]
fn in_out_points_respect_length() {
let mut t = TransportState::new();
t.frame = Frame(50);
t.set_in_point(Frame(10));
assert_eq!(t.in_point, Some(Frame(9)));
t.frame = Frame(50);
t.set_out_point(Frame(10));
assert_eq!(t.out_point, Some(Frame(9)));
assert!(t.loop_range);
t.clear_range();
assert_eq!(t.in_point, None);
assert_eq!(t.out_point, None);
assert!(!t.loop_range);
}
}