Separators in menus

This commit is contained in:
Jonathan Johnson 2024-05-10 09:51:02 -07:00
parent 38cdea9816
commit b8410cd4ba
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
2 changed files with 183 additions and 118 deletions

View file

@ -44,5 +44,6 @@ fn menu(top: bool) -> Menu<MenuOptions> {
}) })
.with(MenuItem::new(MenuOptions::First, "First")) .with(MenuItem::new(MenuOptions::First, "First"))
.with(MenuItem::new(MenuOptions::Second, "Second")) .with(MenuItem::new(MenuOptions::Second, "Second"))
.with_separator()
.with(third) .with(third)
} }

View file

@ -7,7 +7,7 @@ use std::time::Duration;
use alot::LotId; use alot::LotId;
use figures::units::{Px, UPx}; use figures::units::{Px, UPx};
use figures::{Angle, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero}; use figures::{Angle, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero};
use kludgine::shapes::{PathBuilder, Shape}; use kludgine::shapes::{PathBuilder, Shape, StrokeOptions};
use kludgine::DrawableExt; use kludgine::DrawableExt;
use parking_lot::Mutex; use parking_lot::Mutex;
@ -30,6 +30,12 @@ use crate::widget::{
}; };
use crate::ConstraintLimit; use crate::ConstraintLimit;
#[derive(Debug, Clone)]
enum ItemKind<T> {
Item(T),
Separator,
}
/// An overlayable menu of selectable items. /// An overlayable menu of selectable items.
/// ///
/// This widget is designed to implement Cushy's contextual menu system. When /// This widget is designed to implement Cushy's contextual menu system. When
@ -37,7 +43,7 @@ use crate::ConstraintLimit;
/// or at a specific location. /// or at a specific location.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Menu<T, Handler = MenuHandler<T>> { pub struct Menu<T, Handler = MenuHandler<T>> {
items: Vec<MenuItem<T>>, items: Vec<ItemKind<MenuItem<T>>>,
on_click: Handler, on_click: Handler,
} }
@ -84,7 +90,14 @@ where
/// Adds another menu `item` that is displayed using `widget`. /// Adds another menu `item` that is displayed using `widget`.
#[must_use] #[must_use]
pub fn with(mut self, item: impl Into<MenuItem<T>>) -> Self { pub fn with(mut self, item: impl Into<MenuItem<T>>) -> Self {
self.items.push(item.into()); self.items.push(ItemKind::Item(item.into()));
self
}
/// Adds a separator after the last item and returns self.
#[must_use]
pub fn with_separator(mut self) -> Self {
self.items.push(ItemKind::Separator);
self self
} }
} }
@ -109,22 +122,24 @@ where
let handle = OpenMenuHandle(Dynamic::new(None)); let handle = OpenMenuHandle(Dynamic::new(None));
let items = items let items = items
.iter() .iter()
.map(|item| { .map(|item| RenderedItem {
let MenuItem { y: UPx::ZERO,
value, height: UPx::ZERO,
widget, item: match item {
submenu, ItemKind::Item(MenuItem {
} = item; value,
OpenItem { widget,
value: value.clone(), submenu,
contents: WidgetRef::new(widget.clone().align_left()), }) => ItemKind::Item(OpenItem {
y: UPx::ZERO, value: value.clone(),
height: UPx::ZERO, contents: WidgetRef::new(widget.clone().align_left()),
submenu: submenu.clone(), submenu: submenu.clone(),
colors: None, colors: None,
color_animation: AnimationHandle::default(), color_animation: AnimationHandle::default(),
state: VisualState::Normal, state: VisualState::Normal,
} }),
ItemKind::Separator => ItemKind::Separator,
},
}) })
.collect(); .collect();
@ -408,7 +423,7 @@ pub struct MenuHandler<T>(Arc<Mutex<Callback<ChosenMenuItem<T>>>>);
#[derive(Debug)] #[derive(Debug)]
struct OpenMenu<T> { struct OpenMenu<T> {
items: Vec<OpenItem<T>>, items: Vec<RenderedItem<T>>,
on_click: MenuHandler<T>, on_click: MenuHandler<T>,
open_id: LotId, open_id: LotId,
padding: UPx, padding: UPx,
@ -423,57 +438,59 @@ struct OpenMenu<T> {
impl<T> OpenMenu<T> { impl<T> OpenMenu<T> {
fn handle_mouse_movement(&mut self, location: Point<Px>, context: &mut EventContext<'_>) { fn handle_mouse_movement(&mut self, location: Point<Px>, context: &mut EventContext<'_>) {
self.selecting = None; self.selecting = None;
for (index, item) in self.items.iter_mut().enumerate() { for (index, rendered) in self.items.iter_mut().enumerate() {
let hovered = location.y >= item.y - self.padding let hovered = location.y >= rendered.y - self.padding
&& location.y < item.y + item.height + self.padding; && location.y < rendered.y + rendered.height + self.padding;
let new_state = if hovered { if let ItemKind::Item(item) = &mut rendered.item {
self.selecting = Some(index); let new_state = if hovered {
if let Some((submenu_index, handle)) = &self.open_submenu { self.selecting = Some(index);
if *submenu_index != index { if let Some((submenu_index, handle)) = &self.open_submenu {
context.focus(); if *submenu_index != index {
handle.dismiss(); context.focus();
self.open_submenu = None; handle.dismiss();
self.open_submenu = None;
}
} else if let Some(factory) = &item.submenu {
let last_layout = context.last_layout().expect("must have rendered");
let menu_location = Point::new(
last_layout.origin.x + last_layout.size.width
- self.padding.into_signed() * 2,
last_layout.origin.y + (rendered.y - self.padding).into_signed(),
);
self.open_submenu = Some((
index,
factory
.overlay_submenu_in(&self.layer, self.shared.clone())
.parent(self.menu_id)
.at(menu_location)
.show(),
));
}
if self.mouse_down {
VisualState::Active
} else {
VisualState::Hovered
} }
} else if let Some(factory) = &item.submenu {
let last_layout = context.last_layout().expect("must have rendered");
let menu_location = Point::new(
last_layout.origin.x + last_layout.size.width
- self.padding.into_signed() * 2,
last_layout.origin.y + (item.y - self.padding).into_signed(),
);
self.open_submenu = Some((
index,
factory
.overlay_submenu_in(&self.layer, self.shared.clone())
.parent(self.menu_id)
.at(menu_location)
.show(),
));
}
if self.mouse_down {
VisualState::Active
} else { } else {
VisualState::Hovered VisualState::Normal
}
} else {
VisualState::Normal
};
if item.state != new_state {
item.state = new_state;
let new_colors = if hovered {
ButtonKind::Solid.colors_for_default(new_state, context)
} else {
Button::colors_for_transparent(new_state, context)
}; };
if let Some(colors) = &item.colors { if item.state != new_state {
item.color_animation = colors item.state = new_state;
.transition_to(new_colors) let new_colors = if hovered {
.over(Duration::from_millis(150)) ButtonKind::Solid.colors_for_default(new_state, context)
.with_easing(context.get(&Easing)) } else {
.spawn(); Button::colors_for_transparent(new_state, context)
} else { };
item.colors = Some(Dynamic::new(new_colors)); if let Some(colors) = &item.colors {
context.set_needs_redraw(); item.color_animation = colors
.transition_to(new_colors)
.over(Duration::from_millis(150))
.with_easing(context.get(&Easing))
.spawn();
} else {
item.colors = Some(Dynamic::new(new_colors));
context.set_needs_redraw();
}
} }
} }
} }
@ -518,43 +535,62 @@ where
let pt3 = Point::new(disclosure_size, Px::ZERO).rotate_by(Angle::degrees(240)); let pt3 = Point::new(disclosure_size, Px::ZERO).rotate_by(Angle::degrees(240));
let submenu = PathBuilder::new(pt1).line_to(pt2).line_to(pt3).close(); let submenu = PathBuilder::new(pt1).line_to(pt2).line_to(pt3).close();
for item in &mut self.items {
let mounted = item.contents.mounted(context);
if let Some(colors) = &item.colors { let separator = PathBuilder::new(Point::new(self.padding * 2, UPx::ZERO))
let colors = colors.get_tracking_redraw(context); .line_to(Point::new(full_size.width - self.padding * 2, UPx::ZERO))
let child_rect = Rect::new( .close()
Point::new(self.padding, item.y - self.padding), .stroke(
Size::new( StrokeOptions::mm_wide(1)
full_size.width - self.padding * 2, .into_upx(context.gfx.scale())
item.height + self.padding * 2, .colored(context.theme().surface.color),
), );
) for rendered in &mut self.items {
.into_signed(); match &mut rendered.item {
ItemKind::Item(item) => {
let mounted = item.contents.mounted(context);
let bg_shape = if radii.is_zero() { if let Some(colors) = &item.colors {
Shape::filled_rect(child_rect, colors.background) let colors = colors.get_tracking_redraw(context);
} else { let child_rect = Rect::new(
Shape::filled_round_rect(child_rect, radii, colors.background) Point::new(self.padding, rendered.y - self.padding),
}; Size::new(
context.gfx.draw_shape(&bg_shape); full_size.width - self.padding * 2,
rendered.height + self.padding * 2,
),
)
.into_signed();
if item.submenu.is_some() { let bg_shape = if radii.is_zero() {
let disclosure_offset = Point::new( Shape::filled_rect(child_rect, colors.background)
full_size.width - self.disclosure_size / 2 - self.padding * 2, } else {
item.y + item.height / 2, Shape::filled_round_rect(child_rect, radii, colors.background)
) };
.into_signed(); context.gfx.draw_shape(&bg_shape);
if item.submenu.is_some() {
let disclosure_offset = Point::new(
full_size.width - self.disclosure_size / 2 - self.padding * 2,
rendered.y + rendered.height / 2,
)
.into_signed();
context.gfx.draw_shape(
submenu
.fill(colors.foreground)
.translate_by(disclosure_offset),
);
}
let mut context = context.for_other(&mounted);
context.attach_styles(Styles::new().with(&TextColor, colors.foreground));
context.redraw();
}
}
ItemKind::Separator => {
context.gfx.draw_shape( context.gfx.draw_shape(
submenu separator
.fill(colors.foreground) .translate_by(Point::new(UPx::ZERO, rendered.y - self.padding / 2)),
.translate_by(disclosure_offset),
); );
} }
let mut context = context.for_other(&mounted);
context.attach_styles(Styles::new().with(&TextColor, colors.foreground));
context.redraw();
} }
} }
} }
@ -570,7 +606,7 @@ where
self.disclosure_size = self.disclosure_size =
(context.get(&IndicatorSize).into_upx(context.gfx.scale()) / 2).round(); (context.get(&IndicatorSize).into_upx(context.gfx.scale()) / 2).round();
let double_padding = self.padding * 2; let double_padding = self.padding * 2;
let submenu_space = if self.items.iter().any(|i| i.submenu.is_some()) { let submenu_space = if self.items.iter().any(|i| i.submenu().is_some()) {
self.padding + self.disclosure_size self.padding + self.disclosure_size
} else { } else {
UPx::ZERO UPx::ZERO
@ -578,29 +614,38 @@ where
let available_width = available_space.width.max() - double_padding; let available_width = available_space.width.max() - double_padding;
let mut y = self.padding; let mut y = self.padding;
for item in &mut self.items { for rendered in &mut self.items {
let mounted = item.contents.mounted(context); let (height, full_height) = match &mut rendered.item {
let available_width = available_width - submenu_space; ItemKind::Item(item) => {
let size = context.for_other(&mounted).layout(Size::new( let mounted = item.contents.mounted(context);
ConstraintLimit::SizeToFit(available_width), let available_width = available_width - submenu_space;
ConstraintLimit::SizeToFit(remaining_height), let size = context.for_other(&mounted).layout(Size::new(
)); ConstraintLimit::SizeToFit(available_width),
item.y = y; ConstraintLimit::SizeToFit(remaining_height),
item.height = size.height; ));
let full_height = size.height + double_padding; maximum_item_width = maximum_item_width.max(size.width);
(size.height, size.height + double_padding)
}
ItemKind::Separator => (UPx::ZERO, self.padding),
};
rendered.y = y;
rendered.height = height;
y += full_height; y += full_height;
remaining_height = remaining_height.saturating_sub(full_height); remaining_height = remaining_height.saturating_sub(full_height);
maximum_item_width = maximum_item_width.max(size.width);
} }
for item in &mut self.items { for rendered in &mut self.items {
let ItemKind::Item(item) = &mut rendered.item else {
continue;
};
let mounted = item.contents.mounted(context); let mounted = item.contents.mounted(context);
context.set_child_layout( context.set_child_layout(
&mounted, &mounted,
Rect::new( Rect::new(
Point::new(double_padding, item.y), Point::new(double_padding, rendered.y),
Size::new(maximum_item_width, item.height), Size::new(maximum_item_width, rendered.height),
) )
.into_signed(), .into_signed(),
); );
@ -667,8 +712,11 @@ where
_context: &mut crate::context::EventContext<'_>, _context: &mut crate::context::EventContext<'_>,
) { ) {
if let Some(index) = self.selecting { if let Some(index) = self.selecting {
let ItemKind::Item(item) = &self.items[index].item else {
return;
};
self.on_click.0.lock().invoke(ChosenMenuItem { self.on_click.0.lock().invoke(ChosenMenuItem {
item: self.items[index].value.clone(), item: item.value.clone(),
click: None, click: None,
}); });
let mut shared = self.shared.lock(); let mut shared = self.shared.lock();
@ -688,6 +736,9 @@ where
let colors = Button::colors_for_transparent(VisualState::Normal, context); let colors = Button::colors_for_transparent(VisualState::Normal, context);
for item in &mut self.items { for item in &mut self.items {
let ItemKind::Item(item) = &mut item.item else {
continue;
};
item.colors = Some(Dynamic::new(colors)); item.colors = Some(Dynamic::new(colors));
} }
} }
@ -707,12 +758,26 @@ where
} }
} }
#[derive(Debug)]
struct RenderedItem<T> {
item: ItemKind<OpenItem<T>>,
y: UPx,
height: UPx,
}
impl<T> RenderedItem<T> {
fn submenu(&self) -> Option<&Arc<dyn SubmenuFactory>> {
match &self.item {
ItemKind::Item(item) => item.submenu.as_ref(),
ItemKind::Separator => None,
}
}
}
struct OpenItem<T> { struct OpenItem<T> {
value: T, value: T,
contents: WidgetRef, contents: WidgetRef,
submenu: Option<Arc<dyn SubmenuFactory>>, submenu: Option<Arc<dyn SubmenuFactory>>,
y: UPx,
height: UPx,
colors: Option<Dynamic<ButtonColors>>, colors: Option<Dynamic<ButtonColors>>,
color_animation: AnimationHandle, color_animation: AnimationHandle,
state: VisualState, state: VisualState,
@ -727,7 +792,6 @@ where
.field("value", &self.value) .field("value", &self.value)
.field("contents", &self.contents) .field("contents", &self.contents)
.field("submenu", &self.submenu.is_some()) .field("submenu", &self.submenu.is_some())
.field("height", &self.height)
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }