use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::Arc;
use super::{BACKGROUND_COLOR, Plan, Setter, TINT, color, owned_text, slot_of};
use crate::NativeWidgetError;
use crate::events::{EVENT_KIND_RESELECTED, EVENT_KIND_SELECTION, EventPayload, unpack_index};
use crate::registry::SlotId;
use crate::runtime::{NativeEvent, Params};
pub(crate) const KIND: &str = "tab_bar";
pub(crate) const ITEM_COUNT: &str = "itemCount";
pub(crate) const SELECTED: &str = "selected";
pub(crate) const ICONS_REV: &str = "iconsRev";
pub(crate) const UNSELECTED_TINT: &str = "unselectedTint";
pub(crate) const FIELD_TITLE: &str = "Title";
pub(crate) const FIELD_BADGE: &str = "Badge";
pub(crate) const FIELD_ENABLED: &str = "Enabled";
pub(crate) const FIELD_SYMBOL: &str = "Symbol";
pub(crate) const FIELD_SELECTED_SYMBOL: &str = "SelectedSymbol";
pub(crate) const MAX_ITEMS: usize = 16;
pub(crate) const BAR_HEIGHT: f64 = 49.0;
pub(crate) const ICON_POINTS: f64 = 25.0;
pub(crate) fn item_key(index: usize, field: &str) -> String {
format!("tab{index}{field}")
}
pub(crate) fn icon_decode_scale(width: f64, height: f64) -> f64 {
let longest = width.max(height);
if !longest.is_finite() || longest <= ICON_POINTS {
1.0
} else {
longest / ICON_POINTS
}
}
pub(crate) fn tap_kind(showing: Option<usize>, tapped: usize) -> i32 {
if showing == Some(tapped) {
EVENT_KIND_RESELECTED
} else {
EVENT_KIND_SELECTION
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ItemIconBytes {
pub(crate) icon: Option<Arc<[u8]>>,
pub(crate) selected_icon: Option<Arc<[u8]>>,
}
impl ItemIconBytes {
fn is_empty(&self) -> bool {
self.icon.is_none() && self.selected_icon.is_none()
}
}
struct Published {
rev: u64,
items: Arc<[ItemIconBytes]>,
}
thread_local! {
static ICON_BYTES: RefCell<HashMap<SlotId, Published>> = RefCell::new(HashMap::new());
static NEXT_REV: Cell<u64> = const { Cell::new(1) };
}
pub(crate) fn publish_icon_bytes(slot: SlotId, items: Vec<ItemIconBytes>) -> u64 {
ICON_BYTES.with(|table| {
let mut table = table.borrow_mut();
if items.iter().all(ItemIconBytes::is_empty) {
table.remove(&slot);
return 0;
}
if let Some(published) = table.get(&slot)
&& *published.items == *items
{
return published.rev;
}
let rev = NEXT_REV.with(|next| {
let rev = next.get();
next.set(rev + 1);
rev
});
table.insert(
slot,
Published {
rev,
items: items.into(),
},
);
rev
})
}
pub(crate) fn icon_bytes(slot: SlotId) -> Option<Arc<[ItemIconBytes]>> {
ICON_BYTES.with(|table| {
table
.borrow()
.get(&slot)
.map(|published| Arc::clone(&published.items))
})
}
pub(crate) fn retire_icon_bytes(slot: SlotId) {
ICON_BYTES.with(|table| {
table.borrow_mut().remove(&slot);
});
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum IconSource {
Symbol(String),
Bytes(Arc<[u8]>),
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TabItemProps {
pub(crate) title: String,
pub(crate) icon: Option<IconSource>,
pub(crate) selected_icon: Option<IconSource>,
pub(crate) badge: Option<String>,
pub(crate) enabled: bool,
}
pub(crate) struct TabBar;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TabBarProps {
pub(crate) slot: SlotId,
pub(crate) items: Vec<TabItemProps>,
pub(crate) selected: Option<usize>,
pub(crate) tint: Option<i32>,
pub(crate) unselected_tint: Option<i32>,
pub(crate) background: Option<i32>,
}
impl TabBarProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
items: Vec::new(),
selected: None,
tint: None,
unselected_tint: None,
background: None,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
let slot = slot_of(params)?;
let count = params
.int(ITEM_COUNT)
.and_then(|count| usize::try_from(count).ok())
.unwrap_or(0)
.min(MAX_ITEMS);
let bytes = icon_bytes(slot);
let items: Vec<TabItemProps> = (0..count)
.map(|index| {
let published = bytes.as_ref().and_then(|all| all.get(index));
let symbol = |field: &str| {
owned_text(params, &item_key(index, field))
.filter(|name| !name.is_empty())
.map(IconSource::Symbol)
};
TabItemProps {
title: owned_text(params, &item_key(index, FIELD_TITLE)).unwrap_or_default(),
icon: published
.and_then(|b| b.icon.clone())
.map(IconSource::Bytes)
.or_else(|| symbol(FIELD_SYMBOL)),
selected_icon: published
.and_then(|b| b.selected_icon.clone())
.map(IconSource::Bytes)
.or_else(|| symbol(FIELD_SELECTED_SYMBOL)),
badge: owned_text(params, &item_key(index, FIELD_BADGE)),
enabled: params.flag(&item_key(index, FIELD_ENABLED)).unwrap_or(true),
}
})
.collect();
let selected = params
.int(SELECTED)
.and_then(|index| usize::try_from(index).ok())
.filter(|&index| index < items.len());
Ok(Self {
slot,
items,
selected,
tint: color(params, TINT),
unselected_tint: color(params, UNSELECTED_TINT),
background: color(params, BACKGROUND_COLOR),
})
}
pub(crate) fn plan<'a>(old: &Self, new: &'a Self, observed: Option<usize>) -> Plan<'a> {
let mut plan = Plan::new();
let items_changed = old.items != new.items;
if items_changed {
plan.push(Setter::TabItems(&new.items));
}
let drifted = observed.is_some_and(|platform| Some(platform) != new.selected);
if items_changed || old.selected != new.selected || drifted {
plan.push(Setter::SelectedTab(new.selected));
}
if old.tint != new.tint {
plan.push(Setter::TabBarTint(new.tint));
}
if old.unselected_tint != new.unselected_tint {
plan.push(Setter::TabBarUnselectedTint(new.unselected_tint));
}
if old.background != new.background {
plan.push(Setter::TabBarBackground(new.background));
}
plan
}
}
pub(crate) fn decode_event(
observed: &mut Option<usize>,
item_count: usize,
event: NativeEvent,
) -> Option<EventPayload> {
let reselect = match event.kind {
EVENT_KIND_SELECTION => false,
EVENT_KIND_RESELECTED => true,
_ => return None,
};
let index = unpack_index(event.detail).filter(|&index| index < item_count)?;
*observed = Some(index);
Some(if reselect {
EventPayload::Reselected(index)
} else {
EventPayload::Selected(index)
})
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use std::sync::Once;
use objc2::rc::Retained;
use objc2::{MainThreadMarker, MainThreadOnly};
use objc2_foundation::{NSArray, NSData, NSString};
use objc2_ui_kit::{UIImage, UITabBar, UITabBarAppearance, UITabBarItem};
use super::{
IconSource, KIND, TabBar, TabBarProps, TabItemProps, decode_event, icon_decode_scale,
};
use crate::NativeWidgetError;
use crate::apple::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::EventPayload;
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct TabBarState {
view: Retained<UITabBar>,
target: Retained<FrustNativeControlTarget>,
observed: Option<usize>,
item_count: usize,
}
impl NativeWidget for TabBar {
type Props = TabBarProps;
type State = TabBarState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
TabBarProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UITabBar::new(mtm);
install_appearance(mtm, &view, None);
let target = FrustNativeControlTarget::tab_bar_target(mtm, props.slot);
let plan = TabBarProps::plan(&TabBarProps::platform_default(props.slot), props, None);
apply_all(mtm, &view, &target, &plan);
target.attach_tab_bar(&view);
let handle = NativeView::new(Retained::clone(&view).into_super(), mtm);
Ok((
handle,
TabBarState {
view,
target,
observed: None,
item_count: props.items.len(),
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = TabBarProps::plan(old, new, state.observed);
apply_all(ctx.mtm(), &state.view, &state.target, &plan);
state.item_count = new.items.len();
state.observed = None;
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_event(&mut state.observed, state.item_count, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach_tab_bar(&state.view);
Ok(())
}
}
fn apply_all(
mtm: MainThreadMarker,
view: &UITabBar,
target: &FrustNativeControlTarget,
plan: &Plan<'_>,
) {
for setter in plan {
apply(mtm, view, target, setter);
}
}
fn apply(
mtm: MainThreadMarker,
view: &UITabBar,
target: &FrustNativeControlTarget,
setter: &Setter<'_>,
) {
match *setter {
Setter::TabItems(items) => {
let built: Vec<Retained<UITabBarItem>> = items
.iter()
.enumerate()
.map(|(index, item)| build_item(mtm, index, item))
.collect();
view.setItems_animated(Some(&NSArray::from_retained_slice(&built)), false);
}
Setter::SelectedTab(selected) => {
let item = selected.and_then(|index| {
view.items()
.filter(|items| index < items.count())
.map(|items| items.objectAtIndex(index))
});
view.setSelectedItem(item.as_deref());
target.note_tab_showing(item.and(selected));
}
Setter::TabBarTint(argb) => {
let color = platform::optional_ui_color(argb);
unsafe { view.setTintColor(color.as_deref()) };
}
Setter::TabBarUnselectedTint(argb) => {
view.setUnselectedItemTintColor(platform::optional_ui_color(argb).as_deref());
}
Setter::TabBarBackground(argb) => install_appearance(mtm, view, argb),
ref other => platform::warn_unexpected_setter(KIND, other),
}
}
fn install_appearance(mtm: MainThreadMarker, view: &UITabBar, background: Option<i32>) {
let appearance = UITabBarAppearance::init(UITabBarAppearance::alloc(mtm));
match background {
Some(argb) => {
appearance.configureWithOpaqueBackground();
appearance.setBackgroundColor(Some(&platform::ui_color(argb)));
}
None => appearance.configureWithDefaultBackground(),
}
view.setStandardAppearance(&appearance);
view.setScrollEdgeAppearance(Some(&appearance));
}
fn build_item(
mtm: MainThreadMarker,
index: usize,
item: &TabItemProps,
) -> Retained<UITabBarItem> {
let title = NSString::from_str(&item.title);
let image = item.icon.as_ref().and_then(ui_image);
let built = UITabBarItem::initWithTitle_image_tag(
UITabBarItem::alloc(mtm),
Some(&title),
image.as_deref(),
index as isize,
);
if let Some(selected) = item.selected_icon.as_ref().and_then(ui_image) {
built.setSelectedImage(Some(&selected));
}
let badge = item.badge.as_deref().map(NSString::from_str);
built.setBadgeValue(badge.as_deref());
built.setEnabled(item.enabled);
built
}
fn ui_image(source: &IconSource) -> Option<Retained<UIImage>> {
let image = match source {
IconSource::Symbol(name) => UIImage::systemImageNamed(&NSString::from_str(name)),
IconSource::Bytes(bytes) => {
let data = NSData::with_bytes(bytes);
UIImage::imageWithData(&data).and_then(|probe| {
let size = unsafe { probe.size() };
let scale = icon_decode_scale(size.width, size.height);
if scale == 1.0 {
Some(probe)
} else {
UIImage::imageWithData_scale(&data, scale)
}
})
}
};
if image.is_none() {
warn_icon_unavailable_once();
}
image
}
fn warn_icon_unavailable_once() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
log::warn!(
"frust-native-widgets: a native_tab_bar icon produced no image (an unknown SF \
Symbol name, or bytes UIImage cannot decode) — the item shows its title only"
);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::controls::Tier;
use crate::events::pack_index;
use crate::runtime::with_identity;
fn decode_at(slot: SlotId, body: &str) -> TabBarProps {
let raw = with_identity(KIND, slot, body);
TabBarProps::decode(&Params::new(&raw)).expect("decodes")
}
fn decode(body: &str) -> TabBarProps {
decode_at(5, body)
}
const THREE: &str = "\"itemCount\":3,\"tab0Title\":\"Home\",\"tab0Symbol\":\"house\",\
\"tab1Title\":\"Search\",\"tab1Symbol\":\"magnifyingglass\",\"tab1Badge\":\"3\",\
\"tab2Title\":\"Profile\",\"tab2Enabled\":false";
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, TabBarProps::platform_default(5));
assert!(TabBarProps::plan(&TabBarProps::platform_default(5), &props, None).is_empty());
}
#[test]
fn items_decode_from_the_flat_count_and_indexed_keys() {
let props = decode(&format!("{THREE},\"selected\":1"));
assert_eq!(props.items.len(), 3);
assert_eq!(props.items[0].title, "Home");
assert_eq!(
props.items[0].icon,
Some(IconSource::Symbol("house".into()))
);
assert_eq!(props.items[0].selected_icon, None);
assert_eq!(props.items[1].badge.as_deref(), Some("3"));
assert!(props.items[1].enabled);
assert!(!props.items[2].enabled);
assert_eq!(props.items[2].icon, None);
assert_eq!(props.selected, Some(1));
}
#[test]
fn the_count_is_capped_and_an_out_of_range_selection_is_none() {
assert_eq!(decode("\"itemCount\":100000").items.len(), MAX_ITEMS);
assert!(decode("\"itemCount\":-2").items.is_empty());
assert_eq!(decode(&format!("{THREE},\"selected\":3")).selected, None);
assert_eq!(decode(&format!("{THREE},\"selected\":-1")).selected, None);
assert_eq!(decode("\"selected\":0").selected, None);
}
#[test]
fn an_empty_symbol_name_is_no_icon() {
let props = decode("\"itemCount\":1,\"tab0Symbol\":\"\"");
assert_eq!(props.items[0].icon, None);
}
#[test]
fn the_create_plan_is_items_then_selection_then_colours() {
let props = decode(&format!(
"{THREE},\"selected\":2,\"tint\":1,\"unselectedTint\":2,\"backgroundColor\":3"
));
assert_eq!(
TabBarProps::plan(&TabBarProps::platform_default(5), &props, None),
vec![
Setter::TabItems(&props.items),
Setter::SelectedTab(Some(2)),
Setter::TabBarTint(Some(1)),
Setter::TabBarUnselectedTint(Some(2)),
Setter::TabBarBackground(Some(3)),
]
);
}
#[test]
fn a_selection_change_alone_moves_selected_item_without_rebuilding_items() {
let old = decode(&format!("{THREE},\"selected\":0"));
let new = decode(&format!("{THREE},\"selected\":1"));
assert_eq!(
TabBarProps::plan(&old, &new, None),
vec![Setter::SelectedTab(Some(1))]
);
assert!(
TabBarProps::plan(&new, &new, None).is_empty(),
"unchanged props plan nothing"
);
}
#[test]
fn a_badge_change_rebuilds_the_items_and_reasserts_the_selection() {
let old = decode(&format!("{THREE},\"selected\":1"));
let new = decode(&format!("{THREE},\"selected\":1,\"tab0Badge\":\"new\""));
assert_eq!(
TabBarProps::plan(&old, &new, None),
vec![Setter::TabItems(&new.items), Setter::SelectedTab(Some(1))],
"a rebuilt array holds new UITabBarItems the old selectedItem does not name"
);
}
#[test]
fn a_platform_drift_is_written_back() {
let old = decode(&format!("{THREE},\"selected\":0"));
let new = decode(&format!("{THREE},\"selected\":0,\"tint\":4"));
assert_eq!(
TabBarProps::plan(&old, &new, Some(2)),
vec![Setter::SelectedTab(Some(0)), Setter::TabBarTint(Some(4))]
);
assert_eq!(
TabBarProps::plan(&old, &new, Some(0)),
vec![Setter::TabBarTint(Some(4))],
"an observed value matching the app's is no drift"
);
}
#[test]
fn clearing_a_colour_plans_the_nullable_setter() {
let themed = decode("\"tint\":1,\"unselectedTint\":2,\"backgroundColor\":3");
let plain = decode("");
assert_eq!(
TabBarProps::plan(&themed, &plain, None),
vec![
Setter::TabBarTint(None),
Setter::TabBarUnselectedTint(None),
Setter::TabBarBackground(None),
]
);
}
#[test]
fn every_planned_setter_reports_its_documented_tier() {
let props = decode(&format!(
"{THREE},\"selected\":1,\"tint\":1,\"unselectedTint\":2,\"backgroundColor\":3"
));
for setter in TabBarProps::plan(&TabBarProps::platform_default(5), &props, None) {
let expected = if matches!(setter, Setter::TabItems(_)) {
Tier::Relayout
} else {
Tier::Cheap
};
assert_eq!(setter.tier(), expected, "{setter:?}");
}
}
fn bytes(content: &[u8]) -> Arc<[u8]> {
Arc::from(content.to_vec().into_boxed_slice())
}
#[test]
fn byte_icons_decode_from_the_side_table_and_win_over_symbols() {
let slot = 9_001;
let rev = publish_icon_bytes(
slot,
vec![
ItemIconBytes {
icon: Some(bytes(&[1, 2])),
selected_icon: Some(bytes(&[3])),
},
ItemIconBytes::default(),
],
);
assert!(rev > 0);
let props = decode_at(
slot,
&format!(
"\"itemCount\":2,\"iconsRev\":{rev},\"tab0Symbol\":\"house\",\
\"tab1Symbol\":\"gear\""
),
);
assert_eq!(props.items[0].icon, Some(IconSource::Bytes(bytes(&[1, 2]))));
assert_eq!(
props.items[0].selected_icon,
Some(IconSource::Bytes(bytes(&[3])))
);
assert_eq!(props.items[1].icon, Some(IconSource::Symbol("gear".into())));
retire_icon_bytes(slot);
assert!(icon_bytes(slot).is_none());
}
#[test]
fn the_same_content_republishes_at_the_same_revision() {
let slot = 9_002;
let first = publish_icon_bytes(
slot,
vec![ItemIconBytes {
icon: Some(bytes(&[7, 7])),
selected_icon: None,
}],
);
let again = publish_icon_bytes(
slot,
vec![ItemIconBytes {
icon: Some(bytes(&[7, 7])),
selected_icon: None,
}],
);
assert_eq!(first, again, "content-equal icons keep their revision");
let changed = publish_icon_bytes(
slot,
vec![ItemIconBytes {
icon: Some(bytes(&[8])),
selected_icon: None,
}],
);
assert_ne!(changed, first);
retire_icon_bytes(slot);
}
#[test]
fn a_bar_with_no_byte_icons_publishes_nothing() {
let slot = 9_003;
publish_icon_bytes(
slot,
vec![ItemIconBytes {
icon: Some(bytes(&[1])),
selected_icon: None,
}],
);
assert_eq!(
publish_icon_bytes(slot, vec![ItemIconBytes::default(); 3]),
0
);
assert!(
icon_bytes(slot).is_none(),
"a symbol-only bar holds no table entry"
);
}
#[test]
fn byte_icons_normalize_into_the_point_box() {
assert_eq!(icon_decode_scale(25.0, 25.0), 1.0);
assert_eq!(icon_decode_scale(16.0, 10.0), 1.0, "never upscaled");
assert_eq!(icon_decode_scale(75.0, 75.0), 3.0);
assert_eq!(
icon_decode_scale(50.0, 100.0),
4.0,
"the longest side decides"
);
assert_eq!(icon_decode_scale(f64::NAN, 10.0), 1.0);
assert_eq!(icon_decode_scale(0.0, 0.0), 1.0);
}
#[test]
fn a_tap_on_another_item_is_a_selection_and_on_the_showing_one_a_reselect() {
assert_eq!(tap_kind(Some(0), 1), EVENT_KIND_SELECTION);
assert_eq!(tap_kind(None, 0), EVENT_KIND_SELECTION);
assert_eq!(tap_kind(Some(2), 2), EVENT_KIND_RESELECTED);
}
#[test]
fn a_tab_the_app_rejects_stays_a_selection_when_retapped_not_a_reselect() {
let confirmed = Some(0);
assert_eq!(tap_kind(confirmed, 1), EVENT_KIND_SELECTION);
assert_eq!(tap_kind(confirmed, 1), EVENT_KIND_SELECTION);
}
#[test]
fn retapping_the_confirmed_tab_after_a_rejected_tap_elsewhere_is_a_reselect() {
let confirmed = Some(0);
assert_eq!(tap_kind(confirmed, 1), EVENT_KIND_SELECTION);
assert_eq!(tap_kind(confirmed, 0), EVENT_KIND_RESELECTED);
}
fn event(kind: i32, index: isize) -> NativeEvent {
NativeEvent {
kind,
detail: pack_index(index),
}
}
#[test]
fn selection_and_reselect_decode_and_update_the_drift_signal() {
let mut observed = None;
assert_eq!(
decode_event(&mut observed, 3, event(EVENT_KIND_SELECTION, 2)),
Some(EventPayload::Selected(2))
);
assert_eq!(observed, Some(2));
assert_eq!(
decode_event(&mut observed, 3, event(EVENT_KIND_RESELECTED, 2)),
Some(EventPayload::Reselected(2))
);
assert_eq!(observed, Some(2));
}
#[test]
fn an_index_past_the_live_items_or_negative_decodes_to_nothing() {
let mut observed = Some(1);
assert_eq!(
decode_event(&mut observed, 3, event(EVENT_KIND_SELECTION, 3)),
None
);
assert_eq!(
decode_event(&mut observed, 3, event(EVENT_KIND_RESELECTED, -1)),
None
);
assert_eq!(observed, Some(1), "left untouched");
}
#[test]
fn a_misrouted_kind_decodes_to_nothing() {
let mut observed = None;
assert_eq!(
decode_event(&mut observed, 3, event(crate::events::EVENT_KIND_CLICK, 0)),
None
);
assert_eq!(observed, None);
}
}