use crate::env::Env;
use crate::event::{Event, KeyEvent, MouseKind};
use crate::geometry::{Rect, Size, clamp_u16};
use crate::keymap::{Key, KeyChord};
use crate::style::CellStyle;
use crate::text;
use crate::theme::State;
use crate::widget::{EventCx, MeasureCx, Node, PaintCx, Widget};
use super::button::Button;
use super::cells;
use super::popup_menu::{PopupAction, PopupMenu};
use super::press::{self, Press};
const GAP: u16 = 2;
const MORE_ICON: &str = "chevron-down";
const MORE_LABEL: &str = "quvyta.button-row.more";
pub struct ButtonRow<Msg> {
labels: Vec<String>,
buttons: Vec<Node<Msg>>,
}
#[derive(Debug, Default)]
struct RowMemory {
places: Vec<Option<Rect>>,
more: Option<Rect>,
}
struct Placed {
places: Vec<Option<Rect>>,
more: Option<Rect>,
}
impl<Msg: Clone + 'static> ButtonRow<Msg> {
#[must_use]
pub fn new() -> Self {
Self { labels: Vec::new(), buttons: Vec::new() }
}
#[must_use]
pub fn button(mut self, button: Button<Msg>) -> Self {
let index = self.buttons.len();
self.labels.push(button.label().to_owned());
self.buttons.push(Node::new(button, index));
self
}
fn row_width(sizes: &[Size]) -> u16 {
let count = u16::try_from(sizes.len()).unwrap_or(u16::MAX);
cells::sum(sizes.iter().map(|size| size.width)).saturating_add(count.saturating_sub(1) * GAP)
}
fn placed(sizes: &[Size], more: u16, area: Rect) -> Placed {
let height = sizes.iter().map(|size| size.height).max().unwrap_or(1);
if Self::row_width(sizes) <= area.width {
let (mut x, mut places) = (area.x, Vec::with_capacity(sizes.len()));
for size in sizes {
places.push(Some(Rect::new(x, area.y, size.width, height)));
x += i32::from(size.width) + i32::from(GAP);
}
return Placed { places, more: None };
}
let mut x = area.x;
let mut places = vec![None; sizes.len()];
for (index, size) in sizes.iter().enumerate() {
if x + i32::from(cells::sum([size.width, GAP, more])) > area.right() {
break;
}
places[index] = Some(Rect::new(x, area.y, size.width, height));
x += i32::from(size.width) + i32::from(GAP);
}
let width = clamp_u16(area.right() - x);
Placed { places, more: Some(Rect::new(x, area.y, width.min(more), height)) }
}
fn more_width(env: &Env) -> u16 {
let (_, padding) = env.theme().style("button", None, &[]).pair("padding").unwrap_or((0, 2));
cells::sum([
padding.saturating_mul(2),
text::width(&env.icons().glyph(MORE_ICON)) + 1,
text::width(&Self::label(env)),
])
}
fn label(env: &Env) -> String {
env.i18n().translate(MORE_LABEL, &[])
}
fn enter() -> Event {
Event::Key(KeyEvent::from_chord(KeyChord::plain(Key::Enter)))
}
fn menu_lists(&self, places: &[Option<Rect>]) -> (Vec<usize>, Vec<String>) {
let index: Vec<usize> =
places.iter().enumerate().filter_map(|(index, place)| place.is_none().then_some(index)).collect();
let labels = index.iter().map(|index| self.labels[*index].clone()).collect();
(index, labels)
}
fn button_sizes<M: ButtonSizer<Msg>>(&self, cx: &mut M, available: Size) -> Vec<Size> {
self.buttons.iter().map(|button| cx.size_of(button, available)).collect()
}
fn paint_more(&self, cx: &mut PaintCx<'_>, rect: Rect) {
let glyph = cx.env().icons().glyph(MORE_ICON).into_owned();
let label = Self::label(cx.env());
let open = PopupMenu::is_open_paint(cx);
let mut states = Vec::new();
if cx.pointer_within().is_some_and(|(x, y)| rect.contains(x, y)) {
states.push(State::Hover);
}
if cx.is_focused() && (cx.is_focus_visible() || open) {
states.push(State::Focus);
}
if cx.is_pressed() {
states.push(State::Pressed);
}
let style = cx.style("button", None, &states);
let padding = style.padding();
let text_style = style.text();
cx.clear(rect, text_style.bg.unwrap_or_else(|| cx.color("raised")));
cx.register_hit(rect);
if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
cx.pillar(rect.x, rect.y + i32::from(padding.top), color);
}
let y = rect.y + i32::from(padding.top);
let budget = rect.width.saturating_sub(padding.horizontal());
let icon = text::width(&glyph) + 1;
cx.text(
rect.x + i32::from(padding.left),
y,
&glyph,
CellStyle { bg: None, ..text_style },
icon.saturating_sub(1),
);
let shown = text::truncate(&label, budget.saturating_sub(icon)).into_owned();
cx.text(
rect.x + i32::from(padding.left) + i32::from(icon),
y,
&shown,
CellStyle { bg: None, ..text_style },
budget,
);
if open {
cx.request_overlay(rect);
}
}
}
impl<Msg: Clone + 'static> Widget<Msg> for ButtonRow<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
if self.buttons.is_empty() {
return Size::new(0, 0);
}
let sizes = self.button_sizes(cx, available);
let height = sizes.iter().map(|size| size.height).max().unwrap_or(1);
Size::new(Self::row_width(&sizes).min(available.width), height).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
let mut placed = Placed { places: Vec::new(), more: None };
if !self.buttons.is_empty() && !area.is_empty() {
let sizes = self.button_sizes(cx, Size::new(area.width, area.height));
placed = Self::placed(&sizes, Self::more_width(cx.env()), area);
for (button, place) in self.buttons.iter().zip(&placed.places) {
if let Some(rect) = place {
cx.paint_child(button, *rect);
}
}
if let Some(rect) = placed.more {
self.paint_more(cx, rect);
cx.register_focusable();
}
}
let memory = cx.memory::<RowMemory>();
memory.places = placed.places;
memory.more = placed.more;
}
fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
if !PopupMenu::is_open_paint(cx) {
return;
}
let places = cx.memory::<RowMemory>().places.clone();
let (_, labels) = self.menu_lists(&places);
PopupMenu::paint(cx, anchor, &labels, None);
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
let (places, more) = {
let memory = cx.memory::<RowMemory>();
(memory.places.clone(), memory.more)
};
if more.is_none() && PopupMenu::is_open(cx) {
PopupMenu::close(cx);
}
if PopupMenu::is_open(cx) {
let (index, labels) = self.menu_lists(&places);
let area = cx.area();
match PopupMenu::event(cx, event, &labels) {
PopupAction::Chosen(row) => {
if let Some(button) = index.get(row).map(|index| &self.buttons[*index]) {
cx.forward(button, area, &Self::enter());
}
return true;
}
PopupAction::Used | PopupAction::Closed => return true,
PopupAction::Ignored => {}
}
}
let Some(more) = more else {
return false;
};
match event {
Event::Key(key) if cx.is_focused() && (key.is_plain(Key::Enter) || key.is_plain(Key::Space)) => {}
Event::Mouse(mouse)
if matches!(mouse.kind, MouseKind::Down(_) | MouseKind::Up(_)) && more.contains(mouse.x, mouse.y) =>
{
match press::read(cx, event) {
Press::Ignored | Press::Used => return true,
Press::Key | Press::Click(..) => {}
}
}
_ => return false,
}
PopupMenu::open(cx, 0);
cx.flash();
true
}
fn children(&self) -> &[Node<Msg>] {
&self.buttons
}
fn children_mut(&mut self) -> &mut [Node<Msg>] {
&mut self.buttons
}
}
impl<Msg: Clone + 'static> Default for ButtonRow<Msg> {
fn default() -> Self {
Self::new()
}
}
trait ButtonSizer<Msg> {
fn size_of(&mut self, button: &Node<Msg>, available: Size) -> Size;
}
impl<Msg: Clone + 'static> ButtonSizer<Msg> for MeasureCx<'_> {
fn size_of(&mut self, button: &Node<Msg>, available: Size) -> Size {
self.measure_child(button, available)
}
}
impl<Msg: Clone + 'static> ButtonSizer<Msg> for PaintCx<'_> {
fn size_of(&mut self, button: &Node<Msg>, available: Size) -> Size {
self.measure_child(button, available)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::runtime::{App, Command, Harness};
use crate::widget::{Length, View};
const ACTIONS: [&str; 6] = ["open", "save", "rename", "compare", "publish", "settings"];
struct Toolbar {
width: u16,
heard: Vec<&'static str>,
}
impl App for Toolbar {
type Msg = &'static str;
fn update(&mut self, msg: &'static str) -> Command<&'static str> {
self.heard.push(msg);
Command::none()
}
fn view(&self, ui: &mut View<'_, &'static str>) {
let mut row = ButtonRow::new();
for action in ACTIONS {
row = row.button(Button::new(action).on_press(action));
}
ui.add(row).width(Length::Cells(self.width)).id("bar");
}
}
fn toolbar(width: u16) -> Harness<Toolbar> {
Harness::new(Toolbar { width, heard: Vec::new() }, width, 6)
}
const OPEN: Duration = Duration::from_millis(300);
fn pillar(h: &Harness<Toolbar>) -> Option<usize> {
let screen = h.screen();
let line = screen.lines().next().unwrap_or_default();
line.find('▌').map(|byte| line[..byte].chars().count())
}
fn column(h: &Harness<Toolbar>, text: &str) -> i32 {
h.find(text).unwrap_or_else(|| panic!("`{text}` is not on screen:\n{}", h.screen())).0
}
#[test]
fn a_row_wide_enough_for_its_buttons_is_a_plain_row_of_them() {
let h = toolbar(72);
let screen = h.screen();
for action in ACTIONS {
assert!(screen.contains(action), "{action} is on the row:\n{screen}");
}
assert!(!screen.contains("More"), "no other control while every button fits:\n{screen}");
assert_eq!(column(&h, "open"), 2, "a button keeps its padding before the label");
assert_eq!(column(&h, "save"), 12, "and the row keeps two cells between two of them");
}
#[test]
fn a_row_too_narrow_gives_its_end_to_the_menu_of_the_rest() {
let h = toolbar(30);
let screen = h.screen();
assert!(screen.contains("More"), "the control stands at the end of the row:\n{screen}");
for waiting in ["rename", "compare", "publish", "settings"] {
assert!(!screen.contains(waiting), "{waiting} waits in the menu:\n{screen}");
}
assert_eq!(column(&h, "open"), 2, "the buttons that fit keep their places");
assert_eq!(column(&h, "save"), 12, "and the row's gap is the same in a narrow row");
}
#[test]
fn choosing_a_button_in_the_menu_sends_the_message_it_sends_itself() {
let mut h = toolbar(30);
h.click_text("More").advance(OPEN);
assert!(h.screen().contains("publish"), "the menu lists what did not fit:\n{}", h.screen());
h.click_text("publish");
assert_eq!(h.app().heard, ["publish"]);
assert!(!h.screen().contains("publish"), "and the menu closed with the choice:\n{}", h.screen());
}
#[test]
fn tab_walks_the_buttons_and_reaches_the_control_that_opens_the_menu() {
let mut h = toolbar(30);
h.press("tab");
assert_eq!(pillar(&h), Some(0), "the first button has the focus:\n{}", h.screen());
h.press("tab");
assert_eq!(pillar(&h), Some(10), "then the second:\n{}", h.screen());
h.press("tab");
assert!(h.is_focused("bar"), "and the row itself, which is the menu's control:\n{}", h.screen());
assert_eq!(pillar(&h), Some(20), "the pillar stands in the control's first cell:\n{}", h.screen());
h.press("enter").advance(OPEN);
assert!(h.screen().contains("rename"), "Enter opens the menu:\n{}", h.screen());
h.press("enter");
assert_eq!(h.app().heard, ["rename"], "and Enter takes the entry it stands on");
}
#[test]
fn a_press_beside_an_open_menu_presses_the_button_it_landed_on() {
let mut h = toolbar(30);
h.click_text("More").advance(OPEN);
h.click_text("open");
assert_eq!(h.app().heard, ["open"]);
assert!(!h.screen().contains("rename"), "the menu closed with the press:\n{}", h.screen());
}
struct Pair {
labels: [&'static str; 2],
width: u16,
}
impl App for Pair {
type Msg = &'static str;
fn update(&mut self, _msg: &'static str) -> Command<&'static str> {
Command::none()
}
fn view(&self, ui: &mut View<'_, &'static str>) {
let mut row = ButtonRow::new();
for action in self.labels {
row = row.button(Button::new(action).on_press(action));
}
ui.add(row).width(Length::Cells(self.width)).id("bar");
}
}
fn pair(labels: [&'static str; 2], width: u16) -> Harness<Pair> {
Harness::new(Pair { labels, width }, width, 6)
}
#[test]
fn the_row_moves_its_end_from_the_end_and_never_leaves_a_hole() {
let h = pair(["open a file", "ok"], 20);
let screen = h.screen();
assert!(screen.contains("More"), "the control stands alone:\n{screen}");
assert!(!screen.contains("ok"), "and no button from the middle of the row:\n{screen}");
}
#[test]
fn a_button_gives_up_its_place_before_the_control_loses_its_label() {
let h = pair(["Save", "Settings"], 19);
let screen = h.screen();
assert!(screen.contains("More"), "the control stands whole at the left of the row:\n{screen}");
assert!(!screen.contains("Save"), "and the button that did not fit waits in the menu:\n{screen}");
}
#[test]
fn the_control_is_a_button_like_any_other() {
let mut h = toolbar(30);
let (x, y) = h.find("More").expect("the control");
let cell = (u16::try_from(x - 4).expect("on screen"), u16::try_from(y).expect("on screen"));
let (save_x, save_y) = h.find("save").expect("a button to compare with");
let button = (u16::try_from(save_x).expect("on screen"), u16::try_from(save_y).expect("on screen"));
assert_eq!(h.bg(cell.0, cell.1), h.bg(button.0, button.1), "at rest it is a surface, like a button");
h.hover(x, y);
let lit = h.bg(cell.0, cell.1);
h.hover(save_x, save_y);
assert_eq!(lit, h.bg(button.0, button.1), "which brightens as a button does:\n{}", h.screen());
h.hover(0, 5);
h.press("tab").press("tab").press("tab");
assert_eq!(pillar(&h), Some(20), "and the keyboard finds it at the end of the row:\n{}", h.screen());
}
fn brightness(color: Option<crate::color::Rgb>) -> u32 {
color.map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b))
}
#[test]
fn a_press_on_the_control_confirms_it_as_a_button_does() {
let mut h = toolbar(30);
let (x, y) = h.find("More").expect("the control");
let cell = (u16::try_from(x - 4).expect("on screen"), u16::try_from(y).expect("on screen"));
h.hover(x, y);
let rested = brightness(h.bg(cell.0, cell.1));
h.click(x, y);
assert!(brightness(h.bg(cell.0, cell.1)) > rested, "the press flashes it one tone brighter:\n{}", h.screen());
h.advance(Duration::from_millis(200));
assert_eq!(brightness(h.bg(cell.0, cell.1)), rested, "and it settles back to the tone it had");
}
}