use std::collections::HashSet;
use std::os::raw::c_void;
use std::sync::atomic::{AtomicU64, Ordering};
use bevy::ecs::component::Mutable;
use bevy::ecs::relationship::RelationshipTarget;
use bevy::prelude::*;
use indexmap::IndexMap;
use noesis_runtime::binding::ObservableCollection;
use noesis_runtime::classes::{
ClassBuilder, ClassInstance, ClassRegistration, Instance, PropertyChangeHandler, PropertyValue,
};
use noesis_runtime::ffi::{ClassBase, PropType};
use noesis_runtime::view::FrameworkElement;
use crate::plain_vm::{NoesisViewModel, PlainType, PlainValue};
use crate::render::{NoesisRenderState, NoesisSet, NoesisView, ReapOnRemove, add_bridge_reap};
pub(crate) const ENTITY_FIELD: &str = "__entity";
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
#[relationship(relationship_target = ListRows)]
pub struct ListedIn(
#[entities]
pub Entity,
);
#[derive(Component, Default, Debug)]
#[relationship_target(relationship = ListedIn)]
pub struct ListRows(Vec<Entity>);
#[derive(Component, Clone, Copy, Debug, Default)]
pub struct Selected;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ListSort {
pub field: u32,
pub descending: bool,
}
static LIST_CLASS_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Component, Clone, Debug)]
#[require(ListDesired)]
pub struct UiList {
pub view: Entity,
pub name: String,
pub class: String,
pub sort: Option<ListSort>,
}
impl UiList {
#[must_use]
pub fn new(view: Entity, name: impl Into<String>) -> Self {
let seq = LIST_CLASS_SEQ.fetch_add(1, Ordering::Relaxed);
Self {
view,
name: name.into(),
class: format!("DmList.{seq}"),
sort: None,
}
}
#[must_use]
pub fn with_class(mut self, class: impl Into<String>) -> Self {
self.class = class.into();
self
}
#[must_use]
pub fn sorted_by(mut self, field: u32, descending: bool) -> Self {
self.sort = Some(ListSort { field, descending });
self
}
}
#[derive(Message, Debug, Clone)]
pub struct NoesisListSelection {
pub view: Entity,
pub list: String,
pub selected: Option<Entity>,
}
#[derive(EntityEvent, Debug, Clone)]
pub struct NoesisRowSelected {
pub entity: Entity,
pub view: Entity,
pub list: String,
}
#[derive(Message, Debug, Clone)]
pub struct NoesisListOps {
pub view: Entity,
pub list: String,
pub adds: usize,
pub removes: usize,
pub updates: usize,
pub moves: usize,
}
#[derive(Clone, Debug)]
pub(crate) struct DesiredRow {
pub(crate) entity: Entity,
pub(crate) fields: Vec<PlainValue>,
}
#[derive(Component, Default)]
pub(crate) struct ListDesired {
pub(crate) rows: Vec<DesiredRow>,
pub(crate) schema: &'static [(&'static str, PlainType)],
pub(crate) selected: Option<Entity>,
pub(crate) row_type: Option<core::any::TypeId>,
}
struct NoopRowHandler;
impl PropertyChangeHandler for NoopRowHandler {
fn on_changed(&self, _instance: Instance, _prop_index: u32, _value: PropertyValue<'_>) {}
}
struct RowSlot {
instance: ClassInstance,
last_fields: Vec<PlainValue>,
}
pub(crate) enum SelectionOutcome {
Unchanged,
UiSelected(Option<Entity>),
}
#[derive(Default, Clone, Copy)]
pub(crate) struct ListOps {
pub(crate) adds: usize,
pub(crate) removes: usize,
pub(crate) updates: usize,
pub(crate) moves: usize,
}
impl ListOps {
fn touched(self) -> bool {
self.adds + self.removes + self.updates + self.moves > 0
}
}
pub(crate) struct ListBinding {
coll: ObservableCollection,
control: Option<FrameworkElement>,
is_selector: bool,
rows: IndexMap<Entity, RowSlot>,
bound_for_uri: Option<String>,
entity_field_index: u32,
class_ready: bool,
last_currency: Option<Entity>,
selection_primed: bool,
registration: Option<ClassRegistration>,
}
impl Default for ListBinding {
fn default() -> Self {
Self::new()
}
}
impl ListBinding {
pub(crate) fn new() -> Self {
Self {
coll: ObservableCollection::new(),
control: None,
is_selector: false,
rows: IndexMap::new(),
bound_for_uri: None,
entity_field_index: 0,
class_ready: false,
last_currency: None,
selection_primed: false,
registration: None,
}
}
fn ensure_class(&mut self, class_name: &str, schema: &[(&'static str, PlainType)]) {
if self.class_ready {
return;
}
self.class_ready = true;
let mut builder = ClassBuilder::new(class_name, ClassBase::Freezable, NoopRowHandler);
for (name, kind) in schema {
builder.add_property(name, plain_to_prop_type(*kind));
}
self.entity_field_index = schema.len() as u32;
builder.add_property(ENTITY_FIELD, PropType::UInt64);
match builder.register() {
Some(reg) => self.registration = Some(reg),
None => error!(
"UiList: failed to register row class {class_name:?} \
(duplicate `with_class` name?); rows will not realize",
),
}
}
pub(crate) fn reconcile_into(
&mut self,
class_name: &str,
schema: &[(&'static str, PlainType)],
desired: &[DesiredRow],
) -> ListOps {
self.ensure_class(class_name, schema);
self.reconcile(desired)
}
fn reconcile(&mut self, desired: &[DesiredRow]) -> ListOps {
let mut ops = ListOps::default();
if self.registration.is_none() {
return ops;
}
let desired_set: HashSet<Entity> = desired.iter().map(|d| d.entity).collect();
let stale: Vec<usize> = self
.rows
.keys()
.enumerate()
.filter(|(_, e)| !desired_set.contains(e))
.map(|(i, _)| i)
.collect();
for i in stale.into_iter().rev() {
self.coll.remove_at(i);
self.rows.shift_remove_index(i);
ops.removes += 1;
}
for dr in desired {
if let Some(slot) = self.rows.get_mut(&dr.entity) {
let handle = slot.instance.handle();
let mut changed = false;
for (idx, value) in dr.fields.iter().enumerate() {
let differs = slot
.last_fields
.get(idx)
.is_none_or(|old| !values_eq(old, value));
if differs {
set_field(handle, idx as u32, value);
changed = true;
}
}
if changed {
slot.last_fields = dr.fields.clone();
ops.updates += 1;
}
}
}
let has_adds = desired.iter().any(|d| !self.rows.contains_key(&d.entity));
if has_adds {
self.place_with_adds(desired, &mut ops);
} else {
self.reorder_minimal(desired, &mut ops);
}
ops
}
fn place_with_adds(&mut self, desired: &[DesiredRow], ops: &mut ListOps) {
for (t, dr) in desired.iter().enumerate() {
if let Some(cur) = self.rows.get_index_of(&dr.entity) {
if cur != t {
self.coll.move_item(cur, t);
self.rows.move_index(cur, t);
ops.moves += 1;
}
} else if let Some(slot) = self.realize(dr) {
self.coll.insert_object(t, &slot.instance);
self.rows.shift_insert(t, dr.entity, slot);
ops.adds += 1;
}
}
}
fn reorder_minimal(&mut self, desired: &[DesiredRow], ops: &mut ListOps) {
let n = desired.len();
if n < 2 {
return;
}
let mut desired_pos = std::collections::HashMap::with_capacity(n);
for (i, dr) in desired.iter().enumerate() {
desired_pos.insert(dr.entity, i);
}
let cur: Vec<Entity> = self.rows.keys().copied().collect();
let seq: Vec<usize> = cur.iter().map(|e| desired_pos[e]).collect();
let anchored_positions = longest_increasing_subsequence(&seq);
let anchored: HashSet<Entity> = anchored_positions.iter().map(|&i| cur[i]).collect();
for t in (0..n).rev() {
let entity = desired[t].entity;
if anchored.contains(&entity) {
continue;
}
let cur = self.rows.get_index_of(&entity).expect("survivor present");
if cur != t {
self.coll.move_item(cur, t);
self.rows.move_index(cur, t);
ops.moves += 1;
}
}
}
fn realize(&self, dr: &DesiredRow) -> Option<RowSlot> {
let reg = self.registration.as_ref()?;
let instance = reg.create_instance()?;
let handle = instance.handle();
for (idx, value) in dr.fields.iter().enumerate() {
set_field(handle, idx as u32, value);
}
Some(RowSlot {
instance,
last_fields: dr.fields.clone(),
})
}
pub(crate) fn collection(&self) -> &ObservableCollection {
&self.coll
}
pub(crate) fn needs_bind(&self, uri: &str) -> bool {
self.bound_for_uri.as_deref() != Some(uri)
}
pub(crate) fn mark_bound(&mut self, uri: &str) {
self.bound_for_uri = Some(uri.to_owned());
}
pub(crate) fn reset_bind(&mut self) {
self.bound_for_uri = None;
self.control = None;
self.is_selector = false;
self.last_currency = None;
self.selection_primed = false;
}
pub(crate) fn detach(&mut self) {
if let Some(control) = self.control.as_mut() {
control.clear_items_source();
}
}
pub(crate) fn set_control(&mut self, control: FrameworkElement) {
self.is_selector = control.selected_index().is_some();
self.control = Some(control);
}
fn current_entity(&self) -> Option<Entity> {
let ptr: *mut c_void = self.control.as_ref()?.selected_item()?.as_ptr();
self.rows
.iter()
.find(|(_, slot)| std::ptr::eq(slot.instance.raw(), ptr))
.map(|(e, _)| *e)
}
pub(crate) fn poll_selection(&mut self, desired_selected: Option<Entity>) -> SelectionOutcome {
if !self.is_selector {
return SelectionOutcome::Unchanged;
}
let current = self.current_entity();
if !self.selection_primed {
self.selection_primed = true;
self.last_currency = current;
} else if current != self.last_currency {
self.last_currency = current;
return SelectionOutcome::UiSelected(current);
}
if desired_selected != current {
let index = match desired_selected {
Some(e) => self.rows.get_index_of(&e).map_or(-1, |i| i as i32),
None => -1,
};
if let Some(control) = self.control.as_mut() {
let _ = control.set_selected_index(index);
}
self.last_currency = self.current_entity();
}
SelectionOutcome::Unchanged
}
}
fn longest_increasing_subsequence(seq: &[usize]) -> Vec<usize> {
let n = seq.len();
if n == 0 {
return Vec::new();
}
let mut tails: Vec<usize> = Vec::new();
let mut prev = vec![usize::MAX; n];
for i in 0..n {
let mut lo = 0usize;
let mut hi = tails.len();
while lo < hi {
let mid = (lo + hi) / 2;
if seq[tails[mid]] < seq[i] {
lo = mid + 1;
} else {
hi = mid;
}
}
if lo > 0 {
prev[i] = tails[lo - 1];
}
if lo == tails.len() {
tails.push(i);
} else {
tails[lo] = i;
}
}
let mut out = Vec::with_capacity(tails.len());
let mut k = *tails.last().expect("non-empty seq has a tail");
loop {
out.push(k);
if prev[k] == usize::MAX {
break;
}
k = prev[k];
}
out.reverse();
out
}
fn plain_to_prop_type(kind: PlainType) -> PropType {
match kind {
PlainType::Int32 => PropType::Int32,
PlainType::Double => PropType::Double,
PlainType::Bool => PropType::Bool,
PlainType::String => PropType::String,
PlainType::U64 => PropType::UInt64,
PlainType::BaseComponent => PropType::BaseComponent,
}
}
fn set_field(handle: Instance, index: u32, value: &PlainValue) {
match value {
PlainValue::Int32(v) => handle.set_int32(index, *v),
PlainValue::Double(v) => handle.set_double(index, *v),
PlainValue::Bool(v) => handle.set_bool(index, *v),
PlainValue::String(v) => handle.set_string(index, v),
PlainValue::U64(v) => handle.set_u64(index, *v),
PlainValue::Null => {}
}
}
fn values_eq(a: &PlainValue, b: &PlainValue) -> bool {
match (a, b) {
(PlainValue::Int32(x), PlainValue::Int32(y)) => x == y,
(PlainValue::Double(x), PlainValue::Double(y)) => x == y || (x.is_nan() && y.is_nan()),
(PlainValue::Bool(x), PlainValue::Bool(y)) => x == y,
(PlainValue::String(x), PlainValue::String(y)) => x == y,
(PlainValue::U64(x), PlainValue::U64(y)) => x == y,
(PlainValue::Null, PlainValue::Null) => true,
_ => false,
}
}
fn compare_values(a: &PlainValue, b: &PlainValue) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (a, b) {
(PlainValue::Int32(x), PlainValue::Int32(y)) => x.cmp(y),
(PlainValue::Double(x), PlainValue::Double(y)) => {
x.partial_cmp(y).unwrap_or(Ordering::Equal)
}
(PlainValue::Bool(x), PlainValue::Bool(y)) => x.cmp(y),
(PlainValue::String(x), PlainValue::String(y)) => x.cmp(y),
(PlainValue::U64(x), PlainValue::U64(y)) => x.cmp(y),
_ => Ordering::Equal,
}
}
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum NoesisListSet {
Diff,
}
#[allow(clippy::needless_pass_by_value, clippy::type_complexity)]
fn diff_list<T: NoesisViewModel + Component>(
lists: Query<(Entity, &UiList, Option<&ListRows>)>,
rows: Query<(&T, Has<Selected>)>,
mut desired: Query<&mut ListDesired>,
) {
for (list_ent, list, list_rows) in &lists {
let Ok(mut slot) = desired.get_mut(list_ent) else {
continue;
};
let mut gathered: Vec<(Entity, Vec<PlainValue>, bool)> = list_rows
.into_iter()
.flat_map(RelationshipTarget::iter)
.filter_map(|entity| {
let (data, selected) = rows.get(entity).ok()?;
let mut fields = data.noesis_snapshot();
fields.push(PlainValue::U64(entity.to_bits()));
Some((entity, fields, selected))
})
.collect();
let this = core::any::TypeId::of::<T>();
if gathered.is_empty() && slot.row_type != Some(this) {
continue;
}
if let Some(sort) = list.sort {
let field = sort.field as usize;
gathered.sort_by(|(_, a, _), (_, b, _)| {
let ord = match (a.get(field), b.get(field)) {
(Some(x), Some(y)) => compare_values(x, y),
_ => std::cmp::Ordering::Equal,
};
if sort.descending { ord.reverse() } else { ord }
});
}
if let Some(prev) = slot.row_type
&& prev != this
{
debug_assert!(
false,
"UiList {list_ent:?}: two row component types target one list \
(last-writer-wins); use one row type per UiList",
);
bevy::log::warn_once!(
"UiList: multiple row component types target list {list_ent:?}; \
only one row type per UiList is supported (last-writer-wins)",
);
}
slot.schema = T::noesis_properties();
slot.row_type = Some(this);
let selected: Vec<Entity> = gathered
.iter()
.filter(|(_, _, selected)| *selected)
.map(|(e, _, _)| *e)
.collect();
if selected.len() > 1 {
bevy::log::warn_once!(
"UiList {list_ent:?}: {} rows carry Selected; a list has one \
selection — driving the lowest entity",
selected.len(),
);
}
slot.selected = selected.into_iter().min();
slot.rows = gathered
.into_iter()
.map(|(entity, fields, _)| DesiredRow { entity, fields })
.collect();
}
}
#[allow(clippy::needless_pass_by_value, clippy::type_complexity)]
fn sync_lists(
lists: Query<(Entity, &UiList, &ListDesired)>,
alive_views: Query<(), With<NoesisView>>,
selected_rows: Query<(Entity, &ListedIn), With<Selected>>,
state: Option<NonSendMut<NoesisRenderState>>,
click_queue: Res<crate::events::SharedClickQueue>,
mut commands: Commands,
mut ops_writer: MessageWriter<NoesisListOps>,
mut sel_writer: MessageWriter<NoesisListSelection>,
) {
let Some(mut state) = state else {
return;
};
for (list_ent, list, desired) in &lists {
if desired.row_type.is_none() {
continue;
}
if alive_views.get(list.view).is_err() {
continue;
}
let (ops, selection) = state.apply_list_for(
list_ent,
list.view,
&list.name,
&list.class,
desired.schema,
&desired.rows,
desired.selected,
&click_queue,
);
if ops.touched() {
ops_writer.write(NoesisListOps {
view: list.view,
list: list.name.clone(),
adds: ops.adds,
removes: ops.removes,
updates: ops.updates,
moves: ops.moves,
});
}
if let SelectionOutcome::UiSelected(selected) = selection {
for (entity, listed) in &selected_rows {
if listed.0 == list_ent {
commands.entity(entity).remove::<Selected>();
}
}
if let Some(entity) = selected {
commands.entity(entity).insert(Selected);
commands.trigger(NoesisRowSelected {
entity,
view: list.view,
list: list.name.clone(),
});
}
sel_writer.write(NoesisListSelection {
view: list.view,
list: list.name.clone(),
selected,
});
}
}
}
#[allow(clippy::needless_pass_by_value)]
fn despawn_orphan_lists(
mut removed: RemovedComponents<NoesisView>,
lists: Query<(Entity, &UiList)>,
mut commands: Commands,
) {
let gone: HashSet<Entity> = removed.read().collect();
if gone.is_empty() {
return;
}
for (list_ent, list) in &lists {
if gone.contains(&list.view) {
commands.entity(list_ent).despawn();
}
}
}
pub trait NoesisListAppExt {
fn add_noesis_list<T: NoesisViewModel + Component<Mutability = Mutable>>(
&mut self,
) -> &mut Self;
}
impl NoesisListAppExt for App {
fn add_noesis_list<T: NoesisViewModel + Component<Mutability = Mutable>>(
&mut self,
) -> &mut Self {
self.add_systems(PostUpdate, diff_list::<T>.in_set(NoesisListSet::Diff));
self
}
}
impl ReapOnRemove for UiList {
fn reap(state: &mut NoesisRenderState, entity: Entity) {
state.reap_list_for(entity);
}
}
#[derive(Default)]
pub struct NoesisListPlugin;
impl Plugin for NoesisListPlugin {
fn build(&self, app: &mut App) {
app.add_message::<NoesisListOps>();
app.add_message::<NoesisListSelection>();
app.configure_sets(PostUpdate, NoesisListSet::Diff.before(NoesisSet::Apply));
app.add_systems(PostUpdate, sync_lists.in_set(NoesisSet::Apply));
app.add_systems(PostUpdate, despawn_orphan_lists.in_set(NoesisSet::Ensure));
add_bridge_reap::<UiList>(app);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lis_picks_longest_run() {
let positions = longest_increasing_subsequence(&[2, 3, 1, 0]);
let values: Vec<usize> = positions.iter().map(|&i| [2, 3, 1, 0][i]).collect();
assert_eq!(values, vec![2, 3]);
}
#[test]
fn lis_identity_anchors_everything() {
let positions = longest_increasing_subsequence(&[0, 1, 2, 3]);
assert_eq!(positions, vec![0, 1, 2, 3]);
}
#[test]
fn lis_full_reverse_anchors_one() {
let positions = longest_increasing_subsequence(&[3, 2, 1, 0]);
assert_eq!(positions.len(), 1);
}
#[test]
fn compare_orders_primitives() {
use std::cmp::Ordering;
assert_eq!(
compare_values(&PlainValue::Int32(1), &PlainValue::Int32(2)),
Ordering::Less,
);
assert_eq!(
compare_values(
&PlainValue::String("b".into()),
&PlainValue::String("a".into())
),
Ordering::Greater,
);
}
#[test]
fn ui_list_builder_sets_sort() {
let list = UiList::new(Entity::PLACEHOLDER, "Inv").sorted_by(1, true);
assert_eq!(list.name, "Inv");
assert_eq!(
list.sort,
Some(ListSort {
field: 1,
descending: true
})
);
}
#[test]
fn ui_list_auto_class_is_unique() {
let a = UiList::new(Entity::PLACEHOLDER, "Inv");
let b = UiList::new(Entity::PLACEHOLDER, "Inv");
assert_ne!(
a.class, b.class,
"auto-generated row classes must be unique"
);
assert!(a.class.starts_with("DmList."), "got {:?}", a.class);
let c = UiList::new(Entity::PLACEHOLDER, "Inv").with_class("Game.Row");
assert_eq!(c.class, "Game.Row");
}
}