cocopon / tweakpane

:control_knobs: Compact GUI for fine-tuning parameters and monitoring value changes
https://tweakpane.github.io/docs/
MIT License
3.57k stars 90 forks source link

Feat: support custom passing a custom BindingTarget #607

Closed davelsan closed 6 months ago

davelsan commented 6 months ago

Sometimes it would be nice to integrate tweakpane with a custom object/state management solution. The on('change') API works for most cases, but it breaks in certain circumstances.

I've detailed a custom plugin solution in #606. Here I'd like to suggest an alternative approach that involves passing an extended BindingTarget.

Problem

I'm going present one concrete use case. Let's consider a configuration object to be reused by tweakpane and zustand as state management.

import { subscribeWithSelector } from 'zustand/middleware';
import { createStore } from 'zustand/vanilla';

const state = {
  uvDisplacementOffset: 5.0,
  uvStrengthOffset: 5.0,
  // other props...
};

const store = createStore(subscribeWithSelector<typeof state>(() => state));

const pane = new Pane({ title: 'Debug Options' });
const binding = pane.addBinding(store.state, 'uvDisplacementOffset', {
  // options...
});
binding.on('change', ({ value, target }) => store.setState({ [target.key]: value }));

Now the store is type-safe and I can use it like this:

store.subscribe(
  (state) => [state.uvDisplacementOffset, state.uvStrengthOffset], // selector
  ([state.uvDisplacementOffset, state.uvStrengthOffset]) => { /* do something */ }, // callback
  {
    // optional equality comparison function
    equalityFn: (prev, next) => prev[0] === next[0] && prev[1] === next[1],
  }
);

However, that equality function is now efectively broken, because internally the BindingTarget class is going to write the prop before the change event fires. So the previous and next values are always the same and the callback never fires.

In addition, monitor inputs will never update from other setState calls.

Proposed solution

The BindingTarget class is already exported by the core package. I could then extend it and override the read, write, and writeProperty methods so they leverage setState instead.

import {
  Bindable,
  BindingTarget,
} from '@tweakpane/core';

class CustomBindingTarget extends BindingTarget<T extends Bindable, K extends keyof T> {
  public readonly key: K;
  private readonly obj_: T;

  constructor(obj: T, key: K) {
    this.obj_ = obj;
    this.key = key;
  }

  public read(): T[K]

  public write(value: T[K]): void

  public writeProperty(name: K, value: T[K]): void
} 

const bindingTarget = new CustomBindingTarget(store.getState());

I'm not sure about the API implementation. It could be integrated within addBinding or perhaps create a new addBindingTarget method.

From what I could see, a base implementation without generics would not be too complicated. But things get muddier when making BindingTarget<T, K> a generic. I'd have to propagate the types along a bunch of other methods within the RackApi class and beyond.

Thoughts? Is this something you would consider, maybe with a POC to see how it works?