changeset 2246:5c269c03863d

WIP device settings page can now load and save ok. Omitted GraphToControls for now
author drewp@bigasterisk.com
date Sat, 27 May 2023 01:14:45 -0700
parents a1f6f3139995
children f5e4aa36985d
files light9/live/Effect.ts light9/live/Light9AttrControl.ts light9/live/Light9DeviceControl.ts light9/live/Light9DeviceSettings.ts light9/live/Light9LiveControl.ts light9/live/Light9LiveControls.ts light9/live/index.html light9/web/light9-color-picker.ts
diffstat 8 files changed, 507 insertions(+), 399 deletions(-) [+]
line wrap: on
line diff
--- a/light9/live/Effect.ts	Fri May 26 23:07:40 2023 -0700
+++ b/light9/live/Effect.ts	Sat May 27 01:14:45 2023 -0700
@@ -4,6 +4,7 @@
 import { Patch, patchContainsPreds, patchUpdate } from "../web/patch";
 import { SyncedGraph } from "../web/SyncedGraph";
 import { shortShow } from "../web/show_specific";
+import { SubEvent } from "sub-events";
 
 // todo: Align these names with newtypes.py, which uses HexColor and VTUnion.
 type Color = string;
@@ -15,11 +16,13 @@
   return typeof x == "object" && x.termType == "NamedNode";
 }
 
+// todo: eliminate this. address the scaling when we actually scale
+// stuff, instead of making a mess of every setting
 function valuePred(graph: SyncedGraph, attr: NamedNode): NamedNode {
   const U = graph.U();
   const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
   if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
-    return U(":scaledValue");
+    return U(":value");
   } else {
     return U(":value");
   }
