xilem/masonry/src/widgets/split.rs

629 lines
23 KiB
Rust

// Copyright 2019 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0
//! A widget which splits an area in two, with a settable ratio, and optional draggable resizing.
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, warn, Span};
use vello::Scene;
use crate::core::{
AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerButton,
PointerEvent, QueryCtx, RegisterCtx, TextEvent, Widget, WidgetId, WidgetMut, WidgetPod,
};
use crate::kurbo::{Line, Point, Rect, Size};
use crate::peniko::Color;
use crate::theme;
use crate::util::{fill_color, stroke};
use crate::widgets::flex::Axis;
use cursor_icon::CursorIcon;
// TODO - Have child widget type as generic argument
/// A container containing two other widgets, splitting the area either horizontally or vertically.
///
#[doc = crate::include_screenshot!("widget/screenshots/masonry__widget__split__tests__columns.png", "Split panel with two labels.")]
pub struct Split {
split_axis: Axis,
split_point_chosen: f64,
split_point_effective: f64,
min_size: (f64, f64), // Integers only
bar_size: f64, // Integers only
min_bar_area: f64, // Integers only
solid: bool,
draggable: bool,
/// Offset from the split point (bar center) to the actual mouse position when the
/// bar was clicked. This is used to ensure a click without mouse move is a no-op,
/// instead of re-centering the bar on the mouse.
click_offset: f64,
child1: WidgetPod<dyn Widget>,
child2: WidgetPod<dyn Widget>,
}
// --- MARK: BUILDERS ---
impl Split {
/// Create a new split panel, with the specified axis being split in two.
///
/// Horizontal split axis means that the children are left and right.
/// Vertical split axis means that the children are up and down.
fn new(split_axis: Axis, child1: impl Widget + 'static, child2: impl Widget + 'static) -> Self {
Self {
split_axis,
split_point_chosen: 0.5,
split_point_effective: 0.5,
min_size: (0.0, 0.0),
bar_size: 6.0,
min_bar_area: 6.0,
solid: false,
draggable: false,
click_offset: 0.0,
child1: WidgetPod::new(child1).erased(),
child2: WidgetPod::new(child2).erased(),
}
}
/// Create a new split panel, with the horizontal axis split in two by a vertical bar.
/// The children are laid out left and right.
pub fn columns(child1: impl Widget + 'static, child2: impl Widget + 'static) -> Self {
Self::new(Axis::Horizontal, child1, child2)
}
/// Create a new split panel, with the vertical axis split in two by a horizontal bar.
/// The children are laid out up and down.
pub fn rows(child1: impl Widget + 'static, child2: impl Widget + 'static) -> Self {
Self::new(Axis::Vertical, child1, child2)
}
/// Builder-style method to set the split point as a fraction of the split axis.
///
/// The value must be between `0.0` and `1.0`, inclusive.
/// The default split point is `0.5`.
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]!"
);
self.split_point_chosen = split_point;
self
}
/// Builder-style method to set the minimum size for both sides of the split axis.
///
/// The value must be greater than or equal to `0.0`.
/// The value will be rounded up to the nearest integer.
pub fn min_size(mut self, first: f64, second: f64) -> Self {
assert!(first >= 0.0);
assert!(second >= 0.0);
self.min_size = (first.ceil(), second.ceil());
self
}
/// Builder-style method to set the size of the splitter bar.
///
/// The value must be positive or zero.
/// The value will be rounded up to the nearest integer.
/// The default splitter bar size is `6.0`.
pub fn bar_size(mut self, bar_size: f64) -> Self {
assert!(bar_size >= 0.0, "bar_size must be 0.0 or greater!");
self.bar_size = bar_size.ceil();
self
}
/// Builder-style method to set the minimum size of the splitter bar area.
///
/// The minimum splitter bar area defines the minimum size of the area
/// where mouse hit detection is done for the splitter bar.
/// The final area is either this or the splitter bar size, whichever is greater.
///
/// This can be useful when you want to use a very narrow visual splitter bar,
/// but don't want to sacrifice user experience by making it hard to click on.
///
/// The value must be positive or zero.
/// The value will be rounded up to the nearest integer.
/// The default minimum splitter bar area is `6.0`.
pub fn min_bar_area(mut self, min_bar_area: f64) -> Self {
assert!(min_bar_area >= 0.0, "min_bar_area must be 0.0 or greater!");
self.min_bar_area = min_bar_area.ceil();
self
}
/// Builder-style method to set whether the split point can be changed by dragging.
pub fn draggable(mut self, draggable: bool) -> Self {
self.draggable = draggable;
self
}
/// Builder-style method to set whether the splitter bar is drawn as a solid rectangle.
///
/// If this is `false` (the default), the bar will be drawn as two parallel lines.
pub fn solid_bar(mut self, solid: bool) -> Self {
self.solid = solid;
self
}
}
// --- MARK: INTERNALS ---
impl Split {
/// Returns the size of the splitter bar area.
#[inline]
fn bar_area(&self) -> f64 {
self.bar_size.max(self.min_bar_area)
}
/// Returns the padding size added to each side of the splitter bar.
#[inline]
fn bar_padding(&self) -> f64 {
(self.bar_area() - self.bar_size) / 2.0
}
/// Returns the position of the split point (split bar center).
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
}
}
}
/// Returns the location of the edges of the splitter bar area,
/// given the specified total size.
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)
}
}
}
/// Returns true if the provided mouse position is inside the splitter bar area.
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,
}
}
/// Returns the minimum and maximum split coordinate of the provided size.
fn split_side_limits(&self, size: Size) -> (f64, f64) {
let split_axis_size = self.split_axis.major(size);
let (mut min_limit, min_second) = self.min_size;
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)
}
/// Set a new chosen split point.
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,
}
}
/// Returns the color of the splitter bar.
fn bar_color(&self) -> Color {
if self.draggable {
theme::BORDER_LIGHT
} else {
theme::BORDER_DARK
}
}
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();
// Set the line width to a third of the splitter bar size,
// because we'll paint two equal lines at the edges.
let line_width = (self.bar_size / 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);
}
}
// FIXME - Add unit tests for WidgetMut<Split>
// --- MARK: WIDGETMUT ---
impl Split {
/// Set the split point as a fraction of the split axis.
///
/// The value must be between `0.0` and `1.0`, inclusive.
/// The default split point is `0.5`.
pub fn set_split_point(self: &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]!"
);
self.widget.split_point_chosen = split_point;
self.ctx.request_layout();
}
/// Set the minimum size for both sides of the split axis.
///
/// The value must be greater than or equal to `0.0`.
/// The value will be rounded up to the nearest integer.
pub fn set_min_size(self: &mut WidgetMut<'_, Self>, first: f64, second: f64) {
assert!(first >= 0.0);
assert!(second >= 0.0);
self.widget.min_size = (first.ceil(), second.ceil());
self.ctx.request_layout();
}
/// Set the size of the splitter bar.
///
/// The value must be positive or zero.
/// The value will be rounded up to the nearest integer.
/// The default splitter bar size is `6.0`.
pub fn set_bar_size(self: &mut WidgetMut<'_, Self>, bar_size: f64) {
assert!(bar_size >= 0.0, "bar_size must be 0.0 or greater!");
self.widget.bar_size = bar_size.ceil();
self.ctx.request_layout();
}
/// Set the minimum size of the splitter bar area.
///
/// The minimum splitter bar area defines the minimum size of the area
/// where mouse hit detection is done for the splitter bar.
/// The final area is either this or the splitter bar size, whichever is greater.
///
/// This can be useful when you want to use a very narrow visual splitter bar,
/// but don't want to sacrifice user experience by making it hard to click on.
///
/// The value must be positive or zero.
/// The value will be rounded up to the nearest integer.
/// The default minimum splitter bar area is `6.0`.
pub fn set_min_bar_area(self: &mut WidgetMut<'_, Self>, min_bar_area: f64) {
assert!(min_bar_area >= 0.0, "min_bar_area must be 0.0 or greater!");
self.widget.min_bar_area = min_bar_area.ceil();
self.ctx.request_layout();
}
/// Set whether the split point can be changed by dragging.
pub fn set_draggable(self: &mut WidgetMut<'_, Self>, draggable: bool) {
self.widget.draggable = draggable;
// Bar mutability impacts appearance, but not accessibility node
// TODO - This might change in a future implementation
self.ctx.request_paint_only();
}
/// Set whether the splitter bar is drawn as a solid rectangle.
///
/// If this is `false` (the default), the bar will be drawn as two parallel lines.
pub fn set_bar_solid(self: &mut WidgetMut<'_, Self>, solid: bool) {
self.widget.solid = solid;
// Bar solidity impacts appearance, but not accessibility node
self.ctx.request_paint_only();
}
}
// --- MARK: IMPL WIDGET ---
impl Widget for Split {
fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) {
if self.draggable {
match event {
PointerEvent::PointerDown(PointerButton::Primary, state) => {
let mouse_pos = Point::new(state.position.x, state.position.y);
let local_mouse_pos = mouse_pos - ctx.window_origin().to_vec2();
if self.bar_hit_test(ctx.size(), local_mouse_pos) {
ctx.set_handled();
ctx.capture_pointer();
// Save the delta between the mouse click position and the split point
self.click_offset = match self.split_axis {
Axis::Horizontal => state.position.x,
Axis::Vertical => state.position.y,
} - self.bar_position(ctx.size());
}
}
PointerEvent::PointerMove(state) => {
if ctx.is_pointer_capture_target() {
// If widget has pointer capture, assume always it's hovered
let effective_pos = match self.split_axis {
Axis::Horizontal => {
Point::new(state.position.x - self.click_offset, state.position.y)
}
Axis::Vertical => {
Point::new(state.position.x, state.position.y - self.click_offset)
}
};
self.update_split_point(ctx.size(), effective_pos);
ctx.request_layout();
}
}
_ => {}
}
}
}
fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {}
fn on_access_event(&mut self, _ctx: &mut EventCtx, _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, 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.),
);
// Update our effective split point to respect our constraints
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)
}
};
// TODO - The minimum height / width should really be zero here.
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);
// Top-left align for both children, out of laziness.
// Reduce our unsplit direction to the larger of the two widgets
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, scene: &mut Scene) {
// TODO - Paint differently if the bar is draggable and hovered.
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 ctx.is_pointer_capture_target() || 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, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
smallvec![self.child1.id(), self.child2.id()]
}
fn make_trace_span(&self, ctx: &QueryCtx) -> Span {
trace_span!("Split", id = ctx.widget_id().trace())
}
}
// --- MARK: TESTS ---
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use super::*;
use crate::assert_render_snapshot;
use crate::testing::TestHarness;
use crate::widgets::Label;
#[test]
fn columns() {
#[rustfmt::skip]
let widget = Split::columns(
Label::new("Hello"),
Label::new("World"),
);
let mut harness = TestHarness::create(widget);
assert_debug_snapshot!(harness.root_widget());
assert_render_snapshot!(harness, "columns");
}
#[test]
fn rows() {
#[rustfmt::skip]
let widget = Split::rows(
Label::new("Hello"),
Label::new("World"),
);
let mut harness = TestHarness::create(widget);
assert_debug_snapshot!(harness.root_widget());
assert_render_snapshot!(harness, "rows");
}
// FIXME - test moving the split point by mouse
// test draggable and min_bar_area
#[test]
fn edit_splitter() {
let image_1 = {
let widget = Split::rows(Label::new("Hello"), Label::new("World"))
.split_point(0.3)
.min_size(40.0, 10.0)
.bar_size(12.0)
.draggable(true)
.solid_bar(true);
let mut harness = TestHarness::create_with_size(widget, Size::new(100.0, 100.0));
harness.render()
};
let image_2 = {
let widget = Split::rows(Label::new("Hello"), Label::new("World"));
let mut harness = TestHarness::create_with_size(widget, Size::new(100.0, 100.0));
harness.edit_root_widget(|mut splitter| {
let mut splitter = splitter.downcast::<Split>();
Split::set_split_point(&mut splitter, 0.3);
Split::set_min_size(&mut splitter, 40.0, 10.0);
Split::set_bar_size(&mut splitter, 12.0);
Split::set_draggable(&mut splitter, true);
Split::set_bar_solid(&mut splitter, true);
});
harness.render()
};
// We don't use assert_eq because we don't want rich assert
assert!(image_1 == image_2);
}
}