use accesskit::{Node, Role};
use tracing::{Span, trace_span, warn};
use vello::Scene;
use vello::kurbo::{Line, Point, Rect, Size};
use crate::core::{
AccessCtx, AccessEvent, Axis, BoxConstraints, ChildrenIds, CursorIcon, EventCtx, FromDynWidget,
LayoutCtx, NewWidget, NoAction, PaintCtx, PointerButtonEvent, PointerEvent, PointerUpdate,
PropertiesMut, PropertiesRef, QueryCtx, RegisterCtx, TextEvent, Widget, WidgetId, WidgetMut,
WidgetPod,
};
use crate::peniko::Color;
use crate::properties::types::{AsUnit, Length};
use crate::theme;
use crate::util::{fill_color, include_screenshot, stroke};
#[doc = include_screenshot!("split_columns.png", "Split panel with two labels.")]
pub struct Split<ChildA, ChildB>
where
ChildA: Widget + ?Sized,
ChildB: Widget + ?Sized,
{
split_axis: Axis,
split_point_chosen: f64,
split_point_effective: f64,
min_size: (Length, Length), bar_size: Length, min_bar_area: Length, solid: bool,
draggable: bool,
click_offset: f64,
child1: WidgetPod<ChildA>,
child2: WidgetPod<ChildB>,
}
impl<ChildA: Widget + ?Sized, ChildB: Widget + ?Sized> Split<ChildA, ChildB> {
pub fn new(child1: NewWidget<ChildA>, child2: NewWidget<ChildB>) -> Self {
Self {
split_axis: Axis::Horizontal,
split_point_chosen: 0.5,
split_point_effective: 0.5,
min_size: (Length::ZERO, Length::ZERO),
bar_size: 6.px(),
min_bar_area: 6.px(),
solid: false,
draggable: true,
click_offset: 0.0,
child1: child1.to_pod(),
child2: child2.to_pod(),
}
}
pub fn split_axis(mut self, split_axis: Axis) -> Self {
self.split_axis = split_axis;
self
}
pub fn split_point(mut self, split_point: f64) -> Self {
assert!(
(0.0..=1.0).contains(&split_point),
"split_point must be in the range [0.0, 1.0], got {split_point}"
);
self.split_point_chosen = split_point;
self
}
pub fn min_size(mut self, first: Length, second: Length) -> Self {
self.min_size = (ceil_length(first), ceil_length(second));
self
}
pub fn bar_size(mut self, bar_size: Length) -> Self {
self.bar_size = ceil_length(bar_size);
self
}
pub fn min_bar_area(mut self, min_bar_area: Length) -> Self {
self.min_bar_area = ceil_length(min_bar_area);
self
}
pub fn draggable(mut self, draggable: bool) -> Self {
self.draggable = draggable;
self
}
pub fn solid_bar(mut self, solid: bool) -> Self {
self.solid = solid;
self
}
}
#[doc(hidden)]
pub fn ceil_length(l: Length) -> Length {
Length::px(l.get().ceil())
}
impl<ChildA: Widget + ?Sized, ChildB: Widget + ?Sized> Split<ChildA, ChildB> {
#[inline]
fn bar_area(&self) -> f64 {
self.bar_size.get().max(self.min_bar_area.get())
}
#[inline]
fn bar_padding(&self) -> f64 {
(self.bar_area() - self.bar_size.get()) / 2.0
}
fn bar_position(&self, size: Size) -> f64 {
let bar_area = self.bar_area();
match self.split_axis {
Axis::Horizontal => {
let reduced_width = size.width - bar_area;
let edge1 = (reduced_width * self.split_point_effective).floor();
edge1 + bar_area / 2.0
}
Axis::Vertical => {
let reduced_height = size.height - bar_area;
let edge1 = (reduced_height * self.split_point_effective).floor();
edge1 + bar_area / 2.0
}
}
}
fn bar_edges(&self, size: Size) -> (f64, f64) {
let bar_area = self.bar_area();
match self.split_axis {
Axis::Horizontal => {
let reduced_width = size.width - bar_area;
let edge1 = (reduced_width * self.split_point_effective).floor();
let edge2 = edge1 + bar_area;
(edge1, edge2)
}
Axis::Vertical => {
let reduced_height = size.height - bar_area;
let edge1 = (reduced_height * self.split_point_effective).floor();
let edge2 = edge1 + bar_area;
(edge1, edge2)
}
}
}
fn bar_hit_test(&self, size: Size, mouse_pos: Point) -> bool {
let (edge1, edge2) = self.bar_edges(size);
match self.split_axis {
Axis::Horizontal => mouse_pos.x >= edge1 && mouse_pos.x <= edge2,
Axis::Vertical => mouse_pos.y >= edge1 && mouse_pos.y <= edge2,
}
}
fn split_side_limits(&self, size: Size) -> (f64, f64) {
let split_axis_size = self.split_axis.major(size);
let (min_limit, min_second) = self.min_size;
let mut min_limit = min_limit.get();
let min_second = min_second.get();
let mut max_limit = (split_axis_size - min_second).max(0.0);
if min_limit > max_limit {
min_limit = 0.5 * (min_limit + max_limit);
max_limit = min_limit;
}
(min_limit, max_limit)
}
fn update_split_point(&mut self, size: Size, mouse_pos: Point) {
let (min_limit, max_limit) = self.split_side_limits(size);
self.split_point_chosen = match self.split_axis {
Axis::Horizontal => mouse_pos.x.clamp(min_limit, max_limit) / size.width,
Axis::Vertical => mouse_pos.y.clamp(min_limit, max_limit) / size.height,
}
}
fn bar_color(&self) -> Color {
if self.draggable {
theme::ZYNC_500
} else {
theme::ZYNC_700
}
}
fn paint_solid_bar(&mut self, ctx: &mut PaintCtx<'_>, scene: &mut Scene) {
let size = ctx.size();
let (edge1, edge2) = self.bar_edges(size);
let padding = self.bar_padding();
let rect = match self.split_axis {
Axis::Horizontal => Rect::from_points(
Point::new(edge1 + padding.ceil(), 0.0),
Point::new(edge2 - padding.floor(), size.height),
),
Axis::Vertical => Rect::from_points(
Point::new(0.0, edge1 + padding.ceil()),
Point::new(size.width, edge2 - padding.floor()),
),
};
let splitter_color = self.bar_color();
fill_color(scene, &rect, splitter_color);
}
fn paint_stroked_bar(&mut self, ctx: &mut PaintCtx<'_>, scene: &mut Scene) {
let size = ctx.size();
let line_width = (self.bar_size.get() / 3.0).floor();
let line_midpoint = line_width / 2.0;
let (edge1, edge2) = self.bar_edges(size);
let padding = self.bar_padding();
let (line1, line2) = match self.split_axis {
Axis::Horizontal => (
Line::new(
Point::new(edge1 + line_midpoint + padding.ceil(), 0.0),
Point::new(edge1 + line_midpoint + padding.ceil(), size.height),
),
Line::new(
Point::new(edge2 - line_midpoint - padding.floor(), 0.0),
Point::new(edge2 - line_midpoint - padding.floor(), size.height),
),
),
Axis::Vertical => (
Line::new(
Point::new(0.0, edge1 + line_midpoint + padding.ceil()),
Point::new(size.width, edge1 + line_midpoint + padding.ceil()),
),
Line::new(
Point::new(0.0, edge2 - line_midpoint - padding.floor()),
Point::new(size.width, edge2 - line_midpoint - padding.floor()),
),
),
};
let splitter_color = self.bar_color();
stroke(scene, &line1, splitter_color, line_width);
stroke(scene, &line2, splitter_color, line_width);
}
}
impl<ChildA, ChildB> Split<ChildA, ChildB>
where
ChildA: Widget + FromDynWidget + ?Sized,
ChildB: Widget + FromDynWidget + ?Sized,
{
pub fn child1_mut<'t>(this: &'t mut WidgetMut<'_, Self>) -> WidgetMut<'t, ChildA> {
this.ctx.get_mut(&mut this.widget.child1)
}
pub fn child2_mut<'t>(this: &'t mut WidgetMut<'_, Self>) -> WidgetMut<'t, ChildB> {
this.ctx.get_mut(&mut this.widget.child2)
}
pub fn set_split_axis(this: &mut WidgetMut<'_, Self>, split_axis: Axis) {
this.widget.split_axis = split_axis;
this.ctx.request_layout();
}
pub fn set_split_point(this: &mut WidgetMut<'_, Self>, split_point: f64) {
assert!(
(0.0..=1.0).contains(&split_point),
"split_point must be in the range [0.0-1.0]!"
);
this.widget.split_point_chosen = split_point;
this.ctx.request_layout();
}
pub fn set_min_size(this: &mut WidgetMut<'_, Self>, first: Length, second: Length) {
this.widget.min_size = (ceil_length(first), ceil_length(second));
this.ctx.request_layout();
}
pub fn set_bar_size(this: &mut WidgetMut<'_, Self>, bar_size: Length) {
this.widget.bar_size = ceil_length(bar_size);
this.ctx.request_layout();
}
pub fn set_min_bar_area(this: &mut WidgetMut<'_, Self>, min_bar_area: Length) {
this.widget.min_bar_area = ceil_length(min_bar_area);
this.ctx.request_layout();
}
pub fn set_draggable(this: &mut WidgetMut<'_, Self>, draggable: bool) {
this.widget.draggable = draggable;
this.ctx.request_paint_only();
}
pub fn set_bar_solid(this: &mut WidgetMut<'_, Self>, solid: bool) {
this.widget.solid = solid;
this.ctx.request_paint_only();
}
}
impl<ChildA, ChildB> Widget for Split<ChildA, ChildB>
where
ChildA: Widget + ?Sized,
ChildB: Widget + ?Sized,
{
type Action = NoAction;
fn on_pointer_event(
&mut self,
ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
event: &PointerEvent,
) {
if self.draggable {
match event {
PointerEvent::Down(PointerButtonEvent { state, .. }) => {
let pos = ctx.local_position(state.position);
if self.bar_hit_test(ctx.size(), pos) {
ctx.set_handled();
ctx.capture_pointer();
self.click_offset = match self.split_axis {
Axis::Horizontal => pos.x,
Axis::Vertical => pos.y,
} - self.bar_position(ctx.size());
}
}
PointerEvent::Move(PointerUpdate { current, .. }) => {
if ctx.is_active() {
let pos = ctx.local_position(current.position);
let effective_pos = match self.split_axis {
Axis::Horizontal => Point {
x: pos.x - self.click_offset,
y: pos.y,
},
Axis::Vertical => Point {
x: pos.x,
y: pos.y - self.click_offset,
},
};
self.update_split_point(ctx.size(), effective_pos);
ctx.request_layout();
}
}
_ => {}
}
}
}
fn on_text_event(
&mut self,
_ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
_event: &TextEvent,
) {
}
fn on_access_event(
&mut self,
_ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
_event: &AccessEvent,
) {
}
fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) {
ctx.register_child(&mut self.child1);
ctx.register_child(&mut self.child2);
}
fn layout(
&mut self,
ctx: &mut LayoutCtx<'_>,
_props: &mut PropertiesMut<'_>,
bc: &BoxConstraints,
) -> Size {
match self.split_axis {
Axis::Horizontal => {
if !bc.is_width_bounded() {
warn!("A Split widget was given an unbounded width to split.");
}
}
Axis::Vertical => {
if !bc.is_height_bounded() {
warn!("A Split widget was given an unbounded height to split.");
}
}
}
let mut my_size = bc.max();
let bar_area = self.bar_area();
let reduced_size = Size::new(
(my_size.width - bar_area).max(0.),
(my_size.height - bar_area).max(0.),
);
self.split_point_effective = {
let (min_limit, max_limit) = self.split_side_limits(reduced_size);
let reduced_axis_size = self.split_axis.major(reduced_size);
if reduced_axis_size.is_infinite() || reduced_axis_size <= f64::EPSILON {
0.5
} else {
self.split_point_chosen
.clamp(min_limit / reduced_axis_size, max_limit / reduced_axis_size)
}
};
let (child1_bc, child2_bc) = match self.split_axis {
Axis::Horizontal => {
let child1_width = (reduced_size.width * self.split_point_effective)
.floor()
.max(0.0);
let child2_width = (reduced_size.width - child1_width).max(0.0);
(
BoxConstraints::new(
Size::new(child1_width, bc.min().height),
Size::new(child1_width, bc.max().height),
),
BoxConstraints::new(
Size::new(child2_width, bc.min().height),
Size::new(child2_width, bc.max().height),
),
)
}
Axis::Vertical => {
let child1_height = (reduced_size.height * self.split_point_effective)
.floor()
.max(0.0);
let child2_height = (reduced_size.height - child1_height).max(0.0);
(
BoxConstraints::new(
Size::new(bc.min().width, child1_height),
Size::new(bc.max().width, child1_height),
),
BoxConstraints::new(
Size::new(bc.min().width, child2_height),
Size::new(bc.max().width, child2_height),
),
)
}
};
let child1_size = ctx.run_layout(&mut self.child1, &child1_bc);
let child2_size = ctx.run_layout(&mut self.child2, &child2_bc);
let child1_pos = Point::ORIGIN;
let child2_pos = match self.split_axis {
Axis::Horizontal => {
my_size.height = child1_size.height.max(child2_size.height);
Point::new(child1_size.width + bar_area, 0.0)
}
Axis::Vertical => {
my_size.width = child1_size.width.max(child2_size.width);
Point::new(0.0, child1_size.height + bar_area)
}
};
ctx.place_child(&mut self.child1, child1_pos);
ctx.place_child(&mut self.child2, child2_pos);
let child1_paint_rect = ctx.child_paint_rect(&self.child1);
let child2_paint_rect = ctx.child_paint_rect(&self.child2);
let paint_rect = child1_paint_rect.union(child2_paint_rect);
let insets = paint_rect - my_size.to_rect();
ctx.set_paint_insets(insets);
my_size
}
fn paint(&mut self, ctx: &mut PaintCtx<'_>, _props: &PropertiesRef<'_>, scene: &mut Scene) {
if self.solid {
self.paint_solid_bar(ctx, scene);
} else {
self.paint_stroked_bar(ctx, scene);
}
}
fn get_cursor(&self, ctx: &QueryCtx<'_>, pos: Point) -> CursorIcon {
let local_mouse_pos = pos - ctx.window_origin().to_vec2();
let is_bar_hovered = self.bar_hit_test(ctx.size(), local_mouse_pos);
if self.draggable && (ctx.is_active() || is_bar_hovered) {
match self.split_axis {
Axis::Horizontal => CursorIcon::EwResize,
Axis::Vertical => CursorIcon::NsResize,
}
} else {
CursorIcon::Default
}
}
fn accessibility_role(&self) -> Role {
Role::Splitter
}
fn accessibility(
&mut self,
_ctx: &mut AccessCtx<'_>,
_props: &PropertiesRef<'_>,
_node: &mut Node,
) {
}
fn children_ids(&self) -> ChildrenIds {
ChildrenIds::from_slice(&[self.child1.id(), self.child2.id()])
}
fn make_trace_span(&self, id: WidgetId) -> Span {
trace_span!("Split", id = id.trace())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{TestHarness, assert_render_snapshot};
use crate::theme::default_property_set;
use crate::widgets::Label;
#[test]
fn columns() {
#[rustfmt::skip]
let widget = Split::new(
Label::new("Hello").with_auto_id(),
Label::new("World").with_auto_id(),
).split_axis(Axis::Horizontal).draggable(false).with_auto_id();
let window_size = Size::new(150.0, 150.0);
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, window_size);
assert_render_snapshot!(harness, "split_columns");
}
#[test]
fn rows() {
#[rustfmt::skip]
let widget = Split::new(
Label::new("Hello").with_auto_id(),
Label::new("World").with_auto_id(),
).split_axis(Axis::Vertical).draggable(false).with_auto_id();
let window_size = Size::new(150.0, 150.0);
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, window_size);
assert_render_snapshot!(harness, "split_rows");
}
#[test]
fn edit_splitter() {
let image_1 = {
let widget = Split::new(
Label::new("Hello").with_auto_id(),
Label::new("World").with_auto_id(),
)
.split_point(0.3)
.min_size(40.px(), 10.px())
.bar_size(12.px())
.draggable(true)
.solid_bar(true)
.with_auto_id();
let mut harness = TestHarness::create_with_size(
default_property_set(),
widget,
Size::new(100.0, 100.0),
);
harness.render()
};
let image_2 = {
let widget = Split::new(
Label::new("Hello").with_auto_id(),
Label::new("World").with_auto_id(),
)
.with_auto_id();
let mut harness = TestHarness::create_with_size(
default_property_set(),
widget,
Size::new(100.0, 100.0),
);
harness.edit_root_widget(|mut splitter| {
Split::set_split_point(&mut splitter, 0.3);
Split::set_min_size(&mut splitter, 40.px(), 10.px());
Split::set_bar_size(&mut splitter, 12.px());
Split::set_draggable(&mut splitter, true);
Split::set_bar_solid(&mut splitter, true);
});
harness.render()
};
assert!(image_1 == image_2);
}
}