Inspector number boxes edit as you type, and are one undo step per focus

The boxes wrote nothing until blur, so a spinner click showed nothing on
screen. Now every keystroke and step is an edit, and history holds the
step open from focus to blur, so typing 4 then 5 is seen as 4, then 45,
and undone once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-30 01:35:52 -04:00
parent 277c0c3b63
commit 7aaf0a15bd
5 changed files with 82 additions and 25 deletions

View file

@ -69,17 +69,34 @@
(defn record
"History `h` with an edit from leaves `before` to `after` at time `now`."
[{:keys [done] :as h} before after now]
[{:keys [done held?] :as h} before after now]
(let [[b a] (changes before after)
top (peek done)]
(cond
(empty? a) h
(and top (< (- now (:at top)) gap-ms) (= b (:after top)))
{:done (conj (pop done) (assoc top :after a :at now)) :undone []}
(and top (not (:closed? top)) (= b (:after top))
(or held? (< (- now (:at top)) gap-ms)))
(assoc h :done (conj (pop done) (assoc top :after a :at now)) :undone [])
:else
{:done (conj (vec (take-last (dec depth) done))
{:before b :after a :at now :label (label before after (keys a))})
:undone []})))
(assoc h
:done (conj (vec (take-last (dec depth) done))
{:before b :after a :at now :label (label before after (keys a))})
:undone []))))
(defn- close [{:keys [done] :as h}]
(cond-> h (seq done) (assoc :done (conj (pop done) (assoc (peek done) :closed? true)))))
(defn hold
"While a field has focus, everything typed into it is one step, however slowly
— the digits of 45 are seen as 4 and then 45, and undone as one. It starts a
step of its own rather than joining whatever came before."
[h]
(assoc (close h) :held? true))
(defn settle
"The field is done with: its step is finished, and nothing joins it."
[h]
(dissoc (close h) :held?))
(defn steps
"The labels, newest first: `:done` is what undo would take off, `:undone`

View file

@ -61,6 +61,14 @@
(not= (:fps clip) (get-in db [:clip :fps]))
(update :clip merge {:fps (:fps clip) :display-fps (:fps clip)})))
(defn history
"Apply `f` to the loaded entry's undo history, which is not an edit: nothing
is redrawn and nothing becomes unsaved."
[db f]
(if-let [id (store/edit-entry! (:clip/current db) #(update % :history f))]
(assoc db :clip/current id)
db))
(defn edit
"Apply `f` to the loaded clip and return the new db."
[db f]

View file

@ -55,6 +55,9 @@
(rf/reg-event-fx ::undo (fn [{:keys [db]} [_ n]] (steps db history/undo "undone" (or n 1))))
(rf/reg-event-fx ::redo (fn [{:keys [db]} [_ n]] (steps db history/redo "redone" (or n 1))))
(rf/reg-event-db ::hold (fn [db _] (edit/history db history/hold)))
(rf/reg-event-db ::settle (fn [db _] (edit/history db history/settle)))
(rf/reg-sub
::steps
;; The history is on the entry, outside app-db; the revision is what moves

View file

@ -13,6 +13,7 @@
[arthur.domain.node :as node]
[arthur.domain.paint :as paint]
[arthur.domain.params :as params]
[arthur.events.history :as history]
[arthur.events.paint :as paint-events]
[arthur.events.playback :as pb]
[arthur.events.project :as project]
@ -20,7 +21,8 @@
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[re-frame.core :as rf]))
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- brief
"A value that fits the column. The `facts` list puts the whole thing in a
@ -39,14 +41,37 @@
(defn- section [title & body]
(into [:section.section [:h2 title]] body))
(defn- number-input
"A number box that edits as it is typed in or stepped, and is one undo step
from focus to blur. What is typed is kept while it has focus — \"-\" or
\"4.\" is no number yet, and the value it would round-trip to must not
replace it under the caret."
[_]
(let [draft (r/atom nil)]
(fn [{:keys [value parse on-number] :as attrs}]
[:input (merge (dissoc attrs :value :parse :on-number)
{:type "number"
:value (or @draft value "")
:on-focus (fn [e]
(reset! draft (.. e -target -value))
(rf/dispatch [::history/hold]))
:on-blur (fn [_]
(reset! draft nil)
(rf/dispatch [::history/settle]))
:on-key-down #(when (= "Enter" (.-key %)) (.. % -target blur))
:on-change (fn [e]
(let [s (.. e -target -value)
n (parse s)]
(when @draft (reset! draft s))
(when-not (js/isNaN n) (on-number n))))})])))
(defn- number-field [label value on-change & [placeholder disabled?]]
[:label.inspector-field label
[:input {:type "number" :min 1 :step 1 :value (or value "")
:placeholder placeholder
:disabled disabled?
:on-change (fn [e]
(let [v (.. e -target -value)]
(on-change (when (seq v) (js/parseInt v 10)))))}]])
[number-input {:min 1 :step 1 :value value
:placeholder placeholder
:disabled disabled?
:parse #(if (seq %) (js/parseInt % 10) nil)
:on-number on-change}]])
;; ---------------------------------------------------------------------------
;; the clip
@ -151,19 +176,12 @@
off? (and keyed? (nil? frame))
deg? (= path [:xform :rot])
put #(rf/dispatch [::project/set-channel sid id path frame %])
;; Written on blur or Enter rather than per keystroke, so a typed
;; number is one edit. Keyed by what it shows, so a seek or an undo
;; that changes the value puts the new one in the box.
field (fn [i x on-number]
(let [shown (if deg? (/ (js/Math.round (* x (/ 18000 js/Math.PI))) 100) x)]
^{:key (str i " " shown)}
[:input {:type "number" :step "any" :disabled off?
:default-value shown
:on-key-down #(when (= "Enter" (.-key %)) (.. % -target blur))
:on-blur (fn [e]
(let [n (js/parseFloat (.. e -target -value))]
(when-not (or (js/isNaN n) (= n shown))
(on-number (if deg? (* n (/ js/Math.PI 180)) n)))))}]))]
^{:key i}
[number-input {:step "any" :disabled off?
:value (if deg? (/ (js/Math.round (* x (/ 18000 js/Math.PI))) 100) x)
:parse js/parseFloat
:on-number #(on-number (if deg? (* % (/ js/Math.PI 180)) %))}])]
[:dd.channel
[:button.key {:class (cond (contains? (:keys ch) frame) "on" keyed? "keyed")
:disabled (nil? frame)

View file

@ -23,6 +23,17 @@
(is (= 1 (count (:done h))))
(is (= {"v" 0} (:leaves (history/undo h {"v" 3}))))))
(deftest typing-into-a-field-is-one-step-however-slow
(let [h (-> nil
(history/record {"w" 1} {"w" 2} 0)
history/hold
(history/record {"w" 2} {"w" 4} 100)
(history/record {"w" 4} {"w" 45} 9000)
history/settle
(history/record {"w" 45} {"w" 46} 9100))]
(is (= 3 (count (:done h))) "the edit before focus and the one after blur stand apart")
(is (= {"w" 2} (:leaves (history/undo (:history (history/undo h {"w" 46})) {"w" 45}))))))
(deftest their-write-between-two-of-mine-keeps-them-apart
(let [h (-> nil
(history/record {"fps" 30} {"fps" 12} 0)