@@ -29,11 +32,12 @@
 export class Effect {
   private settings: Array<{ device: NamedNode; deviceAttr: NamedNode; setting: NamedNode; value: ControlValue }> = [];
   private ctxForEffect: NamedNode
+  settingsChanged:SubEvent<void>=new SubEvent()
   constructor(
     public graph: SyncedGraph,
     public uri: NamedNode,
     // called if the graph changes our values and not when the caller uses edit()
-    private onValuesChanged: (values: void) => void
+    // private onValuesChanged: (values: void) => void
   ) {
     this.ctxForEffect = this.graph.Uri(this.uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
     graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
@@ -61,7 +65,7 @@
       //   return;
     }
 
-    // log("syncFromGraph", this.uri);
+    log("syncFromGraph", this.uri);
 
     // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
     const newSettings = [];
@@ -69,7 +73,7 @@
     const seenDevAttrPairs: Set<string> = new Set();
 
     for (let setting of Array.from(this.graph.objects(this.uri, U(":setting")))) {
-      //   log(`  setting ${setting.value}`);
+        log(`  setting ${setting.value}`);
       if (!isUri(setting)) throw new Error();
       let value: ControlValue;
       const device = this.graph.uriValue(setting, U(":device"));
@@ -92,9 +96,11 @@
 
       newSettings.push({ device, deviceAttr, setting, value });
     }
+    log(newSettings)
     this.settings = newSettings;
     log(`rebuild to ${this.settings.length}`);
-    this.onValuesChanged();
+    this.settingsChanged.emit()
+    // this.onValuesChanged();
   }
 
   currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Light9AttrControl.ts	Sat May 27 01:14:45 2023 -0700
@@ -0,0 +1,260 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { Literal, NamedNode } from "n3";
+import { SubEvent } from "sub-events";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { ControlValue, Effect } from "./Effect";
+import { GraphToControls } from "./GraphToControls";
+import { DeviceAttrRow } from "./Light9DeviceControl";
+import { Choice } from "./Light9Listbox";
+import { getTopGraph } from "../web/RdfdbSyncedGraph";
+export { Slider } from "@material/mwc-slider";
+export { Light9ColorPicker } from "../web/light9-color-picker";
+
+const log = debug("settings.dev.attr");
+
+type DataTypeNames = "scalar" | "color" | "choice";
+const makeType = (d: DataTypeNames) => new NamedNode(`http://light9.bigasterisk.com/${d}`);
+
+// UI for one device attr (of any type).
+@customElement("light9-attr-control")
+export class Light9AttrControl extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      #colorControls {
+        display: flex;
+        align-items: center;
+      }
+      #colorControls > * {
+        margin: 0 3px;
+      }
+      :host {
+        border: 2px solid white;
+      }
+      mwc-slider {
+        width: 250px;
+      }
+    `,
+  ];
+
+  @property() deviceAttrRow: DeviceAttrRow | null = null;
+  @state() dataType: DataTypeNames = "scalar";
+
+  @property() effect: Effect | null = null;
+  // we'll connect to this and receive graphValueChanged and send uiValueChanged
+  // @property() graphToControls!: GraphToControls;
+
+  @property() enableChange: boolean = false;
+  @property() value: ControlValue | null = null; // e.g. color string
+
+  // slider mode
+  // @property() sliderValue: number = 0;
+
+  // color mode
+
+  // choice mode
+  // @property() pickedChoice: Choice | null = null;
+  // @property() choiceValue: Choice | null = null;
+
+  valueChanged: SubEvent<Literal> = new SubEvent();
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      if (this.deviceAttrRow === null) throw new Error();
+      // this.graph.runHandler(this.graphReads.bind(this), `${this.deviceAttrRow.device} ${this.deviceAttrRow.uri} reads`);
+    });
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+  }
+
+  render() {
+    if (this.deviceAttrRow === null) throw new Error();
+    const dbg = html` live-control ${this.dataType}  ]`;
+    if (this.dataType == "scalar") {
+      const v = this.value || 0;
+      return html`${dbg} <mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onSliderInput}></mwc-slider> `;
+    } else if ((this.dataType = "color")) {
+      const v = this.value || '#000'
+      return html`
+        ${dbg}
+        <div id="colorControls">
+          <button on-click="goBlack">0.0</button>
+          <light9-color-picker .color=${v} @input=${this.onColorInput}></light9-color-picker>
+        </div>
+      `;
+    } else if (this.dataType == "choice") {
+      return html`${dbg} <light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.choiceValue}> </light9-listbox> `;
+    }
+  }
+
+  // graphReads() {
+  //   if (this.deviceAttrRow === null) throw new Error();
+  //   const U = this.graph.U();
+  //   this.effect?.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
+  // }
+
+  updated(changedProperties: PropertyValues<this>) {
+    super.updated(changedProperties);
+    // if (changedProperties.has("graphToControls")) {
+    //   // this.graphToControls.register(this.device, this.deviceAttrRow.uri, this.onGraphValueChanged.bind(this));
+    //   this.enableChange = true;
+    // }
+
+    if (changedProperties.has("deviceAttrRow")) {
+      this.onDeviceAttrRowProperty();
+    }
+    if (changedProperties.has("effect")) {
+      this.onEffectProperty();
+    }
+    if (changedProperties.has("value")) {
+      this.onValueProperty();
+    }
+  }
+
+  private onValueProperty() {
+    if (this.deviceAttrRow === null) throw new Error();
+    if (this.effect !== null && this.graph !== undefined) {
+      const p = this.effect.edit(this.deviceAttrRow.device, this.deviceAttrRow.uri, this.value);
+      log("patch", p, "to", this.graph);
+      if (p.adds.length || p.dels.length) {
+        this.graph.applyAndSendPatch(p);
+      }
+    }
+  }
+
+  private onEffectProperty() {
+    if (this.effect === null) throw new Error();
+    // effect will read graph changes on its own, but emit an event when it does
+    this.effect.settingsChanged.subscribe(() => {
+      this.effectSettingsChanged();
+    });
+    this.effectSettingsChanged();
+  }
+
+  private effectSettingsChanged() {
+    // anything in the settings graph is new
+    log("i check the effect current value");
+    if (this.deviceAttrRow === null) throw new Error();
+    if (this.effect === null) throw new Error();
+    log("graph->ui on ", this.deviceAttrRow.device, this.deviceAttrRow.uri);
+    const v=this.effect.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
+    this.onGraphValueChanged(v);
+  }
+
+  private onDeviceAttrRowProperty() {
+    if (this.deviceAttrRow === null) throw new Error();
+    const d = this.deviceAttrRow.dataType;
+    if (d.equals(makeType("scalar"))) {
+      this.dataType = "scalar";
+    } else if (d.equals(makeType("color"))) {
+      this.dataType = "color";
+    } else if (d.equals(makeType("choice"))) {
+      this.dataType = "choice";
+    }
+  }
+
+  onSliderInput(ev: CustomEvent) {
+    if (ev.detail === undefined) {
+      // not sure what this is, but it seems to be followed by good events
+      return;
+    }
+    log(ev.type, ev.detail.value);
+    this.value = ev.detail.value;
+    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
+  }
+
+onColorInput(ev: CustomEvent) {
+  this.value = ev.detail.value;
+}
+
+  onGraphValueChanged(v: ControlValue | null) {
+    if (this.deviceAttrRow === null) throw new Error();
+    log("change: control must display", v, "for", this.deviceAttrRow.device.value, this.deviceAttrRow.uri.value);
+    // this.enableChange = false;
+    if (this.dataType == "scalar") {
+      if (v !== null) {
+        setTimeout(() => {
+          // only needed once per page layout
+          this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
+        }, 1);
+        this.value = v;
+      } else {
+         this.value = 0;
+      }
+    } else if (this.dataType == "color") {
+      log('graph sets coolor', v)
+      this.value=v;
+    }
+    // if (v === null) {
+    //   this.clear();
+    // } else {
+    //   this.value = v;
+    // }
+    // if (this.deviceAttrRow.useChoice) {
+    //   this.choiceValue = v === null ? v : v.value;
+    // }
+    // this.enableChange = true;
+  }
+
+  graphToColor(v: ControlValue | null) {
+    this.value = v === null ? "#000" : v;
+    return;
+    // const cp = this.shadowRoot?.querySelector("light9-color-picker") as Light9ColorPicker | null;
+    // if (cp) {
+    //   if (typeof v != "string") throw new Error("type v is " + typeof v);
+    //   if (v === null) {
+    //     v = "#000";
+    //   }
+    //   cp.setColor(v as string);
+    // }
+  }
+
+  goBlack() {
+    this.value = "#000000";
+  }
+
+  onChoice(value: any) {
+    // if (this.graphToControls == null || !this.enableChange) {
+    //   return;
+    // }
+    // if (value != null) {
+    //   value = this.graph.Uri(value);
+    // } else {
+    //   value = null;
+    // }
+    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+  }
+
+  onChange(value: any) {
+    // if (this.graphToControls == null || !this.enableChange) {
+    //   return;
+    // }
+    // if (typeof value === "number" && isNaN(value)) {
+    //   return;
+    // } // let onChoice do it
+    // //log('change: control tells graph', @deviceAttrRow.uri.value, value)
+    // if (value === undefined) {
+    //   value = null;
+    // }
+    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+  }
+
+  // clear() {
+  //   this.pickedChoice = null;
+  //   this.sliderWriteValue = 0;
+  //   if (this.deviceAttrRow.useColor) {
+  //     return (this.value = "#000000");
+  //   } else if (this.deviceAttrRow.useChoice) {
+  //     return (this.value = this.pickedChoice = null);
+  //   } else {
+  //     return (this.value = this.sliderValue = 0);
+  //   }
+  // }
+}
--- a/light9/live/Light9DeviceControl.ts	Fri May 26 23:07:40 2023 -0700
+++ b/light9/live/Light9DeviceControl.ts	Sat May 27 01:14:45 2023 -0700
@@ -8,13 +8,15 @@
 import { SyncedGraph } from "../web/SyncedGraph";
 import { GraphToControls } from "./GraphToControls";
 import { Choice } from "./Light9Listbox";
-import { Light9LiveControl } from "./Light9LiveControl";
+import { Light9AttrControl } from "./Light9AttrControl";
+import { Effect } from "./Effect";
 export { ResourceDisplay } from "../web/ResourceDisplay";
-export { Light9LiveControl };
-const log = debug("devcontrol");
+export { Light9AttrControl };
+const log = debug("settings.dev");
 
 export interface DeviceAttrRow {
   uri: NamedNode; //devattr
+  device: NamedNode;
   attrClasses: string; // the css kind
   dataType: NamedNode;
   showColorPicker: boolean;
@@ -26,6 +28,7 @@
   max: number;
 }
 
+// Widgets for one device with multiple Light9LiveControl rows for the attr(s).
 @customElement("light9-device-control")
 export class Light9DeviceControl extends LitElement {
   graph!: SyncedGraph;
@@ -88,13 +91,13 @@
         ${this.deviceAttrs.map(
           (dattr: DeviceAttrRow) => html`
             <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
