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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+752
-740
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user