use std::borrow::Cow;
use crate::event::Rect;
use crate::types::{Color, Icon, WidgetId};
use serde::{Deserialize, Serialize};
fn default_true() -> bool {
true
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toolbar {
pub id: WidgetId,
pub buttons: Vec<ToolbarButton>,
#[serde(default)]
pub bg: Option<Color>,
#[serde(default)]
pub focused_index: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ToolbarVAlign {
#[default]
Top,
Center,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ToolbarPaintOptions {
pub valign: ToolbarVAlign,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolbarButton {
Action {
id: WidgetId,
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
key_hint: Option<String>,
#[serde(default = "default_true")]
enabled: bool,
#[serde(default)]
is_active: bool,
#[serde(default)]
tooltip: String,
},
Separator,
Label {
text: String,
#[serde(default)]
fg: Option<Color>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolbarIcons {
entries: Vec<(WidgetId, Icon)>,
}
impl ToolbarIcons {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, id: WidgetId, icon: Icon) -> Self {
self.entries.push((id, icon));
self
}
pub fn get(&self, id: &WidgetId) -> Option<&Icon> {
self.entries
.iter()
.find(|(oid, _)| oid == id)
.map(|(_, icon)| icon)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn resolve_button<'a>(
&self,
btn: &'a ToolbarButton,
nerd_fonts_enabled: bool,
) -> Cow<'a, ToolbarButton> {
if let ToolbarButton::Action { id, .. } = btn {
if let Some(icon) = self.get(id) {
let resolved = if nerd_fonts_enabled {
icon.glyph.clone()
} else {
icon.fallback.clone()
};
let mut cloned = btn.clone();
if let ToolbarButton::Action {
icon: icon_field, ..
} = &mut cloned
{
*icon_field = Some(resolved);
}
return Cow::Owned(cloned);
}
}
Cow::Borrowed(btn)
}
#[must_use]
pub fn apply(&self, bar: &Toolbar, nerd_fonts_enabled: bool) -> Toolbar {
if self.is_empty() {
return bar.clone();
}
Toolbar {
id: bar.id,
buttons: bar
.buttons
.iter()
.map(|btn| self.resolve_button(btn, nerd_fonts_enabled).into_owned())
.collect(),
bg: bar.bg,
focused_index: bar.focused_index,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolbarItemKind {
Action,
Separator,
Label,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleToolbarItem {
pub item_idx: usize,
pub kind: ToolbarItemKind,
pub bounds: Rect,
pub clickable: bool,
pub action_id: Option<WidgetId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolbarHit {
Button(WidgetId),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolbarLayout {
pub bar_bounds: Rect,
pub visible_items: Vec<VisibleToolbarItem>,
}
impl ToolbarLayout {
pub fn empty(bar_bounds: Rect) -> Self {
ToolbarLayout {
bar_bounds,
visible_items: Vec::new(),
}
}
pub fn hit_test(&self, x: f32, y: f32) -> ToolbarHit {
for vis in &self.visible_items {
if !vis.clickable {
continue;
}
let r = vis.bounds;
if x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height {
if let Some(id) = &vis.action_id {
return ToolbarHit::Button(*id);
}
}
}
ToolbarHit::Empty
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ToolbarItemMeasure {
pub width: f32,
}
impl ToolbarItemMeasure {
pub fn new(width: f32) -> Self {
Self { width }
}
}
pub const ACTION_H_PAD: f32 = 8.0;
pub const SEPARATOR_PX: f32 = 12.0;
pub fn action_text(label: &str, icon: Option<&str>, key_hint: Option<&str>) -> String {
let mut s = String::new();
if let Some(icon) = icon {
s.push_str(icon);
s.push(' ');
}
s.push_str(label);
if let Some(hint) = key_hint {
s.push_str(" (");
s.push_str(hint);
s.push(')');
}
s
}
pub fn valign_offset_y(
valign: ToolbarVAlign,
slot_y: f32,
slot_height: f32,
content_height: f32,
) -> f32 {
match valign {
ToolbarVAlign::Top => slot_y,
ToolbarVAlign::Center => slot_y + (slot_height - content_height) / 2.0,
ToolbarVAlign::Bottom => slot_y + (slot_height - content_height),
}
}
pub fn measure_button(
measure: &dyn crate::primitives::layout_metrics::TextMeasure,
btn: &ToolbarButton,
) -> f32 {
match btn {
ToolbarButton::Action {
label,
icon,
key_hint,
..
} => {
let text = action_text(label, icon.as_deref(), key_hint.as_deref());
measure.width_of(&text) + 2.0 * ACTION_H_PAD
}
ToolbarButton::Separator => SEPARATOR_PX,
ToolbarButton::Label { text, .. } => measure.width_of(text),
}
}
impl Default for Toolbar {
fn default() -> Self {
Self::new(WidgetId::new(String::new()))
}
}
impl Toolbar {
pub fn new(id: WidgetId) -> Self {
Self {
id,
buttons: Vec::new(),
bg: None,
focused_index: None,
}
}
#[must_use]
pub fn with_buttons(mut self, buttons: Vec<ToolbarButton>) -> Self {
self.buttons = buttons;
self
}
#[must_use]
pub fn with_bg(mut self, bg: Color) -> Self {
self.bg = Some(bg);
self
}
#[must_use]
pub fn with_focused_index(mut self, focused_index: Option<usize>) -> Self {
self.focused_index = focused_index;
self
}
pub fn layout<F>(
&self,
origin_x: f32,
origin_y: f32,
bar_width: f32,
bar_height: f32,
measure: F,
) -> ToolbarLayout
where
F: Fn(&ToolbarButton) -> ToolbarItemMeasure,
{
let bar_bounds = Rect::new(origin_x, origin_y, bar_width, bar_height);
let mut visible_items: Vec<VisibleToolbarItem> = Vec::with_capacity(self.buttons.len());
let widths: Vec<f32> = self
.buttons
.iter()
.map(|btn| measure(btn).width.max(0.0))
.collect();
let cursors = item_cursor_positions(origin_x, bar_height, &widths);
let right_edge = origin_x + bar_width;
for (i, btn) in self.buttons.iter().enumerate() {
let w = widths[i];
let cursor = cursors[i];
let visible_w = (right_edge - cursor).max(0.0).min(w);
let bounds = Rect::new(cursor, origin_y, visible_w, bar_height);
let (kind, clickable, action_id) = match btn {
ToolbarButton::Action { id, enabled, .. } => (
ToolbarItemKind::Action,
*enabled && visible_w > 0.0,
Some(*id),
),
ToolbarButton::Separator => (ToolbarItemKind::Separator, false, None),
ToolbarButton::Label { .. } => (ToolbarItemKind::Label, false, None),
};
visible_items.push(VisibleToolbarItem {
item_idx: i,
kind,
bounds,
clickable,
action_id,
});
}
ToolbarLayout {
bar_bounds,
visible_items,
}
}
}
#[cfg(feature = "layout")]
fn item_cursor_positions(origin_x: f32, bar_height: f32, widths: &[f32]) -> Vec<f32> {
crate::flex::pack_1d(
crate::flex::FlexDirection::Row,
origin_x,
widths,
0.0,
bar_height,
)
}
#[cfg(not(feature = "layout"))]
fn item_cursor_positions(origin_x: f32, _bar_height: f32, widths: &[f32]) -> Vec<f32> {
let mut cursor = origin_x;
let mut cursors = Vec::with_capacity(widths.len());
for &w in widths {
cursors.push(cursor);
cursor += w;
}
cursors
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{
action_text, valign_offset_y, Toolbar, ToolbarButton, ToolbarLayout, ToolbarPaintOptions,
};
use crate::event::{Point, Rect};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::types::WidgetId;
const CORNER_RADIUS: f32 = 4.0;
pub(crate) fn paint(
bar: &Toolbar,
layout: &ToolbarLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
hovered_id: Option<&WidgetId>,
pressed_id: Option<&WidgetId>,
options: ToolbarPaintOptions,
) {
if layout.bar_bounds.width <= 0.0 || layout.bar_bounds.height <= 0.0 {
return;
}
let bar_bg = bar.bg.unwrap_or(theme.header_bg);
surface.surface_fill_rect(layout.bar_bounds, bar_bg);
for vis in &layout.visible_items {
let item = vis.bounds;
if item.width <= 0.0 || item.height <= 0.0 {
continue;
}
let btn = &bar.buttons[vis.item_idx];
match btn {
ToolbarButton::Action {
id,
label,
icon,
key_hint,
enabled,
is_active,
..
} => {
let is_hovered = *enabled && hovered_id == Some(id);
let is_pressed = *enabled && pressed_id == Some(id);
let is_focused = *enabled && bar.focused_index == Some(vis.item_idx);
let highlight = if is_pressed || *is_active {
Some(theme.selected_bg)
} else if is_hovered {
Some(theme.hover_bg)
} else {
None
};
if let Some(bg) = highlight {
let inset = Rect::new(
item.x + 2.0,
item.y + 2.0,
(item.width - 4.0).max(0.0),
(item.height - 4.0).max(0.0),
);
surface.surface_fill_rounded_rect(inset, CORNER_RADIUS, bg);
}
if is_focused && !is_hovered && !is_pressed && !*is_active {
let ring = Rect::new(
item.x + 1.5,
item.y + 1.5,
(item.width - 3.0).max(0.0),
(item.height - 3.0).max(0.0),
);
surface.surface_stroke_rect(ring, theme.accent_fg, 1.0);
}
let fg = if !*enabled {
theme.muted_fg
} else if is_hovered {
theme.hover_fg
} else {
theme.foreground
};
let text = action_text(label, icon.as_deref(), key_hint.as_deref());
let (tw, th) = surface.surface_measure_text(&text);
let tx = item.x + (item.width - tw) / 2.0;
let ty = valign_offset_y(options.valign, item.y, item.height, th);
surface.surface_draw_text_run(
Rect::new(tx, ty, tw.max(0.0), th.max(0.0)),
&text,
fg,
);
}
ToolbarButton::Separator => {
let mid_x = item.x + item.width / 2.0;
let pad_y = (item.height * 0.2).max(2.0);
surface.surface_draw_line(
Point::new(mid_x, item.y + pad_y),
Point::new(mid_x, item.y + item.height - pad_y),
theme.muted_fg,
1.0,
);
}
ToolbarButton::Label { text, fg } => {
let color = fg.unwrap_or(theme.muted_fg);
let (tw, th) = surface.surface_measure_text(text);
let ty = valign_offset_y(options.valign, item.y, item.height, th);
surface.surface_draw_text_run(
Rect::new(item.x, ty, tw.max(0.0), th.max(0.0)),
text,
color,
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::WidgetId;
fn mk_action(id: &str, label: &str, enabled: bool) -> ToolbarButton {
ToolbarButton::Action {
id: WidgetId::new(id),
label: label.to_string(),
icon: None,
key_hint: None,
enabled,
is_active: false,
tooltip: String::new(),
}
}
fn cell_measure() -> impl Fn(&ToolbarButton) -> ToolbarItemMeasure {
|btn| match btn {
ToolbarButton::Action {
label,
icon,
key_hint,
..
} => {
let icon_w = icon
.as_ref()
.map(|s| crate::text_util::display_width(s) + 1)
.unwrap_or(0);
let hint_w = key_hint
.as_ref()
.map(|s| crate::text_util::display_width(s) + 1)
.unwrap_or(0);
let label_w = crate::text_util::display_width(label);
ToolbarItemMeasure::new((4 + icon_w + label_w + hint_w) as f32)
}
ToolbarButton::Separator => ToolbarItemMeasure::new(2.0),
ToolbarButton::Label { text, .. } => {
ToolbarItemMeasure::new(crate::text_util::display_width(text) as f32)
}
}
}
#[test]
fn empty_toolbar_layout() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![],
bg: None,
focused_index: None,
};
let layout = bar.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
assert!(layout.visible_items.is_empty());
assert_eq!(layout.bar_bounds.width, 80.0);
}
#[test]
fn layout_places_buttons_left_to_right() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "Refine", true), mk_action("b", "Drop", true)],
bg: None,
focused_index: None,
};
let layout = bar.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
assert_eq!(layout.visible_items.len(), 2);
assert_eq!(layout.visible_items[0].bounds.x, 0.0);
let w0 = layout.visible_items[0].bounds.width;
assert_eq!(layout.visible_items[1].bounds.x, w0);
}
#[test]
fn hit_test_routes_enabled_action_to_button() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("refine", "Refine", true)],
bg: None,
focused_index: None,
};
let layout = bar.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
let r = layout.visible_items[0].bounds;
let hit = layout.hit_test(r.x + 1.0, r.y);
match hit {
ToolbarHit::Button(id) => assert_eq!(id.as_str(), "refine"),
_ => panic!("expected Button hit, got {:?}", hit),
}
}
#[test]
fn hit_test_skips_disabled_action() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("refine", "Refine", false)],
bg: None,
focused_index: None,
};
let layout = bar.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
assert!(!layout.visible_items[0].clickable);
let r = layout.visible_items[0].bounds;
assert_eq!(layout.hit_test(r.x + 1.0, r.y), ToolbarHit::Empty);
}
#[test]
fn hit_test_skips_separator_and_label() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![
ToolbarButton::Separator,
ToolbarButton::Label {
text: "2 of 5".into(),
fg: None,
},
],
bg: None,
focused_index: None,
};
let layout = bar.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
assert!(!layout.visible_items[0].clickable);
assert!(!layout.visible_items[1].clickable);
let r = layout.visible_items[1].bounds;
assert_eq!(layout.hit_test(r.x, r.y), ToolbarHit::Empty);
}
#[test]
fn layout_clips_to_bar_width() {
let mk_wide = |id: &str| ToolbarButton::Action {
id: WidgetId::new(id),
label: "xxxxxxxx".into(), icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
};
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_wide("a"), mk_wide("b")],
bg: None,
focused_index: None,
};
let measure = |_: &ToolbarButton| ToolbarItemMeasure::new(6.0);
let layout = bar.layout(0.0, 0.0, 9.0, 1.0, measure);
assert_eq!(layout.visible_items.len(), 2);
assert_eq!(layout.visible_items[0].bounds.x, 0.0);
assert_eq!(layout.visible_items[0].bounds.width, 6.0);
assert_eq!(layout.visible_items[1].bounds.x, 6.0);
assert_eq!(layout.visible_items[1].bounds.width, 3.0);
}
#[test]
fn layout_zero_width_bar_produces_zero_width_items() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "X", true)],
bg: None,
focused_index: None,
};
let layout = bar.layout(0.0, 0.0, 0.0, 1.0, cell_measure());
assert_eq!(layout.visible_items.len(), 1);
assert_eq!(layout.visible_items[0].bounds.width, 0.0);
assert!(!layout.visible_items[0].clickable);
}
#[test]
fn origin_offset_propagates() {
let bar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![mk_action("a", "X", true)],
bg: None,
focused_index: None,
};
let layout = bar.layout(10.0, 5.0, 80.0, 1.0, cell_measure());
assert_eq!(layout.bar_bounds.x, 10.0);
assert_eq!(layout.bar_bounds.y, 5.0);
assert_eq!(layout.visible_items[0].bounds.x, 10.0);
assert_eq!(layout.visible_items[0].bounds.y, 5.0);
}
#[test]
fn serde_roundtrip_toolbar() {
let bar = Toolbar {
id: WidgetId::new("debug-toolbar"),
buttons: vec![
ToolbarButton::Action {
id: WidgetId::new("debug:continue"),
label: "Continue".into(),
icon: Some("▶".into()),
key_hint: Some("F5".into()),
enabled: true,
is_active: false,
tooltip: "Resume execution".into(),
},
ToolbarButton::Separator,
ToolbarButton::Action {
id: WidgetId::new("debug:stop"),
label: "Stop".into(),
icon: None,
key_hint: None,
enabled: false,
is_active: false,
tooltip: String::new(),
},
ToolbarButton::Label {
text: "paused".into(),
fg: Some(Color::rgb(200, 100, 50)),
},
],
bg: Some(Color::rgb(40, 40, 40)),
focused_index: None,
};
let json = serde_json::to_string(&bar).unwrap();
let back: Toolbar = serde_json::from_str(&json).unwrap();
assert_eq!(bar, back);
}
struct FakeMeasure;
impl crate::primitives::layout_metrics::TextMeasure for FakeMeasure {
fn width_of(&self, text: &str) -> f32 {
text.chars().count() as f32 * 6.0
}
}
#[test]
fn action_text_formats_icon_label_hint() {
assert_eq!(action_text("Refine", None, None), "Refine");
assert_eq!(action_text("Refine", Some("▶"), None), "▶ Refine");
assert_eq!(action_text("Refine", None, Some("r")), "Refine (r)");
assert_eq!(action_text("Refine", Some("▶"), Some("r")), "▶ Refine (r)");
}
#[test]
fn measure_button_action_is_text_width_plus_double_pad() {
let btn = mk_action("a", "Go", true); let w = measure_button(&FakeMeasure, &btn);
assert_eq!(w, 12.0 + 2.0 * ACTION_H_PAD);
}
#[test]
fn measure_button_separator_is_fixed_width() {
let w = measure_button(&FakeMeasure, &ToolbarButton::Separator);
assert_eq!(w, SEPARATOR_PX);
}
#[test]
fn measure_button_label_is_unpadded_text_width() {
let btn = ToolbarButton::Label {
text: "2 of 5".into(), fg: None,
};
let w = measure_button(&FakeMeasure, &btn);
assert_eq!(w, 36.0);
}
#[test]
fn valign_offset_y_top_ignores_slot_height() {
assert_eq!(valign_offset_y(ToolbarVAlign::Top, 10.0, 40.0, 12.0), 10.0);
}
#[test]
fn valign_offset_y_center_splits_remaining_space_evenly() {
assert_eq!(
valign_offset_y(ToolbarVAlign::Center, 10.0, 40.0, 12.0),
10.0 + 14.0
);
}
#[test]
fn valign_offset_y_bottom_flushes_content_to_the_end_of_the_slot() {
assert_eq!(
valign_offset_y(ToolbarVAlign::Bottom, 10.0, 40.0, 12.0),
10.0 + 28.0
);
}
#[test]
fn valign_offset_y_every_variant_agrees_on_a_one_row_slot() {
for valign in [
ToolbarVAlign::Top,
ToolbarVAlign::Center,
ToolbarVAlign::Bottom,
] {
assert_eq!(
valign_offset_y(valign, 5.0, 20.0, 20.0),
5.0,
"valign={valign:?}"
);
}
}
#[test]
fn toolbar_valign_defaults_to_top() {
assert_eq!(ToolbarVAlign::default(), ToolbarVAlign::Top);
assert_eq!(ToolbarPaintOptions::default().valign, ToolbarVAlign::Top);
}
#[test]
fn action_enabled_defaults_to_true_in_serde() {
let json = r#"{
"type": "Action",
"id": "x",
"label": "X"
}"#;
let _ = json; let btn = ToolbarButton::Action {
id: WidgetId::new("x"),
label: "X".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
};
let json = serde_json::to_string(&btn).unwrap();
let back: ToolbarButton = serde_json::from_str(&json).unwrap();
assert_eq!(btn, back);
}
fn mk_icon_action(id: &str, label: &str, icon: Option<&str>) -> ToolbarButton {
ToolbarButton::Action {
id: WidgetId::new(id),
label: label.to_string(),
icon: icon.map(|s| s.to_string()),
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}
}
fn icon_of(btn: &ToolbarButton) -> Option<&str> {
match btn {
ToolbarButton::Action { icon, .. } => icon.as_deref(),
_ => None,
}
}
fn bar_with(buttons: Vec<ToolbarButton>) -> Toolbar {
Toolbar {
id: WidgetId::new("tb"),
buttons,
bg: None,
focused_index: None,
}
}
#[test]
fn icon_overrides_pick_glyph_or_fallback_by_flag() {
let bar = bar_with(vec![mk_icon_action("a", "Refresh", Some("~"))]);
let icons = ToolbarIcons::new().with(WidgetId::new("a"), Icon::new("\u{f021}", "R"));
assert_eq!(
icon_of(&icons.apply(&bar, true).buttons[0]),
Some("\u{f021}"),
"nerd fonts on → the override's glyph replaces the button's own icon"
);
assert_eq!(
icon_of(&icons.apply(&bar, false).buttons[0]),
Some("R"),
"nerd fonts off → the override's ASCII fallback"
);
}
#[test]
fn apply_without_overrides_is_byte_identical_to_the_input_bar() {
let bar = bar_with(vec![
mk_icon_action("a", "Refine", Some("\u{f021}")),
ToolbarButton::Separator,
ToolbarButton::Label {
text: "2 of 5".into(),
fg: None,
},
]);
let empty = ToolbarIcons::new();
assert!(empty.is_empty());
assert_eq!(empty.apply(&bar, true), bar);
assert_eq!(empty.apply(&bar, false), bar);
let unrelated = ToolbarIcons::new().with(WidgetId::new("nope"), Icon::new("X", "Y"));
assert_eq!(unrelated.apply(&bar, true), bar);
}
#[test]
fn apply_leaves_non_action_items_and_unregistered_buttons_alone() {
let bar = bar_with(vec![
mk_icon_action("a", "Refresh", Some("~")),
ToolbarButton::Separator,
mk_icon_action("b", "Drop", Some("-")),
]);
let icons = ToolbarIcons::new().with(WidgetId::new("a"), Icon::new("\u{f021}", "R"));
let out = icons.apply(&bar, true);
assert_eq!(icon_of(&out.buttons[0]), Some("\u{f021}"));
assert_eq!(out.buttons[1], ToolbarButton::Separator);
assert_eq!(
icon_of(&out.buttons[2]),
Some("-"),
"an unregistered button keeps its own icon"
);
assert_eq!(out.id, bar.id);
assert_eq!(out.bg, bar.bg);
assert_eq!(out.focused_index, bar.focused_index);
}
#[test]
fn first_registration_wins_for_a_duplicated_id() {
let bar = bar_with(vec![mk_icon_action("a", "Refresh", None)]);
let icons = ToolbarIcons::new()
.with(WidgetId::new("a"), Icon::new("first", "F"))
.with(WidgetId::new("a"), Icon::new("second", "S"));
assert_eq!(icons.len(), 2);
assert_eq!(icon_of(&icons.apply(&bar, true).buttons[0]), Some("first"));
}
#[test]
fn resolve_button_borrows_when_there_is_nothing_to_resolve() {
let icons = ToolbarIcons::new().with(WidgetId::new("a"), Icon::new("\u{f021}", "R"));
let sep = ToolbarButton::Separator;
assert!(matches!(
icons.resolve_button(&sep, true),
Cow::Borrowed(ToolbarButton::Separator)
));
let other = mk_icon_action("b", "Drop", Some("-"));
assert!(matches!(
icons.resolve_button(&other, true),
Cow::Borrowed(_)
));
let hit = mk_icon_action("a", "Refresh", None);
assert!(matches!(icons.resolve_button(&hit, true), Cow::Owned(_)));
}
#[test]
fn a_wide_override_glyph_shifts_the_next_button_by_two_cells() {
let bar = bar_with(vec![
mk_icon_action("a", "Refresh", None),
mk_icon_action("b", "Drop", None),
]);
let icons = ToolbarIcons::new().with(WidgetId::new("a"), Icon::new("一", "R"));
let narrow = icons
.apply(&bar, false)
.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
let wide = icons
.apply(&bar, true)
.layout(0.0, 0.0, 80.0, 1.0, cell_measure());
assert_eq!(
wide.visible_items[0].bounds.width - narrow.visible_items[0].bounds.width,
1.0,
"the wide glyph is one cell wider than its ASCII fallback"
);
assert_eq!(
wide.visible_items[1].bounds.x - narrow.visible_items[1].bounds.x,
1.0,
"the next button's hit region moves with it"
);
}
#[test]
fn default_matches_new_with_empty_id() {
assert_eq!(Toolbar::default(), Toolbar::new(WidgetId::new("")));
}
#[test]
fn with_builders_reach_every_field_a_struct_literal_can_set() {
let bar = Toolbar::new(WidgetId::new("sidebar-action-bar"))
.with_buttons(vec![mk_action("sidebar:refresh", "Refresh", true)])
.with_bg(Color::rgb(10, 20, 30))
.with_focused_index(Some(0));
assert_eq!(
bar,
Toolbar {
id: WidgetId::new("sidebar-action-bar"),
buttons: vec![mk_action("sidebar:refresh", "Refresh", true)],
bg: Some(Color::rgb(10, 20, 30)),
focused_index: Some(0),
}
);
}
#[test]
fn with_focused_index_accepts_none() {
let bar = Toolbar::new(WidgetId::new("bar")).with_focused_index(Some(2));
assert_eq!(bar.focused_index, Some(2));
assert_eq!(bar.with_focused_index(None).focused_index, None);
}
}