-              <span>attr <resource-display minor .uri="${dattr.uri}"></resource-display></span>
-              <light9-live-control
-                .device="${this.uri}"
-                .deviceAttrRow="${dattr}"
-                .effect="${this.effect}"
-                .graphToControls="${this.graphToControls}"
-              ></light9-live-control>
+              <span>
+                attr
+                <resource-display minor .uri=${dattr.uri}></resource-display>
+              </span>
+              <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}>
+                .graphToControls={this.graphToControls}
+              </light9-attr-control>
             </div>
           `
         )}
@@ -103,8 +106,8 @@
   }
 
   @property() uri!: NamedNode;
-  @property() effect!: NamedNode;
-  @property() graphToControls!: GraphToControls;
+  @property() effect!: Effect;
+  // @property() graphToControls!: GraphToControls;
 
   @property() devClasses: string = ""; // the css kind
   @property() deviceAttrs: DeviceAttrRow[] = [];
@@ -151,9 +154,9 @@
       return;
     }
     try {
-    this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
-    } catch(e) {
-      // what's likely is we're going through a graph reload and the graph 
+      this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
+    } catch (e) {
+      // what's likely is we're going through a graph reload and the graph
       // is gone but the controls remain
     }
     this.deviceAttrs = [];
@@ -169,6 +172,7 @@
     const dataType = this.graph.uriValue(devAttr, U(":dataType"));
     const daRow = {
       uri: devAttr,
+      device: this.uri,
       dataType,
       showColorPicker: dataType.equals(U(":color")),
       attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
@@ -206,8 +210,8 @@
   clear() {
     // why can't we just set their values ? what's diff about
     // the clear state, and should it be represented with `null` value?
-    throw new Error()
-    Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
+    throw new Error();
+    // Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
   }
 
   onClick(ev: any) {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Light9DeviceSettings.ts	Sat May 27 01:14:45 2023 -0700
@@ -0,0 +1,208 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { sortBy, uniq } from "underscore";
+import { Patch, patchContainsPreds } from "../web/patch";
+import { getTopGraph } from "../web/RdfdbSyncedGraph";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { GraphToControls } from "./GraphToControls";
+import { Effect } from "./Effect";
+export { EditChoice } from "../web/EditChoice";
+export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
+const log = debug("settings");
+
+@customElement("light9-device-settings")
+export class Light9DeviceSettings extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      #preview {
+        width: 100%;
+      }
+      #deviceControls {
+        flex-grow: 1;
+        position: relative;
+        width: 100%;
+        overflow-y: auto;
+      }
+
+      light9-live-device-control > div {
+        break-inside: avoid-column;
+      }
+      light9-live-device-control {
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <h1>effect EeviceSettings</h1>
+
+      <div id="save">
+        <div>
+          <button @click=${this.newEffect}>New effect</button>
+          <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2}></edit-choice>
+          <button @click=${this.clearAll}>clear settings in this effect</button>
+        </div>
+      </div>
+
+      <div id="deviceControls">
+        ${this.devices.map(
+          (device: NamedNode) => html`
+            <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  devices: Array<NamedNode> = [];
+  // uri of the effect being edited, or null. This is the
+  // master value; GraphToControls follows.
+  @property() currentEffect: Effect | null = null;
+  // graphToControls!: GraphToControls;
+  okToWriteUrl: boolean = false;
+
+  constructor() {
+    super();
+
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
+      // this.graphToControls = new GraphToControls(this.graph);
+      // this.graph.runHandler(this.update.bind(this), "Light9LiveControls update");
+      this.setEffectFromUrl();
+    });
+  }
+  onEffectChoice2(ev: CustomEvent) {
+    const uri = ev.detail.newValue as NamedNode;
+    if (uri === null) {
+      this.currentEffect = null;
+    } else {
+      this.currentEffect = new Effect(this.graph, uri);
+    }
+  }
+  updated(changedProperties: PropertyValues<this>) {
+    log("ctls udpated", changedProperties);
+    if (changedProperties.has("currentEffect")) {
+      log(`effectChoice to ${this.currentEffect?.uri?.value}`);
+      this.onEffectChoice();
+    }
+    // this.graphToControls?.debugDump();
+  }
+
+  // Note that this doesn't fetch setting values, so it only should get rerun
+  // upon (rarer) changes to the devices etc.
+  findDevices(patch?: Patch) {
+    const U = this.graph.U();
+    // if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
+    //   return;
+    // }
+
+    this.devices = [];
+    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
+    log(`found ${classes.length} device classes`);
+    uniq(sortBy(classes, "value"), true).forEach((dc) => {
+      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
+        log(`found dev ${dev.value}`);
+        this.devices.push(dev as NamedNode);
+      });
+    });
+    this.requestUpdate();
+  }
+
+  setEffectFromUrl() {
+    // not a continuous bidi link between url and effect; it only reads
+    // the url when the page loads.
+    const effect = new URL(window.location.href).searchParams.get("effect");
+    if (effect != null) {
+      log(`found effect in url ${effect}`);
+      this.currentEffect = new Effect(this.graph, this.graph.Uri(effect));
+    }
+    this.okToWriteUrl = true;
+  }
+
+  writeToUrl(effect: NamedNode | undefined) {
+    const effectStr = effect ? this.graph.shorten(effect) : "";
+    if (!this.okToWriteUrl) {
+      return;
+    }
+    const u = new URL(window.location.href);
+    if ((u.searchParams.get("effect") || "") === effectStr) {
+      return;
+    }
+    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
+    window.history.replaceState({}, "", u.href);
+    log("wrote new url", u.href);
+  }
+
+  newEffect() {
+    // this.effectChoice = this.graphToControls.newEffect();
+  }
+
+  onEffectChoice() {
+    const U = (x: any) => this.graph.Uri(x);
+    // if (this.effectChoice == null) {
+    //   // unlink
+    //   log("onEffectChoice unlink");
+    //   if (this.graphToControls != null) {
+    //     this.graphToControls.setEffect(null);
+    //   }
+    // } else {
+    //   if (this.graphToControls != null) {
+    //     this.graphToControls.setEffect(this.effectChoice);
+    //   } else {
+    //     throw new Error("graphToControls not set");
+    //   }
+    // }
+    this.writeToUrl(this.currentEffect?.uri);
+  }
+
+  clearAll() {
+    // clears the effect!
+    return; //this.graphToControls.emptyEffect();
+  }
+
+  // configureFromGraph() {
+  //   const U = (x: string) => this.graph.Uri(x);
+
+  //   const newDevs: NamedNode[] = [];
+  //   for (let dc of Array.from(this.graph.sortedUris(this.graph.subjects(U("rdf:type"), U(":DeviceClass"))))) {
+  //     for (let dev of Array.from(this.graph.sortedUris(this.graph.subjects(U("rdf:type"), dc)))) {
+  //       if (this.graph.contains(dev, U(":hideInLiveUi"), null)) {
+  //         continue;
+  //       }
+  //       if (newDevs.length == 0) newDevs.push(dev);
+  //     }
+  //   }
+  //   log("is this called?");
+  //   log(`controls update now has ${newDevs.length} devices`);
+  //   this.devices = newDevs;
+  //   this.requestUpdate();
+
+  //   return;
+
+  //   // Tried css columns- big slowdown from relayout as I'm scrolling.
+  //   // Tried isotope- seems to only scroll to the right.
+  //   // Tried columnize- fails in jquery maybe from weird elements.
+
+  //   // not sure how to get this run after the children are created
+  //   return setTimeout(
+  //     () =>
+  //       $("#deviceControls").isotope({
+  //         // fitColumns would be nice, but it doesn't scroll vertically
+  //         layoutMode: "masonry",
+  //         containerStyle: null,
+  //       }),
+  //     2000
+  //   );
+  // }
+}
--- a/light9/live/Light9LiveControl.ts	Fri May 26 23:07:40 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,172 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { Literal, NamedNode } from "n3";
-import { SubEvent } from "sub-events";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { ControlValue } from "./Effect";
-import { GraphToControls } from "./GraphToControls";
-import { DeviceAttrRow } from "./Light9DeviceControl";
-import { Choice } from "./Light9Listbox";
-export { Slider } from "@material/mwc-slider";
-export { Light9ColorPicker } from "../web/light9-color-picker";
-
-const log = debug("control");
-
-
-const makeType = (d: "scalar" | "color" | "choice") => new NamedNode(`http://light9.bigasterisk.com/${d}`);
-
-// UI for one device attr (of any type).
-@customElement("light9-live-control")
-export class Light9LiveControl extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      #colorControls {
-        display: flex;
-        align-items: center;
-      }
-      #colorControls > * {
-        margin: 0 3px;
-      }
-    :host {
-      border: 2px solid white;
-    }
-    `,
-  ];
-
-  // passed from parent
-  @property() device!: NamedNode;
-  @property() dataType: NamedNode;
-  @property() deviceAttrRow!: DeviceAttrRow;
-  // we'll connect to this and receive graphValueChanged and send uiValueChanged
-  @property() graphToControls!: GraphToControls;
-
-  @property() enableChange: boolean = false;
-  @property() value: ControlValue | null = null;
-
-  // slider mode
-  @property() sliderValue: number = 0;
-
-  // color mode
-
-  // choice mode
-  @property() pickedChoice: Choice | null = null;
-  @property() choiceValue: Choice | null = null;
-
-  valueChanged: SubEvent<Literal> = new SubEvent();
-
-  constructor() {
-    super();
-    this.dataType = makeType("color");
-    // getTopGraph().then((g) => {
-    //   this.graph = g;
-    //   // this.graph.runHandler(this.graphReads.bind(this), `${this.device} ${this.deviceAttrRow.uri} reads`);
-    // });
-  }
-
-  render() {
-    const dbg=html`
-       ]`
-    if (this.dataType.equals(makeType("scalar"))) {
-      return html`${dbg} <mwc-slider .value=${this.sliderValue} step=${1 / 255} min="0" max="1" @input=${this.onSliderInput}></mwc-slider> `;
-    } else if (this.dataType.equals(makeType("color"))) {
-      return html` ${dbg}
-        <div id="colorControls">
-          <button on-click="goBlack">0.0</button>
-          <light9-color-picker color="${this.value}"></light9-color-picker>
-        </div>
-      `;
-    } else if (this.dataType.equals(makeType("choice"))) {
-      return html`${dbg} <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}"> </light9-listbox> `;
-    }
-  }
-
-  // graphReads() {
-  //   const U = this.graph.U();
-  // }
-
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("graphToControls")) {
-      this.graphToControls.register(this.device, this.deviceAttrRow.uri, this.onGraphValueChanged.bind(this));
-      this.enableChange = true;
-    }
-  }
-
-  onSliderInput(ev: CustomEvent) {
-    if (ev.detail === undefined) {
-      // not sure what this is, but it seems to be followed by good events
-      return;
-    }
-    log(ev.type, ev.detail?.value);
-    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
-  }
-
-  onGraphValueChanged(v: ControlValue | null) {
-    // log("change: control must display", v);
-    // this.enableChange = false;
-    if (this.dataType.equals(makeType("scalar"))) {
-      if (v !== null) {
-        setTimeout(() => {
-          // only needed once per page layout
-          this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
-        }, 1);
-        this.sliderValue = v as number;
-      } else {
-        this.sliderValue = 0;
-      }
-    }
-    // if (v === null) {
-    //   this.clear();
-    // } else {
-    //   this.value = v;
-    // }
-    // if (this.deviceAttrRow.useChoice) {
-    //   this.choiceValue = v === null ? v : v.value;
-    // }
-    // this.enableChange = true;
-  }
-
-  goBlack() {
-    this.value = "#000000";
-  }
-
-  onChoice(value: any) {
-    if (this.graphToControls == null || !this.enableChange) {
-      return;
-    }
-    if (value != null) {
-      value = this.graph.Uri(value);
-    } else {
-      value = null;
-    }
-    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
-  }
-
-  onChange(value: any) {
-    if (this.graphToControls == null || !this.enableChange) {
-      return;
-    }
-    if (typeof value === "number" && isNaN(value)) {
-      return;
-    } // let onChoice do it
-    //log('change: control tells graph', @deviceAttrRow.uri.value, value)
-    if (value === undefined) {
-      value = null;
-    }
-    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
-  }
-
-  // clear() {
-  //   this.pickedChoice = null;
-  //   this.sliderWriteValue = 0;
-  //   if (this.deviceAttrRow.useColor) {
-  //     return (this.value = "#000000");
-  //   } else if (this.deviceAttrRow.useChoice) {
-  //     return (this.value = this.pickedChoice = null);
-  //   } else {
-  //     return (this.value = this.sliderValue = 0);
-  //   }
-  // }
-}
--- a/light9/live/Light9LiveControls.ts	Fri May 26 23:07:40 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,200 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { sortBy, uniq } from "underscore";
-import { Patch, patchContainsPreds } from "../web/patch";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { GraphToControls } from "./GraphToControls";
-export { EditChoice } from "../web/EditChoice";
-export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
-const log = debug("controls");
-
-@customElement("light9-live-controls")
-export class Light9LiveControls extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      :host {
-        display: flex;
-        flex-direction: column;
-      }
-      #preview {
-        width: 100%;
-      }
-      #deviceControls {
-        flex-grow: 1;
-        position: relative;
-        width: 100%;
-        overflow-y: auto;
-      }
-
-      light9-live-device-control > div {
-        break-inside: avoid-column;
-      }
-      light9-live-device-control {
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <h1>effect deviceattrs</h1>
-
-      <div id="save">
-        <div>
-          <button @click=${this.newEffect}>New effect</button>
-          <edit-choice .uri=${this.effectChoice} @edited=${this.onEffectChoice2}></edit-choice>
-          <button @click=${this.clearAll}>clear settings in this effect</button>
-        </div>
-      </div>
-
-      <div id="deviceControls">
-        ${this.devices.map(
-          (device: NamedNode) => html`
-            <light9-device-control .uri=${device} .effect=${this.effectChoice} .graphToControls=${this.graphToControls}></light9-device-control>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  devices: Array<NamedNode> = [];
-  // uri of the effect being edited, or null. This is the
-  // master value; GraphToControls follows.
-  @property() effectChoice: NamedNode | null = null;
-  graphToControls!: GraphToControls;
-  okToWriteUrl: boolean = false;
-
-  constructor() {
-    super();
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
-      this.graphToControls = new GraphToControls(this.graph);
-      // this.graph.runHandler(this.update.bind(this), "Light9LiveControls update");
-      this.setEffectFromUrl();
-    });
-  }
-  onEffectChoice2(ev: CustomEvent) {
-    this.effectChoice = ev.detail.newValue as NamedNode;
-  }
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("effectChoice")) {
-      log(`effectChoice to ${this.effectChoice?.value}`);
-      this.onEffectChoice();
-    }
-  }
-
-  // Note that this doesn't fetch setting values, so it only should get rerun
-  // upon (rarer) changes to the devices etc.
-  findDevices(patch?: Patch) {
-    const U = this.graph.U();
-    // if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
-    //   return;
-    // }
-
-    this.devices = [];
-    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
-    log(`found ${classes.length} device classes`);
-    uniq(sortBy(classes, "value"), true).forEach((dc) => {
-      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
-        log(`found dev ${dev.value}`);
-        this.devices.push(dev as NamedNode);
-      });
-    });
-    this.requestUpdate();
-  }
-
-  setEffectFromUrl() {
-    // not a continuous bidi link between url and effect; it only reads
-    // the url when the page loads.
-    const effect = new URL(window.location.href).searchParams.get("effect");
-    if (effect != null) {
-      log(`found effect in url ${effect}`);
-      this.effectChoice = this.graph.Uri(effect);
-    }
-    this.okToWriteUrl = true;
-  }
-
-  writeToUrl(effect: NamedNode | null) {
-    const effectStr = effect ? this.graph.shorten(effect) : "";
-    if (!this.okToWriteUrl) {
-      return;
-    }
-    const u = new URL(window.location.href);
-    if ((u.searchParams.get("effect") || "") === effectStr) {
-      return;
-    }
-    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
-    window.history.replaceState({}, "", u.href);
-    log("wrote new url", u.href);
-  }
-
-  newEffect() {
-    this.effectChoice = this.graphToControls.newEffect();
-  }
-
-  onEffectChoice() {
-    const U = (x: any) => this.graph.Uri(x);
-    if (this.effectChoice == null) {
-      // unlink
-      log("onEffectChoice unlink");
-      if (this.graphToControls != null) {
-        this.graphToControls.setEffect(null);
-      }
-    } else {
-      if (this.graphToControls != null) {
-        this.graphToControls.setEffect(this.effectChoice);
-      } else {
-        throw new Error("graphToControls not set");
-      }
-    }
-    this.writeToUrl(this.effectChoice);
-  }
-
-  clearAll() {
-    // clears the effect!
-    return this.graphToControls.emptyEffect();
-  }
-
-  // configureFromGraph() {
-  //   const U = (x: string) => this.graph.Uri(x);
-
-  //   const newDevs: NamedNode[] = [];
-  //   for (let dc of Array.from(this.graph.sortedUris(this.graph.subjects(U("rdf:type"), U(":DeviceClass"))))) {
-  //     for (let dev of Array.from(this.graph.sortedUris(this.graph.subjects(U("rdf:type"), dc)))) {
-  //       if (this.graph.contains(dev, U(":hideInLiveUi"), null)) {
-  //         continue;
-  //       }
-  //       if (newDevs.length == 0) newDevs.push(dev);
-  //     }
-  //   }
-  //   log("is this called?");
-  //   log(`controls update now has ${newDevs.length} devices`);
-  //   this.devices = newDevs;
-  //   this.requestUpdate();
-
-  //   return;
-
-  //   // Tried css columns- big slowdown from relayout as I'm scrolling.
-  //   // Tried isotope- seems to only scroll to the right.
-  //   // Tried columnize- fails in jquery maybe from weird elements.
-
-  //   // not sure how to get this run after the children are created
-  //   return setTimeout(
-  //     () =>
-  //       $("#deviceControls").isotope({
-  //         // fitColumns would be nice, but it doesn't scroll vertically
-  //         layoutMode: "masonry",
-  //         containerStyle: null,
-  //       }),
-  //     2000
-  //   );
-  // }
-}
--- a/light9/live/index.html	Fri May 26 23:07:40 2023 -0700
+++ b/light9/live/index.html	Sat May 27 01:14:45 2023 -0700
@@ -1,10 +1,10 @@
 <!DOCTYPE html>
 <html>
   <head>
-    <title>device control</title>
+    <title>device settings</title>
     <meta charset="utf-8" />
     <link rel="stylesheet" href="../style.css" />
-    <script type="module" src="../live/Light9LiveControls"></script>
+    <script type="module" src="./Light9DeviceSettings"></script>
   </head>
   <body>
     <style>
@@ -12,7 +12,7 @@
       html {
         margin: 0;
       }
-      light9-live-controls {
+      light9-device-settings {
         position: absolute;
         left: 2px;
         top: 2px;
@@ -20,6 +20,6 @@
         bottom: 0;
       }
     </style>
-    <light9-live-controls></light9-live-controls>
+    <light9-device-settings></light9-device-settings>
   </body>
 </html>
--- a/light9/web/light9-color-picker.ts	Fri May 26 23:07:40 2023 -0700
+++ b/light9/web/light9-color-picker.ts	Sat May 27 01:14:45 2023 -0700
@@ -58,6 +58,7 @@
     pickerFloat.pageInit();
   }
   update(changedProperties: PropertyValueMap<this>) {
+    super.update(changedProperties);
     if (changedProperties.has("color")) {
       this.setColor(this.color);
     }
@@ -66,11 +67,12 @@
         .value(this.value / 255)
         .hex();
 
+      this.dispatchEvent(new CustomEvent("input", { detail: { value: this.color } }));
+
       this.swatchEl.then((sw) => {
         sw.style.borderColor = this.hueSatColor;
       });
     }
-    super.update(changedProperties);
   }
 
   private onVSliderChange(ev: CustomEvent) {