Skip to main content

kitchen_ui/
param_editor.rs

1//! The parameters of one op, editable: numbers as inputs (Enter or leaving the field applies
2//! them), a short list of numbers as one input each, choices as menus, switches as checkboxes.
3//! It works on any op that serializes to a JSON object ([`Params`]): lumen's ops, dsp-kitchen's
4//! pipeline nodes. Whoever owns the op decides how a change is applied (a [`Commit`]: a project
5//! or session command), so a side panel and a pipeline inspector can share it.
6
7use std::rc::Rc;
8
9use gpui_kit::component::checkbox::Checkbox;
10use gpui_kit::component::input::{Input, InputEvent, InputState};
11use gpui_kit::component::{Sizable as _, v_flex};
12use gpui_kit::{App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, div};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use serde_json::{Value, json};
16
17use crate::theme::{self, color};
18use crate::widgets::{MenuSelect, row};
19
20/// An op the editor can edit: it serializes to a JSON object whose numbers, short number lists,
21/// choices and switches are its parameters, and deserializing checks new values. The hooks say
22/// what JSON alone cannot (which text fields are choices, labels of list elements); each has a
23/// default, so `impl Params for MyOp {}` is enough for an op with numbers only.
24pub trait Params: Serialize + DeserializeOwned + Clone + PartialEq + 'static {
25    /// The options of a text field shown as a menu (`band`: low pass, high pass…); `None` for a
26    /// field that is not a choice (it is not shown).
27    fn choices(_field: &str) -> Option<&'static [&'static str]> {
28        None
29    }
30
31    /// The label of element `i` of a list of `len` numbers (`cutoff`, 2, 0 → `Low (Hz)`).
32    fn element_label(field: &str, _len: usize, i: usize) -> String {
33        format!("{} {}", label_of(field), i + 1)
34    }
35
36    /// Adjusts the other values when a choice changes (a band change keeps the cutoffs it can).
37    fn on_choice(_value: &mut Value, _field: &str, _choice: &str) {}
38}
39
40/// Sets the new op; an error is shown under the fields.
41pub type Commit<T> = Rc<dyn Fn(T, &mut App) -> Result<(), String>>;
42
43/// Longest list of numbers edited as separate inputs; longer lists are only counted.
44const MAX_LIST: usize = 2;
45
46/// One editable number: its place in the op's JSON (`cutoff`, index 1).
47struct Field {
48    key: String,
49    index: Option<usize>,
50    label: String,
51    input: Entity<InputState>,
52}
53
54/// The editor of one op's parameters (a view: `cx.new(|_| ParamEditor::<MyOp>::new())`).
55pub struct ParamEditor<T: Params> {
56    fields: Vec<Field>,
57    /// What the fields were built for (key and op JSON).
58    fields_for: String,
59    field_subs: Vec<Subscription>,
60    error: Option<String>,
61    target: Option<(T, Commit<T>)>,
62}
63
64impl<T: Params> Default for ParamEditor<T> {
65    fn default() -> Self {
66        Self { fields: Vec::new(), fields_for: String::new(), field_subs: Vec::new(), error: None, target: None }
67    }
68}
69
70impl<T: Params> ParamEditor<T> {
71    /// An editor with nothing to edit yet.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Edits `op`, known as `key` (its node id, `v1/iir`); inputs are rebuilt when the key or the
77    /// values change, so calling it on every render is cheap.
78    pub fn set_target(&mut self, key: &str, op: &T, commit: Commit<T>, window: &mut Window, cx: &mut Context<Self>) {
79        let value = serde_json::to_value(op).unwrap_or(Value::Null);
80        self.target = Some((op.clone(), commit));
81        let fresh = format!("{key}/{value}");
82        if fresh == self.fields_for {
83            return;
84        }
85        if !self.fields_for.starts_with(&format!("{key}/")) {
86            self.error = None;
87        }
88        self.fields_for = fresh;
89        self.fields.clear();
90        self.field_subs.clear();
91        let Value::Object(map) = &value else { return };
92        for (k, v) in map {
93            match v {
94                Value::Number(n) => self.add_field(k, None, label_of(k), n.as_f64().unwrap_or(0.0), window, cx),
95                Value::Array(a) if a.len() <= MAX_LIST && !a.is_empty() && a.iter().all(Value::is_number) => {
96                    for (i, n) in a.iter().enumerate() {
97                        self.add_field(k, Some(i), T::element_label(k, a.len(), i), n.as_f64().unwrap_or(0.0), window, cx);
98                    }
99                }
100                _ => {}
101            }
102        }
103    }
104
105    /// The op being edited, if any.
106    pub fn target(&self) -> Option<&T> {
107        self.target.as_ref().map(|(op, _)| op)
108    }
109
110    /// The last error (a value that does not parse, or one the op or its owner rejected).
111    pub fn error(&self) -> Option<&str> {
112        self.error.as_deref()
113    }
114
115    fn add_field(&mut self, key: &str, index: Option<usize>, label: String, v: f64, window: &mut Window, cx: &mut Context<Self>) {
116        let input = cx.new(|cx| InputState::new(window, cx).default_value(trim_number(v)));
117        let sub = cx.subscribe(&input, |this, _, e: &InputEvent, cx| {
118            if matches!(e, InputEvent::PressEnter { .. } | InputEvent::Blur) {
119                this.apply_fields(cx);
120            }
121        });
122        self.field_subs.push(sub);
123        self.fields.push(Field { key: key.to_string(), index, label, input });
124    }
125
126    /// The numbers typed in, on the op; a value that does not parse, or parameters the op
127    /// rejects, leave it unchanged and say why.
128    fn apply_fields(&mut self, cx: &mut Context<Self>) {
129        let Some((op, _)) = &self.target else { return };
130        let mut value = serde_json::to_value(op).unwrap_or(Value::Null);
131        for f in &self.fields {
132            let text = f.input.read(cx).value().to_string();
133            let Ok(n) = text.trim().parse::<f64>() else {
134                self.error = Some(format!("{}: {text:?} is not a number", f.label));
135                cx.notify();
136                return;
137            };
138            let slot = match f.index {
139                Some(i) => value.get_mut(&f.key).and_then(|a| a.get_mut(i)),
140                None => value.get_mut(&f.key),
141            };
142            if let Some(slot) = slot {
143                *slot = number_like(slot, n);
144            }
145        }
146        self.set_value(value, cx);
147    }
148
149    fn set_value(&mut self, value: Value, cx: &mut Context<Self>) {
150        let Some((op, commit)) = self.target.clone() else { return };
151        match serde_json::from_value::<T>(value) {
152            Ok(new) if new == op => self.error = None,
153            Ok(new) => self.error = commit(new, cx).err(),
154            Err(e) => self.error = Some(e.to_string()),
155        }
156        cx.notify();
157    }
158
159    fn set_choice(&mut self, key: &str, choice: &str, cx: &mut Context<Self>) {
160        let Some((op, _)) = &self.target else { return };
161        let mut value = serde_json::to_value(op).unwrap_or(Value::Null);
162        value[key] = json!(choice);
163        T::on_choice(&mut value, key, choice);
164        self.set_value(value, cx);
165    }
166
167    fn set_bool(&mut self, key: &str, on: bool, cx: &mut Context<Self>) {
168        let Some((op, _)) = &self.target else { return };
169        let mut value = serde_json::to_value(op).unwrap_or(Value::Null);
170        value[key] = json!(on);
171        self.set_value(value, cx);
172    }
173}
174
175impl<T: Params> Render for ParamEditor<T> {
176    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
177        let mut out = v_flex().w_full().gap(gpui_kit::px(theme::SM));
178        let Some((op, _)) = &self.target else { return out };
179        let value = serde_json::to_value(op).unwrap_or(Value::Null);
180        for f in &self.fields {
181            out = out.child(row(f.label.clone(), div().w_full().child(Input::new(&f.input).small())));
182        }
183        let mut shown = !self.fields.is_empty();
184        if let Value::Object(map) = &value {
185            for (k, v) in map {
186                if let (Some(opts), Some(cur)) = (T::choices(k), v.as_str()) {
187                    let this = cx.entity().downgrade();
188                    let key = k.clone();
189                    let names: Vec<SharedString> = opts.iter().map(|o| SharedString::from(o.replace('_', " "))).collect();
190                    let sel = opts.iter().position(|o| *o == cur);
191                    out = out.child(row(
192                        label_of(k),
193                        MenuSelect::new(SharedString::from(format!("param-{k}")), names, sel, move |i, _, cx| {
194                            let _ = this.update(cx, |p, cx| p.set_choice(&key, opts[i], cx));
195                        }),
196                    ));
197                    shown = true;
198                } else if let Some(b) = v.as_bool() {
199                    let this = cx.entity().downgrade();
200                    let key = k.clone();
201                    out = out.child(row(
202                        label_of(k),
203                        Checkbox::new(SharedString::from(format!("param-{k}"))).checked(b).on_click(move |on: &bool, _, cx| {
204                            let on = *on;
205                            let _ = this.update(cx, |p, cx| p.set_bool(&key, on, cx));
206                        }),
207                    ));
208                    shown = true;
209                } else if let Some(a) = v.as_array().filter(|a| a.len() > MAX_LIST) {
210                    out = out.child(row(label_of(k), div().text_xs().text_color(color::text_muted()).child(format!("{} values (edit in the file)", a.len()))));
211                    shown = true;
212                }
213            }
214        }
215        if !shown {
216            out = out.child(div().text_xs().text_color(color::text_muted()).child("No parameters."));
217        }
218        out.children(self.error.clone().map(|e| div().text_xs().text_color(color::danger()).child(e)))
219    }
220}
221
222/// `n` in the kind of number `slot` held: integers stay integers (radii, orders, counts); a
223/// negative number for an unsigned field is left for deserializing to reject, with its reason.
224fn number_like(slot: &Value, n: f64) -> Value {
225    if slot.is_u64() || slot.is_i64() { json!(n.round() as i64) } else { json!(n) }
226}
227
228/// `clip_limit` → `Clip limit`
229pub fn label_of(key: &str) -> String {
230    let mut s = key.replace('_', " ");
231    if let Some(c) = s.get_mut(0..1) {
232        c.make_ascii_uppercase();
233    }
234    s
235}
236
237/// `300` rather than `300.0`, at most 6 decimals.
238pub fn trim_number(v: f64) -> String {
239    if v.fract() == 0.0 && v.abs() < 1e15 {
240        format!("{}", v as i64)
241    } else {
242        let s = format!("{v:.6}");
243        s.trim_end_matches('0').trim_end_matches('.').to_string()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn labels_and_numbers() {
253        assert_eq!(label_of("clip_limit"), "Clip limit");
254        assert_eq!(label_of("sigma_samples"), "Sigma samples");
255        assert_eq!(trim_number(300.0), "300");
256        assert_eq!(trim_number(0.25), "0.25");
257        assert_eq!(trim_number(-1.5), "-1.5");
258    }
259
260    #[test]
261    fn integers_stay_integers() {
262        assert_eq!(number_like(&json!(3), 4.6), json!(5));
263        assert_eq!(number_like(&json!(-3), -4.4), json!(-4));
264        assert_eq!(number_like(&json!(0.5), 2.0), json!(2.0));
265        // Negative for an unsigned field: deserializing says why
266        let v = number_like(&json!(3), -2.0);
267        assert!(serde_json::from_value::<u32>(v).is_err());
268    }
269}